gitlink-cli/doc/reading_notes/05_issue_batch.md

13 KiB
Raw Permalink Blame History

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

文件概述

这个文件实现了 Issue 批量操作功能,可以对多个 Issue 进行批量关闭、修改状态、修改优先级、分配人和修改标签等操作。


一、包声明和导入

package issue

import (
    "encoding/csv"
    "fmt"
    "os"
    "strconv"
    "strings"

    "github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
导入库 作用
encoding/csv CSV 文件解析,用于从文件读取 Issue 编号
fmt 格式化输出
os 文件操作,用于打开 CSV 文件
strconv 字符串和数字之间的转换
strings 字符串处理
common 公共工具包

二、常量定义

const (
    priorityLow    = 1
    priorityNormal = 2
    priorityHigh   = 3
    priorityUrgent = 4
)

优先级常量:定义了 Issue 优先级对应的数字 ID

const (
    statusNew        = 1
    statusInProgress = 2
    statusResolved   = 3
    statusClosed     = 5
    statusRejected   = 6
)

状态常量:定义了 Issue 状态对应的数字 ID

const (
    trackerBug       = 1
    trackerFeature   = 2
    trackerSupport   = 3
    trackerDoc       = 4
    trackerTest      = 5
    trackerDuplicate = 6
    trackerQuestion  = 7
)

类型常量:定义了 Issue 类型对应的数字 ID


三、名称映射表

var priorityNames = map[int]string{
    priorityLow:    "low",
    priorityNormal: "normal",
    priorityHigh:   "high",
    priorityUrgent: "urgent",
}

var statusNames = map[int]string{
    statusNew:        "new",
    statusInProgress: "in-progress",
    statusResolved:   "resolved",
    statusClosed:     "closed",
    statusRejected:   "rejected",
}

var trackerNames = map[int]string{
    trackerBug:       "bug",
    trackerFeature:   "feature",
    // ...
}

作用:把数字 ID 转换成可读的英文名称,方便输出结果


四、标签 ID 映射

var tagIDs = map[string]int{
    "缺陷": 315526,
    "功能": 315527,
    "文档": 315533,
    "重复": 315525,
    "疑问": 315528,
    "支持": 315529,
    "任务": 315530,
    "测试": 315534,
    "协助": 315531,
    "搁置": 315532,
}

作用:中文标签名称到 GitLink 标签 ID 的映射

注意:这些 ID 是从网页端 DevTools 抓包获取的,不同项目可能不同


五、结果结构体

type BatchResult struct {
    Number string `json:"number" yaml:"number"`
    Action string `json:"action" yaml:"action"`
    Status string `json:"status" yaml:"status"`
    Error  string `json:"error,omitempty" yaml:"error,omitempty"`
}

单个操作结果:记录单个 Issue 的操作结果

type BatchSummary struct {
    Repository string        `json:"repository" yaml:"repository"`
    Action     string        `json:"action" yaml:"action"`
    Value      string        `json:"value,omitempty" yaml:"value,omitempty"`
    DryRun     bool          `json:"dry_run" yaml:"dry_run"`
    Total      int           `json:"total" yaml:"total"`
    Succeeded  int           `json:"succeeded" yaml:"succeeded"`
    Failed     int           `json:"failed" yaml:"failed"`
    Results    []BatchResult `json:"results" yaml:"results"`
}

批量操作汇总:记录整个批量操作的统计信息


六、批量操作命令

6.1 batch-close 命令

func newBatchCloseShortcut() *common.Shortcut {
    return &common.Shortcut{
        Name:        "batch-close",
        Description: "Close multiple issues by issue numbers or a CSV file",
        Flags: []common.Flag{
            {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers"},
            {Name: "from", Usage: "Read issue numbers from a CSV file"},
            {Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
        },
        Run: runBatchClose,
    }
}

执行函数

func runBatchClose(ctx *common.RuntimeContext) error {
    if err := ctx.ResolveOwnerRepo(); err != nil {
        return err
    }
    numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
    if err != nil {
        return err
    }
    if len(numbers) == 0 {
        return fmt.Errorf("no issue numbers provided")
    }

    dryRun := parseBool(ctx.Arg("dry-run"))
    summary := BatchSummary{
        Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
        Action:     "close",
        DryRun:     dryRun,
        Total:      len(numbers),
        Results:    make([]BatchResult, 0, len(numbers)),
    }

    for _, number := range numbers {
        result := BatchResult{Number: number, Action: "close"}
        if dryRun {
            result.Status = "planned"
            summary.Succeeded++
            summary.Results = append(summary.Results, result)
            continue
        }
        if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusClosed}); err != nil {
            result.Status = "failed"
            result.Error = err.Error()
            summary.Failed++
        } else {
            result.Status = "closed"
            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", summary.Failed, summary.Total)
    }
    return nil
}

执行流程

  1. 解析仓库信息
  2. 收集 Issue 编号(从 --numbers 参数或 CSV 文件)
  3. 初始化 BatchSummary 汇总对象
  4. 遍历每个 Issue 编号:
    • 如果是 dry-run直接标记为 planned
    • 否则调用 updateIssueField 更新状态为 closed
  5. 输出汇总结果

6.2 batch-status 命令

func runBatchStatus(ctx *common.RuntimeContext) error {
    // ... 解析参数
    state := ctx.Arg("state")
    statusID, err := parseStatus(state)
    if err != nil {
        return err
    }
    // ... 遍历更新
    updateIssueField(ctx, number, map[string]interface{}{"status_id": statusID})
}

功能:批量修改 Issue 状态

参数--state 指定目标状态new/in-progress/resolved/closed/rejected


6.3 batch-priority 命令

func runBatchPriority(ctx *common.RuntimeContext) error {
    // ...
    priority := ctx.Arg("priority")
    priorityID, err := parsePriority(priority)
    // ...
    updateIssueField(ctx, number, map[string]interface{}{"priority_id": priorityID})
}

功能:批量修改 Issue 优先级

参数--priority 指定目标优先级low/normal/high/urgent


6.4 batch-assign 命令

func runBatchAssign(ctx *common.RuntimeContext) error {
    // ...
    assignee := ctx.Arg("assignee")
    var assigneeID interface{}
    if !dryRun {
        id, err := resolveUserID(ctx, assignee)
        assigneeID = id
    }
    // ...
    updateIssueField(ctx, number, map[string]interface{}{"assigned_to_id": assigneeID})
}

功能:批量分配 Issue 给指定用户

亮点:需要先把用户名转换成用户 ID


6.5 batch-label 命令

func runBatchLabel(ctx *common.RuntimeContext) error {
    // ...
    label := ctx.Arg("label")
    trackerID, err := parseTracker(label)
    // ...
    updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []int{trackerID}})
}

