diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index 2345b6c..48d67af 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -10,32 +10,68 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -const closedIssueStatusID = 5 +// Priority constants +const ( + priorityLow = 1 + priorityNormal = 2 + priorityHigh = 3 + priorityUrgent = 4 +) -type batchCloseResult struct { +// Status constants +const ( + statusNew = 1 + statusInProgress = 2 + statusResolved = 3 + statusClosed = 5 + statusRejected = 6 +) + +// Tag name → GitLink tag ID mapping +// Collect IDs from web UI DevTools: change tag → capture PATCH payload → get issue_tag_ids value +var tagIDs = map[string]int{ + "缺陷": 315526, + "功能": 315527, + "文档": 315533, + "重复": 315525, + "疑问": 315528, + "支持": 315529, + "任务": 315530, + "测试": 315534, + "协助": 315531, + "搁置": 315532, +} + +// BatchResult is a single item result in a batch operation. +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 batchCloseSummary 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"` - Results []batchCloseResult `json:"results" yaml:"results"` +// BatchSummary is the aggregate result of a batch operation. +type BatchSummary struct { + Repository string `json:"repository" yaml:"repository"` + Action string `json:"action" yaml:"action"` + Value string `json:"value,omitempty" yaml:"value,omitempty"` + 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-close ---- + 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"}, + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"}, + {Name: "from", Usage: "Read issue numbers from a CSV file"}, + {Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"}, }, Run: runBatchClose, } @@ -45,7 +81,6 @@ func runBatchClose(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from")) if err != nil { return err @@ -55,23 +90,23 @@ func runBatchClose(ctx *common.RuntimeContext) error { } dryRun := parseBool(ctx.Arg("dry-run")) - summary := batchCloseSummary{ + summary := BatchSummary{ Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + Action: "close", DryRun: dryRun, Total: len(numbers), - Results: make([]batchCloseResult, 0, len(numbers)), + Results: make([]BatchResult, 0, len(numbers)), } for _, number := range numbers { - result := batchCloseResult{Number: number, Action: "close"} + 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 { + if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusClosed}); err != nil { result.Status = "failed" result.Error = err.Error() summary.Failed++ @@ -86,28 +121,411 @@ func runBatchClose(ctx *common.RuntimeContext) error { return err } if summary.Failed > 0 { - return fmt.Errorf("%d of %d issue(s) failed to close", summary.Failed, summary.Total) + return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total) } return nil } -func closeIssue(ctx *common.RuntimeContext, number string) error { +// ---- batch-status ---- + +func newBatchStatusShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-status", + Description: "Change status for multiple issues", + Flags: []common.Flag{ + {Name: "state", Short: "s", Usage: "Target state: new, in-progress, resolved, closed, rejected", Required: true}, + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"}, + {Name: "from", Usage: "Read issue numbers from a CSV file"}, + {Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"}, + }, + Run: runBatchStatus, + } +} + +func runBatchStatus(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + state := ctx.Arg("state") + statusID, err := parseStatus(state) + 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("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") + } + + dryRun := parseBool(ctx.Arg("dry-run")) + summary := BatchSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + Action: "set-status", + Value: state, + DryRun: dryRun, + Total: len(numbers), + Results: make([]BatchResult, 0, len(numbers)), + } + + for _, number := range numbers { + result := BatchResult{Number: number, Action: "set-status"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusID}); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = state + 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 of %d issue(s) failed", summary.Failed, summary.Total) + } + return nil +} + +// ---- batch-priority ---- + +func newBatchPriorityShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-priority", + Description: "Change priority for multiple issues", + Flags: []common.Flag{ + {Name: "priority", Short: "p", Usage: "Target priority: low, normal, high, urgent", Required: true}, + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"}, + {Name: "from", Usage: "Read issue numbers from a CSV file"}, + {Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"}, + }, + Run: runBatchPriority, + } +} + +func runBatchPriority(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + priority := ctx.Arg("priority") + priorityID, err := parsePriority(priority) + 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("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") + } + + dryRun := parseBool(ctx.Arg("dry-run")) + summary := BatchSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + Action: "set-priority", + Value: priority, + DryRun: dryRun, + Total: len(numbers), + Results: make([]BatchResult, 0, len(numbers)), + } + + for _, number := range numbers { + result := BatchResult{Number: number, Action: "set-priority"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + if err := updateIssueField(ctx, number, map[string]interface{}{"priority_id": priorityID}); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = priority + 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 of %d issue(s) failed", summary.Failed, summary.Total) + } + return nil +} + +// ---- batch-assign ---- + +func newBatchAssignShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-assign", + Description: "Change assignee for multiple issues", + Flags: []common.Flag{ + {Name: "assignee", Short: "a", Usage: "Assignee login name or user ID", Required: true}, + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"}, + {Name: "from", Usage: "Read issue numbers from a CSV file"}, + {Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"}, + }, + Run: runBatchAssign, + } +} + +func runBatchAssign(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + assignee := ctx.Arg("assignee") + + numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from")) + if err != nil { + return err + } + if len(numbers) == 0 { + return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") + } + + dryRun := parseBool(ctx.Arg("dry-run")) + summary := BatchSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + Action: "set-assignee", + Value: assignee, + DryRun: dryRun, + Total: len(numbers), + Results: make([]BatchResult, 0, len(numbers)), + } + + var assigneeID interface{} + if !dryRun { + id, err := resolveUserID(ctx, assignee) + if err != nil { + return fmt.Errorf("cannot resolve assignee %q: %w", assignee, err) + } + assigneeID = id + } + + for _, number := range numbers { + result := BatchResult{Number: number, Action: "set-assignee"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + body := map[string]interface{}{"assigner_ids": []interface{}{assigneeID}} + if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); 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 of %d issue(s) failed", summary.Failed, summary.Total) + } + return nil +} + +// ---- batch-label ---- + +func newBatchLabelShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-label", + Description: "Change tracker label for multiple issues", + Flags: []common.Flag{ + {Name: "label", Short: "l", Usage: "Target label: bug, feature, support, doc, test, duplicate, question", Required: true}, + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"}, + {Name: "from", Usage: "Read issue numbers from a CSV file"}, + {Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"}, + }, + Run: runBatchLabel, + } +} + +func runBatchLabel(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + label := ctx.Arg("label") + trackerID, err := parseLabel(label) + 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("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") + } + + dryRun := parseBool(ctx.Arg("dry-run")) + summary := BatchSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + Action: "set-label", + Value: label, + DryRun: dryRun, + Total: len(numbers), + Results: make([]BatchResult, 0, len(numbers)), + } + + for _, number := range numbers { + result := BatchResult{Number: number, Action: "set-label"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + if err := updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []interface{}{trackerID}}); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = label + 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 of %d issue(s) failed", summary.Failed, summary.Total) + } + return nil +} + +// ---- shared helpers ---- + +// updateIssueField fetches the current issue to preserve subject/description, +// then PATCHes with the given fields merged in. +func updateIssueField(ctx *common.RuntimeContext, number string, fields map[string]interface{}) error { current, err := fetchExistingIssue(ctx, number) if err != nil { - return fmt.Errorf("fetch issue: %w", err) + return fmt.Errorf("fetch issue #%s: %w", number, err) } body := map[string]interface{}{ "subject": current.Subject, "description": current.Description, - "status_id": closedIssueStatusID, } + for k, v := range fields { + body[k] = v + } + if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil { - return fmt.Errorf("close issue: %w", err) + return fmt.Errorf("update issue #%s: %w", number, err) } return nil } +// resolveUserID converts a login name to a numeric user ID via the users API. +func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error) { + if id, err := strconv.Atoi(login); err == nil { + return id, nil + } + + env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil) + if err != nil { + return nil, fmt.Errorf("lookup user %q: %w", login, err) + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("unexpected response for user %q", login) + } + idFloat, ok := data["id"].(float64) + if ok { + return int(idFloat), nil + } + userIDFloat, ok := data["user_id"].(float64) + if ok { + return int(userIDFloat), nil + } + return nil, fmt.Errorf("cannot determine user ID for %q", login) +} + +func parseStatus(state string) (int, error) { + switch strings.ToLower(strings.TrimSpace(state)) { + case "new": + return statusNew, nil + case "in-progress", "in_progress", "inprogress": + return statusInProgress, nil + case "resolved": + return statusResolved, nil + case "closed": + return statusClosed, nil + case "rejected": + return statusRejected, nil + default: + if id, err := strconv.Atoi(state); err == nil { + return id, nil + } + return 0, fmt.Errorf("invalid state %q: use new, in-progress, resolved, closed, or rejected", state) + } +} + +func parsePriority(p string) (int, error) { + switch strings.ToLower(strings.TrimSpace(p)) { + case "low": + return priorityLow, nil + case "normal": + return priorityNormal, nil + case "high": + return priorityHigh, nil + case "urgent": + return priorityUrgent, nil + default: + if id, err := strconv.Atoi(p); err == nil { + return id, nil + } + return 0, fmt.Errorf("invalid priority %q: use low, normal, high, or urgent", p) + } +} + +func parseLabel(name string) (int, error) { + name = strings.TrimSpace(name) + if id, ok := tagIDs[name]; ok && id != 0 { + return id, nil + } + if id, err := strconv.Atoi(name); err == nil { + return id, nil + } + return 0, fmt.Errorf("label %q not found or tag ID not configured; valid names: %s", name, labelNames()) +} + +func labelNames() string { + names := make([]string, 0, len(tagIDs)) + for n := range tagIDs { + names = append(names, n) + } + return strings.Join(names, ", ") +} + func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) { numbers, err := parseIssueNumbers(numbersValue) if err != nil { @@ -134,7 +552,7 @@ func parseIssueNumbers(value string) ([]string, error) { func readIssueNumbersFromCSV(path string) ([]string, error) { file, err := os.Open(path) if err != nil { - return nil, fmt.Errorf("read issue numbers from CSV: %w", err) + return nil, fmt.Errorf("read CSV: %w", err) } defer file.Close() @@ -142,7 +560,7 @@ func readIssueNumbersFromCSV(path string) ([]string, error) { reader.TrimLeadingSpace = true records, err := reader.ReadAll() if err != nil { - return nil, fmt.Errorf("parse issue numbers from CSV: %w", err) + return nil, fmt.Errorf("parse CSV: %w", err) } if len(records) == 0 { return nil, nil @@ -180,7 +598,7 @@ func normalizeIssueNumbers(values []string) ([]string, error) { continue } if _, err := strconv.ParseInt(number, 10, 64); err != nil { - return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number) + return nil, fmt.Errorf("invalid issue number %q: must be an integer", number) } if seen[number] { continue diff --git a/shortcuts/issue/batch_create.go b/shortcuts/issue/batch_create.go new file mode 100644 index 0000000..aecd997 --- /dev/null +++ b/shortcuts/issue/batch_create.go @@ -0,0 +1,338 @@ +package issue + +import ( + "encoding/csv" + "fmt" + "os" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func newBatchCreateShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-create", + Description: "Create multiple issues from CLI flags or a CSV file", + Flags: []common.Flag{ + {Name: "titles", Usage: "Comma-separated issue titles, e.g. 标题1,标题2"}, + {Name: "priority", Short: "p", Usage: "Priority: low, normal, high, urgent (default: normal)"}, + {Name: "label", Short: "l", Usage: "Label name, e.g. 缺陷"}, + {Name: "assignee", Short: "a", Usage: "Assignee login name"}, + {Name: "state", Short: "s", Usage: "Initial state: new, in-progress, resolved, closed, rejected (default: new)", Default: "new"}, + {Name: "from", Usage: "CSV file path"}, + {Name: "template", Short: "t", Usage: "Template: bug or feature (only with --from)"}, + {Name: "dry-run", Usage: "Preview without creating issues", Bool: true, Default: "false"}, + }, + Run: runBatchCreate, + } +} + +type createIssueInput struct { + Title string + Body string + Priority string + Label string + Assignee string + Status string + // template-specific fields + Version string + Severity string + Steps string + Expected string + Actual string + UserStory string + Acceptance string +} + +func runBatchCreate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + dryRun := parseBool(ctx.Arg("dry-run")) + template := strings.ToLower(strings.TrimSpace(ctx.Arg("template"))) + + // Collect inputs from --titles and/or --from + var inputs []createIssueInput + if titlesStr := ctx.Arg("titles"); titlesStr != "" { + inputs = append(inputs, parseTitles(titlesStr, ctx)...) + } + if csvPath := ctx.Arg("from"); csvPath != "" { + csvInputs, err := readCreateInputsFromCSV(csvPath, template) + if err != nil { + return err + } + inputs = append(inputs, csvInputs...) + } + if len(inputs) == 0 { + return fmt.Errorf("no issue titles provided; use --titles 标题1,标题2 or --from issues.csv") + } + + // Apply CLI --state as fallback for inputs without an explicit status + cliState := ctx.Arg("state") + for i := range inputs { + if inputs[i].Status == "" { + inputs[i].Status = cliState + } + } + + summary := BatchSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + Action: "create", + Value: template, + DryRun: dryRun, + Total: len(inputs), + Results: make([]BatchResult, 0, len(inputs)), + } + + for i, input := range inputs { + label := fmt.Sprintf("#%d", i+1) + if input.Title != "" { + label = truncate(input.Title, 40) + } + result := BatchResult{Number: label, Action: "create"} + + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + body := buildCreateBody(ctx, input, template) + env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body) + if err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "created" + if data, ok := env.Data.(map[string]interface{}); ok { + if num, ok := data["project_issues_index"]; ok { + result.Number = fmt.Sprintf("%v", num) + } + } + 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 of %d issue(s) failed to create", summary.Failed, summary.Total) + } + return nil +} + +func buildCreateBody(ctx *common.RuntimeContext, input createIssueInput, template string) map[string]interface{} { + statusID := statusNew + if input.Status != "" { + if sid, err := parseStatus(input.Status); err == nil { + statusID = sid + } + } + body := map[string]interface{}{ + "subject": input.Title, + "status_id": statusID, + "priority_id": priorityNormal, + "done_ratio": 0, + } + + if template != "" { + body["description"] = buildTemplateDescription(input, template) + if template == "bug" { + body["issue_tag_ids"] = []interface{}{tagIDs["缺陷"]} + } else if template == "feature" { + body["issue_tag_ids"] = []interface{}{tagIDs["功能"]} + } + } else if input.Body != "" { + body["description"] = input.Body + } + + if input.Priority != "" { + if pid, err := parsePriority(input.Priority); err == nil { + body["priority_id"] = pid + } + } + if input.Label != "" { + if tid, err := parseLabel(input.Label); err == nil { + body["issue_tag_ids"] = []interface{}{tid} + } + } + if input.Assignee != "" { + if id, err := resolveUserID(ctx, input.Assignee); err == nil { + body["assigner_ids"] = []interface{}{id} + } + } + + return body +} + +func buildTemplateDescription(input createIssueInput, template string) string { + switch template { + case "bug": + return buildBugDescription(input) + case "feature": + return buildFeatureDescription(input) + default: + return input.Body + } +} + +func buildBugDescription(input createIssueInput) string { + var b strings.Builder + b.WriteString("## Bug 描述\n") + b.WriteString(input.Title) + b.WriteString("\n") + + if input.Version != "" { + b.WriteString("\n## 版本\n") + b.WriteString(input.Version) + b.WriteString("\n") + } + if input.Severity != "" { + b.WriteString("\n## 严重程度\n") + b.WriteString(input.Severity) + b.WriteString("\n") + } + if input.Steps != "" { + b.WriteString("\n## 复现步骤\n") + b.WriteString(input.Steps) + b.WriteString("\n") + } + if input.Expected != "" { + b.WriteString("\n## 期望结果\n") + b.WriteString(input.Expected) + b.WriteString("\n") + } + if input.Actual != "" { + b.WriteString("\n## 实际结果\n") + b.WriteString(input.Actual) + b.WriteString("\n") + } + return b.String() +} + +func buildFeatureDescription(input createIssueInput) string { + var b strings.Builder + b.WriteString("## 用户故事\n") + if input.UserStory != "" { + b.WriteString(input.UserStory) + } else { + b.WriteString(input.Title) + } + b.WriteString("\n") + + if input.Body != "" { + b.WriteString("\n## 描述\n") + b.WriteString(input.Body) + b.WriteString("\n") + } + if input.Acceptance != "" { + b.WriteString("\n## 验收标准\n") + b.WriteString(input.Acceptance) + b.WriteString("\n") + } + if input.Priority != "" { + b.WriteString("\n## 优先级\n") + b.WriteString(input.Priority) + b.WriteString("\n") + } + return b.String() +} + +func readCreateInputsFromCSV(path string, template string) ([]createIssueInput, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("read CSV: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + reader.TrimLeadingSpace = true + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("parse CSV: %w", err) + } + if len(records) < 2 { + return nil, fmt.Errorf("CSV must have a header row and at least one data row") + } + + header := records[0] + col := make(map[string]int) + for i, h := range header { + col[normalizeHeader(h)] = i + } + + if _, ok := col["title"]; !ok { + return nil, fmt.Errorf("CSV must have a 'title' column") + } + + var inputs []createIssueInput + for _, record := range records[1:] { + input := createIssueInput{ + Title: getCol(record, col, "title"), + Body: getCol(record, col, "body"), + Priority: getCol(record, col, "priority"), + Label: getCol(record, col, "label"), + Assignee: getCol(record, col, "assignee"), + Status: getCol(record, col, "status"), + Version: getCol(record, col, "version"), + Severity: getCol(record, col, "severity"), + Steps: getCol(record, col, "steps"), + Expected: getCol(record, col, "expected"), + Actual: getCol(record, col, "actual"), + // Support alternate heading for feature template + UserStory: getCol(record, col, "user_story"), + Acceptance: getCol(record, col, "acceptance"), + } + if input.UserStory == "" { + input.UserStory = getCol(record, col, "user story") + } + if input.Title == "" { + continue + } + inputs = append(inputs, input) + } + return inputs, nil +} + +func parseTitles(titlesStr string, ctx *common.RuntimeContext) []createIssueInput { + parts := strings.Split(titlesStr, ",") + inputs := make([]createIssueInput, 0, len(parts)) + for _, title := range parts { + title = strings.TrimSpace(title) + if title == "" { + continue + } + inputs = append(inputs, createIssueInput{ + Title: title, + Priority: ctx.Arg("priority"), + Label: ctx.Arg("label"), + Assignee: ctx.Arg("assignee"), + Status: ctx.Arg("state"), + }) + } + return inputs +} + +func normalizeHeader(h string) string { + return strings.ToLower(strings.TrimSpace(h)) +} + +func getCol(record []string, col map[string]int, name string) string { + if idx, ok := col[name]; ok && idx < len(record) { + return strings.TrimSpace(record[idx]) + } + return "" +} + +func truncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "..." +} diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 4ccc575..aa77ff5 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -22,6 +22,11 @@ type existingIssue struct { func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ newBatchCloseShortcut(), + newBatchStatusShortcut(), + newBatchPriorityShortcut(), + newBatchAssignShortcut(), + newBatchLabelShortcut(), + newBatchCreateShortcut(), { Name: "list", Description: "List issues",