gitlink-cli/shortcuts/common/batch.go

282 lines
7.2 KiB
Go

package common
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"strings"
)
// --- Result structures ---
// BatchResult records the outcome of a single item in a batch operation.
type BatchResult struct {
Item string `json:"item" yaml:"item"`
Action string `json:"action" yaml:"action"`
Status string `json:"status" yaml:"status"` // "planned", "ok", "failed"
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
// BatchSummary is the unified output of every batch command.
type BatchSummary 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 []BatchResult `json:"results" yaml:"results"`
}
// --- Input collection ---
// CollectNumbers collects integer identifiers from --numbers and --from CSV.
func CollectNumbers(numbersValue, csvPath string) ([]string, error) {
numbers, err := ParseNumberList(numbersValue)
if err != nil {
return nil, err
}
if csvPath == "" {
return numbers, nil
}
csvNumbers, err := ReadColumnFromCSV(csvPath, []string{"number", "issue_number", "project_issues_index"})
if err != nil {
return nil, err
}
return DedupeStrings(append(numbers, csvNumbers...)), nil
}
// CollectStrings collects string identifiers (e.g. branch names) from --names and --from file.
// The file is read one entry per line (plain text, not CSV).
func CollectStrings(namesValue, filePath string) ([]string, error) {
names := ParseStringList(namesValue)
if filePath == "" {
return names, nil
}
fileNames, err := ReadLinesFromFile(filePath)
if err != nil {
return nil, err
}
return DedupeStrings(append(names, fileNames...)), nil
}
// --- Parsing helpers ---
// ParseNumberList parses a comma-separated list of integers.
func ParseNumberList(value string) ([]string, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
return NormalizeIntStrings(strings.Split(value, ","))
}
// ParseStringList parses a comma-separated list of strings (trimmed).
func ParseStringList(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
return result
}
// --- CSV / file reading ---
// ReadColumnFromCSV reads a single column from a CSV file.
// columnNames lists acceptable header names; if none match, the first column is used.
func ReadColumnFromCSV(path string, columnNames []string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV %s: %w", path, err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV %s: %w", path, err)
}
if len(records) == 0 {
return nil, nil
}
column := -1
startRow := 0
for i, cell := range records[0] {
for _, name := range columnNames {
if strings.EqualFold(strings.TrimSpace(cell), name) {
column = i
startRow = 1
break
}
}
if column >= 0 {
break
}
}
if column < 0 {
column = 0
}
values := make([]string, 0, len(records)-startRow)
for _, record := range records[startRow:] {
if column >= len(record) {
continue
}
v := strings.TrimSpace(record[column])
if v != "" {
values = append(values, v)
}
}
return values, nil
}
// ReadRowsFromCSV reads multiple columns from a CSV file.
// Returns a slice of maps keyed by the required column names.
func ReadRowsFromCSV(path string, requiredCols []string) ([]map[string]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV %s: %w", path, err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV %s: %w", path, err)
}
if len(records) == 0 {
return nil, nil
}
// Map column name → column index from the header row
header := records[0]
colIndex := make(map[string]int)
for i, cell := range header {
normalized := strings.ToLower(strings.TrimSpace(cell))
for _, req := range requiredCols {
if normalized == req {
colIndex[req] = i
}
}
}
// Validate all required columns found
for _, req := range requiredCols {
if _, ok := colIndex[req]; !ok {
return nil, fmt.Errorf("CSV missing required column %q; found headers: %v", req, header)
}
}
rows := make([]map[string]string, 0, len(records)-1)
for _, record := range records[1:] {
row := make(map[string]string, len(requiredCols))
for _, req := range requiredCols {
idx := colIndex[req]
if idx < len(record) {
row[req] = strings.TrimSpace(record[idx])
}
}
rows = append(rows, row)
}
return rows, nil
}
// ReadLinesFromFile reads non-empty lines from a plain-text file.
func ReadLinesFromFile(path string) ([]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read file %s: %w", path, err)
}
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
result := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
result = append(result, line)
}
}
return result, nil
}
// --- Validation & deduplication ---
// NormalizeIntStrings validates that every value is an integer and deduplicates.
func NormalizeIntStrings(values []string) ([]string, error) {
result := make([]string, 0, len(values))
seen := map[string]bool{}
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
if _, err := strconv.ParseInt(v, 10, 64); err != nil {
return nil, fmt.Errorf("invalid number %q: must be an integer", v)
}
if seen[v] {
continue
}
seen[v] = true
result = append(result, v)
}
return result, nil
}
// DedupeStrings deduplicates strings while preserving order.
func DedupeStrings(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, v := range values {
if seen[v] {
continue
}
seen[v] = true
result = append(result, v)
}
return result
}
// --- Batch processing loop ---
// ProcessBatch runs fn on each item. On dry-run it marks items as "planned".
// Single-item failure does not stop the loop; the caller gets a full summary.
func ProcessBatch(items []string, dryRun bool, action string, fn func(string) error) *BatchSummary {
summary := &BatchSummary{
DryRun: dryRun,
Total: len(items),
Results: make([]BatchResult, 0, len(items)),
}
for _, item := range items {
r := BatchResult{Item: item, Action: action}
if dryRun {
r.Status = "planned"
summary.Succeeded++
} else if err := fn(item); err != nil {
r.Status = "failed"
r.Error = err.Error()
summary.Failed++
} else {
r.Status = "ok"
summary.Succeeded++
}
summary.Results = append(summary.Results, r)
}
return summary
}
// --- Utility ---
// ParseBool parses a boolean string.
func ParseBool(value string) bool {
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && parsed
}