forked from Gitlink/gitlink-cli
feat(issue): add batch close shortcut with dry-run support
This commit is contained in:
parent
b49cf8ffda
commit
b2e840f498
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 "已修复"
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ type Flag struct {
|
|||
Usage string
|
||||
Required bool
|
||||
Default string
|
||||
Bool bool
|
||||
}
|
||||
|
||||
// RuntimeContext provides helpers for shortcut implementations.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ type existingIssue struct {
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newBatchCloseShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List issues",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 并自动带回)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
Loading…
Reference in New Issue