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

546 lines
13 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.go面向 Go 小白)
## 文件概述
这个文件实现了 **Issue 批量操作**功能,可以对多个 Issue 进行批量关闭、修改状态、修改优先级、分配人和修改标签等操作。
---
## 一、包声明和导入
```go
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` | 公共工具包 |
---
## 二、常量定义
```go
const (
priorityLow = 1
priorityNormal = 2
priorityHigh = 3
priorityUrgent = 4
)
```
**优先级常量**:定义了 Issue 优先级对应的数字 ID
```go
const (
statusNew = 1
statusInProgress = 2
statusResolved = 3
statusClosed = 5
statusRejected = 6
)
```
**状态常量**:定义了 Issue 状态对应的数字 ID
```go
const (
trackerBug = 1
trackerFeature = 2
trackerSupport = 3
trackerDoc = 4
trackerTest = 5
trackerDuplicate = 6
trackerQuestion = 7
)
```
**类型常量**:定义了 Issue 类型对应的数字 ID
---
## 三、名称映射表
```go
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 映射
```go
var tagIDs = map[string]int{
"缺陷": 315526,
"功能": 315527,
"文档": 315533,
"重复": 315525,
"疑问": 315528,
"支持": 315529,
"任务": 315530,
"测试": 315534,
"协助": 315531,
"搁置": 315532,
}
```
**作用**:中文标签名称到 GitLink 标签 ID 的映射
**注意**:这些 ID 是从网页端 DevTools 抓包获取的,不同项目可能不同
---
## 五、结果结构体
```go
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 的操作结果
```go
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 命令
```go
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,
}
}
```
**执行函数**
```go
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 命令
```go
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 命令
```go
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 命令
```go
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 命令
```go
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
```go
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. 必须在请求体中包含 `subject``description`,否则会被清空
3. 把要更新的字段合并到 body 中
4. 发送 PATCH 请求
---
### 7.2 resolveUserID
```go
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. 从响应中提取 `id``user_id` 字段
4. API 返回的数字是 float64 类型,需要转换成 int
---
### 7.3 collectIssueNumbers
```go
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
```go
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 编号
**智能表头识别**
- 自动识别 `number`、`issue_number`、`project_issues_index` 列
- 如果没有匹配的表头,默认使用第一列
- 跳过表头行,从第二行开始读取
---
## 八、类型转换函数
```go
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-progress`、`in_progress`、`inprogress`
- 如果输入是数字,直接返回
`parsePriority``parseTracker` 函数类似
---
## 九、Go 语言知识点
### 1. const 常量定义
```go
const (
priorityLow = 1
priorityNormal = 2
)
```
`const` 块中后续常量会继承前一个常量的值并自动加1
### 2. defer 语句
```go
file, err := os.Open(path)
defer file.Close()
```
`defer` 会在函数返回前执行,确保文件被关闭
### 3. map 遍历
```go
for k, v := range fields {
body[k] = v
}
```
遍历 map 的键值对
### 4. type assertion类型断言
```go
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response")
}
```
把接口类型转换成具体类型,`ok` 表示转换是否成功
### 5. strconv.Atoi
```go
id, err := strconv.Atoi(login)
```
把字符串转换成整数,如果失败返回错误