功能:批量修改 Issue 的标签

参数--label 可以是英文bug/feature或中文缺陷/功能)


七、核心辅助函数

7.1 updateIssueField

func updateIssueField(ctx *common.RuntimeContext, number string, fields map[string]interface{}) error {
    current, err := fetchExistingIssue(ctx, number)
    if err != nil {
        return fmt.Errorf("fetch issue #%s: %w", number, err)
    }

    body := map[string]interface{}{
        "subject":     current.Subject,
        "description": current.Description,
    }
    for k, v := range fields {
        body[k] = v
    }

    if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
        return fmt.Errorf("update issue #%s: %w", number, err)
    }
    return nil
}

功能:更新 Issue 的指定字段

关键点

  1. 先调用 fetchExistingIssue 获取当前 Issue 的标题和描述
  2. 必须在请求体中包含 subjectdescription,否则会被清空
  3. 把要更新的字段合并到 body 中
  4. 发送 PATCH 请求

7.2 resolveUserID

func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error) {
    if id, err := strconv.Atoi(login); err == nil {
        return id, nil
    }

    env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
    if err != nil {
        return nil, fmt.Errorf("lookup user %q: %w", login, err)
    }
    data, ok := env.Data.(map[string]interface{})
    if !ok {
        return nil, fmt.Errorf("unexpected response for user %q", login)
    }
    idFloat, ok := data["id"].(float64)
    if ok {
        return int(idFloat), nil
    }
    userIDFloat, ok := data["user_id"].(float64)
    if ok {
        return int(userIDFloat), nil
    }
    return nil, fmt.Errorf("cannot determine user ID for %q", login)
}

功能:把用户名转换成用户 ID

工作原理

  1. 如果输入已经是数字,直接返回
  2. 否则调用 /users/{login} API 获取用户信息
  3. 从响应中提取 iduser_id 字段
  4. API 返回的数字是 float64 类型,需要转换成 int

7.3 collectIssueNumbers

func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
    numbers, err := parseIssueNumbers(numbersValue)
    if err != nil {
        return nil, err
    }
    if csvPath == "" {
        return numbers, nil
    }
    csvNumbers, err := readIssueNumbersFromCSV(csvPath)
    if err != nil {
        return nil, err
    }
    return mergeIssueNumbers(numbers, csvNumbers), nil
}

功能:从 --numbers 参数和 CSV 文件中收集 Issue 编号


7.4 readIssueNumbersFromCSV

func readIssueNumbersFromCSV(path string) ([]string, 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)
    }

    numberColumn := -1
    startRow := 0
    for i, cell := range records[0] {
        switch strings.ToLower(strings.TrimSpace(cell)) {
        case "number", "issue_number", "project_issues_index":
            numberColumn = i
            startRow = 1
        }
    }
    if numberColumn == -1 {
        numberColumn = 0
    }

    values := make([]string, 0, len(records)-startRow)
    for _, record := range records[startRow:] {
        if numberColumn >= len(record) {
            continue
        }
        values = append(values, record[numberColumn])
    }
    return normalizeIssueNumbers(values)
}

功能:从 CSV 文件读取 Issue 编号

智能表头识别

  • 自动识别 numberissue_numberproject_issues_index
  • 如果没有匹配的表头,默认使用第一列
  • 跳过表头行,从第二行开始读取

八、类型转换函数

func parseStatus(state string) (int, error) {
    switch strings.ToLower(strings.TrimSpace(state)) {
    case "new":
        return statusNew, nil
    case "in-progress", "in_progress", "inprogress":
        return statusInProgress, nil
    // ...
    default:
        if id, err := strconv.Atoi(state); err == nil {
            return id, nil
        }
        return 0, fmt.Errorf("invalid state %q", state)
    }
}

功能:把用户输入的状态字符串转换成数字 ID

容错处理

  • 支持多种写法:in-progressin_progressinprogress
  • 如果输入是数字,直接返回

parsePriorityparseTracker 函数类似


九、Go 语言知识点

1. const 常量定义

const (
    priorityLow    = 1
    priorityNormal = 2
)

const 块中后续常量会继承前一个常量的值并自动加1

2. defer 语句

file, err := os.Open(path)
defer file.Close()

defer 会在函数返回前执行,确保文件被关闭

3. map 遍历

for k, v := range fields {
    body[k] = v
}

遍历 map 的键值对

4. type assertion类型断言

data, ok := env.Data.(map[string]interface{})
if !ok {
    return nil, fmt.Errorf("unexpected response")
}

把接口类型转换成具体类型,ok 表示转换是否成功

5. strconv.Atoi

id, err := strconv.Atoi(login)

把字符串转换成整数,如果失败返回错误