gitlink-cli/shortcuts/issue/batch.go

896 lines
27 KiB
Go
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.

package issue
import (
"encoding/csv" // CSV 文件解析,用于从文件读取 Issue 编号
"fmt" // 格式化输出,用于构建字符串和错误信息
"os" // 文件操作,用于打开 CSV 文件
"strconv" // 字符串和数字之间的转换
"strings" // 字符串处理,用于分割、修剪等操作
"github.com/gitlink-org/gitlink-cli/shortcuts/common" // 公共工具包,包含 Shortcut、RuntimeContext 等
)
// === 优先级常量 ===
// priorityLow: 低优先级
// priorityNormal: 普通优先级(默认)
// priorityHigh: 高优先级
// priorityUrgent: 紧急优先级
const (
priorityLow = 1
priorityNormal = 2
priorityHigh = 3
priorityUrgent = 4
)
// === 状态常量 ===
// statusNew: 新建
// statusInProgress: 进行中
// statusResolved: 已解决
// statusClosed: 已关闭
// statusRejected: 已拒绝
const (
statusNew = 1
statusInProgress = 2
statusResolved = 3
statusClosed = 5
statusRejected = 6
)
// === 类型常量Tracker===
// trackerBug: 缺陷
// trackerFeature: 功能
// trackerSupport: 支持
// trackerDoc: 文档
// trackerTest: 测试
// trackerDuplicate: 重复
// trackerQuestion: 疑问
const (
trackerBug = 1
trackerFeature = 2
trackerSupport = 3
trackerDoc = 4
trackerTest = 5
trackerDuplicate = 6
trackerQuestion = 7
)
// === 名称映射表 ===
// priorityNames: 优先级数字 ID → 英文名称
// statusNames: 状态数字 ID → 英文名称
// trackerNames: 类型数字 ID → 英文名称
// 作用:把数字 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",
trackerSupport: "support",
trackerDoc: "doc",
trackerTest: "test",
trackerDuplicate: "duplicate",
trackerQuestion: "question",
}
// === 标签 ID 映射 ===
// tagIDs: 中文标签名称 → GitLink 标签 ID
// 获取方式:从网页端 DevTools 抓包获取(修改标签 → 捕获 PATCH 请求体 → 获取 issue_tag_ids 值)
// 注意:这些 ID 是项目特定的,不同项目可能不同
var tagIDs = map[string]int{
"缺陷": 315526,
"功能": 315527,
"文档": 315533,
"重复": 315525,
"疑问": 315528,
"支持": 315529,
"任务": 315530,
"测试": 315534,
"协助": 315531,
"搁置": 315532,
}
// labelNames 返回所有已知的标签名称(逗号分隔)
// 参数: tags - 标签名称到 ID 的映射
// 返回: 所有标签名称的字符串,用逗号分隔
func labelNames(tags map[string]int) string {
var names []string
for name := range tags {
names = append(names, name)
}
return strings.Join(names, ", ")
}
// === 结果结构体 ===
// BatchResult 表示单个 Issue 的操作结果
type BatchResult struct {
Number string `json:"number" yaml:"number"` // Issue 编号
Action string `json:"action" yaml:"action"` // 操作类型close/set-status/set-priority/set-assignee/set-label
Status string `json:"status" yaml:"status"` // 操作状态planned预览/closed已关闭/failed失败
Error string `json:"error,omitempty" yaml:"error,omitempty"` // 错误信息(失败时)
}
// BatchSummary 表示批量操作的汇总报告
type BatchSummary struct {
Repository string `json:"repository" yaml:"repository"` // 仓库名称owner/repo
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"` // 所有操作结果列表
}
// === batch-close 命令:批量关闭 Issue ===
// newBatchCloseShortcut 创建 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, e.g. 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
},
Run: runBatchClose,
}
}
// runBatchClose 执行批量关闭操作
// 执行流程:
// 1. 解析仓库信息
// 2. 收集 Issue 编号(从 --numbers 参数或 CSV 文件)
// 3. 初始化 BatchSummary 汇总对象
// 4. 遍历每个 Issue 编号:
// - 如果是 dry-run直接标记为 planned
// - 否则调用 updateIssueField 更新状态为 closed
// 5. 输出汇总结果
func runBatchClose(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2收集 Issue 编号(支持 --numbers 参数和 --from CSV 文件)
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; use --numbers 1,2,3 or --from issues.csv")
}
// 步骤3初始化汇总对象
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)),
}
// 步骤4遍历处理每个 Issue
for _, number := range numbers {
result := BatchResult{Number: number, Action: "close"}
if dryRun {
// 预览模式:不实际操作,只标记为 planned
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
// 实际操作:调用 updateIssueField 更新状态为 closed
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)
}
// 步骤5输出汇总结果
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
}
// === batch-status 命令:批量修改 Issue 状态 ===
// newBatchStatusShortcut 创建 batch-status 命令
func newBatchStatusShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-status",
Description: "Change status for multiple issues",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Target state: new, in-progress, resolved, closed, rejected", Required: true},
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
},
Run: runBatchStatus,
}
}
// runBatchStatus 执行批量修改状态操作
// 参数 --state 指定目标状态new/in-progress/resolved/closed/rejected
func runBatchStatus(ctx *common.RuntimeContext) error {
// 步骤1解析仓库信息
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 步骤2获取状态参数并转换为数字 ID
state := ctx.Arg("state")
statusID, err := parseStatus(state)
if err != nil {
return err
}
// 步骤3收集 Issue 编号
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; use --numbers 1,2,3 or --from issues.csv")
}
// 步骤4初始化汇总对象
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "set-status",
Value: state,
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
// 步骤5遍历处理每个 Issue
for _, number := range numbers {
result := BatchResult{Number: number, Action: "set-status"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
// 调用 updateIssueField 更新状态
if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusID}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = state
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
// 步骤6输出汇总结果
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
}
// === batch-priority 命令:批量修改 Issue 优先级 ===
// newBatchPriorityShortcut 创建 batch-priority 命令
func newBatchPriorityShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-priority",
Description: "Change priority for multiple issues",
Flags: []common.Flag{
{Name: "priority", Short: "p", Usage: "Target priority: low, normal, high, urgent", Required: true},
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
},
Run: runBatchPriority,
}
}
// runBatchPriority 执行批量修改优先级操作
// 参数 --priority 指定目标优先级low/normal/high/urgent
func runBatchPriority(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 获取优先级参数并转换为数字 ID
priority := ctx.Arg("priority")
priorityID, err := parsePriority(priority)
if 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; use --numbers 1,2,3 or --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "set-priority",
Value: priority,
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := BatchResult{Number: number, Action: "set-priority"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
// 调用 updateIssueField 更新优先级
if err := updateIssueField(ctx, number, map[string]interface{}{"priority_id": priorityID}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = priority
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
}
// === batch-assign 命令:批量分配 Issue ===
// newBatchAssignShortcut 创建 batch-assign 命令
func newBatchAssignShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-assign",
Description: "Change assignee for multiple issues",
Flags: []common.Flag{
{Name: "assignee", Short: "a", Usage: "Assignee login name or user ID", Required: true},
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
},
Run: runBatchAssign,
}
}
// runBatchAssign 执行批量分配操作
// 亮点:需要先把用户名转换成用户 ID通过 API 查询)
func runBatchAssign(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
assignee := ctx.Arg("assignee")
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; use --numbers 1,2,3 or --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "set-assignee",
Value: assignee,
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
// 非预览模式下,先解析用户 ID
var assigneeID interface{}
if !dryRun {
id, err := resolveUserID(ctx, assignee)
if err != nil {
return fmt.Errorf("cannot resolve assignee %q: %w", assignee, err)
}
assigneeID = id
}
for _, number := range numbers {
result := BatchResult{Number: number, Action: "set-assignee"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
// 调用 updateIssueField 分配用户
if err := updateIssueField(ctx, number, map[string]interface{}{"assigner_ids": []interface{}{assigneeID}}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "assigned"
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
}
// === batch-label 命令:批量修改 Issue 标签 ===
// newBatchLabelShortcut 创建 batch-label 命令
func newBatchLabelShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-label",
Description: "Change tracker label for multiple issues",
Flags: []common.Flag{
{Name: "label", Short: "l", Usage: "Target label: bug, feature, support, doc, test, duplicate, question, or Chinese names (缺陷/功能/文档/重复/疑问/支持/任务/测试/协助/搁置)", Required: true},
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
},
Run: runBatchLabel,
}
}
// runBatchLabel 执行批量修改标签操作
// 参数 --label 可以是英文bug/feature或中文缺陷/功能)
func runBatchLabel(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// 获取标签参数并转换为数字 ID
label := ctx.Arg("label")
trackerID, err := parseTracker(label)
if 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; use --numbers 1,2,3 or --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "set-label",
Value: label,
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := BatchResult{Number: number, Action: "set-label"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
// 调用 updateIssueField 修改标签issue_tag_ids 是数组)
if err := updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []int{trackerID}}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = label
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
}
// === batch-destroy 命令:批量删除 Issue ===
// newBatchDestroyShortcut 创建 batch-destroy 命令
func newBatchDestroyShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-destroy",
Description: "Delete multiple issues by issue numbers or a CSV file",
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
},
Run: runBatchDestroy,
}
}
// runBatchDestroy 执行批量删除操作
// 使用 GitLink 原生批量删除接口 DELETE /v1/{owner}/{repo}/issues/batch_destroy
// body: {"ids": [1, 2, 3]}
func runBatchDestroy(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; use --numbers 1,2,3 or --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "destroy",
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
if dryRun {
for _, number := range numbers {
summary.Results = append(summary.Results, BatchResult{Number: number, Action: "destroy", Status: "planned"})
summary.Succeeded++
}
} else {
// 构建 ids 数组
ids := make([]int, 0, len(numbers))
for _, number := range numbers {
id, err := strconv.Atoi(number)
if err != nil {
return fmt.Errorf("invalid issue number %q: %w", number, err)
}
ids = append(ids, id)
}
// 调用原生批量删除接口
body := map[string]interface{}{"ids": ids}
if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/batch_destroy", v1RepoPath(ctx)), body); err != nil {
// 整体失败,标记所有为 failed
for _, number := range numbers {
summary.Results = append(summary.Results, BatchResult{Number: number, Action: "destroy", Status: "failed", Error: err.Error()})
summary.Failed++
}
} else {
for _, number := range numbers {
summary.Results = append(summary.Results, BatchResult{Number: number, Action: "destroy", Status: "deleted"})
summary.Succeeded++
}
}
}
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
}
// === 共享辅助函数 ===
// updateIssueField 更新 Issue 的指定字段
// 关键点:
// 1. 先调用 fetchExistingIssue 获取当前 Issue 的标题和描述
// 2. 必须在请求体中包含 subject 和 description否则会被清空
// 3. 把要更新的字段合并到 body 中
// 4. 发送 PATCH 请求
func updateIssueField(ctx *common.RuntimeContext, number string, fields map[string]interface{}) error {
// 获取当前 Issue 的标题和描述(避免更新时丢失)
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
}
// 发送 PATCH 请求更新 Issue
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
}
// resolveUserID 把用户名转换成用户 ID
// 工作原理:
// 1. 如果输入已经是数字,直接返回
// 2. 否则调用 /users/{login} API 获取用户信息
// 3. 从响应中提取 id 或 user_id 字段
// 4. API 返回的数字是 float64 类型,需要转换成 int
func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error) {
// 如果输入是数字,直接返回
if id, err := strconv.Atoi(login); err == nil {
return id, nil
}
// 调用 API 获取用户信息
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 转换为 map[string]interface{}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response for user %q", login)
}
// 尝试提取 id 字段
idFloat, ok := data["id"].(float64)
if ok {
return int(idFloat), nil
}
// 尝试提取 user_id 字段
userIDFloat, ok := data["user_id"].(float64)
if ok {
return int(userIDFloat), nil
}
return nil, fmt.Errorf("cannot determine user ID for %q", login)
}
// parseStatus 把用户输入的状态字符串转换成数字 ID
// 支持多种写法in-progress、in_progress、inprogress
// 如果输入是数字,直接返回
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
case "resolved":
return statusResolved, nil
case "closed":
return statusClosed, nil
case "rejected":
return statusRejected, nil
default:
if id, err := strconv.Atoi(state); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid state %q: use new, in-progress, resolved, closed, or rejected", state)
}
}
// parsePriority 把用户输入的优先级字符串转换成数字 ID
func parsePriority(p string) (int, error) {
switch strings.ToLower(strings.TrimSpace(p)) {
case "low":
return priorityLow, nil
case "normal":
return priorityNormal, nil
case "high":
return priorityHigh, nil
case "urgent":
return priorityUrgent, nil
default:
if id, err := strconv.Atoi(p); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid priority %q: use low, normal, high, or urgent", p)
}
}
// parseTracker 把用户输入的标签字符串转换成数字 ID
// 支持中英文标签:
// - 中文:缺陷/功能/文档/重复/疑问/支持/任务/测试/协助/搁置
// - 英文bug/feature/support/doc/test/duplicate/question
func parseTracker(label string) (int, error) {
trimmed := strings.TrimSpace(label)
// 先检查中文标签名称
if id, ok := tagIDs[trimmed]; ok {
return id, nil
}
// 再检查英文标签名称
switch strings.ToLower(trimmed) {
case "bug":
return trackerBug, nil
case "feature":
return trackerFeature, nil
case "support":
return trackerSupport, nil
case "doc":
return trackerDoc, nil
case "test":
return trackerTest, nil
case "duplicate":
return trackerDuplicate, nil
case "question":
return trackerQuestion, nil
default:
if id, err := strconv.Atoi(label); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid label %q: use bug, feature, support, doc, test, duplicate, question, or Chinese names (%s)", label, labelNames(tagIDs))
}
}
// parseLabel 根据项目的标签映射表,把标签名称转换成 GitLink 标签 ID
// 参数: name - 标签名称; tags - 项目的名称→ID 映射
func parseLabel(name string, tags map[string]int) (int, error) {
if id, ok := tags[name]; ok && id != 0 {
return id, nil
}
if id, err := strconv.Atoi(name); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid label %q: not found in project issue tags", name)
}
// collectIssueNumbers 从 --numbers 参数和 CSV 文件中收集 Issue 编号
// 参数: numbersValue - --numbers 参数的值; csvPath - CSV 文件路径
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
}
// parseIssueNumbers 解析逗号分隔的 Issue 编号字符串
func parseIssueNumbers(value string) ([]string, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
return normalizeIssueNumbers(strings.Split(value, ","))
}
// readIssueNumbersFromCSV 从 CSV 文件读取 Issue 编号
// 智能表头识别:
// - 自动识别 number、issue_number、project_issues_index 列
// - 如果没有匹配的表头,默认使用第一列
// - 跳过表头行,从第二行开始读取
func readIssueNumbersFromCSV(path string) ([]string, error) {
// 打开文件defer 确保函数返回前关闭文件)
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV: %w", err)
}
defer file.Close()
// 创建 CSV 阅读器
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) == 0 {
return nil, nil
}
// 智能识别表头:查找 number 列
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 // 没有找到表头,默认使用第一列
}
// 提取 Issue 编号
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)
}
// normalizeIssueNumbers 规范化 Issue 编号列表
// 功能:去重、验证格式、过滤空值
func normalizeIssueNumbers(values []string) ([]string, error) {
numbers := make([]string, 0, len(values))
seen := map[string]bool{} // 用于去重
for _, value := range values {
number := strings.TrimSpace(value)
if number == "" {
continue // 跳过空值
}
// 验证是否是有效的整数
if _, err := strconv.ParseInt(number, 10, 64); err != nil {
return nil, fmt.Errorf("invalid issue number %q: must be an integer", number)
}
if seen[number] {
continue // 跳过重复值
}
seen[number] = true
numbers = append(numbers, number)
}
return numbers, nil
}
// mergeIssueNumbers 合并多个 Issue 编号列表(去重)
func mergeIssueNumbers(values ...[]string) []string {
merged := []string{}
seen := map[string]bool{}
for _, numbers := range values {
for _, number := range numbers {
if seen[number] {
continue
}
seen[number] = true
merged = append(merged, number)
}
}
return merged
}
// parseBool 解析布尔值字符串
// 返回 true 的条件:字符串解析成功且值为 true
func parseBool(value string) bool {
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && parsed
}