From 0703d3eba9a47f5bcd0728698d7c399adb8bd443 Mon Sep 17 00:00:00 2001 From: wangyue789 Date: Tue, 9 Jun 2026 21:49:22 +0800 Subject: [PATCH] feat(issue): add batch maintenance shortcuts --- README.md | 12 +- README.zh-CN.md | 12 +- doc/changes/issue-batch-maintenance.md | 68 +++++++++ shortcuts/issue/batch.go | 182 +++++++++++++++++++++++++ shortcuts/issue/batch_test.go | 134 ++++++++++++++++++ shortcuts/issue/issue.go | 2 + skills/README.md | 2 +- skills/gitlink-issue/SKILL.md | 19 ++- 8 files changed, 425 insertions(+), 6 deletions(-) create mode 100644 doc/changes/issue-batch-maintenance.md diff --git a/README.md b/README.md index 289eb16..71837ae 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | Category | Capabilities | |----------|-------------| | 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions | -| 🐛 Issue | Create, update, close, batch close, comment on issues | +| 🐛 Issue | Create, update, close, batch close/update/delete, comment on issues | | 🔖 Label | Create, list, update, delete issue labels | | 🔀 PR | Create, merge, review pull requests, view changed files | | 👥 Member | List, add, remove repository members, change roles, create and accept invite links | @@ -322,6 +322,14 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12 # Batch close issues from a CSV file gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv +# Preview batch metadata update by API issue IDs +# Note: --ids uses API issue IDs, not web URL issue numbers. +gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 --status-id 3 --priority-id 2 --dry-run + +# Destructive batch delete requires both dry-run first and --yes for real execution +gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run +gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes + # Add a comment gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed" @@ -707,7 +715,7 @@ See [skills/README.md](./skills/README.md) for details. |-------|-------------| | `gitlink-shared` | Authentication, global parameters, safety rules, API notes | | `gitlink-repo` | Repository operations (create, view, delete, fork, insights, etc.) | -| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) | +| `gitlink-issue` | Issue operations (create, update, close, batch update/delete, comment, etc.) | | `gitlink-pr` | Pull request operations (create, merge, review, etc.) | | `gitlink-member` | Repository member and invite link management | | `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) | diff --git a/README.zh-CN.md b/README.zh-CN.md index ce6f964..1661a1e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -104,7 +104,7 @@ | 分类 | 能力 | |------|------| | 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息、洞察数据和互动状态 | -| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue | +| 🐛 Issue | 创建、更新、关闭、批量关闭/更新/删除、评论 Issue | | 🔖 标签 | 创建、列出、更新、删除 Issue 标签 | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | | 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 | @@ -333,6 +333,14 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12 # 从 CSV 文件批量关闭 Issue gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv +# 按 API issue id 预览批量更新元数据 +# 注意:--ids 是 API issue id,不是网页 URL 中的 Issue 编号。 +gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 --status-id 3 --priority-id 2 --dry-run + +# 危险批量删除必须先 dry-run,真实执行还要显式 --yes +gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run +gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes + # 添加评论 gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复" @@ -581,7 +589,7 @@ git push gitlink |-------|------| | `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 | | `gitlink-repo` | 仓库操作(创建、查看、删除、Fork、洞察数据等) | -| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) | +| `gitlink-issue` | Issue 操作(创建、更新、关闭、批量更新/删除、评论等) | | `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) | | `gitlink-member` | 仓库成员与邀请链接管理 | | `gitlink-release` | 发布管理(创建、编辑、更新、查看、删除等) | diff --git a/doc/changes/issue-batch-maintenance.md b/doc/changes/issue-batch-maintenance.md new file mode 100644 index 0000000..b2f945d --- /dev/null +++ b/doc/changes/issue-batch-maintenance.md @@ -0,0 +1,68 @@ +# Issue batch maintenance shortcuts + +## Summary + +Add OpenAPI-backed Issue batch maintenance shortcuts: + +- `issue +batch-update` — batch update Issue status, priority, milestone, tags, and assigners by API issue IDs. +- `issue +batch-delete` — batch delete Issues by API issue IDs with explicit confirmation. + +This complements the existing `issue +batch-close` command. `batch-close` uses web URL issue numbers, while the OpenAPI batch update/delete endpoints use API issue IDs. + +## OpenAPI coverage + +| Command | Method | Endpoint | +|---|---|---| +| `issue +batch-update` | PATCH | `/api/v1/{owner}/{repo}/issues/batch_update.json` | +| `issue +batch-delete` | DELETE | `/api/v1/{owner}/{repo}/issues/batch_destroy.json` | + +## ID semantics + +- `issue +batch-close --numbers` uses web URL Issue numbers (`project_issues_index`). +- `issue +batch-update --ids` and `issue +batch-delete --ids` use API Issue IDs returned by Issue APIs. + +The docs and help text explicitly call this out to avoid mixing the two ID types. + +## Safety and usability + +- Both commands support `--dry-run`. +- `issue +batch-update` requires at least one update field. +- `issue +batch-delete` is destructive and requires `--yes` for real execution. +- ID lists are validated as positive integers and de-duplicated. + +## Examples + +```bash +gitlink-cli issue +batch-update \ + --owner Gitlink \ + --repo forgeplus \ + --ids 101,102 \ + --status-id 3 \ + --priority-id 2 \ + --tag-ids 7,8 \ + --assigner-ids 11,12 \ + --dry-run + +gitlink-cli issue +batch-delete \ + --owner Gitlink \ + --repo forgeplus \ + --ids 101,102 \ + --dry-run + +gitlink-cli issue +batch-delete \ + --owner Gitlink \ + --repo forgeplus \ + --ids 101,102 \ + --yes +``` + +## Tests + +```bash +GOPROXY=https://goproxy.cn,direct go test ./... +go vet ./... +go run . issue +batch-update --help +go run . issue +batch-delete --help +go run . issue +batch-update --owner wangyue111 --repo gitlink-cli --ids 101,102 --status-id 3 --dry-run --format json +go run . issue +batch-delete --owner wangyue111 --repo gitlink-cli --ids 101,102 --dry-run --format json +``` diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index 2345b6c..4d69f2f 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -210,3 +210,185 @@ func parseBool(value string) bool { parsed, err := strconv.ParseBool(strings.TrimSpace(value)) return err == nil && parsed } + +type batchMaintenanceDryRun struct { + Repository string `json:"repository" yaml:"repository"` + DryRun bool `json:"dry_run" yaml:"dry_run"` + Action string `json:"action" yaml:"action"` + Method string `json:"method" yaml:"method"` + Path string `json:"path" yaml:"path"` + Body map[string]interface{} `json:"body" yaml:"body"` +} + +func newBatchUpdateShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-update", + Description: "Batch update issue metadata by API issue IDs", + Flags: []common.Flag{ + {Name: "ids", Usage: "Comma-separated API issue IDs, not web URL issue numbers", Required: true}, + {Name: "status-id", Usage: "Issue status ID"}, + {Name: "priority-id", Usage: "Issue priority ID"}, + {Name: "milestone-id", Usage: "Issue milestone ID"}, + {Name: "tag-ids", Usage: "Comma-separated issue tag IDs"}, + {Name: "assigner-ids", Usage: "Comma-separated assignee user IDs"}, + {Name: "dry-run", Usage: "Preview request without updating issues", Bool: true, Default: "false"}, + }, + Run: runBatchUpdate, + } +} + +func newBatchDeleteShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-delete", + Description: "Batch delete issues by API issue IDs", + Flags: []common.Flag{ + {Name: "ids", Usage: "Comma-separated API issue IDs, not web URL issue numbers", Required: true}, + {Name: "dry-run", Usage: "Preview request without deleting issues", Bool: true, Default: "false"}, + {Name: "yes", Usage: "Confirm real batch deletion", Bool: true, Default: "false"}, + }, + Run: runBatchDelete, + } +} + +func runBatchUpdate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + body, err := buildBatchUpdateBody(ctx) + if err != nil { + return err + } + path := fmt.Sprintf("%s/issues/batch_update", v1RepoPath(ctx)) + if parseBool(ctx.Arg("dry-run")) { + return ctx.OutputData(batchMaintenanceDryRun{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + DryRun: true, + Action: "batch_update_issues", + Method: "PATCH", + Path: path, + Body: body, + }) + } + env, err := ctx.CallAPI("PATCH", path, body) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runBatchDelete(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + ids, err := parseIntIDList(ctx.Arg("ids"), "ids") + if err != nil { + return err + } + body := map[string]interface{}{"ids": ids} + path := fmt.Sprintf("%s/issues/batch_destroy", v1RepoPath(ctx)) + dryRun := parseBool(ctx.Arg("dry-run")) + if dryRun { + return ctx.OutputData(batchMaintenanceDryRun{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + DryRun: true, + Action: "batch_delete_issues", + Method: "DELETE", + Path: path, + Body: body, + }) + } + if !parseBool(ctx.Arg("yes")) { + return fmt.Errorf("batch-delete is destructive; run with --dry-run first, then pass --yes to confirm") + } + env, err := ctx.CallAPI("DELETE", path, body) + if err != nil { + return err + } + return ctx.Output(env) +} + +func buildBatchUpdateBody(ctx *common.RuntimeContext) (map[string]interface{}, error) { + ids, err := parseIntIDList(ctx.Arg("ids"), "ids") + if err != nil { + return nil, err + } + body := map[string]interface{}{"ids": ids} + changed := false + if value := ctx.Arg("status-id"); value != "" { + id, err := parseSingleIntID(value, "status-id") + if err != nil { + return nil, err + } + body["status_id"] = id + changed = true + } + if value := ctx.Arg("priority-id"); value != "" { + id, err := parseSingleIntID(value, "priority-id") + if err != nil { + return nil, err + } + body["priority_id"] = id + changed = true + } + if value := ctx.Arg("milestone-id"); value != "" { + id, err := parseSingleIntID(value, "milestone-id") + if err != nil { + return nil, err + } + body["milestone_id"] = id + changed = true + } + if value := ctx.Arg("tag-ids"); value != "" { + ids, err := parseIntIDList(value, "tag-ids") + if err != nil { + return nil, err + } + body["issue_tag_ids"] = ids + changed = true + } + if value := ctx.Arg("assigner-ids"); value != "" { + ids, err := parseIntIDList(value, "assigner-ids") + if err != nil { + return nil, err + } + body["assigner_ids"] = ids + changed = true + } + if !changed { + return nil, fmt.Errorf("no update fields provided; set at least one of --status-id, --priority-id, --milestone-id, --tag-ids, --assigner-ids") + } + return body, nil +} + +func parseSingleIntID(value, field string) (int, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, fmt.Errorf("%s cannot be empty", field) + } + id, err := strconv.Atoi(value) + if err != nil || id <= 0 { + return 0, fmt.Errorf("invalid %s %q: must be a positive integer", field, value) + } + return id, nil +} + +func parseIntIDList(value, field string) ([]int, error) { + if strings.TrimSpace(value) == "" { + return nil, fmt.Errorf("%s cannot be empty", field) + } + parts := strings.Split(value, ",") + ids := make([]int, 0, len(parts)) + seen := map[int]bool{} + for _, part := range parts { + id, err := parseSingleIntID(part, field) + if err != nil { + return nil, err + } + if seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + return ids, nil +} diff --git a/shortcuts/issue/batch_test.go b/shortcuts/issue/batch_test.go index cf94cda..42e3648 100644 --- a/shortcuts/issue/batch_test.go +++ b/shortcuts/issue/batch_test.go @@ -1,6 +1,7 @@ package issue import ( + "net/http" "os" "path/filepath" "reflect" @@ -213,3 +214,136 @@ func writeTempCSV(t *testing.T, content string) string { } return path } + +func TestBatchUpdateDryRun(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() + + err := runShortcut(t, server, "batch-update", map[string]string{ + "ids": "101,102", + "status-id": "3", + "priority-id": "2", + "tag-ids": "7,8", + "assigner-ids": "11", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("batch-update dry-run failed: %v", err) + } +} + +func TestBatchUpdateCallsAPI(t *testing.T) { + var payload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/batch_update.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runShortcut(t, server, "batch-update", map[string]string{ + "ids": "101,102,101", + "status-id": "3", + "priority-id": "2", + "milestone-id": "9", + "tag-ids": "7,8", + "assigner-ids": "11,12", + }) + if err != nil { + t.Fatalf("batch-update failed: %v", err) + } + assertFloatSlice(t, payload["ids"], []float64{101, 102}) + assertEqual(t, payload["status_id"], float64(3)) + assertEqual(t, payload["priority_id"], float64(2)) + assertEqual(t, payload["milestone_id"], float64(9)) + assertFloatSlice(t, payload["issue_tag_ids"], []float64{7, 8}) + assertFloatSlice(t, payload["assigner_ids"], []float64{11, 12}) +} + +func TestBatchUpdateRequiresUpdateField(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-update", map[string]string{"ids": "101"}); err == nil { + t.Fatal("expected error when no update fields are provided") + } +} + +func TestBatchUpdateRejectsInvalidIDs(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() + + cases := []map[string]string{ + {"ids": "abc", "status-id": "3"}, + {"ids": "101", "status-id": "bad"}, + {"ids": "101", "tag-ids": "7,,8"}, + } + for _, args := range cases { + if err := runShortcut(t, server, "batch-update", args); err == nil { + t.Fatalf("expected validation error for args %#v", args) + } + } +} + +func TestBatchDeleteDryRun(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-delete", map[string]string{"ids": "101,102", "dry-run": "true"}); err != nil { + t.Fatalf("batch-delete dry-run failed: %v", err) + } +} + +func TestBatchDeleteRequiresYes(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected API call without --yes: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + if err := runShortcut(t, server, "batch-delete", map[string]string{"ids": "101"}); err == nil { + t.Fatal("expected --yes confirmation error") + } +} + +func TestBatchDeleteCallsAPIWithYes(t *testing.T) { + var payload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/issues/batch_destroy.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + if err := runShortcut(t, server, "batch-delete", map[string]string{"ids": "101,102,101", "yes": "true"}); err != nil { + t.Fatalf("batch-delete failed: %v", err) + } + assertFloatSlice(t, payload["ids"], []float64{101, 102}) +} + +func assertFloatSlice(t *testing.T, got interface{}, want []float64) { + t.Helper() + items, ok := got.([]interface{}) + if !ok { + t.Fatalf("got %#v, want []interface{}", got) + } + if len(items) != len(want) { + t.Fatalf("got len %d, want %d: %#v", len(items), len(want), got) + } + for i := range want { + if items[i] != want[i] { + t.Fatalf("item %d = %#v, want %#v", i, items[i], want[i]) + } + } +} diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 9e5a117..b19027e 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(), + newBatchUpdateShortcut(), + newBatchDeleteShortcut(), { Name: "list", Description: tr.T("cmd.issue.list.short"), diff --git a/skills/README.md b/skills/README.md index af78bca..d4df553 100644 --- a/skills/README.md +++ b/skills/README.md @@ -135,7 +135,7 @@ skills/ |-------|------|----------| | **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` | | **gitlink-repo** | 仓库管理与洞察 | `repo +list`, `repo +info`, `repo +languages`, `repo +contributors`, `repo +code-stats`, `repo +follow`, `repo +like` | -| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close` | +| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close`, `issue +batch-update`, `issue +batch-delete` | | **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` | | **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` | | **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` | diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index c5a4aa2..9bdb1b3 100644 --- a/skills/gitlink-issue/SKILL.md +++ b/skills/gitlink-issue/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-issue version: 2.0.0 -description: "Issue 管理:创建、查看、更新、关闭/批量关闭 Issue,添加评论。当用户需要操作 GitLink Issue 时触发。" +description: "Issue 管理:创建、查看、更新、关闭/批量关闭/批量更新/批量删除 Issue,添加评论。当用户需要操作 GitLink Issue 时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -26,6 +26,8 @@ metadata: | `issue +update` | 更新 Issue | 是 | | `issue +close` | 关闭 Issue | 是 | | `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) | +| `issue +batch-update` | 按 API issue id 批量更新状态、优先级、里程碑、标签、负责人 | 是(dry-run 不写入) | +| `issue +batch-delete` | 按 API issue id 批量删除 Issue;真实删除必须 `--yes` | 是(dry-run 不写入) | | `issue +comment` | 添加评论 | 是 | | `issue +assigners` | 查询 Issue 负责人列表 | 否(公开项目) | | `issue +authors` | 查询 Issue 发布人列表 | 否(公开项目) | @@ -60,6 +62,13 @@ gitlink-cli issue +batch-close --owner myuser --repo myrepo --numbers 123,124 -- # 从 CSV 文件批量关闭 Issue gitlink-cli issue +batch-close --owner myuser --repo myrepo --from issues.csv +# 按 API issue id 预览批量更新元数据(注意不是网页 Issue 编号) +gitlink-cli issue +batch-update --owner myuser --repo myrepo --ids 101,102 --status-id 3 --priority-id 2 --dry-run + +# 危险批量删除:必须先 dry-run,真实执行还要 --yes +gitlink-cli issue +batch-delete --owner myuser --repo myrepo --ids 101,102 --dry-run +gitlink-cli issue +batch-delete --owner myuser --repo myrepo --ids 101,102 --yes + # 添加评论 gitlink-cli issue +comment --number 4 --body "已修复,请验证" @@ -70,6 +79,14 @@ gitlink-cli issue +assigners --owner Gitlink --repo forgeplus --keyword alice gitlink-cli issue +authors --owner Gitlink --repo forgeplus --keyword bob ``` +## 批量维护安全约束 + +- `issue +batch-close --numbers` 使用网页 URL 中的 Issue 编号,即 `project_issues_index`。 +- `issue +batch-update --ids` 和 `issue +batch-delete --ids` 使用 OpenAPI 返回的 API issue id,不是网页 Issue 编号。 +- 执行 `batch-update` / `batch-delete` 前,先用 `issue +list` 或 `issue +view` 确认 id 来源。 +- 写操作先执行 `--dry-run`,展示 `method`、`path`、`body` 给用户确认。 +- `batch-delete` 是破坏性操作,真实执行必须显式传 `--yes`。 + ## Raw API 补充 ```bash