forked from Gitlink/gitlink-cli
896 lines
27 KiB
Go
896 lines
27 KiB
Go
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
|
||
}
|