diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index 76faf5b..a5e2f92 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -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) -} - - diff --git a/shortcuts/issue/batch_test.go b/shortcuts/issue/batch_test.go index 54cdf55..4ec2e5e 100644 --- a/shortcuts/issue/batch_test.go +++ b/shortcuts/issue/batch_test.go @@ -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) } } diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 088f82c..ba08b3b 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -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")