forked from Gitlink/gitlink-cli
326 lines
10 KiB
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)
|
|
}
|