diff --git a/README.md b/README.md index 39f537e..a728b07 100644 --- a/README.md +++ b/README.md @@ -165,10 +165,16 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..." # View an issue -gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123 +gitlink-cli issue +view --owner Gitlink --repo forgeplus -n 123 # Close an issue -gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 +gitlink-cli issue +close --owner Gitlink --repo forgeplus -n 123 + +# Preview batch comment without changing data +gitlink-cli issue +batch-comment --owner Gitlink --repo forgeplus --numbers 123,124 -b "Please confirm whether this issue is still relevant." --dry-run + +# Batch add a comment before closing stale issues +gitlink-cli issue +batch-comment --owner Gitlink --repo forgeplus --from issues.csv -b "This issue is inactive. Please reply if it is still relevant." # Preview batch close without changing data gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run @@ -177,7 +183,7 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12 gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv # Add a comment -gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed" +gitlink-cli issue +comment --owner Gitlink --repo forgeplus -n 123 -b "Fixed" ``` ### Pull Requests diff --git a/README.zh-CN.md b/README.zh-CN.md index ba4be15..b5006d7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -165,10 +165,16 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..." # 查看 Issue -gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123 +gitlink-cli issue +view --owner Gitlink --repo forgeplus -n 123 # 关闭 Issue -gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 +gitlink-cli issue +close --owner Gitlink --repo forgeplus -n 123 + +# 预览批量评论,不修改数据 +gitlink-cli issue +batch-comment --owner Gitlink --repo forgeplus --numbers 123,124 -b "请确认该 Issue 是否仍需处理。" --dry-run + +# 从 CSV 文件批量添加评论 +gitlink-cli issue +batch-comment --owner Gitlink --repo forgeplus --from issues.csv -b "该 Issue 长期无更新,如仍需处理请回复。" # 预览批量关闭,不修改数据 gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run @@ -177,7 +183,7 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12 gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv # 添加评论 -gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复" +gitlink-cli issue +comment --owner Gitlink --repo forgeplus -n 123 -b "已修复" ``` ### Pull Request diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index 2345b6c..754c824 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -12,20 +12,20 @@ import ( const closedIssueStatusID = 5 -type batchCloseResult struct { +type batchOperationResult struct { Number string `json:"number" yaml:"number"` Action string `json:"action" yaml:"action"` Status string `json:"status" yaml:"status"` Error string `json:"error,omitempty" yaml:"error,omitempty"` } -type batchCloseSummary struct { - Repository string `json:"repository" yaml:"repository"` - 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 []batchCloseResult `json:"results" yaml:"results"` +type batchOperationSummary struct { + Repository string `json:"repository" yaml:"repository"` + 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 []batchOperationResult `json:"results" yaml:"results"` } func newBatchCloseShortcut() *common.Shortcut { @@ -55,15 +55,15 @@ func runBatchClose(ctx *common.RuntimeContext) error { } dryRun := parseBool(ctx.Arg("dry-run")) - summary := batchCloseSummary{ + summary := batchOperationSummary{ Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), DryRun: dryRun, Total: len(numbers), - Results: make([]batchCloseResult, 0, len(numbers)), + Results: make([]batchOperationResult, 0, len(numbers)), } for _, number := range numbers { - result := batchCloseResult{Number: number, Action: "close"} + result := batchOperationResult{Number: number, Action: "close"} if dryRun { result.Status = "planned" summary.Succeeded++ @@ -108,6 +108,85 @@ func closeIssue(ctx *common.RuntimeContext, number string) error { 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 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 := batchOperationSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + DryRun: dryRun, + Total: len(numbers), + Results: make([]batchOperationResult, 0, len(numbers)), + } + + for _, number := range numbers { + result := batchOperationResult{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("comment issue: %w", err) + } + return nil +} + func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) { numbers, err := parseIssueNumbers(numbersValue) if err != nil { diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 4ccc575..a7073a1 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -22,6 +22,7 @@ type existingIssue struct { func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ newBatchCloseShortcut(), + newBatchCommentShortcut(), { Name: "list", Description: "List issues", diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 088f82c..3ce6332 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -131,6 +131,64 @@ func TestBatchClosePreservesCurrentDescription(t *testing.T) { assertEqual(t, updatePayload["status_id"], float64(5)) } +func TestBatchCommentPostsNotesToEachIssue(t *testing.T) { + posted := map[string]string{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/42/journals.json": + posted["42"] = decodeJSON(t, r)["notes"].(string) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/43/journals.json": + posted["43"] = decodeJSON(t, r)["notes"].(string) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runIssueShortcut(t, server, "batch-comment", map[string]string{ + "numbers": "42,43", + "body": "stale issue reminder", + }) + if err != nil { + t.Fatalf("batch-comment shortcut failed: %v", err) + } + + assertEqual(t, posted["42"], "stale issue reminder") + assertEqual(t, posted["43"], "stale issue reminder") +} + +func TestBatchCommentDryRunDoesNotPost(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 := runIssueShortcut(t, server, "batch-comment", map[string]string{ + "numbers": "42,43", + "body": "stale issue reminder", + "dry-run": "true", + }) + if 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("missing body should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runIssueShortcut(t, server, "batch-comment", map[string]string{ + "numbers": "42", + }) + if err == nil { + t.Fatal("batch-comment without body expected an error") + } +} + func runIssueShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { t.Helper() shortcut := findIssueShortcut(t, name) diff --git a/skills/README.md b/skills/README.md index 3f09e10..56c92e1 100644 --- a/skills/README.md +++ b/skills/README.md @@ -122,7 +122,7 @@ skills/ |-------|------|----------| | **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` | | **gitlink-repo** | 仓库管理 | `repo +list`, `repo +create`, `repo +info`, `repo +fork` | -| **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-comment`, `issue +batch-close` | | **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +review` | | **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +protect` | | **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +view` | @@ -162,13 +162,16 @@ gitlink-cli repo +info --owner wbtiger --repo gitlink-cli gitlink-cli issue +create -t "Bug: 登录失败" -b "复现步骤..." # 查看 Issue -gitlink-cli issue +view -i 123 +gitlink-cli issue +view -n 123 # 添加评论 -gitlink-cli issue +comment -i 123 -b "已修复" +gitlink-cli issue +comment -n 123 -b "已修复" # 关闭 Issue -gitlink-cli issue +close -i 123 +gitlink-cli issue +close -n 123 + +# 预览批量评论 Issue +gitlink-cli issue +batch-comment --numbers 123,124 -b "请确认该 Issue 是否仍需处理。" --dry-run # 预览批量关闭 Issue gitlink-cli issue +batch-close --numbers 123,124 --dry-run diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index 1938da6..9fce5d4 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"] @@ -25,8 +25,9 @@ metadata: | `issue +view` | Issue 详情 | 否(公开项目) | | `issue +update` | 更新 Issue | 是 | | `issue +close` | 关闭 Issue | 是 | -| `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) | | `issue +comment` | 添加评论 | 是 | +| `issue +batch-comment` | 批量添加评论,支持 `--dry-run` 预览 | 是(dry-run 不写入) | +| `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) | ## 使用示例 @@ -46,6 +47,12 @@ gitlink-cli issue +update --number 4 --title "新标题" --body "更新描述" # 关闭 Issue gitlink-cli issue +close --number 4 +# 预览批量评论 Issue,不修改数据 +gitlink-cli issue +batch-comment --owner myuser --repo myrepo --numbers 123,124 --body "请确认该 Issue 是否仍需处理。" --dry-run + +# 从 CSV 文件批量添加评论 +gitlink-cli issue +batch-comment --owner myuser --repo myrepo --from issues.csv --body "该 Issue 长期无更新,如仍需处理请回复。" + # 预览批量关闭 Issue,不修改数据 gitlink-cli issue +batch-close --owner myuser --repo myrepo --numbers 123,124 --dry-run diff --git a/skills/gitlink-issue/references/gitlink-issue-batch-comment.md b/skills/gitlink-issue/references/gitlink-issue-batch-comment.md new file mode 100644 index 0000000..ee0e901 --- /dev/null +++ b/skills/gitlink-issue/references/gitlink-issue-batch-comment.md @@ -0,0 +1,94 @@ +# issue +batch-comment + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +批量给多个 Issue 添加评论。支持直接传入 Issue 编号列表、从 CSV 读取 Issue 编号,以及用 `--dry-run` 安全预览。 + +> **Issue 编号说明:** `--numbers` 使用的是网页 URL 中可见的 Issue 编号,即 v1 API 的 `project_issues_index`,不是数据库内部 ID。 + +## 命令 + +```bash +# 预览,不修改数据 +gitlink-cli issue +batch-comment --owner Gitlink --repo forgeplus --numbers 42,43 --body "请确认该 Issue 是否仍需处理。" --dry-run + +# 按 Issue 编号批量添加评论 +gitlink-cli issue +batch-comment --owner Gitlink --repo forgeplus --numbers 42,43 --body "该 Issue 长期无更新,如仍需处理请回复。" + +# 从 CSV 文件读取 Issue 编号 +gitlink-cli issue +batch-comment --owner Gitlink --repo forgeplus --from issues.csv --body "该 Issue 长期无更新,如仍需处理请回复。" +``` + +## CSV 格式 + +CSV 文件可以包含 `number`、`issue_number` 或 `project_issues_index` 列: + +```csv +number,title +42,stale issue +43,duplicate issue +``` + +如果没有表头,则默认第一列是 Issue 编号: + +```csv +42,stale issue +43,duplicate issue +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--numbers, -n` | 否 | 逗号分隔的 Issue 编号,例如 `1,2,3` | +| `--from` | 否 | 包含 Issue 编号的 CSV 文件 | +| `--body, -b` | 是 | 评论内容 | +| `--dry-run` | 否 | 仅预览计划操作,不添加评论 | +| `--owner` | 否 | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否 | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 开启调试输出 | + +`--numbers` 和 `--from` 至少提供一个。两者同时提供时,会按顺序合并并去重。 + +## 输出 + +命令会输出批量操作汇总: + +```json +{ + "repository": "Gitlink/forgeplus", + "dry_run": true, + "total": 2, + "succeeded": 2, + "failed": 0, + "results": [ + {"number": "42", "action": "comment", "status": "planned"}, + {"number": "43", "action": "comment", "status": "planned"} + ] +} +``` + +## API + +每个 Issue 使用 v1 评论接口: + +```text +POST /v1/{owner}/{repo}/issues/{number}/journals +Body: { "notes": } +``` + +## Workflow + +1. 与用户确认目标仓库、要评论的 Issue 编号和评论内容。 +2. 先执行 `--dry-run` 并展示计划结果。 +3. 用户确认后,再执行不带 `--dry-run` 的命令。 +4. 汇报成功数量、失败数量和失败原因。 + +> [!CAUTION] +> 不带 `--dry-run` 是 **写操作**,执行前必须确认用户意图。 + +## References + +- [gitlink-issue](../SKILL.md) +- [gitlink-shared](../../gitlink-shared/SKILL.md)