forked from Gitlink/gitlink-cli
282 lines
7.2 KiB
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
|
|
}
|