diff --git a/README.md b/README.md index af32884..39f537e 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,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 | -| 🐛 Issue | Create, update, close, comment on issues | +| 🐛 Issue | Create, update, close, batch close, comment on issues | | 🔀 PR | Create, merge, review pull requests, view changed files | | 🌿 Branch | Create, delete, protect branches | | 🏷️ Release | Create, view, delete releases | @@ -170,6 +170,12 @@ gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123 # Close an issue gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 +# Preview batch close without changing data +gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run + +# Batch close issues from a CSV file +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" ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 6848bb1..ba4be15 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -27,7 +27,7 @@ | 分类 | 能力 | |------|------| | 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 | -| 🐛 Issue | 创建、更新、关闭、评论 Issue | +| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | | 🌿 分支 | 创建、删除、保护分支 | | 🏷️ 发布 | 创建、查看、删除 Release | @@ -170,6 +170,12 @@ gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123 # 关闭 Issue gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 +# 预览批量关闭,不修改数据 +gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run + +# 从 CSV 文件批量关闭 Issue +gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv + # 添加评论 gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复" ``` diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 4b43fcb..056b595 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -1,6 +1,8 @@ package common import ( + "strconv" + "github.com/spf13/cobra" ) @@ -13,6 +15,11 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) { // Collect flag values flagValues := make(map[string]string) for _, f := range s.Flags { + if f.Bool { + val, _ := cmd.Flags().GetBool(f.Name) + flagValues[f.Name] = strconv.FormatBool(val) + continue + } val, _ := cmd.Flags().GetString(f.Name) if val != "" { flagValues[f.Name] = val @@ -29,7 +36,14 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) { } for _, f := range s.Flags { - if f.Short != "" { + if f.Bool { + defaultValue, _ := strconv.ParseBool(f.Default) + if f.Short != "" { + cmd.Flags().BoolP(f.Name, f.Short, defaultValue, f.Usage) + } else { + cmd.Flags().Bool(f.Name, defaultValue, f.Usage) + } + } else if f.Short != "" { cmd.Flags().StringP(f.Name, f.Short, f.Default, f.Usage) } else { cmd.Flags().String(f.Name, f.Default, f.Usage) diff --git a/shortcuts/common/runner_test.go b/shortcuts/common/runner_test.go new file mode 100644 index 0000000..c8254ed --- /dev/null +++ b/shortcuts/common/runner_test.go @@ -0,0 +1,31 @@ +package common + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestMountShortcutSupportsBoolFlags(t *testing.T) { + var got string + root := &cobra.Command{Use: "root"} + MountShortcut(root, &Shortcut{ + Name: "preview", + Description: "preview command", + Flags: []Flag{ + {Name: "dry-run", Usage: "preview only", Bool: true, Default: "false"}, + }, + Run: func(ctx *RuntimeContext) error { + got = ctx.Arg("dry-run") + return nil + }, + }) + + root.SetArgs([]string{"+preview", "--dry-run"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute returned error: %v", err) + } + if got != "true" { + t.Fatalf("dry-run flag = %q, want true", got) + } +} diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index e6a2aec..15441c9 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -26,6 +26,7 @@ type Flag struct { Usage string Required bool Default string + Bool bool } // RuntimeContext provides helpers for shortcut implementations. diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go new file mode 100644 index 0000000..2345b6c --- /dev/null +++ b/shortcuts/issue/batch.go @@ -0,0 +1,212 @@ +package issue + +import ( + "encoding/csv" + "fmt" + "os" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +const closedIssueStatusID = 5 + +type batchCloseResult 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"` +} + +func newBatchCloseShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-close", + Description: "Close 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: "dry-run", Usage: "Preview the issues that would be closed without changing them", Bool: true, Default: "false"}, + }, + Run: runBatchClose, + } +} + +func runBatchClose(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: "close"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + if err := closeIssue(ctx, number); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "closed" + 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 close", summary.Failed, summary.Total) + } + return nil +} + +func closeIssue(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": closedIssueStatusID, + } + if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil { + return fmt.Errorf("close issue: %w", err) + } + return nil +} + +func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) { + numbers, err := parseIssueNumbers(numbersValue) + if err != nil { + return nil, err + } + if csvPath == "" { + return numbers, nil + } + + csvNumbers, err := readIssueNumbersFromCSV(csvPath) + if err != nil { + return nil, err + } + return mergeIssueNumbers(numbers, csvNumbers), nil +} + +func parseIssueNumbers(value string) ([]string, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + return normalizeIssueNumbers(strings.Split(value, ",")) +} + +func readIssueNumbersFromCSV(path string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("read issue numbers from CSV: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + reader.TrimLeadingSpace = true + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("parse issue numbers from CSV: %w", err) + } + if len(records) == 0 { + return nil, nil + } + + numberColumn := -1 + startRow := 0 + for i, cell := range records[0] { + switch strings.ToLower(strings.TrimSpace(cell)) { + case "number", "issue_number", "project_issues_index": + numberColumn = i + startRow = 1 + } + } + if numberColumn == -1 { + numberColumn = 0 + } + + values := make([]string, 0, len(records)-startRow) + for _, record := range records[startRow:] { + if numberColumn >= len(record) { + continue + } + values = append(values, record[numberColumn]) + } + return normalizeIssueNumbers(values) +} + +func normalizeIssueNumbers(values []string) ([]string, error) { + numbers := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + number := strings.TrimSpace(value) + if number == "" { + continue + } + if _, err := strconv.ParseInt(number, 10, 64); err != nil { + return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number) + } + if seen[number] { + continue + } + seen[number] = true + numbers = append(numbers, number) + } + return numbers, nil +} + +func mergeIssueNumbers(values ...[]string) []string { + merged := []string{} + seen := map[string]bool{} + for _, numbers := range values { + for _, number := range numbers { + if seen[number] { + continue + } + seen[number] = true + merged = append(merged, number) + } + } + return merged +} + +func parseBool(value string) bool { + parsed, err := strconv.ParseBool(strings.TrimSpace(value)) + return err == nil && parsed +} diff --git a/shortcuts/issue/batch_test.go b/shortcuts/issue/batch_test.go new file mode 100644 index 0000000..54cdf55 --- /dev/null +++ b/shortcuts/issue/batch_test.go @@ -0,0 +1,91 @@ +package issue + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestParseIssueNumbers(t *testing.T) { + got, err := parseIssueNumbers("1, 2,2, 3") + if err != nil { + t.Fatalf("parseIssueNumbers returned error: %v", err) + } + want := []string{"1", "2", "3"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseIssueNumbers() = %#v, want %#v", got, want) + } +} + +func TestParseIssueNumbersRejectsInvalidNumber(t *testing.T) { + if _, err := parseIssueNumbers("1,abc"); err == nil { + t.Fatal("parseIssueNumbers() expected an error for a non-integer issue number") + } +} + +func TestReadIssueNumbersFromCSVWithHeader(t *testing.T) { + path := writeTempCSV(t, "title,number,state\nfirst,12,open\nsecond,13,open\n") + got, err := readIssueNumbersFromCSV(path) + if err != nil { + t.Fatalf("readIssueNumbersFromCSV returned error: %v", err) + } + want := []string{"12", "13"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want) + } +} + +func TestReadIssueNumbersFromCSVWithProjectIssuesIndexHeader(t *testing.T) { + path := writeTempCSV(t, "title,project_issues_index,state\nfirst,12,open\nsecond,13,open\n") + got, err := readIssueNumbersFromCSV(path) + if err != nil { + t.Fatalf("readIssueNumbersFromCSV returned error: %v", err) + } + want := []string{"12", "13"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want) + } +} + +func TestReadIssueNumbersFromCSVWithoutHeaderUsesFirstColumn(t *testing.T) { + path := writeTempCSV(t, "21,open\n22,closed\n21,duplicate\n") + got, err := readIssueNumbersFromCSV(path) + if err != nil { + t.Fatalf("readIssueNumbersFromCSV returned error: %v", err) + } + want := []string{"21", "22"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want) + } +} + +func TestCollectIssueNumbersMergesCLIAndCSV(t *testing.T) { + path := writeTempCSV(t, "number\n2\n3\n") + got, err := collectIssueNumbers("1,2", path) + if err != nil { + t.Fatalf("collectIssueNumbers returned error: %v", err) + } + want := []string{"1", "2", "3"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("collectIssueNumbers() = %#v, want %#v", got, want) + } +} + +func TestParseBool(t *testing.T) { + if !parseBool("true") { + t.Fatal("parseBool(true) = false, want true") + } + if parseBool("") { + t.Fatal("parseBool(empty) = true, want false") + } +} + +func writeTempCSV(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "issues.csv") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write temp csv: %v", err) + } + return path +} diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 13b3252..4ccc575 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -21,6 +21,7 @@ type existingIssue struct { func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ + newBatchCloseShortcut(), { Name: "list", Description: "List issues", diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 0df2bd8..088f82c 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -14,12 +14,12 @@ func TestIssueClosePreservesCurrentDescription(t *testing.T) { var updatePayload map[string]interface{} server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == "GET" && r.URL.Path == "/owner/repo/issues/42.json": + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": writeJSON(t, w, map[string]interface{}{ "subject": "Existing title", "description": "Existing description", }) - case r.Method == "PUT" && r.URL.Path == "/owner/repo/issues/42.json": + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": updatePayload = decodeJSON(t, r) writeJSON(t, w, updatePayload) default: @@ -28,7 +28,7 @@ func TestIssueClosePreservesCurrentDescription(t *testing.T) { }) defer server.Close() - err := runIssueShortcut(t, server, "close", map[string]string{"id": "42"}) + err := runIssueShortcut(t, server, "close", map[string]string{"number": "42"}) if err != nil { t.Fatalf("close shortcut failed: %v", err) } @@ -42,12 +42,12 @@ func TestIssueUpdatePreservesCurrentDescriptionWhenChangingTitleAndState(t *test var updatePayload map[string]interface{} server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == "GET" && r.URL.Path == "/owner/repo/issues/42.json": + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": writeJSON(t, w, map[string]interface{}{ "subject": "Existing title", "description": "Existing description", }) - case r.Method == "PUT" && r.URL.Path == "/owner/repo/issues/42.json": + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": updatePayload = decodeJSON(t, r) writeJSON(t, w, updatePayload) default: @@ -57,9 +57,9 @@ func TestIssueUpdatePreservesCurrentDescriptionWhenChangingTitleAndState(t *test defer server.Close() err := runIssueShortcut(t, server, "update", map[string]string{ - "id": "42", - "title": "New title", - "state": "closed", + "number": "42", + "title": "New title", + "state": "closed", }) if err != nil { t.Fatalf("update shortcut failed: %v", err) @@ -74,12 +74,12 @@ func TestIssueUpdatePreservesCurrentSubjectWhenChangingDescription(t *testing.T) var updatePayload map[string]interface{} server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == "GET" && r.URL.Path == "/owner/repo/issues/42.json": + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": writeJSON(t, w, map[string]interface{}{ "subject": "Existing title", "description": "Existing description", }) - case r.Method == "PUT" && r.URL.Path == "/owner/repo/issues/42.json": + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": updatePayload = decodeJSON(t, r) writeJSON(t, w, updatePayload) default: @@ -89,8 +89,8 @@ func TestIssueUpdatePreservesCurrentSubjectWhenChangingDescription(t *testing.T) defer server.Close() err := runIssueShortcut(t, server, "update", map[string]string{ - "id": "42", - "body": "New description", + "number": "42", + "body": "New description", }) if err != nil { t.Fatalf("update shortcut failed: %v", err) @@ -100,6 +100,37 @@ func TestIssueUpdatePreservesCurrentSubjectWhenChangingDescription(t *testing.T) assertEqual(t, updatePayload["description"], "New description") } +func TestBatchClosePreservesCurrentDescription(t *testing.T) { + var updatePayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": + writeJSON(t, w, map[string]interface{}{ + "subject": "Existing title", + "description": "Existing description", + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + updatePayload = decodeJSON(t, r) + writeJSON(t, w, updatePayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runIssueShortcut(t, server, "batch-close", map[string]string{ + "numbers": "42", + "dry-run": "false", + }) + if err != nil { + t.Fatalf("batch-close shortcut failed: %v", err) + } + + assertEqual(t, updatePayload["subject"], "Existing title") + assertEqual(t, updatePayload["description"], "Existing description") + assertEqual(t, updatePayload["status_id"], float64(5)) +} + 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 1f4930a..3f09e10 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` | +| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `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` | @@ -169,6 +169,9 @@ gitlink-cli issue +comment -i 123 -b "已修复" # 关闭 Issue gitlink-cli issue +close -i 123 + +# 预览批量关闭 Issue +gitlink-cli issue +batch-close --numbers 123,124 --dry-run ``` 详见: [gitlink-issue/examples/issue-workflow.md](gitlink-issue/examples/issue-workflow.md) diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index e2a2643..1938da6 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,6 +25,7 @@ metadata: | `issue +view` | Issue 详情 | 否(公开项目) | | `issue +update` | 更新 Issue | 是 | | `issue +close` | 关闭 Issue | 是 | +| `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) | | `issue +comment` | 添加评论 | 是 | ## 使用示例 @@ -45,6 +46,12 @@ gitlink-cli issue +update --number 4 --title "新标题" --body "更新描述" # 关闭 Issue gitlink-cli issue +close --number 4 +# 预览批量关闭 Issue,不修改数据 +gitlink-cli issue +batch-close --owner myuser --repo myrepo --numbers 123,124 --dry-run + +# 从 CSV 文件批量关闭 Issue +gitlink-cli issue +batch-close --owner myuser --repo myrepo --from issues.csv + # 添加评论 gitlink-cli issue +comment --number 4 --body "已修复,请验证" ``` @@ -73,6 +80,7 @@ gitlink-cli api POST /:owner/:repo/issues/series_update --body '{"ids":[1,2,3]," ## API 注意事项 - **Issue 编号(`--number`)是网页 URL 中看到的序号**(如 `issues/4` 中的 `4`),不是数据库内部 ID +- **批量关闭使用 `--numbers`,同样传网页 URL 中的 Issue 编号**,不是数据库内部 ID - Issue 操作使用 v1 API(`/api/v1/`),支持按 Issue 编号查询和操作 - **创建 Issue 时 CLI 会自动设置 `status_id: 1`(新增)和 `priority_id: 2`(正常)** - **更新/关闭 Issue 时必须保留当前 `subject` 和 `description`**,即使只修改状态(CLI 会先读取当前 Issue 并自动带回) diff --git a/skills/gitlink-issue/references/gitlink-issue-batch-close.md b/skills/gitlink-issue/references/gitlink-issue-batch-close.md new file mode 100644 index 0000000..7a97abf --- /dev/null +++ b/skills/gitlink-issue/references/gitlink-issue-batch-close.md @@ -0,0 +1,98 @@ +# issue +batch-close + +> **前置条件:** 先阅读 [`../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-close --owner Gitlink --repo forgeplus --numbers 42,43 --dry-run + +# 按 Issue 编号批量关闭 +gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 42,43 + +# 从 CSV 文件读取 Issue 编号 +gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv +``` + +## 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 文件 | +| `--dry-run` | 否 | 仅预览计划操作,不关闭 Issue | +| `--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": "close", "status": "planned"}, + {"number": "43", "action": "close", "status": "planned"} + ] +} +``` + +## API + +每个 Issue 复用 `issue +close` 的安全关闭流程: + +1. **读取**当前 Issue,保留 `subject` 和 `description`: + ``` + GET /v1/{owner}/{repo}/issues/{number} + ``` +2. **更新**Issue 状态为关闭,同时保留原有描述: + ``` + PATCH /v1/{owner}/{repo}/issues/{number} + Body: { "subject": , "description": , "status_id": 5 } + ``` + +## Workflow + +1. 与用户确认目标仓库和要关闭的 Issue 编号。 +2. 先执行 `--dry-run` 并展示计划结果。 +3. 用户确认后,再执行不带 `--dry-run` 的命令。 +4. 汇报成功数量、失败数量和失败原因。 + +> [!CAUTION] +> 不带 `--dry-run` 是 **写操作**,执行前必须确认用户意图。 + +## References + +- [gitlink-issue](../SKILL.md) +- [gitlink-shared](../../gitlink-shared/SKILL.md)