feat(issue): add batch-reopen and batch-comment shortcuts

在现有 batch-close/update/delete 基础上补全 Issue 批量操作:

- issue +batch-reopen  批量重新打开已关闭的 Issue
- issue +batch-comment 批量为多个 Issue 添加评论

两者均复用现有批量基础设施(collectIssueNumbers / parseBool /
fetchExistingIssue / batchCloseSummary),支持 --numbers、--from CSV、
--dry-run 预演,与 batch-close 行为一致。

reopenIssue 复用 fetchExistingIssue 保留 subject/description 后将
status_id 置为 openIssueStatusID(1);commentIssue 向
/v1/.../issues/{n}/journals POST notes。

含单元测试(dry-run 路径 + 必填参数校验)。
This commit is contained in:
chroe 2026-07-09 21:49:27 +08:00 committed by chroe
parent 9749a4c832
commit f986634498
4 changed files with 287 additions and 0 deletions

View File

@ -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
```

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -45,6 +45,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
return []*common.Shortcut{
newBatchCloseShortcut(),
newBatchReopenShortcut(),
newBatchCommentShortcut(),
newBatchUpdateShortcut(),
newBatchDeleteShortcut(),
{