feat(issue): add batch comment shortcut with dry-run support #14

Closed
wangyue111 wants to merge 1 commits from wangyue111/gitlink-cli:feat/issue-batch-comment into master
8 changed files with 277 additions and 23 deletions

View File

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

View File

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

View File

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

View File

@ -22,6 +22,7 @@ type existingIssue struct {
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchCloseShortcut(),
newBatchCommentShortcut(),
{
Name: "list",
Description: "List issues",

View File

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

View File

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

View File

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

View File

@ -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": <comment body> }
```
## Workflow
1. 与用户确认目标仓库、要评论的 Issue 编号和评论内容。
2. 先执行 `--dry-run` 并展示计划结果。
3. 用户确认后,再执行不带 `--dry-run` 的命令。
4. 汇报成功数量、失败数量和失败原因。
> [!CAUTION]
> 不带 `--dry-run`**写操作**,执行前必须确认用户意图。
## References
- [gitlink-issue](../SKILL.md)
- [gitlink-shared](../../gitlink-shared/SKILL.md)