feat: add batch-update CSV mode

This commit is contained in:
wauxing 2026-05-29 08:56:27 +08:00
parent 843bf0b505
commit 56f7e428a3
1 changed files with 146 additions and 0 deletions

View File

@ -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
}