gitlink-cli/shortcuts/common/batch.go

305 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package common
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"strings"
)
// --- Result structures 批量操作输出结构 ---
// BatchResult 记录批量操作中单个项目的处理结果
type BatchResult struct {
Item string `json:"item" yaml:"item"` // 项目标识issue/PR 编号、分支名、用户名等)
Action string `json:"action" yaml:"action"` // 操作类型(如 "close"、"delete"、"add"
Status string `json:"status" yaml:"status"` // 状态:"planned"(dry-run)、"ok"(成功)、"failed"(失败)
Error string `json:"error,omitempty" yaml:"error,omitempty"` // 失败原因
}
// BatchSummary 是所有批量命令的统一输出格式
// 无论操作哪种资源,输出结构都一致,方便脚本处理
type BatchSummary struct {
Repository string `json:"repository" yaml:"repository"` // 仓库路径 owner/repo
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"` // 每个项目的详细结果
}
// --- Batch processing loop 核心处理循环 ---
// ProcessBatch 是批处理的"发动机"——对每项执行操作,支持 dry-run错误不中断
// items: 待处理项列表(如 ["1","2","3"]
// dryRun: true 时只展示计划,不执行任何 API 调用
// action: 操作名称(如 "close"、"delete"),会填入 BatchResult.Action
// fn: 业务逻辑函数(回调/控制反转模式),调用方定义"对单个项做什么操作"
// 返回值: BatchSummary 包含每项详细结果
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)),
}
// 逐项处理dry-run 模式只标记不执行;否则调用 fn 执行实际操作
for _, item := range items {
r := BatchResult{Item: item, Action: action}
if dryRun {
r.Status = "planned" // 预览模式:不调 API仅标记
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 中
summary.Results = append(summary.Results, r)
}
return summary
}
// --- Input collection ---
// CollectNumbers 收集整数标识符issue/PR 编号),支持两种输入方式合并去重
// numbersValue: 来自 --numbers 参数(逗号分隔,如 "1,2,3"
// csvPath: 来自 --from 参数CSV 文件路径)
// 两种方式可组合使用CollectNumbers("1,2", "data.csv") → ["1","2","5","6"]
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 收集字符串标识符(分支名、用户名等),支持 --names 和 --from 文件合并去重
// 与 CollectNumbers 不同,此类标识符不需要是整数
// 文件格式:每行一个名称(纯文本,不是 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 解析逗号分隔数字列表 → 校验每个值都是整数 + 去重
// 输入 "7,8,9" → ["7","8","9"];空字符串 → nil合法空值
// 输入 "7,abc" → 报错 "invalid number \"abc\": must be an integer"
func ParseNumberList(value string) ([]string, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
return NormalizeIntStrings(strings.Split(value, ","))
}
// ParseStringList 解析逗号分隔字符串列表,自动去除空格和空值
// 输入 "branch1, branch2, " → ["branch1","branch2"]
// 注意:此函数不做整数校验,适用于分支名/用户名等非数字标识符
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 从 CSV 文件中读取单列数据(支持表头自动检测)
// columnNames: 可接受的列名列表,按优先级排序
// 列名检测逻辑:依次匹配 number / issue_number / project_issues_index
// 都不匹配则默认使用第一列有表头从第2行读无表头从第1行读
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 从 CSV 文件读取多列数据,返回 [{col:val, ...}, ...]
// 适用于需要同时读取多列的批量操作,如 collaborator 需要 user_id + role 两列
// requiredCols: 需要的列名列表,函数会校验这些列是否都存在,不存在则报错
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
}
// 步骤1从表头行构建列名→列索引的映射表
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 // 记录该列在第几列
}
}
}
// 步骤2校验所有必需的列是否都在表头中找到缺列直接报错
for _, req := range requiredCols {
if _, ok := colIndex[req]; !ok {
return nil, fmt.Errorf("CSV missing required column %q; found headers: %v", req, header)
}
}
// 步骤3逐行读取数据按列名取值
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 从纯文本文件逐行读取非CSV自动忽略空行
// 用于支持 --from 参数传入分支名、用户名列表(每行一个)
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 校验每个值都是合法整数 + 去重,批处理的输入"安检"
// 输入 ["7","8","8","abc"] → 报错("abc" 不是整数)
// 输入 ["7","8","8","9"] → ["7","8","9"](重复的 "8" 被去掉)
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 // 忽略空字符串
}
// 核心校验strconv.ParseInt 尝试将字符串解析为 64 位整数
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 字符串去重(保持原始顺序,保留第一个出现的)
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
}
// --- Utility ---
// ParseBool 解析布尔字符串,用于处理 --prerelease 等布尔参数
func ParseBool(value string) bool {
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && parsed
}