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:
parent
9749a4c832
commit
f986634498
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
tr := shortcutTranslator(translators...)
|
||||
return []*common.Shortcut{
|
||||
newBatchCloseShortcut(),
|
||||
newBatchReopenShortcut(),
|
||||
newBatchCommentShortcut(),
|
||||
newBatchUpdateShortcut(),
|
||||
newBatchDeleteShortcut(),
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue