gitlink-cli/shortcuts/issue/batch.go

1449 lines
40 KiB
Go

package issue
import (
"encoding/csv"
"encoding/json"
"fmt"
"net/url"
"os"
"sort"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
const (
closedIssueStatusID = 5
openIssueStatusID = 1
)
type batchCloseResult 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"`
}
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 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 := batchCloseSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Total: len(numbers),
Results: make([]batchCloseResult, 0, len(numbers)),
}
for _, number := range numbers {
result := batchCloseResult{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)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed to close", 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("fetch 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("close issue: %w", err)
}
return nil
}
// batch-reopen implementation
type batchReopenResult 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 batchReopenSummary 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 []batchReopenResult `json:"results" yaml:"results"`
}
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 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 := batchReopenSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Total: len(numbers),
Results: make([]batchReopenResult, 0, len(numbers)),
}
for _, number := range numbers {
result := batchReopenResult{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)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed to reopen", 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("fetch 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("reopen issue: %w", err)
}
return nil
}
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("read issue numbers from 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 issue numbers from 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("invalid issue number %q: issue numbers must be integers", 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
}
type batchMaintenanceDryRun struct {
Repository string `json:"repository" yaml:"repository"`
DryRun bool `json:"dry_run" yaml:"dry_run"`
Action string `json:"action" yaml:"action"`
Method string `json:"method" yaml:"method"`
Path string `json:"path" yaml:"path"`
Body map[string]interface{} `json:"body" yaml:"body"`
}
func newBatchUpdateShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-update",
Description: "Batch update issue metadata by API issue IDs",
Flags: []common.Flag{
{Name: "ids", Usage: "Comma-separated API issue IDs, not web URL issue numbers", Required: true},
{Name: "status-id", Usage: "Issue status ID"},
{Name: "priority-id", Usage: "Issue priority ID"},
{Name: "milestone-id", Usage: "Issue milestone ID"},
{Name: "tag-ids", Usage: "Comma-separated issue tag IDs"},
{Name: "assigner-ids", Usage: "Comma-separated assignee user IDs"},
{Name: "dry-run", Usage: "Preview request without updating issues", Bool: true, Default: "false"},
},
Run: runBatchUpdate,
}
}
func newBatchDeleteShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-delete",
Description: "Batch delete issues by API issue IDs",
Flags: []common.Flag{
{Name: "ids", Usage: "Comma-separated API issue IDs, not web URL issue numbers", Required: true},
{Name: "dry-run", Usage: "Preview request without deleting issues", Bool: true, Default: "false"},
{Name: "yes", Usage: "Confirm real batch deletion", Bool: true, Default: "false"},
},
Run: runBatchDelete,
}
}
func runBatchUpdate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
body, err := buildBatchUpdateBody(ctx)
if err != nil {
return err
}
path := fmt.Sprintf("%s/issues/batch_update", v1RepoPath(ctx))
if parseBool(ctx.Arg("dry-run")) {
return ctx.OutputData(batchMaintenanceDryRun{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: true,
Action: "batch_update_issues",
Method: "PATCH",
Path: path,
Body: body,
})
}
env, err := ctx.CallAPI("PATCH", path, body)
if err != nil {
return err
}
return ctx.Output(env)
}
func runBatchDelete(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
ids, err := parseIntIDList(ctx.Arg("ids"), "ids")
if err != nil {
return err
}
body := map[string]interface{}{"ids": ids}
path := fmt.Sprintf("%s/issues/batch_destroy", v1RepoPath(ctx))
dryRun := parseBool(ctx.Arg("dry-run"))
if dryRun {
return ctx.OutputData(batchMaintenanceDryRun{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: true,
Action: "batch_delete_issues",
Method: "DELETE",
Path: path,
Body: body,
})
}
if !parseBool(ctx.Arg("yes")) {
return fmt.Errorf("batch-delete is destructive; run with --dry-run first, then pass --yes to confirm")
}
env, err := ctx.CallAPI("DELETE", path, body)
if err != nil {
return err
}
return ctx.Output(env)
}
func buildBatchUpdateBody(ctx *common.RuntimeContext) (map[string]interface{}, error) {
ids, err := parseIntIDList(ctx.Arg("ids"), "ids")
if err != nil {
return nil, err
}
body := map[string]interface{}{"ids": ids}
changed := false
if value := ctx.Arg("status-id"); value != "" {
id, err := parseSingleIntID(value, "status-id")
if err != nil {
return nil, err
}
body["status_id"] = id
changed = true
}
if value := ctx.Arg("priority-id"); value != "" {
id, err := parseSingleIntID(value, "priority-id")
if err != nil {
return nil, err
}
body["priority_id"] = id
changed = true
}
if value := ctx.Arg("milestone-id"); value != "" {
id, err := parseSingleIntID(value, "milestone-id")
if err != nil {
return nil, err
}
body["milestone_id"] = id
changed = true
}
if value := ctx.Arg("tag-ids"); value != "" {
ids, err := parseIntIDList(value, "tag-ids")
if err != nil {
return nil, err
}
body["issue_tag_ids"] = ids
changed = true
}
if value := ctx.Arg("assigner-ids"); value != "" {
ids, err := parseIntIDList(value, "assigner-ids")
if err != nil {
return nil, err
}
body["assigner_ids"] = ids
changed = true
}
if !changed {
return nil, fmt.Errorf("no update fields provided; set at least one of --status-id, --priority-id, --milestone-id, --tag-ids, --assigner-ids")
}
return body, nil
}
func parseSingleIntID(value, field string) (int, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, fmt.Errorf("%s cannot be empty", field)
}
id, err := strconv.Atoi(value)
if err != nil || id <= 0 {
return 0, fmt.Errorf("invalid %s %q: must be a positive integer", field, value)
}
return id, nil
}
func parseIntIDList(value, field string) ([]int, error) {
if strings.TrimSpace(value) == "" {
return nil, fmt.Errorf("%s cannot be empty", field)
}
parts := strings.Split(value, ",")
ids := make([]int, 0, len(parts))
seen := map[int]bool{}
for _, part := range parts {
id, err := parseSingleIntID(part, field)
if err != nil {
return nil, err
}
if seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
return ids, nil
}
// batch-label implementation
type batchLabelResult struct {
ID string `json:"id" yaml:"id"`
Action string `json:"action" yaml:"action"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
type batchLabelSummary 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 []batchLabelResult `json:"results" yaml:"results"`
}
func newBatchLabelShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-label",
Description: "Batch add or remove labels from issues by API issue IDs",
Flags: []common.Flag{
{Name: "ids", Usage: "Comma-separated API issue IDs, not web URL issue numbers", Required: true},
{Name: "add", Usage: "Comma-separated tag IDs to add to issues"},
{Name: "remove", Usage: "Comma-separated tag IDs to remove from issues"},
{Name: "dry-run", Usage: "Preview changes without updating issues", Bool: true, Default: "false"},
},
Run: runBatchLabel,
}
}
func runBatchLabel(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
ids, err := parseIntIDList(ctx.Arg("ids"), "ids")
if err != nil {
return err
}
addTags, err := parseOptionalIntIDList(ctx.Arg("add"))
if err != nil {
return fmt.Errorf("parse add tags: %w", err)
}
removeTags, err := parseOptionalIntIDList(ctx.Arg("remove"))
if err != nil {
return fmt.Errorf("parse remove tags: %w", err)
}
if len(addTags) == 0 && len(removeTags) == 0 {
return fmt.Errorf("no label changes provided; use --add and/or --remove with tag IDs")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchLabelSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Total: len(ids),
Results: make([]batchLabelResult, 0, len(ids)),
}
for _, id := range ids {
result := batchLabelResult{ID: strconv.Itoa(id), Action: "update_labels"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := updateIssueLabels(ctx, id, addTags, removeTags); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "updated"
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 update labels", summary.Failed, summary.Total)
}
return nil
}
func parseOptionalIntIDList(value string) ([]int, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
return parseIntIDList(value, "tag-ids")
}
func updateIssueLabels(ctx *common.RuntimeContext, issueID int, addTags, removeTags []int) error {
// Fetch current issue to get existing tags
issueData, err := fetchIssueDataByID(ctx, issueID)
if err != nil {
return fmt.Errorf("fetch issue: %w", err)
}
// Get current tag IDs
currentTagIDs := issueObjectIDs(issueData, "tags", "issue_tags")
currentTags := make(map[int]bool)
for _, id := range currentTagIDs {
if tagID, ok := id.(float64); ok {
currentTags[int(tagID)] = true
} else if tagID, ok := id.(int); ok {
currentTags[tagID] = true
}
}
// Add new tags
for _, tagID := range addTags {
currentTags[tagID] = true
}
// Remove tags
for _, tagID := range removeTags {
delete(currentTags, tagID)
}
// Convert back to slice and sort for consistent ordering
newTagIDs := make([]int, 0, len(currentTags))
for tagID := range currentTags {
newTagIDs = append(newTagIDs, tagID)
}
sort.Ints(newTagIDs)
// Convert to []interface{} for JSON
newTags := make([]interface{}, len(newTagIDs))
for i, id := range newTagIDs {
newTags[i] = id
}
// Update issue
body := map[string]interface{}{
"ids": []int{issueID},
"issue_tag_ids": newTags,
}
path := fmt.Sprintf("%s/issues/batch_update", v1RepoPath(ctx))
if _, err := ctx.CallAPI("PATCH", path, body); err != nil {
return fmt.Errorf("update issue labels: %w", err)
}
return nil
}
func fetchIssueDataByID(ctx *common.RuntimeContext, id int) (map[string]interface{}, error) {
path := fmt.Sprintf("%s/issues/%d", v1RepoPath(ctx), id)
env, err := ctx.CallAPI("GET", path, nil)
if err != nil {
return nil, err
}
issueData, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("failed to parse issue data")
}
return issueData, nil
}
// batch-assign implementation
type batchAssignResult struct {
ID string `json:"id" yaml:"id"`
Action string `json:"action" yaml:"action"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
type batchAssignSummary 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 []batchAssignResult `json:"results" yaml:"results"`
}
func newBatchAssignShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-assign",
Description: "Batch assign or unassign users from issues by API issue IDs",
Flags: []common.Flag{
{Name: "ids", Usage: "Comma-separated API issue IDs, not web URL issue numbers", Required: true},
{Name: "add", Usage: "Comma-separated user IDs to assign to issues"},
{Name: "remove", Usage: "Comma-separated user IDs to unassign from issues"},
{Name: "dry-run", Usage: "Preview changes without updating issues", Bool: true, Default: "false"},
},
Run: runBatchAssign,
}
}
func runBatchAssign(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
ids, err := parseIntIDList(ctx.Arg("ids"), "ids")
if err != nil {
return err
}
addUsers, err := parseOptionalIntIDList(ctx.Arg("add"))
if err != nil {
return fmt.Errorf("parse add users: %w", err)
}
removeUsers, err := parseOptionalIntIDList(ctx.Arg("remove"))
if err != nil {
return fmt.Errorf("parse remove users: %w", err)
}
if len(addUsers) == 0 && len(removeUsers) == 0 {
return fmt.Errorf("no assignee changes provided; use --add and/or --remove with user IDs")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchAssignSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Total: len(ids),
Results: make([]batchAssignResult, 0, len(ids)),
}
for _, id := range ids {
result := batchAssignResult{ID: strconv.Itoa(id), Action: "update_assignees"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := updateIssueAssignees(ctx, id, addUsers, removeUsers); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "updated"
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 update assignees", summary.Failed, summary.Total)
}
return nil
}
func updateIssueAssignees(ctx *common.RuntimeContext, issueID int, addUsers, removeUsers []int) error {
// Fetch current issue to get existing assignees
issueData, err := fetchIssueDataByID(ctx, issueID)
if err != nil {
return fmt.Errorf("fetch issue: %w", err)
}
// Get current assignee IDs
currentAssigneeIDs := issueObjectIDs(issueData, "assigners")
currentAssignees := make(map[int]bool)
for _, id := range currentAssigneeIDs {
if userID, ok := id.(float64); ok {
currentAssignees[int(userID)] = true
} else if userID, ok := id.(int); ok {
currentAssignees[userID] = true
}
}
// Add new assignees
for _, userID := range addUsers {
currentAssignees[userID] = true
}
// Remove assignees
for _, userID := range removeUsers {
delete(currentAssignees, userID)
}
// Convert back to slice and sort for consistent ordering
newUserIDs := make([]int, 0, len(currentAssignees))
for userID := range currentAssignees {
newUserIDs = append(newUserIDs, userID)
}
sort.Ints(newUserIDs)
// Convert to []interface{} for JSON
newAssignees := make([]interface{}, len(newUserIDs))
for i, id := range newUserIDs {
newAssignees[i] = id
}
// Update issue
body := map[string]interface{}{
"ids": []int{issueID},
"assigner_ids": newAssignees,
}
path := fmt.Sprintf("%s/issues/batch_update", v1RepoPath(ctx))
if _, err := ctx.CallAPI("PATCH", path, body); err != nil {
return fmt.Errorf("update issue assignees: %w", err)
}
return nil
}
// batch-comment implementation
type batchCommentResult 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 batchCommentSummary struct {
Repository string `json:"repository" yaml:"repository"`
DryRun bool `json:"dry_run" yaml:"dry_run"`
Message string `json:"message" yaml:"message"`
Total int `json:"total" yaml:"total"`
Succeeded int `json:"succeeded" yaml:"succeeded"`
Failed int `json:"failed" yaml:"failed"`
Results []batchCommentResult `json:"results" yaml:"results"`
}
func newBatchCommentShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-comment",
Description: "Batch add comments 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: "message", Short: "m", Usage: "Comment message to add to all issues", Required: true},
{Name: "dry-run", Usage: "Preview the issues that would receive comments without posting them", Bool: true, Default: "false"},
},
Run: runBatchComment,
}
}
func runBatchComment(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
}
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
}
message, err := ctx.RequireArg("message")
if err != nil {
return err
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchCommentSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Message: message,
Total: len(numbers),
Results: make([]batchCommentResult, 0, len(numbers)),
}
for _, number := range numbers {
result := batchCommentResult{Number: number, Action: "add_comment"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := addIssueComment(ctx, number, message); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "commented"
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 add comment", summary.Failed, summary.Total)
}
return nil
}
func addIssueComment(ctx *common.RuntimeContext, number, message string) error {
payload := map[string]interface{}{
"notes": message,
}
path := fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number)
if _, err := ctx.CallAPI("POST", path, payload); err != nil {
return fmt.Errorf("add comment: %w", err)
}
return nil
}
// ============================================================================
// Batch Export
// ============================================================================
type batchExportSummary struct {
Repository string `json:"repository" yaml:"repository"`
Format string `json:"format" yaml:"format"`
Output string `json:"output" yaml:"output"`
Total int `json:"total" yaml:"total"`
}
func newBatchExportShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-export",
Description: "Export issues to CSV or JSON file",
Flags: []common.Flag{
{Name: "format", Short: "f", Usage: "Export format: csv or json", Default: "csv"},
{Name: "output", Short: "o", Usage: "Output file path (default: issues.csv or issues.json)"},
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, or all", Default: "open"},
{Name: "keyword", Short: "k", Usage: "Filter by keyword"},
{Name: "author-id", Usage: "Filter by author ID"},
{Name: "assignee-id", Usage: "Filter by assignee ID"},
{Name: "milestone-id", Usage: "Filter by milestone ID"},
{Name: "status-id", Usage: "Filter by status ID"},
{Name: "tag-ids", Usage: "Filter by comma-separated tag IDs"},
{Name: "limit", Short: "l", Usage: "Maximum number of issues to export (0 for all)", Default: "0"},
},
Run: runBatchExport,
}
}
func runBatchExport(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
// Build query parameters
q := url.Values{}
if s := ctx.Arg("state"); s != "" {
q.Set("category", normalizeIssueListState(s))
}
if keyword := ctx.Arg("keyword"); keyword != "" {
q.Set("keyword", keyword)
}
if authorID := ctx.Arg("author-id"); authorID != "" {
q.Set("author_id", authorID)
}
if assigneeID := ctx.Arg("assignee-id"); assigneeID != "" {
q.Set("assigner_id", assigneeID)
}
if milestoneID := ctx.Arg("milestone-id"); milestoneID != "" {
q.Set("milestone_id", milestoneID)
}
if statusID := ctx.Arg("status-id"); statusID != "" {
q.Set("status_id", statusID)
}
if tagIDs := ctx.Arg("tag-ids"); tagIDs != "" {
q.Set("issue_tag_ids", tagIDs)
}
// Fetch all issues with pagination
limitStr := ctx.Arg("limit")
maxLimit := 0
if limitStr != "" && limitStr != "0" {
if l, err := strconv.Atoi(limitStr); err == nil {
maxLimit = l
}
}
allIssues := make([]map[string]interface{}, 0)
page := 1
pageSize := 100
for {
q.Set("page", strconv.Itoa(page))
q.Set("limit", strconv.Itoa(pageSize))
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return fmt.Errorf("fetch issues: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return fmt.Errorf("unexpected response format")
}
issues, ok := data["issues"].([]interface{})
if !ok {
break
}
for _, item := range issues {
issue, ok := item.(map[string]interface{})
if !ok {
continue
}
// Normalize issue data
if num, ok := issue["project_issues_index"]; ok {
issue["number"] = num
}
if id, ok := issue["id"]; ok {
issue["database_id"] = id
delete(issue, "id")
}
allIssues = append(allIssues, issue)
}
if len(issues) < pageSize {
break
}
if maxLimit > 0 && len(allIssues) >= maxLimit {
allIssues = allIssues[:maxLimit]
break
}
page++
}
// Determine output format and file
format := strings.ToLower(ctx.Arg("format"))
if format != "csv" && format != "json" {
format = "csv"
}
outputPath := ctx.Arg("output")
if outputPath == "" {
if format == "csv" {
outputPath = "issues.csv"
} else {
outputPath = "issues.json"
}
}
// Export to file
var err error
if format == "csv" {
err = exportIssuesToCSV(allIssues, outputPath)
} else {
err = exportIssuesToJSON(allIssues, outputPath)
}
if err != nil {
return fmt.Errorf("export issues: %w", err)
}
summary := batchExportSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Format: format,
Output: outputPath,
Total: len(allIssues),
}
return ctx.OutputData(summary)
}
func exportIssuesToCSV(issues []map[string]interface{}, path string) error {
if len(issues) == 0 {
return fmt.Errorf("no issues to export")
}
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("create file: %w", err)
}
defer file.Close()
writer := csv.NewWriter(file)
defer writer.Flush()
// Define CSV columns
headers := []string{
"number", "subject", "description", "status", "priority",
"author", "assigners", "tags", "milestone", "branch_name",
"start_date", "due_date", "created_at", "updated_at",
}
if err := writer.Write(headers); err != nil {
return fmt.Errorf("write headers: %w", err)
}
for _, issue := range issues {
record := make([]string, len(headers))
record[0] = getStringField(issue, "number")
record[1] = getStringField(issue, "subject")
record[2] = getStringField(issue, "description")
record[3] = getNestedStringField(issue, "status", "name")
record[4] = getNestedStringField(issue, "priority", "name")
record[5] = getNestedStringField(issue, "author", "login")
record[6] = getNestedArrayField(issue, "assigners", "login")
record[7] = getNestedArrayField(issue, "tags", "name")
record[8] = getNestedStringField(issue, "milestone", "name")
record[9] = getStringField(issue, "branch_name")
record[10] = getStringField(issue, "start_date")
record[11] = getStringField(issue, "due_date")
record[12] = getStringField(issue, "created_at")
record[13] = getStringField(issue, "updated_at")
if err := writer.Write(record); err != nil {
return fmt.Errorf("write record: %w", err)
}
}
return nil
}
func exportIssuesToJSON(issues []map[string]interface{}, path string) error {
data, err := json.MarshalIndent(issues, "", " ")
if err != nil {
return fmt.Errorf("marshal JSON: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("write file: %w", err)
}
return nil
}
func getStringField(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
switch val := v.(type) {
case string:
return val
case float64:
return strconv.FormatFloat(val, 'f', -1, 64)
case int:
return strconv.Itoa(val)
}
}
return ""
}
func getNestedStringField(m map[string]interface{}, keys ...string) string {
current := m
for i, key := range keys {
if i == len(keys)-1 {
return getStringField(current, key)
}
if v, ok := current[key]; ok {
if nested, ok := v.(map[string]interface{}); ok {
current = nested
} else {
break
}
} else {
break
}
}
return ""
}
func getNestedArrayField(m map[string]interface{}, arrayKey, fieldKey string) string {
if v, ok := m[arrayKey]; ok {
if arr, ok := v.([]interface{}); ok {
values := make([]string, 0, len(arr))
for _, item := range arr {
if obj, ok := item.(map[string]interface{}); ok {
if field, ok := obj[fieldKey]; ok {
if s, ok := field.(string); ok {
values = append(values, s)
}
}
}
}
return strings.Join(values, ",")
}
}
return ""
}
// ============================================================================
// Batch Import
// ============================================================================
type batchImportResult struct {
Row int `json:"row" yaml:"row"`
Title string `json:"title" yaml:"title"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
type batchImportSummary struct {
Repository string `json:"repository" yaml:"repository"`
DryRun bool `json:"dry_run" yaml:"dry_run"`
Input string `json:"input" yaml:"input"`
Total int `json:"total" yaml:"total"`
Succeeded int `json:"succeeded" yaml:"succeeded"`
Failed int `json:"failed" yaml:"failed"`
Results []batchImportResult `json:"results" yaml:"results"`
}
func newBatchImportShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-import",
Description: "Create issues from a CSV file",
Flags: []common.Flag{
{Name: "from", Short: "f", Usage: "CSV file path to import issues from", Required: true},
{Name: "dry-run", Usage: "Preview the issues that would be created without creating them", Bool: true, Default: "false"},
},
Run: runBatchImport,
}
}
func runBatchImport(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
csvPath := ctx.Arg("from")
if csvPath == "" {
return fmt.Errorf("--from is required")
}
issues, err := readIssuesFromCSV(csvPath)
if err != nil {
return fmt.Errorf("read CSV: %w", err)
}
if len(issues) == 0 {
return fmt.Errorf("no issues found in CSV file")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchImportSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Input: csvPath,
Total: len(issues),
Results: make([]batchImportResult, 0, len(issues)),
}
for i, issue := range issues {
result := batchImportResult{
Row: i + 2, // +2 because row 1 is header
Title: issue.Title,
}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := createIssueFromImport(ctx, issue); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "created"
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
}
type importIssue struct {
Title string
Description string
PriorityID int
TagIDs []int
AssignerIDs []int
MilestoneID int
BranchName string
StartDate string
DueDate string
}
func readIssuesFromCSV(path string) ([]importIssue, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open file: %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) == 0 {
return nil, nil
}
// Parse header to find column indices
header := records[0]
colIndex := make(map[string]int)
for i, col := range header {
colIndex[strings.ToLower(strings.TrimSpace(col))] = i
}
issues := make([]importIssue, 0, len(records)-1)
for i, record := range records[1:] {
if len(record) == 0 {
continue
}
issue := importIssue{
PriorityID: 2, // default: normal
}
// Title (required)
if idx, ok := colIndex["title"]; ok && idx < len(record) {
issue.Title = strings.TrimSpace(record[idx])
} else if idx, ok := colIndex["subject"]; ok && idx < len(record) {
issue.Title = strings.TrimSpace(record[idx])
}
if issue.Title == "" {
return nil, fmt.Errorf("row %d: title is required", i+2)
}
// Description
if idx, ok := colIndex["description"]; ok && idx < len(record) {
issue.Description = strings.TrimSpace(record[idx])
} else if idx, ok := colIndex["body"]; ok && idx < len(record) {
issue.Description = strings.TrimSpace(record[idx])
}
// Priority ID
if idx, ok := colIndex["priority_id"]; ok && idx < len(record) {
if val := strings.TrimSpace(record[idx]); val != "" {
if id, err := strconv.Atoi(val); err == nil {
issue.PriorityID = id
}
}
}
// Tag IDs
if idx, ok := colIndex["tag_ids"]; ok && idx < len(record) {
if val := strings.TrimSpace(record[idx]); val != "" {
ids, err := parseIntList(val)
if err != nil {
return nil, fmt.Errorf("row %d: invalid tag_ids: %w", i+2, err)
}
issue.TagIDs = ids
}
}
// Assigner IDs
if idx, ok := colIndex["assigner_ids"]; ok && idx < len(record) {
if val := strings.TrimSpace(record[idx]); val != "" {
ids, err := parseIntList(val)
if err != nil {
return nil, fmt.Errorf("row %d: invalid assigner_ids: %w", i+2, err)
}
issue.AssignerIDs = ids
}
}
// Milestone ID
if idx, ok := colIndex["milestone_id"]; ok && idx < len(record) {
if val := strings.TrimSpace(record[idx]); val != "" {
if id, err := strconv.Atoi(val); err == nil {
issue.MilestoneID = id
}
}
}
// Branch name
if idx, ok := colIndex["branch_name"]; ok && idx < len(record) {
issue.BranchName = strings.TrimSpace(record[idx])
}
// Start date
if idx, ok := colIndex["start_date"]; ok && idx < len(record) {
issue.StartDate = strings.TrimSpace(record[idx])
}
// Due date
if idx, ok := colIndex["due_date"]; ok && idx < len(record) {
issue.DueDate = strings.TrimSpace(record[idx])
}
issues = append(issues, issue)
}
return issues, nil
}
func parseIntList(value string) ([]int, error) {
parts := strings.Split(value, ",")
ids := make([]int, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
id, err := strconv.Atoi(part)
if err != nil {
return nil, fmt.Errorf("invalid ID %q: %w", part, err)
}
ids = append(ids, id)
}
return ids, nil
}
func createIssueFromImport(ctx *common.RuntimeContext, issue importIssue) error {
body := map[string]interface{}{
"subject": issue.Title,
"status_id": 1, // open
"priority_id": issue.PriorityID,
"done_ratio": 0,
}
if issue.Description != "" {
body["description"] = issue.Description
}
if len(issue.TagIDs) > 0 {
body["issue_tag_ids"] = issue.TagIDs
}
if len(issue.AssignerIDs) > 0 {
body["assigner_ids"] = issue.AssignerIDs
}
if issue.MilestoneID > 0 {
body["fixed_version_id"] = issue.MilestoneID
}
if issue.BranchName != "" {
body["branch_name"] = issue.BranchName
}
if issue.StartDate != "" {
body["start_date"] = issue.StartDate
}
if issue.DueDate != "" {
body["due_date"] = issue.DueDate
}
if _, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body); err != nil {
return fmt.Errorf("create issue: %w", err)
}
return nil
}