feat(issue): add batch close shortcut with dry-run support

This commit is contained in:
wangyue789 2026-05-11 20:09:18 +08:00
parent b49cf8ffda
commit b2e840f498
12 changed files with 519 additions and 17 deletions

View File

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

View File

@ -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 "已修复"
```

View File

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

View File

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

View File

@ -26,6 +26,7 @@ type Flag struct {
Usage string
Required bool
Default string
Bool bool
}
// RuntimeContext provides helpers for shortcut implementations.

212
shortcuts/issue/batch.go Normal file
View File

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

View File

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

View File

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

View File

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

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` |
| **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)

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,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 并自动带回)

View File

@ -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": <current subject>, "description": <current 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)