forked from Gitlink/gitlink-cli
305 lines
9.8 KiB
Go
305 lines
9.8 KiB
Go
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
|
||
}
|