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

16 KiB
Raw Blame History

逐行讲解 shortcuts/issue/batch_create.go面向 Go 小白)

文件概述

这个文件实现了 Issue 批量创建功能,可以从命令行或 CSV 文件批量创建多个 Issue并支持 bug 和 feature 两种模板。


一、包声明和导入

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 公共工具包

二、标签缓存

var issueTagCache sync.Map

作用:缓存项目的标签列表,避免重复请求 API

sync.MapGo 语言提供的并发安全的 map可以在多个 goroutine 中安全地读写


三、resolveIssueTags 函数

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. 返回标签映射

四、命令定义

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:初始状态
  • --fromCSV 文件路径
  • --template/-t模板类型bug/feature
  • --dry-run:预览模式

五、输入结构体

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 的所有输入参数

模板专用字段

  • VersionSeverityStepsExpectedActual:用于 bug 模板
  • UserStoryAcceptance:用于 feature 模板

六、runBatchCreate 主函数

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 函数

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

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

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 描述

输出格式

## Bug 描述
标题内容

## 版本
v1.0.0

## 严重程度
## 复现步骤
步骤1
步骤2

## 期望结果
期望的行为

## 实际结果
实际的行为

8.3 buildFeatureDescription

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 描述

输出格式

## 用户故事
作为用户,我想...

## 描述
详细描述

## 验收标准
- 标准1
- 标准2

## 优先级
high

九、CSV 读取

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

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

func normalizeHeader(h string) string {
    return strings.ToLower(strings.TrimSpace(h))
}

功能:标准化 CSV 表头(转小写、去空格)

10.3 getCol

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

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

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

var b strings.Builder
b.WriteString("## Bug 描述\n")
b.WriteString(input.Title)
return b.String()

作用:高效拼接字符串,避免产生大量临时字符串

3. []interface

body["issue_tag_ids"] = []interface{}{tags["缺陷"]}

作用:创建一个包含任意类型的数组,用于 JSON 序列化

4. switch 类型断言

switch v := tag["id"].(type) {
case float64:
    id = int(v)
case int:
    id = v
default:
    id, _ = strconv.Atoi(fmt.Sprintf("%v", v))
}

作用:根据值的实际类型执行不同的处理逻辑

5. 可变参数

inputs = append(inputs, parseTitles(titlesStr, ctx)...)

... 表示把切片展开成多个参数