gitlink-cli/shortcuts/issue/batch.go

326 lines
10 KiB
Go

package issue
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
const closedIssueStatusID = 5
// ---------------------------------------------------------------------------
// Shared: resolve database IDs from user-facing issue numbers
// ---------------------------------------------------------------------------
// resolveIssueDBIDs fetches all issues and maps user-facing project_issues_index
// values to their internal database IDs. Returns a slice of interface{} values
// suitable for JSON serialization in batch API calls.
func resolveIssueDBIDs(ctx *common.RuntimeContext, numbers []string) ([]interface{}, error) {
q := url.Values{}
q.Set("limit", "200")
q.Set("page", "1")
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return nil, fmt.Errorf("fetch issues for ID mapping: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected issues response format")
}
issues, ok := data["issues"].([]interface{})
if !ok {
return nil, fmt.Errorf("unexpected issues list format")
}
// Build mapping: project_issues_index (as string) → database id
indexToID := make(map[string]interface{}, len(issues))
for _, item := range issues {
issue, ok := item.(map[string]interface{})
if !ok {
continue
}
idx, ok := issue["project_issues_index"]
if !ok {
continue
}
id, ok := issue["id"]
if !ok {
continue
}
indexToID[fmt.Sprintf("%v", idx)] = id
}
dbIDs := make([]interface{}, 0, len(numbers))
for _, n := range numbers {
id, found := indexToID[n]
if !found {
return nil, fmt.Errorf("issue #%s not found in repository", n)
}
dbIDs = append(dbIDs, id)
}
return dbIDs, nil
}
// ---------------------------------------------------------------------------
// batch-close
// ---------------------------------------------------------------------------
func newBatchCloseShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-close",
Description: "Close multiple issues by issue numbers or a CSV file",
Long: `Close multiple issues in a single operation.
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
Use --dry-run to preview which issues would be closed without making changes.
The CSV file may have a "number", "issue_number", or "project_issues_index"
header column; otherwise the first column is used.`,
Example: ` # Close issues 1, 2, and 3
gitlink issue +batch-close --numbers 1,2,3
# Dry-run to preview
gitlink issue +batch-close --numbers 1,2,3 --dry-run
# Close issues listed in a CSV file
gitlink issue +batch-close --from issues.csv`,
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 := common.CollectNumbers(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 := common.ParseBool(ctx.Arg("dry-run"))
summary := common.ProcessBatch(numbers, dryRun, "close", func(number string) error {
return closeIssue(ctx, number)
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
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-update
// ---------------------------------------------------------------------------
func newBatchUpdateShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-update",
Description: "Update multiple issues at once using the native batch API",
Long: `Update multiple issues in a single API call.
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
At least one update field (--state, --milestone, --assignee, --label, or --priority)
must be specified. Use --dry-run to preview changes without applying them.`,
Example: ` # Close multiple issues
gitlink issue +batch-update --numbers 1,2,3 --state closed
# Set milestone for issues from a CSV file
gitlink issue +batch-update --from issues.csv --milestone 5
# Assign issues to a user and add labels
gitlink issue +batch-update --numbers 10,11,12 --assignee 3 --label 7,8
# Dry-run to preview
gitlink issue +batch-update --numbers 1,2,3 --priority 1 --dry-run`,
Flags: []common.Flag{
{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: "state", Short: "s", Usage: "Set state: open, closed, or numeric status_id"},
{Name: "milestone", Short: "m", Usage: "Set milestone ID"},
{Name: "assignee", Short: "a", Usage: "Set assignee user ID"},
{Name: "label", Short: "l", Usage: "Set label ID(s), comma-separated for multiple"},
{Name: "priority", Short: "p", Usage: "Set priority ID"},
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
},
Run: runBatchUpdate,
}
}
func runBatchUpdate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
numbers, err := common.CollectNumbers(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")
}
state := ctx.Arg("state")
milestone := ctx.Arg("milestone")
assignee := ctx.Arg("assignee")
label := ctx.Arg("label")
priority := ctx.Arg("priority")
if state == "" && milestone == "" && assignee == "" && label == "" && priority == "" {
return fmt.Errorf("at least one of --state, --milestone, --assignee, --label, or --priority is required")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
dbIDs, err := resolveIssueDBIDs(ctx, numbers)
if err != nil {
return err
}
body := map[string]interface{}{
"ids": dbIDs,
}
if state != "" {
statusID, err := normalizeIssueStatus(state)
if err != nil {
return err
}
body["status_id"] = statusID
}
if milestone != "" {
body["fixed_version_id"] = milestone
}
if assignee != "" {
body["assigned_to_id"] = assignee
}
if label != "" {
tagIDs := common.ParseStringList(label)
body["issue_tag_ids"] = tagIDs
}
if priority != "" {
body["priority_id"] = priority
}
if dryRun {
plan := map[string]interface{}{
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
"dry_run": true,
"issues": numbers,
"updates": body,
}
return ctx.OutputData(plan)
}
env, err := ctx.CallAPI("PATCH", v1RepoPath(ctx)+"/issues/batch_update", body)
if err != nil {
return fmt.Errorf("batch update: %w", err)
}
return ctx.Output(env)
}
// ---------------------------------------------------------------------------
// batch-destroy
// ---------------------------------------------------------------------------
func newBatchDestroyShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-destroy",
Description: "Delete multiple issues at once using the native batch API",
Long: `Permanently delete multiple issues in a single API call.
Provide issue numbers via --numbers (comma-separated) or --from (CSV file).
This action is irreversible. Use --dry-run to preview which issues would be deleted.
You will be prompted for confirmation unless --yes is set.`,
Example: ` # Delete issues 7, 8, and 9
gitlink issue +batch-destroy --numbers 7,8,9
# Dry-run to preview
gitlink issue +batch-destroy --numbers 7,8,9 --dry-run
# Delete issues listed in a CSV file
gitlink issue +batch-destroy --from old_issues.csv`,
Flags: []common.Flag{
{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 deletions without executing them", Bool: true, Default: "false"},
},
Run: runBatchDestroy,
}
}
func runBatchDestroy(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
numbers, err := common.CollectNumbers(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 := common.ParseBool(ctx.Arg("dry-run"))
dbIDs, err := resolveIssueDBIDs(ctx, numbers)
if err != nil {
return err
}
if dryRun {
plan := map[string]interface{}{
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
"dry_run": true,
"issues": numbers,
"database_ids": dbIDs,
}
return ctx.OutputData(plan)
}
if err := common.ConfirmAction(fmt.Sprintf("batch delete %d issue(s)", len(numbers))); err != nil {
return err
}
body := map[string]interface{}{
"ids": dbIDs,
}
// Use CallAPIWithQuery for DELETE since the body needs to be sent.
// The client.Do method sends body for any method, so CallAPI works.
env, err := ctx.CallAPI("DELETE", v1RepoPath(ctx)+"/issues/batch_destroy", body)
if err != nil {
return fmt.Errorf("batch destroy: %w", err)
}
return ctx.Output(env)
}