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 |
|
| Category | Capabilities |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| 📦 Repo | List, create, fork, delete repositories, view repo info |
|
| 📦 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 |
|
| 🔀 PR | Create, merge, review pull requests, view changed files |
|
||||||
| 🌿 Branch | Create, delete, protect branches |
|
| 🌿 Branch | Create, delete, protect branches |
|
||||||
| 🏷️ Release | Create, view, delete releases |
|
| 🏷️ Release | Create, view, delete releases |
|
||||||
|
|
@ -170,6 +170,12 @@ gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
|
||||||
# Close an issue
|
# Close an issue
|
||||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
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
|
# Add a comment
|
||||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
|
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
| 分类 | 能力 |
|
| 分类 | 能力 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 |
|
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 |
|
||||||
| 🐛 Issue | 创建、更新、关闭、评论 Issue |
|
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
|
||||||
| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 |
|
| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 |
|
||||||
| 🌿 分支 | 创建、删除、保护分支 |
|
| 🌿 分支 | 创建、删除、保护分支 |
|
||||||
| 🏷️ 发布 | 创建、查看、删除 Release |
|
| 🏷️ 发布 | 创建、查看、删除 Release |
|
||||||
|
|
@ -170,6 +170,12 @@ gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
|
||||||
# 关闭 Issue
|
# 关闭 Issue
|
||||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
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 "已修复"
|
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -13,6 +15,11 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
|
||||||
// Collect flag values
|
// Collect flag values
|
||||||
flagValues := make(map[string]string)
|
flagValues := make(map[string]string)
|
||||||
for _, f := range s.Flags {
|
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)
|
val, _ := cmd.Flags().GetString(f.Name)
|
||||||
if val != "" {
|
if val != "" {
|
||||||
flagValues[f.Name] = val
|
flagValues[f.Name] = val
|
||||||
|
|
@ -29,7 +36,14 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, f := range s.Flags {
|
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)
|
cmd.Flags().StringP(f.Name, f.Short, f.Default, f.Usage)
|
||||||
} else {
|
} else {
|
||||||
cmd.Flags().String(f.Name, f.Default, f.Usage)
|
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
|
Usage string
|
||||||
Required bool
|
Required bool
|
||||||
Default string
|
Default string
|
||||||
|
Bool bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuntimeContext provides helpers for shortcut implementations.
|
// 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 {
|
func Shortcuts() []*common.Shortcut {
|
||||||
return []*common.Shortcut{
|
return []*common.Shortcut{
|
||||||
|
newBatchCloseShortcut(),
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List issues",
|
Description: "List issues",
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,12 @@ func TestIssueClosePreservesCurrentDescription(t *testing.T) {
|
||||||
var updatePayload map[string]interface{}
|
var updatePayload map[string]interface{}
|
||||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch {
|
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{}{
|
writeJSON(t, w, map[string]interface{}{
|
||||||
"subject": "Existing title",
|
"subject": "Existing title",
|
||||||
"description": "Existing description",
|
"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)
|
updatePayload = decodeJSON(t, r)
|
||||||
writeJSON(t, w, updatePayload)
|
writeJSON(t, w, updatePayload)
|
||||||
default:
|
default:
|
||||||
|
|
@ -28,7 +28,7 @@ func TestIssueClosePreservesCurrentDescription(t *testing.T) {
|
||||||
})
|
})
|
||||||
defer server.Close()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("close shortcut failed: %v", err)
|
t.Fatalf("close shortcut failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -42,12 +42,12 @@ func TestIssueUpdatePreservesCurrentDescriptionWhenChangingTitleAndState(t *test
|
||||||
var updatePayload map[string]interface{}
|
var updatePayload map[string]interface{}
|
||||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch {
|
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{}{
|
writeJSON(t, w, map[string]interface{}{
|
||||||
"subject": "Existing title",
|
"subject": "Existing title",
|
||||||
"description": "Existing description",
|
"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)
|
updatePayload = decodeJSON(t, r)
|
||||||
writeJSON(t, w, updatePayload)
|
writeJSON(t, w, updatePayload)
|
||||||
default:
|
default:
|
||||||
|
|
@ -57,9 +57,9 @@ func TestIssueUpdatePreservesCurrentDescriptionWhenChangingTitleAndState(t *test
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
err := runIssueShortcut(t, server, "update", map[string]string{
|
err := runIssueShortcut(t, server, "update", map[string]string{
|
||||||
"id": "42",
|
"number": "42",
|
||||||
"title": "New title",
|
"title": "New title",
|
||||||
"state": "closed",
|
"state": "closed",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("update shortcut failed: %v", err)
|
t.Fatalf("update shortcut failed: %v", err)
|
||||||
|
|
@ -74,12 +74,12 @@ func TestIssueUpdatePreservesCurrentSubjectWhenChangingDescription(t *testing.T)
|
||||||
var updatePayload map[string]interface{}
|
var updatePayload map[string]interface{}
|
||||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch {
|
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{}{
|
writeJSON(t, w, map[string]interface{}{
|
||||||
"subject": "Existing title",
|
"subject": "Existing title",
|
||||||
"description": "Existing description",
|
"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)
|
updatePayload = decodeJSON(t, r)
|
||||||
writeJSON(t, w, updatePayload)
|
writeJSON(t, w, updatePayload)
|
||||||
default:
|
default:
|
||||||
|
|
@ -89,8 +89,8 @@ func TestIssueUpdatePreservesCurrentSubjectWhenChangingDescription(t *testing.T)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
err := runIssueShortcut(t, server, "update", map[string]string{
|
err := runIssueShortcut(t, server, "update", map[string]string{
|
||||||
"id": "42",
|
"number": "42",
|
||||||
"body": "New description",
|
"body": "New description",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("update shortcut failed: %v", err)
|
t.Fatalf("update shortcut failed: %v", err)
|
||||||
|
|
@ -100,6 +100,37 @@ func TestIssueUpdatePreservesCurrentSubjectWhenChangingDescription(t *testing.T)
|
||||||
assertEqual(t, updatePayload["description"], "New description")
|
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 {
|
func runIssueShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
shortcut := findIssueShortcut(t, name)
|
shortcut := findIssueShortcut(t, name)
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ skills/
|
||||||
|-------|------|----------|
|
|-------|------|----------|
|
||||||
| **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` |
|
| **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` |
|
||||||
| **gitlink-repo** | 仓库管理 | `repo +list`, `repo +create`, `repo +info`, `repo +fork` |
|
| **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-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +review` |
|
||||||
| **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +protect` |
|
| **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +protect` |
|
||||||
| **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +view` |
|
| **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +view` |
|
||||||
|
|
@ -169,6 +169,9 @@ gitlink-cli issue +comment -i 123 -b "已修复"
|
||||||
|
|
||||||
# 关闭 Issue
|
# 关闭 Issue
|
||||||
gitlink-cli issue +close -i 123
|
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)
|
详见: [gitlink-issue/examples/issue-workflow.md](gitlink-issue/examples/issue-workflow.md)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
name: gitlink-issue
|
name: gitlink-issue
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
description: "Issue 管理:创建、查看、更新、关闭 Issue,添加评论。当用户需要操作 GitLink Issue 时触发。"
|
description: "Issue 管理:创建、查看、更新、关闭/批量关闭 Issue,添加评论。当用户需要操作 GitLink Issue 时触发。"
|
||||||
metadata:
|
metadata:
|
||||||
requires:
|
requires:
|
||||||
bins: ["gitlink-cli"]
|
bins: ["gitlink-cli"]
|
||||||
|
|
@ -25,6 +25,7 @@ metadata:
|
||||||
| `issue +view` | Issue 详情 | 否(公开项目) |
|
| `issue +view` | Issue 详情 | 否(公开项目) |
|
||||||
| `issue +update` | 更新 Issue | 是 |
|
| `issue +update` | 更新 Issue | 是 |
|
||||||
| `issue +close` | 关闭 Issue | 是 |
|
| `issue +close` | 关闭 Issue | 是 |
|
||||||
|
| `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) |
|
||||||
| `issue +comment` | 添加评论 | 是 |
|
| `issue +comment` | 添加评论 | 是 |
|
||||||
|
|
||||||
## 使用示例
|
## 使用示例
|
||||||
|
|
@ -45,6 +46,12 @@ gitlink-cli issue +update --number 4 --title "新标题" --body "更新描述"
|
||||||
# 关闭 Issue
|
# 关闭 Issue
|
||||||
gitlink-cli issue +close --number 4
|
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 "已修复,请验证"
|
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 注意事项
|
## API 注意事项
|
||||||
|
|
||||||
- **Issue 编号(`--number`)是网页 URL 中看到的序号**(如 `issues/4` 中的 `4`),不是数据库内部 ID
|
- **Issue 编号(`--number`)是网页 URL 中看到的序号**(如 `issues/4` 中的 `4`),不是数据库内部 ID
|
||||||
|
- **批量关闭使用 `--numbers`,同样传网页 URL 中的 Issue 编号**,不是数据库内部 ID
|
||||||
- Issue 操作使用 v1 API(`/api/v1/`),支持按 Issue 编号查询和操作
|
- Issue 操作使用 v1 API(`/api/v1/`),支持按 Issue 编号查询和操作
|
||||||
- **创建 Issue 时 CLI 会自动设置 `status_id: 1`(新增)和 `priority_id: 2`(正常)**
|
- **创建 Issue 时 CLI 会自动设置 `status_id: 1`(新增)和 `priority_id: 2`(正常)**
|
||||||
- **更新/关闭 Issue 时必须保留当前 `subject` 和 `description`**,即使只修改状态(CLI 会先读取当前 Issue 并自动带回)
|
- **更新/关闭 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