gitlink-cli/doc/reading_notes/06_issue_batch_create.md

673 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 逐行讲解 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. 构建缓存 keyowner/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)...)
```
`...` 表示把切片展开成多个参数