forked from Gitlink/gitlink-cli
546 lines
13 KiB
Markdown
546 lines
13 KiB
Markdown
# 逐行讲解 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)
|
||
```
|
||
|
||
把字符串转换成整数,如果失败返回错误 |