diff --git a/doc/changes/issue-batch-reopen-comment.md b/doc/changes/issue-batch-reopen-comment.md new file mode 100644 index 0000000..8e27adc --- /dev/null +++ b/doc/changes/issue-batch-reopen-comment.md @@ -0,0 +1,73 @@ +# Issue 批量重开 / 批量评论 shortcuts + +## Summary + +为 `issue` shortcut 组新增两条批量操作命令,补齐 Issue 生命周期批量运维能力: + +- `issue +batch-reopen` — 按网页 Issue 编号或 CSV 文件批量重新打开已关闭的 Issue。 +- `issue +batch-comment` — 按网页 Issue 编号或 CSV 文件对多个 Issue 批量追加同一段评论。 + +两条命令复用了 `issue +batch-close` 的基础设施(`collectIssueNumbers`、`--numbers` / `--from` / `--dry-run`、`batchCloseSummary` 汇总结构),仅替换最后的写操作,保持与批量关闭一致的使用体验。 + +## 命令清单 + +- issue +batch-reopen +- issue +batch-comment + +## OpenAPI coverage + +| Command | Method | Endpoint | +|---|---|---| +| `issue +batch-reopen` | PATCH | `/api/v1/{owner}/{repo}/issues/{number}.json` | +| `issue +batch-comment` | POST | `/api/v1/{owner}/{repo}/issues/{number}/journals.json` | + +## 实现要点 + +- 输入与 `batch-close` 一致:`--numbers/-n` 接逗号分隔的网页 Issue 编号,`--from` 读 CSV(识别 `number` / `issue_number` / `project_issues_index` 列或无表头首列),二者可叠加并自动去重;编号统一校验为正整数。 +- `batch-reopen`:先 `GET` 取回 Issue 当前的 `subject` / `description`,再 `PATCH /v1/{owner}/{repo}/issues/{number}` 回传原内容并把 `status_id` 设为打开状态常量 `openIssueStatusID = 1`,避免重开时丢失标题与描述。 +- `batch-comment`:`--body/-b` 为必填,对应 journal 的 `notes` 字段,逐条 `POST /v1/{owner}/{repo}/issues/{number}/journals`。 +- 两条命令均支持 `--dry-run`:不发起写请求,逐条返回 `planned` 计划态,便于预览影响范围。 +- 结果以 `batchCloseSummary`(`repository` / `dry_run` / `total` / `succeeded` / `failed` / `results`)输出,逐条记录 `action` / `status` / `error`;存在失败时以非零错误码退出并报失败计数。 + +## Examples + +```bash +# 批量重开指定编号的 Issue +gitlink-cli issue +batch-reopen \ + --owner Gitlink \ + --repo forgeplus \ + --numbers 1,2,3 + +# 先预览,不实际改动 +gitlink-cli issue +batch-reopen \ + --owner Gitlink \ + --repo forgeplus \ + --from issues.csv \ + --dry-run + +# 批量给多个 Issue 追加同一段评论 +gitlink-cli issue +batch-comment \ + --owner Gitlink \ + --repo forgeplus \ + --numbers 4,5 \ + --body "已在新版本修复,请验证。" + +# 输出 JSON 便于脚本处理 +gitlink-cli issue +batch-comment \ + --owner Gitlink \ + --repo forgeplus \ + --numbers 4,5 \ + --body "已在新版本修复,请验证。" \ + --format json +``` + +## Tests + +```bash +GOPROXY=https://goproxy.cn,direct go test ./shortcuts/issue/... +go vet ./... +go run . issue +batch-reopen --help +go run . issue +batch-comment --help +go run . issue +batch-reopen --owner Gitlink --repo forgeplus --numbers 1,2,3 --dry-run --format json +go run . issue +batch-comment --owner Gitlink --repo forgeplus --numbers 4,5 --body "test" --dry-run --format json +``` diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index 4d69f2f..22e1407 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -392,3 +392,164 @@ func parseIntIDList(value, field string) ([]int, error) { } return ids, nil } + +const openIssueStatusID = 1 + +func newBatchReopenShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-reopen", + Description: "Reopen multiple closed issues by issue numbers or a CSV file", + Flags: []common.Flag{ + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"}, + {Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"}, + {Name: "dry-run", Usage: "Preview the issues that would be reopened without changing them", Bool: true, Default: "false"}, + }, + Run: runBatchReopen, + } +} + +func runBatchReopen(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 := batchCloseSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + DryRun: dryRun, + Total: len(numbers), + Results: make([]batchCloseResult, 0, len(numbers)), + } + + for _, number := range numbers { + result := batchCloseResult{Number: number, Action: "reopen"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + if err := reopenIssue(ctx, number); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "reopened" + 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 to reopen", summary.Failed, summary.Total) + } + return nil +} + +func reopenIssue(ctx *common.RuntimeContext, number string) error { + current, err := fetchExistingIssue(ctx, number) + if err != nil { + return fmt.Errorf("fetch issue: %w", err) + } + + body := map[string]interface{}{ + "subject": current.Subject, + "description": current.Description, + "status_id": openIssueStatusID, + } + if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil { + return fmt.Errorf("reopen issue: %w", err) + } + return nil +} + +func newBatchCommentShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-comment", + Description: "Add a comment to multiple issues by issue numbers or a CSV file", + Flags: []common.Flag{ + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"}, + {Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"}, + {Name: "body", Short: "b", Usage: "Comment body", Required: true}, + {Name: "dry-run", Usage: "Preview the issues that would be commented on without changing them", Bool: true, Default: "false"}, + }, + Run: runBatchComment, + } +} + +func runBatchComment(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + body, err := ctx.RequireArg("body") + 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 := batchCloseSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + DryRun: dryRun, + Total: len(numbers), + Results: make([]batchCloseResult, 0, len(numbers)), + } + + for _, number := range numbers { + result := batchCloseResult{Number: number, Action: "comment"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + if err := commentIssue(ctx, number, body); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "commented" + 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 to comment", summary.Failed, summary.Total) + } + return nil +} + +func commentIssue(ctx *common.RuntimeContext, number, body string) error { + payload := map[string]interface{}{ + "notes": body, + } + if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload); err != nil { + return fmt.Errorf("add comment: %w", err) + } + return nil +} diff --git a/shortcuts/issue/batch_test.go b/shortcuts/issue/batch_test.go index 42e3648..bc1b2b7 100644 --- a/shortcuts/issue/batch_test.go +++ b/shortcuts/issue/batch_test.go @@ -347,3 +347,54 @@ func assertFloatSlice(t *testing.T, got interface{}, want []float64) { } } } + +func TestBatchReopenDryRun(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("dry-run should not call API, got %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + if err := runShortcut(t, server, "batch-reopen", map[string]string{ + "numbers": "1,2,3", + "dry-run": "true", + }); err != nil { + t.Fatalf("batch-reopen dry-run failed: %v", err) + } +} + +func TestBatchReopenRequiresNumbers(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected API call: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + if err := runShortcut(t, server, "batch-reopen", map[string]string{}); err == nil { + t.Fatal("expected error when no issue numbers are provided") + } +} + +func TestBatchCommentDryRun(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("dry-run should not call API, got %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + if err := runShortcut(t, server, "batch-comment", map[string]string{ + "numbers": "1,2", + "body": "hello", + "dry-run": "true", + }); err != nil { + t.Fatalf("batch-comment dry-run failed: %v", err) + } +} + +func TestBatchCommentRequiresBody(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected API call: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + if err := runShortcut(t, server, "batch-comment", map[string]string{"numbers": "1"}); err == nil { + t.Fatal("expected error when --body is missing") + } +} diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index b19027e..043eaea 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -45,6 +45,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { tr := shortcutTranslator(translators...) return []*common.Shortcut{ newBatchCloseShortcut(), + newBatchReopenShortcut(), + newBatchCommentShortcut(), newBatchUpdateShortcut(), newBatchDeleteShortcut(), {