forked from Gitlink/gitlink-cli
673 lines
16 KiB
Markdown
673 lines
16 KiB
Markdown
# 逐行讲解 shortcuts/issue/batch_create.go(面向 Go 小白)
|
||
|
||
## 文件概述
|
||
|
||
这个文件实现了 **Issue 批量创建**功能,可以从命令行或 CSV 文件批量创建多个 Issue,并支持 bug 和 feature 两种模板。
|
||
|
||
---
|
||
|
||
## 一、包声明和导入
|
||
|
||
```go
|
||
package issue
|
||
|
||
import (
|
||
"encoding/csv"
|
||
"fmt"
|
||
"net/url"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
|
||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||
)
|
||
```
|
||
|
||
| 导入库 | 作用 |
|
||
|-------|------|
|
||
| `encoding/csv` | CSV 文件解析 |
|
||
| `fmt` | 格式化输出 |
|
||
| `net/url` | URL 查询参数构建 |
|
||
| `os` | 文件操作 |
|
||
| `strconv` | 字符串和数字转换 |
|
||
| `strings` | 字符串处理 |
|
||
| `sync` | 并发安全,用于缓存 |
|
||
| `common` | 公共工具包 |
|
||
|
||
---
|
||
|
||
## 二、标签缓存
|
||
|
||
```go
|
||
var issueTagCache sync.Map
|
||
```
|
||
|
||
**作用**:缓存项目的标签列表,避免重复请求 API
|
||
|
||
**sync.Map**:Go 语言提供的并发安全的 map,可以在多个 goroutine 中安全地读写
|
||
|
||
---
|
||
|
||
## 三、resolveIssueTags 函数
|
||
|
||
```go
|
||
func resolveIssueTags(ctx *common.RuntimeContext) (map[string]int, error) {
|
||
key := ctx.Owner + "/" + ctx.Repo
|
||
if cached, ok := issueTagCache.Load(key); ok {
|
||
return cached.(map[string]int), nil
|
||
}
|
||
|
||
path := fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo)
|
||
q := url.Values{}
|
||
q.Set("only_name", "true")
|
||
env, err := ctx.CallAPIWithQuery("GET", path, q)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取项目标签列表失败: %w", err)
|
||
}
|
||
|
||
data, ok := env.Data.(map[string]interface{})
|
||
if !ok {
|
||
return nil, fmt.Errorf("标签列表响应格式异常")
|
||
}
|
||
|
||
rawTags, ok := data["issue_tags"].([]interface{})
|
||
if !ok {
|
||
return nil, fmt.Errorf("标签列表响应缺少 issue_tags 字段")
|
||
}
|
||
|
||
tags := make(map[string]int, len(rawTags))
|
||
for _, item := range rawTags {
|
||
tag, ok := item.(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
name, _ := tag["name"].(string)
|
||
if name == "" {
|
||
continue
|
||
}
|
||
var id int
|
||
switch v := tag["id"].(type) {
|
||
case float64:
|
||
id = int(v)
|
||
case int:
|
||
id = v
|
||
default:
|
||
id, _ = strconv.Atoi(fmt.Sprintf("%v", v))
|
||
}
|
||
if id == 0 {
|
||
continue
|
||
}
|
||
tags[name] = id
|
||
}
|
||
|
||
if len(tags) == 0 {
|
||
return nil, fmt.Errorf("项目没有配置任何标签,请先在 GitLink 网页端创建标签")
|
||
}
|
||
|
||
issueTagCache.Store(key, tags)
|
||
return tags, nil
|
||
}
|
||
```
|
||
|
||
**功能**:获取项目的 Issue 标签列表,并缓存结果
|
||
|
||
**执行流程**:
|
||
1. 构建缓存 key(owner/repo)
|
||
2. 先从缓存中查找,如果有就直接返回
|
||
3. 如果缓存中没有,调用 API 获取标签列表
|
||
4. 解析 API 响应,提取标签名称和 ID
|
||
5. 处理多种 ID 类型(float64、int、其他)
|
||
6. 把结果存入缓存
|
||
7. 返回标签映射
|
||
|
||
---
|
||
|
||
## 四、命令定义
|
||
|
||
```go
|
||
func newBatchCreateShortcut() *common.Shortcut {
|
||
return &common.Shortcut{
|
||
Name: "batch-create",
|
||
Description: "Create multiple issues from CLI flags or a CSV file",
|
||
Flags: []common.Flag{
|
||
{Name: "titles", Usage: "Comma-separated issue titles"},
|
||
{Name: "priority", Short: "p", Usage: "Priority: low, normal, high, urgent"},
|
||
{Name: "label", Short: "l", Usage: "Label name"},
|
||
{Name: "assignee", Short: "a", Usage: "Assignee login name"},
|
||
{Name: "state", Short: "s", Usage: "Initial state", Default: "new"},
|
||
{Name: "from", Usage: "CSV file path"},
|
||
{Name: "template", Short: "t", Usage: "Template: bug or feature"},
|
||
{Name: "dry-run", Usage: "Preview without creating", Bool: true, Default: "false"},
|
||
},
|
||
Run: runBatchCreate,
|
||
}
|
||
}
|
||
```
|
||
|
||
**Flags 参数说明**:
|
||
- `--titles`:逗号分隔的 Issue 标题
|
||
- `--priority/-p`:优先级
|
||
- `--label/-l`:标签
|
||
- `--assignee/-a`:分配人
|
||
- `--state/-s`:初始状态
|
||
- `--from`:CSV 文件路径
|
||
- `--template/-t`:模板类型(bug/feature)
|
||
- `--dry-run`:预览模式
|
||
|
||
---
|
||
|
||
## 五、输入结构体
|
||
|
||
```go
|
||
type createIssueInput struct {
|
||
Title string
|
||
Body string
|
||
Priority string
|
||
Label string
|
||
Assignee string
|
||
Status string
|
||
// template-specific fields
|
||
Version string
|
||
Severity string
|
||
Steps string
|
||
Expected string
|
||
Actual string
|
||
UserStory string
|
||
Acceptance string
|
||
}
|
||
```
|
||
|
||
**作用**:存储创建 Issue 的所有输入参数
|
||
|
||
**模板专用字段**:
|
||
- `Version`、`Severity`、`Steps`、`Expected`、`Actual`:用于 bug 模板
|
||
- `UserStory`、`Acceptance`:用于 feature 模板
|
||
|
||
---
|
||
|
||
## 六、runBatchCreate 主函数
|
||
|
||
```go
|
||
func runBatchCreate(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return err
|
||
}
|
||
|
||
tags, err := resolveIssueTags(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||
template := strings.ToLower(strings.TrimSpace(ctx.Arg("template")))
|
||
|
||
var inputs []createIssueInput
|
||
if titlesStr := ctx.Arg("titles"); titlesStr != "" {
|
||
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
|
||
}
|
||
if csvPath := ctx.Arg("from"); csvPath != "" {
|
||
csvInputs, err := readCreateInputsFromCSV(csvPath, template)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
inputs = append(inputs, csvInputs...)
|
||
}
|
||
if len(inputs) == 0 {
|
||
return fmt.Errorf("no issue titles provided")
|
||
}
|
||
|
||
cliState := ctx.Arg("state")
|
||
for i := range inputs {
|
||
if inputs[i].Status == "" {
|
||
inputs[i].Status = cliState
|
||
}
|
||
}
|
||
|
||
summary := BatchSummary{
|
||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||
Action: "create",
|
||
Value: template,
|
||
DryRun: dryRun,
|
||
Total: len(inputs),
|
||
Results: make([]BatchResult, 0, len(inputs)),
|
||
}
|
||
|
||
for i, input := range inputs {
|
||
label := fmt.Sprintf("#%d", i+1)
|
||
if input.Title != "" {
|
||
label = truncate(input.Title, 40)
|
||
}
|
||
result := BatchResult{Number: label, Action: "create"}
|
||
|
||
if dryRun {
|
||
result.Status = "planned"
|
||
summary.Succeeded++
|
||
summary.Results = append(summary.Results, result)
|
||
continue
|
||
}
|
||
|
||
body := buildCreateBody(ctx, input, template, tags)
|
||
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
|
||
if err != nil {
|
||
result.Status = "failed"
|
||
result.Error = err.Error()
|
||
summary.Failed++
|
||
} else {
|
||
result.Status = "created"
|
||
if data, ok := env.Data.(map[string]interface{}); ok {
|
||
if num, ok := data["project_issues_index"]; ok {
|
||
result.Number = fmt.Sprintf("%v", num)
|
||
}
|
||
}
|
||
summary.Succeeded++
|
||
}
|
||
summary.Results = append(summary.Results, result)
|
||
}
|
||
|
||
if err := ctx.OutputData(summary); err != nil {
|
||
return err
|
||
}
|
||
if summary.Failed > 0 {
|
||
return fmt.Errorf("%d of %d issue(s) failed to create", summary.Failed, summary.Total)
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
**执行流程**:
|
||
1. 解析仓库信息
|
||
2. 获取项目标签列表
|
||
3. 收集输入(从 `--titles` 和/或 `--from`)
|
||
4. 为没有指定状态的输入应用默认状态
|
||
5. 遍历创建每个 Issue:
|
||
- 如果是 dry-run,标记为 planned
|
||
- 否则构建请求体并调用 API
|
||
- 从响应中提取新创建的 Issue 编号
|
||
6. 输出汇总结果
|
||
|
||
---
|
||
|
||
## 七、buildCreateBody 函数
|
||
|
||
```go
|
||
func buildCreateBody(ctx *common.RuntimeContext, input createIssueInput, template string, tags map[string]int) map[string]interface{} {
|
||
statusID := statusNew
|
||
if input.Status != "" {
|
||
if sid, err := parseStatus(input.Status); err == nil {
|
||
statusID = sid
|
||
}
|
||
}
|
||
body := map[string]interface{}{
|
||
"subject": input.Title,
|
||
"status_id": statusID,
|
||
"priority_id": priorityNormal,
|
||
"done_ratio": 0,
|
||
}
|
||
|
||
if template != "" {
|
||
body["description"] = buildTemplateDescription(input, template)
|
||
if template == "bug" {
|
||
body["issue_tag_ids"] = []interface{}{tags["缺陷"]}
|
||
} else if template == "feature" {
|
||
body["issue_tag_ids"] = []interface{}{tags["功能"]}
|
||
}
|
||
} else if input.Body != "" {
|
||
body["description"] = input.Body
|
||
}
|
||
|
||
if input.Priority != "" {
|
||
if pid, err := parsePriority(input.Priority); err == nil {
|
||
body["priority_id"] = pid
|
||
}
|
||
}
|
||
if input.Label != "" {
|
||
if tid, err := parseLabel(input.Label, tags); err == nil {
|
||
body["issue_tag_ids"] = []interface{}{tid}
|
||
}
|
||
}
|
||
if input.Assignee != "" {
|
||
if id, err := resolveUserID(ctx, input.Assignee); err == nil {
|
||
body["assigner_ids"] = []interface{}{id}
|
||
}
|
||
}
|
||
|
||
return body
|
||
}
|
||
```
|
||
|
||
**功能**:构建创建 Issue 的请求体
|
||
|
||
**逻辑**:
|
||
1. 设置默认值(状态、优先级、完成比例)
|
||
2. 如果指定了模板,构建模板描述并设置对应的标签
|
||
3. 否则使用自定义描述
|
||
4. 应用优先级、标签、分配人等可选参数
|
||
|
||
---
|
||
|
||
## 八、模板描述构建
|
||
|
||
### 8.1 buildTemplateDescription
|
||
|
||
```go
|
||
func buildTemplateDescription(input createIssueInput, template string) string {
|
||
switch template {
|
||
case "bug":
|
||
return buildBugDescription(input)
|
||
case "feature":
|
||
return buildFeatureDescription(input)
|
||
default:
|
||
return input.Body
|
||
}
|
||
}
|
||
```
|
||
|
||
### 8.2 buildBugDescription
|
||
|
||
```go
|
||
func buildBugDescription(input createIssueInput) string {
|
||
var b strings.Builder
|
||
b.WriteString("## Bug 描述\n")
|
||
b.WriteString(input.Title)
|
||
b.WriteString("\n")
|
||
|
||
if input.Version != "" {
|
||
b.WriteString("\n## 版本\n")
|
||
b.WriteString(input.Version)
|
||
}
|
||
if input.Severity != "" {
|
||
b.WriteString("\n## 严重程度\n")
|
||
b.WriteString(input.Severity)
|
||
}
|
||
if input.Steps != "" {
|
||
b.WriteString("\n## 复现步骤\n")
|
||
b.WriteString(input.Steps)
|
||
}
|
||
if input.Expected != "" {
|
||
b.WriteString("\n## 期望结果\n")
|
||
b.WriteString(input.Expected)
|
||
}
|
||
if input.Actual != "" {
|
||
b.WriteString("\n## 实际结果\n")
|
||
b.WriteString(input.Actual)
|
||
}
|
||
return b.String()
|
||
}
|
||
```
|
||
|
||
**功能**:构建标准化的 Bug 描述
|
||
|
||
**输出格式**:
|
||
```markdown
|
||
## Bug 描述
|
||
标题内容
|
||
|
||
## 版本
|
||
v1.0.0
|
||
|
||
## 严重程度
|
||
高
|
||
|
||
## 复现步骤
|
||
步骤1
|
||
步骤2
|
||
|
||
## 期望结果
|
||
期望的行为
|
||
|
||
## 实际结果
|
||
实际的行为
|
||
```
|
||
|
||
### 8.3 buildFeatureDescription
|
||
|
||
```go
|
||
func buildFeatureDescription(input createIssueInput) string {
|
||
var b strings.Builder
|
||
b.WriteString("## 用户故事\n")
|
||
if input.UserStory != "" {
|
||
b.WriteString(input.UserStory)
|
||
} else {
|
||
b.WriteString(input.Title)
|
||
}
|
||
|
||
if input.Body != "" {
|
||
b.WriteString("\n## 描述\n")
|
||
b.WriteString(input.Body)
|
||
}
|
||
if input.Acceptance != "" {
|
||
b.WriteString("\n## 验收标准\n")
|
||
b.WriteString(input.Acceptance)
|
||
}
|
||
if input.Priority != "" {
|
||
b.WriteString("\n## 优先级\n")
|
||
b.WriteString(input.Priority)
|
||
}
|
||
return b.String()
|
||
}
|
||
```
|
||
|
||
**功能**:构建标准化的 Feature 描述
|
||
|
||
**输出格式**:
|
||
```markdown
|
||
## 用户故事
|
||
作为用户,我想...
|
||
|
||
## 描述
|
||
详细描述
|
||
|
||
## 验收标准
|
||
- 标准1
|
||
- 标准2
|
||
|
||
## 优先级
|
||
high
|
||
```
|
||
|
||
---
|
||
|
||
## 九、CSV 读取
|
||
|
||
```go
|
||
func readCreateInputsFromCSV(path string, template string) ([]createIssueInput, error) {
|
||
file, err := os.Open(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("read CSV: %w", err)
|
||
}
|
||
defer file.Close()
|
||
|
||
reader := csv.NewReader(file)
|
||
reader.TrimLeadingSpace = true
|
||
records, err := reader.ReadAll()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("parse CSV: %w", err)
|
||
}
|
||
if len(records) < 2 {
|
||
return nil, fmt.Errorf("CSV must have a header row and at least one data row")
|
||
}
|
||
|
||
header := records[0]
|
||
col := make(map[string]int)
|
||
for i, h := range header {
|
||
col[normalizeHeader(h)] = i
|
||
}
|
||
|
||
if _, ok := col["title"]; !ok {
|
||
return nil, fmt.Errorf("CSV must have a 'title' column")
|
||
}
|
||
|
||
var inputs []createIssueInput
|
||
for _, record := range records[1:] {
|
||
input := createIssueInput{
|
||
Title: getCol(record, col, "title"),
|
||
Body: getCol(record, col, "body"),
|
||
Priority: getCol(record, col, "priority"),
|
||
Label: getCol(record, col, "label"),
|
||
Assignee: getCol(record, col, "assignee"),
|
||
Status: getCol(record, col, "status"),
|
||
Version: getCol(record, col, "version"),
|
||
Severity: getCol(record, col, "severity"),
|
||
Steps: getCol(record, col, "steps"),
|
||
Expected: getCol(record, col, "expected"),
|
||
Actual: getCol(record, col, "actual"),
|
||
UserStory: getCol(record, col, "user_story"),
|
||
Acceptance: getCol(record, col, "acceptance"),
|
||
}
|
||
if input.UserStory == "" {
|
||
input.UserStory = getCol(record, col, "user story")
|
||
}
|
||
if input.Title == "" {
|
||
continue
|
||
}
|
||
inputs = append(inputs, input)
|
||
}
|
||
return inputs, nil
|
||
}
|
||
```
|
||
|
||
**CSV 列支持**:
|
||
- `title`(必填):Issue 标题
|
||
- `body`:描述内容
|
||
- `priority`:优先级
|
||
- `label`:标签
|
||
- `assignee`:分配人
|
||
- `status`:状态
|
||
- `version`:版本(bug 模板)
|
||
- `severity`:严重程度(bug 模板)
|
||
- `steps`:复现步骤(bug 模板)
|
||
- `expected`:期望结果(bug 模板)
|
||
- `actual`:实际结果(bug 模板)
|
||
- `user_story` / `user story`:用户故事(feature 模板)
|
||
- `acceptance`:验收标准(feature 模板)
|
||
|
||
---
|
||
|
||
## 十、辅助函数
|
||
|
||
### 10.1 parseTitles
|
||
|
||
```go
|
||
func parseTitles(titlesStr string, ctx *common.RuntimeContext) []createIssueInput {
|
||
parts := strings.Split(titlesStr, ",")
|
||
inputs := make([]createIssueInput, 0, len(parts))
|
||
for _, title := range parts {
|
||
title = strings.TrimSpace(title)
|
||
if title == "" {
|
||
continue
|
||
}
|
||
inputs = append(inputs, createIssueInput{
|
||
Title: title,
|
||
Priority: ctx.Arg("priority"),
|
||
Label: ctx.Arg("label"),
|
||
Assignee: ctx.Arg("assignee"),
|
||
Status: ctx.Arg("state"),
|
||
})
|
||
}
|
||
return inputs
|
||
}
|
||
```
|
||
|
||
**功能**:从逗号分隔的标题字符串创建输入对象
|
||
|
||
### 10.2 normalizeHeader
|
||
|
||
```go
|
||
func normalizeHeader(h string) string {
|
||
return strings.ToLower(strings.TrimSpace(h))
|
||
}
|
||
```
|
||
|
||
**功能**:标准化 CSV 表头(转小写、去空格)
|
||
|
||
### 10.3 getCol
|
||
|
||
```go
|
||
func getCol(record []string, col map[string]int, name string) string {
|
||
if idx, ok := col[name]; ok && idx < len(record) {
|
||
return strings.TrimSpace(record[idx])
|
||
}
|
||
return ""
|
||
}
|
||
```
|
||
|
||
**功能**:从 CSV 记录中获取指定列的值
|
||
|
||
### 10.4 truncate
|
||
|
||
```go
|
||
func truncate(s string, n int) string {
|
||
runes := []rune(s)
|
||
if len(runes) <= n {
|
||
return s
|
||
}
|
||
return string(runes[:n]) + "..."
|
||
}
|
||
```
|
||
|
||
**功能**:截断字符串到指定长度,超出部分用 `...` 表示
|
||
|
||
**注意**:使用 `[]rune` 处理,可以正确处理中文等多字节字符
|
||
|
||
---
|
||
|
||
## 十一、Go 语言知识点
|
||
|
||
### 1. sync.Map
|
||
|
||
```go
|
||
var issueTagCache sync.Map
|
||
|
||
// 读取
|
||
if cached, ok := issueTagCache.Load(key); ok {
|
||
return cached.(map[string]int), nil
|
||
}
|
||
|
||
// 写入
|
||
issueTagCache.Store(key, tags)
|
||
```
|
||
|
||
**作用**:并发安全的 map,用于多个 goroutine 同时读写
|
||
|
||
### 2. strings.Builder
|
||
|
||
```go
|
||
var b strings.Builder
|
||
b.WriteString("## Bug 描述\n")
|
||
b.WriteString(input.Title)
|
||
return b.String()
|
||
```
|
||
|
||
**作用**:高效拼接字符串,避免产生大量临时字符串
|
||
|
||
### 3. []interface{}
|
||
|
||
```go
|
||
body["issue_tag_ids"] = []interface{}{tags["缺陷"]}
|
||
```
|
||
|
||
**作用**:创建一个包含任意类型的数组,用于 JSON 序列化
|
||
|
||
### 4. switch 类型断言
|
||
|
||
```go
|
||
switch v := tag["id"].(type) {
|
||
case float64:
|
||
id = int(v)
|
||
case int:
|
||
id = v
|
||
default:
|
||
id, _ = strconv.Atoi(fmt.Sprintf("%v", v))
|
||
}
|
||
```
|
||
|
||
**作用**:根据值的实际类型执行不同的处理逻辑
|
||
|
||
### 5. 可变参数
|
||
|
||
```go
|
||
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
|
||
```
|
||
|
||
`...` 表示把切片展开成多个参数 |