forked from Gitlink/gitlink-cli
refactor: migrate batch-close to shared engine
This commit is contained in:
parent
58cea89a07
commit
2e26d586a5
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue