forked from chroe/gitlink-cli
675 lines
19 KiB
Go
675 lines
19 KiB
Go
package issue
|
||
|
||
import (
|
||
"encoding/csv"
|
||
"fmt"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||
)
|
||
|
||
const closedIssueStatusID = 5
|
||
const openIssueStatusID = 1
|
||
|
||
type batchResult 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 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"`
|
||
Duration string `json:"duration" yaml:"duration"`
|
||
Results []batchResult `json:"results" yaml:"results"`
|
||
}
|
||
|
||
func newBatchCloseShortcut() *common.Shortcut {
|
||
return &common.Shortcut{
|
||
Name: "batch-close",
|
||
Description: "Close multiple issues by issue numbers or a CSV file",
|
||
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: "dry-run", Usage: "Preview the issues that would be closed without changing them", Bool: true, Default: "false"},
|
||
},
|
||
Run: runBatchClose,
|
||
}
|
||
}
|
||
|
||
func runBatchClose(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||
}
|
||
|
||
start := time.Now()
|
||
|
||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(numbers) == 0 {
|
||
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||
}
|
||
|
||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||
summary := batchSummary{
|
||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||
DryRun: dryRun,
|
||
Total: len(numbers),
|
||
Results: make([]batchResult, 0, len(numbers)),
|
||
}
|
||
|
||
for _, number := range numbers {
|
||
result := batchResult{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)
|
||
}
|
||
|
||
summary.Duration = time.Since(start).String()
|
||
|
||
if err := ctx.OutputData(summary); err != nil {
|
||
return err
|
||
}
|
||
if summary.Failed > 0 {
|
||
return fmt.Errorf("%d / %d 个 Issue 关闭失败", summary.Failed, summary.Total)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func closeIssue(ctx *common.RuntimeContext, number string) error {
|
||
current, err := fetchExistingIssue(ctx, number)
|
||
if err != nil {
|
||
return fmt.Errorf("获取 Issue 详情: %w", err)
|
||
}
|
||
|
||
body := map[string]interface{}{
|
||
"subject": current.Subject,
|
||
"description": current.Description,
|
||
"status_id": closedIssueStatusID,
|
||
}
|
||
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
||
return fmt.Errorf("关闭 Issue: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func newBatchReopenShortcut() *common.Shortcut {
|
||
return &common.Shortcut{
|
||
Name: "batch-reopen",
|
||
Description: "Reopen multiple closed issues by issue numbers or a CSV file",
|
||
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: "dry-run", Usage: "Preview the issues that would be reopened without changing them", Bool: true, Default: "false"},
|
||
},
|
||
Run: runBatchReopen,
|
||
}
|
||
}
|
||
|
||
func runBatchReopen(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||
}
|
||
|
||
start := time.Now()
|
||
|
||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(numbers) == 0 {
|
||
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||
}
|
||
|
||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||
summary := batchSummary{
|
||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||
DryRun: dryRun,
|
||
Total: len(numbers),
|
||
Results: make([]batchResult, 0, len(numbers)),
|
||
}
|
||
|
||
for _, number := range numbers {
|
||
result := batchResult{Number: number, Action: "reopen"}
|
||
if dryRun {
|
||
result.Status = "planned"
|
||
summary.Succeeded++
|
||
summary.Results = append(summary.Results, result)
|
||
continue
|
||
}
|
||
|
||
if err := reopenIssue(ctx, number); err != nil {
|
||
result.Status = "failed"
|
||
result.Error = err.Error()
|
||
summary.Failed++
|
||
} else {
|
||
result.Status = "reopened"
|
||
summary.Succeeded++
|
||
}
|
||
summary.Results = append(summary.Results, result)
|
||
}
|
||
|
||
summary.Duration = time.Since(start).String()
|
||
|
||
if err := ctx.OutputData(summary); err != nil {
|
||
return err
|
||
}
|
||
if summary.Failed > 0 {
|
||
return fmt.Errorf("%d / %d 个 Issue 重新打开失败", summary.Failed, summary.Total)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func reopenIssue(ctx *common.RuntimeContext, number string) error {
|
||
current, err := fetchExistingIssue(ctx, number)
|
||
if err != nil {
|
||
return fmt.Errorf("获取 Issue 详情: %w", err)
|
||
}
|
||
|
||
body := map[string]interface{}{
|
||
"subject": current.Subject,
|
||
"description": current.Description,
|
||
"status_id": openIssueStatusID,
|
||
}
|
||
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
||
return fmt.Errorf("重新打开 Issue: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func newBatchAssignShortcut() *common.Shortcut {
|
||
return &common.Shortcut{
|
||
Name: "batch-assign",
|
||
Description: "Assign multiple issues to a user by issue numbers or a CSV file",
|
||
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: "user", Short: "u", Usage: "Assignee user ID (numeric)", Required: true},
|
||
{Name: "dry-run", Usage: "Preview the issues that would be assigned without changing them", Bool: true, Default: "false"},
|
||
},
|
||
Run: runBatchAssign,
|
||
}
|
||
}
|
||
|
||
func runBatchAssign(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||
}
|
||
|
||
user, err := ctx.RequireArg("user")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(numbers) == 0 {
|
||
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||
}
|
||
|
||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||
summary := batchSummary{
|
||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||
DryRun: dryRun,
|
||
Total: len(numbers),
|
||
Results: make([]batchResult, 0, len(numbers)),
|
||
}
|
||
|
||
for _, number := range numbers {
|
||
result := batchResult{Number: number, Action: "assign"}
|
||
if dryRun {
|
||
result.Status = "planned"
|
||
summary.Succeeded++
|
||
summary.Results = append(summary.Results, result)
|
||
continue
|
||
}
|
||
|
||
if err := assignIssue(ctx, number, user); err != nil {
|
||
result.Status = "failed"
|
||
result.Error = err.Error()
|
||
summary.Failed++
|
||
} else {
|
||
result.Status = "assigned"
|
||
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 / %d 个 Issue 分配失败", summary.Failed, summary.Total)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func assignIssue(ctx *common.RuntimeContext, number, user string) error {
|
||
current, err := fetchExistingIssue(ctx, number)
|
||
if err != nil {
|
||
return fmt.Errorf("获取 Issue 详情: %w", err)
|
||
}
|
||
|
||
userID, err := strconv.Atoi(user)
|
||
if err != nil {
|
||
return fmt.Errorf("user 参数必须是数字 ID,而不是用户名")
|
||
}
|
||
|
||
body := map[string]interface{}{
|
||
"subject": current.Subject,
|
||
"description": current.Description,
|
||
"assigned_to_id": userID,
|
||
}
|
||
if current.StatusID != nil {
|
||
body["status_id"] = current.StatusID
|
||
}
|
||
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
||
return fmt.Errorf("分配 Issue: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func newBatchLabelShortcut() *common.Shortcut {
|
||
return &common.Shortcut{
|
||
Name: "batch-label",
|
||
Description: "Add or remove labels on multiple issues by issue numbers or a CSV file",
|
||
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: "add", Short: "a", Usage: "Comma-separated label IDs to add"},
|
||
{Name: "remove", Short: "r", Usage: "Comma-separated label IDs to remove"},
|
||
{Name: "dry-run", Usage: "Preview the issues that would be labeled without changing them", Bool: true, Default: "false"},
|
||
},
|
||
Run: runBatchLabel,
|
||
}
|
||
}
|
||
|
||
func runBatchLabel(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||
}
|
||
|
||
addIDs := ctx.Arg("add")
|
||
removeIDs := ctx.Arg("remove")
|
||
if addIDs == "" && removeIDs == "" {
|
||
return fmt.Errorf("至少需要指定 --add 或 --remove 中的一个")
|
||
}
|
||
|
||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(numbers) == 0 {
|
||
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||
}
|
||
|
||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||
summary := batchSummary{
|
||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||
DryRun: dryRun,
|
||
Total: len(numbers),
|
||
Results: make([]batchResult, 0, len(numbers)),
|
||
}
|
||
|
||
addLabels := parseCommaSeparated(addIDs)
|
||
removeLabels := parseCommaSeparated(removeIDs)
|
||
|
||
for _, number := range numbers {
|
||
result := batchResult{Number: number, Action: "label"}
|
||
if dryRun {
|
||
result.Status = "planned"
|
||
summary.Succeeded++
|
||
summary.Results = append(summary.Results, result)
|
||
continue
|
||
}
|
||
|
||
if err := labelIssue(ctx, number, addLabels, removeLabels); err != nil {
|
||
result.Status = "failed"
|
||
result.Error = err.Error()
|
||
summary.Failed++
|
||
} else {
|
||
result.Status = "labeled"
|
||
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 / %d 个 Issue 标签操作失败", summary.Failed, summary.Total)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func labelIssue(ctx *common.RuntimeContext, number string, addLabels, removeLabels []string) error {
|
||
var errs []string
|
||
for _, labelID := range addLabels {
|
||
body := map[string]interface{}{
|
||
"tag_id": labelID,
|
||
}
|
||
if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/tags", v1RepoPath(ctx), number), body); err != nil {
|
||
errs = append(errs, fmt.Sprintf("添加标签 %s: %v", labelID, err))
|
||
}
|
||
}
|
||
for _, labelID := range removeLabels {
|
||
if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/tags/%s", v1RepoPath(ctx), number, labelID), nil); err != nil {
|
||
errs = append(errs, fmt.Sprintf("移除标签 %s: %v", labelID, err))
|
||
}
|
||
}
|
||
if len(errs) > 0 {
|
||
return fmt.Errorf("%s", strings.Join(errs, "\n"))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func newBatchMilestoneShortcut() *common.Shortcut {
|
||
return &common.Shortcut{
|
||
Name: "batch-milestone",
|
||
Description: "Set milestone on multiple issues by issue numbers or a CSV file",
|
||
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: "milestone", Short: "m", Usage: "Milestone ID", Required: true},
|
||
{Name: "dry-run", Usage: "Preview the issues that would be updated without changing them", Bool: true, Default: "false"},
|
||
},
|
||
Run: runBatchMilestone,
|
||
}
|
||
}
|
||
|
||
func runBatchMilestone(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||
}
|
||
|
||
milestoneID, err := ctx.RequireArg("milestone")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(numbers) == 0 {
|
||
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||
}
|
||
|
||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||
summary := batchSummary{
|
||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||
DryRun: dryRun,
|
||
Total: len(numbers),
|
||
Results: make([]batchResult, 0, len(numbers)),
|
||
}
|
||
|
||
for _, number := range numbers {
|
||
result := batchResult{Number: number, Action: "milestone"}
|
||
if dryRun {
|
||
result.Status = "planned"
|
||
summary.Succeeded++
|
||
summary.Results = append(summary.Results, result)
|
||
continue
|
||
}
|
||
|
||
if err := setMilestone(ctx, number, milestoneID); err != nil {
|
||
result.Status = "failed"
|
||
result.Error = err.Error()
|
||
summary.Failed++
|
||
} else {
|
||
result.Status = "milestoned"
|
||
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 / %d 个 Issue 设置里程碑失败", summary.Failed, summary.Total)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func setMilestone(ctx *common.RuntimeContext, number, milestoneID string) error {
|
||
current, err := fetchExistingIssue(ctx, number)
|
||
if err != nil {
|
||
return fmt.Errorf("获取 Issue 详情: %w", err)
|
||
}
|
||
|
||
body := map[string]interface{}{
|
||
"subject": current.Subject,
|
||
"description": current.Description,
|
||
"fixed_version_id": milestoneID,
|
||
}
|
||
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
|
||
return fmt.Errorf("设置里程碑: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func newBatchCommentShortcut() *common.Shortcut {
|
||
return &common.Shortcut{
|
||
Name: "batch-comment",
|
||
Description: "Add a comment to multiple issues by issue numbers or a CSV file",
|
||
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: "body", Short: "b", Usage: "Comment body", Required: true},
|
||
{Name: "dry-run", Usage: "Preview the issues that would be commented on without changing them", Bool: true, Default: "false"},
|
||
},
|
||
Run: runBatchComment,
|
||
}
|
||
}
|
||
|
||
func runBatchComment(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||
}
|
||
|
||
body, err := ctx.RequireArg("body")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
start := time.Now()
|
||
|
||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(numbers) == 0 {
|
||
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
|
||
}
|
||
|
||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||
summary := batchSummary{
|
||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||
DryRun: dryRun,
|
||
Total: len(numbers),
|
||
Results: make([]batchResult, 0, len(numbers)),
|
||
}
|
||
|
||
for _, number := range numbers {
|
||
result := batchResult{Number: number, Action: "comment"}
|
||
if dryRun {
|
||
result.Status = "planned"
|
||
summary.Succeeded++
|
||
summary.Results = append(summary.Results, result)
|
||
continue
|
||
}
|
||
|
||
if err := commentIssue(ctx, number, body); err != nil {
|
||
result.Status = "failed"
|
||
result.Error = err.Error()
|
||
summary.Failed++
|
||
} else {
|
||
result.Status = "commented"
|
||
summary.Succeeded++
|
||
}
|
||
summary.Results = append(summary.Results, result)
|
||
}
|
||
|
||
summary.Duration = time.Since(start).String()
|
||
|
||
if err := ctx.OutputData(summary); err != nil {
|
||
return err
|
||
}
|
||
if summary.Failed > 0 {
|
||
return fmt.Errorf("%d / %d 个 Issue 评论失败", summary.Failed, summary.Total)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func commentIssue(ctx *common.RuntimeContext, number, body string) error {
|
||
payload := map[string]interface{}{
|
||
"notes": body,
|
||
}
|
||
if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload); err != nil {
|
||
return fmt.Errorf("添加评论: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func parseCommaSeparated(value string) []string {
|
||
if strings.TrimSpace(value) == "" {
|
||
return nil
|
||
}
|
||
var result []string
|
||
for _, item := range strings.Split(value, ",") {
|
||
if trimmed := strings.TrimSpace(item); trimmed != "" {
|
||
result = append(result, trimmed)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
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 parseIssueNumbers(value string) ([]string, error) {
|
||
if strings.TrimSpace(value) == "" {
|
||
return nil, nil
|
||
}
|
||
return normalizeIssueNumbers(strings.Split(value, ","))
|
||
}
|
||
|
||
func readIssueNumbersFromCSV(path string) ([]string, error) {
|
||
file, err := os.Open(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
|
||
}
|
||
defer file.Close()
|
||
|
||
reader := csv.NewReader(file)
|
||
reader.TrimLeadingSpace = true
|
||
records, err := reader.ReadAll()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("解析 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)
|
||
}
|
||
|
||
func normalizeIssueNumbers(values []string) ([]string, error) {
|
||
numbers := make([]string, 0, len(values))
|
||
seen := map[string]bool{}
|
||
for _, value := range values {
|
||
number := strings.TrimSpace(value)
|
||
if number == "" {
|
||
continue
|
||
}
|
||
if _, err := strconv.ParseInt(number, 10, 64); err != nil {
|
||
return nil, fmt.Errorf("无效的 Issue 编号 %q: Issue 编号必须是整数", number)
|
||
}
|
||
if seen[number] {
|
||
continue
|
||
}
|
||
seen[number] = true
|
||
numbers = append(numbers, number)
|
||
}
|
||
return numbers, nil
|
||
}
|
||
|
||
func mergeIssueNumbers(values ...[]string) []string {
|
||
merged := []string{}
|
||
seen := map[string]bool{}
|
||
for _, numbers := range values {
|
||
for _, number := range numbers {
|
||
if seen[number] {
|
||
continue
|
||
}
|
||
seen[number] = true
|
||
merged = append(merged, number)
|
||
}
|
||
}
|
||
return merged
|
||
}
|
||
|
||
func parseBool(value string) bool {
|
||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||
return err == nil && parsed
|
||
}
|