From 56f7e428a3acecfc09e5281bee3c3344f7539ff8 Mon Sep 17 00:00:00 2001 From: wauxing Date: Fri, 29 May 2026 08:56:27 +0800 Subject: [PATCH] feat: add batch-update CSV mode --- shortcuts/issue/batch_update.go | 146 ++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 shortcuts/issue/batch_update.go diff --git a/shortcuts/issue/batch_update.go b/shortcuts/issue/batch_update.go new file mode 100644 index 0000000..531d83d --- /dev/null +++ b/shortcuts/issue/batch_update.go @@ -0,0 +1,146 @@ +package issue + +import ( + "fmt" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +var updateFieldMapping = map[string]string{ + "title": "subject", + "body": "description", + "state": "status_id", + "assignee": "assigned_to_id", + "milestone": "fixed_version_id", + "label": "label_ids", + "priority": "priority_id", +} + +func newBatchUpdateShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-update", + Description: "Update multiple issues via CSV", + Flags: []common.Flag{ + {Name: "from", Usage: "CSV file path with updates", Required: true}, + {Name: "dry-run", Usage: "Preview", Bool: true, Default: "false"}, + {Name: "confirm", Usage: "Confirm", Bool: true, Default: "false"}, + {Name: "max", Usage: "Max items", Default: "100"}, + {Name: "delay", Usage: "Delay ms", Default: "0"}, + {Name: "verbose", Short: "v", Usage: "Per-item progress", Bool: true, Default: "false"}, + }, + Run: runBatchUpdate, + } +} + +func runBatchUpdate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + headers, rows, err := ReadCSV(ctx.Arg("from")) + if err != nil { + return err + } + + numberCol := FindColumn(headers, "number", "issue_number", "project_issues_index") + if numberCol == -1 { + return fmt.Errorf("CSV missing number column") + } + + numbers := make([]string, 0, len(rows)) + rowByNumber := make(map[string][]string) + for _, row := range rows { + if numberCol < len(row) { + n := strings.TrimSpace(row[numberCol]) + if n != "" { + numbers = append(numbers, n) + rowByNumber[n] = row + } + } + } + + dryRun := parseBool(ctx.Arg("dry-run")) + maxItems := parseIntArg(ctx, "max", 100) + delayMs := parseIntArg(ctx, "delay", 0) + confirm := parseBool(ctx.Arg("confirm")) + + updateFn := func(c *common.RuntimeContext, number string) error { + row, ok := rowByNumber[number] + if !ok { + return fmt.Errorf("no CSV data for issue #%s", number) + } + return applyIssueUpdates(c, number, row, headers) + } + _, err = RunBatch(ctx, numbers, "update", dryRun, maxItems, delayMs, confirm, updateFn) + return err +} + +func applyIssueUpdates(ctx *common.RuntimeContext, number string, row []string, headers []string) error { + current, err := fetchIssueData(ctx, number) + if err != nil { + return fmt.Errorf("fetch issue: %w", err) + } + + body := map[string]interface{}{ + "subject": current.Subject, + "description": current.Description, + } + + for i, colName := range headers { + colName = strings.TrimSpace(colName) + apiField, ok := updateFieldMapping[colName] + if !ok || i >= len(row) { + continue + } + val := strings.TrimSpace(row[i]) + if val == "" { + continue + } + + switch apiField { + case "subject": + body["subject"] = val + case "description": + body["description"] = val + case "status_id": + sid, err := normalizeIssueStatus(val) + if err != nil { + return fmt.Errorf("issue #%s state %q: %w", number, val, err) + } + body["status_id"] = sid + case "assigned_to_id": + id, err := resolveAssigneeID(ctx, val) + if err != nil { + return fmt.Errorf("issue #%s assignee %q: %w", number, val, err) + } + body["assigned_to_id"] = id + case "fixed_version_id": + if id, err := strconv.Atoi(val); err == nil { + body["fixed_version_id"] = id + } else { + id, err := ResolveMilestoneID(ctx, val) + if err != nil { + return fmt.Errorf("issue #%s milestone %q: %w", number, val, err) + } + body["fixed_version_id"] = id + } + case "label_ids": + labelIDs, err := resolveLabelArgs(ctx, val, "") + if err != nil { + return fmt.Errorf("issue #%s label %q: %w", number, val, err) + } + body["label_ids"] = labelIDs + case "priority_id": + if pid, err := strconv.Atoi(val); err == nil { + body["priority_id"] = pid + } + } + } + _, err = ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) + if err != nil { + return fmt.Errorf("update issue: %w", err) + } + return nil +}