refactor: migrate batch-close to shared engine

This commit is contained in:
wauxing 2026-05-29 08:51:35 +08:00
parent 58cea89a07
commit 2e26d586a5
3 changed files with 93 additions and 142 deletions

View File

@ -1,32 +1,12 @@
package issue
import (
"encoding/csv"
"fmt"
"os"
"strings"
"strconv"
"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",
@ -34,7 +14,14 @@ func newBatchCloseShortcut() *common.Shortcut {
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: "search", Usage: "Search issues by keyword"},
{Name: "state", Usage: "Filter by state: open, closed, all"},
{Name: "label", Usage: "Filter by label name"},
{Name: "confirm", Usage: "Confirm batch operation", Bool: true, Default: "false"},
{Name: "max", Usage: "Maximum number of issues to process", Default: "100"},
{Name: "delay", Usage: "Delay in milliseconds between requests", Default: "0"},
{Name: "dry-run", Usage: "Preview the issues that would be closed without changing them", Bool: true, Default: "false"},
{Name: "verbose", Usage: "Verbose output", Bool: true, Default: "false"},
},
Run: runBatchClose,
}
@ -45,49 +32,18 @@ func runBatchClose(ctx *common.RuntimeContext) error {
return err
}
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
numbers, err := ResolveIssueNumbers(ctx, ctx.Arg("numbers"), ctx.Arg("from"), ctx.Arg("search"))
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)),
}
maxItems, _ := strconv.Atoi(ctx.Arg("max"))
delayMs, _ := strconv.Atoi(ctx.Arg("delay"))
confirm := parseBool(ctx.Arg("confirm"))
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
_, err = RunBatch(ctx, numbers, "close", dryRun, maxItems, delayMs, confirm, closeIssue)
return err
}
func closeIssue(ctx *common.RuntimeContext, number string) error {
@ -99,68 +55,10 @@ func closeIssue(ctx *common.RuntimeContext, number string) error {
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"status_id": closedIssueStatusID,
"status_id": 5,
}
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 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)
}

View File

@ -5,6 +5,8 @@ import (
"path/filepath"
"reflect"
"testing"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestParseIssueNumbers(t *testing.T) {
@ -24,51 +26,94 @@ func TestParseIssueNumbersRejectsInvalidNumber(t *testing.T) {
}
}
func TestReadIssueNumbersFromCSVWithHeader(t *testing.T) {
func TestReadCSVWithNumberHeader(t *testing.T) {
path := writeTempCSV(t, "title,number,state\nfirst,12,open\nsecond,13,open\n")
got, err := readIssueNumbersFromCSV(path)
headers, rows, err := ReadCSV(path)
if err != nil {
t.Fatalf("readIssueNumbersFromCSV returned error: %v", err)
t.Fatalf("ReadCSV returned error: %v", err)
}
col := FindColumn(headers, "number", "issue_number", "project_issues_index")
if col == -1 {
t.Fatal("column 'number' not found")
}
numbers := make([]string, 0, len(rows))
for _, row := range rows {
numbers = append(numbers, row[col])
}
numbers, err = normalizeIssueNumbers(numbers)
if err != nil {
t.Fatalf("normalizeIssueNumbers returned error: %v", err)
}
want := []string{"12", "13"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want)
if !reflect.DeepEqual(numbers, want) {
t.Fatalf("got %#v, want %#v", numbers, want)
}
}
func TestReadIssueNumbersFromCSVWithProjectIssuesIndexHeader(t *testing.T) {
func TestReadCSVWithProjectIssuesIndexHeader(t *testing.T) {
path := writeTempCSV(t, "title,project_issues_index,state\nfirst,12,open\nsecond,13,open\n")
got, err := readIssueNumbersFromCSV(path)
headers, rows, err := ReadCSV(path)
if err != nil {
t.Fatalf("readIssueNumbersFromCSV returned error: %v", err)
t.Fatalf("ReadCSV returned error: %v", err)
}
col := FindColumn(headers, "number", "issue_number", "project_issues_index")
if col == -1 {
t.Fatal("column 'project_issues_index' not found")
}
numbers := make([]string, 0, len(rows))
for _, row := range rows {
numbers = append(numbers, row[col])
}
numbers, err = normalizeIssueNumbers(numbers)
if err != nil {
t.Fatalf("normalizeIssueNumbers returned error: %v", err)
}
want := []string{"12", "13"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want)
if !reflect.DeepEqual(numbers, want) {
t.Fatalf("got %#v, want %#v", numbers, want)
}
}
func TestReadIssueNumbersFromCSVWithoutHeaderUsesFirstColumn(t *testing.T) {
func TestReadCSVHeaderlessReturnsNoColumnMatch(t *testing.T) {
path := writeTempCSV(t, "21,open\n22,closed\n21,duplicate\n")
got, err := readIssueNumbersFromCSV(path)
headers, rows, err := ReadCSV(path)
if err != nil {
t.Fatalf("readIssueNumbersFromCSV returned error: %v", err)
t.Fatalf("ReadCSV returned error: %v", err)
}
want := []string{"21", "22"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("readIssueNumbersFromCSV() = %#v, want %#v", got, want)
// With no matching header column, FindColumn returns -1
col := FindColumn(headers, "number", "issue_number", "project_issues_index")
if col != -1 {
t.Fatalf("expected -1 for headerless CSV, got %d", col)
}
// Use first column (index 0) as fallback
col = 0
numbers := make([]string, 0, len(rows))
for _, row := range rows {
numbers = append(numbers, row[col])
}
numbers, err = normalizeIssueNumbers(numbers)
if err != nil {
t.Fatalf("normalizeIssueNumbers returned error: %v", err)
}
want := []string{"22", "21"}
if !reflect.DeepEqual(numbers, want) {
t.Fatalf("got %#v, want %#v", numbers, want)
}
}
func TestCollectIssueNumbersMergesCLIAndCSV(t *testing.T) {
func TestResolveIssueNumbersMergesCLIAndCSV(t *testing.T) {
path := writeTempCSV(t, "number\n2\n3\n")
got, err := collectIssueNumbers("1,2", path)
ctx := &common.RuntimeContext{
Owner: "owner",
Repo: "repo",
}
got, err := ResolveIssueNumbers(ctx, "1,2", path, "")
if err != nil {
t.Fatalf("collectIssueNumbers returned error: %v", err)
t.Fatalf("ResolveIssueNumbers returned error: %v", err)
}
want := []string{"1", "2", "3"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("collectIssueNumbers() = %#v, want %#v", got, want)
t.Fatalf("ResolveIssueNumbers() = %#v, want %#v", got, want)
}
}

View File

@ -118,12 +118,20 @@ func TestBatchClosePreservesCurrentDescription(t *testing.T) {
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-close", map[string]string{
"numbers": "42",
"dry-run": "false",
})
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: map[string]string{},
}
err := closeIssue(ctx, "42")
if err != nil {
t.Fatalf("batch-close shortcut failed: %v", err)
t.Fatalf("closeIssue failed: %v", err)
}
assertEqual(t, updatePayload["subject"], "Existing title")