diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index 2345b6c..1ec9eb7 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -12,20 +12,20 @@ import ( const closedIssueStatusID = 5 -type batchCloseResult struct { +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"` +type batchSummary 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 []batchResult `json:"results" yaml:"results"` } func newBatchCloseShortcut() *common.Shortcut { @@ -55,15 +55,15 @@ 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), 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++ @@ -108,6 +108,279 @@ func closeIssue(ctx *common.RuntimeContext, number string) error { return nil } +func newBatchAssignShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-assign", + Description: "Assign multiple issues to a user 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: "user", Short: "u", Usage: "Assignee user login", Required: true}, + {Name: "dry-run", Usage: "Preview the issues that would be assigned without changing them", Bool: true, Default: "false"}, + }, + Run: runBatchAssign, + } +} + +func runBatchAssign(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + user, err := ctx.RequireArg("user") + 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), + DryRun: dryRun, + Total: len(numbers), + Results: make([]batchResult, 0, len(numbers)), + } + + for _, number := range numbers { + result := batchResult{Number: number, Action: "assign"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + if err := assignIssue(ctx, number, user); 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 to assign", summary.Failed, summary.Total) + } + return nil +} + +func assignIssue(ctx *common.RuntimeContext, number, user string) error { + body := map[string]interface{}{ + "assigned_to_id": user, + } + if _, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/issues/%s/assignees", v1RepoPath(ctx), number), body); err != nil { + return fmt.Errorf("assign issue: %w", err) + } + return nil +} + +func newBatchLabelShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-label", + Description: "Add or remove labels on 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: "add", Short: "a", Usage: "Comma-separated label IDs to add"}, + {Name: "remove", Short: "r", Usage: "Comma-separated label IDs to remove"}, + {Name: "dry-run", Usage: "Preview the issues that would be labeled without changing them", Bool: true, Default: "false"}, + }, + Run: runBatchLabel, + } +} + +func runBatchLabel(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + addIDs := ctx.Arg("add") + removeIDs := ctx.Arg("remove") + if addIDs == "" && removeIDs == "" { + return fmt.Errorf("at least one of --add or --remove is required") + } + + 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), + DryRun: dryRun, + Total: len(numbers), + Results: make([]batchResult, 0, len(numbers)), + } + + addLabels := parseCommaSeparated(addIDs) + removeLabels := parseCommaSeparated(removeIDs) + + for _, number := range numbers { + result := batchResult{Number: number, Action: "label"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + if err := labelIssue(ctx, number, addLabels, removeLabels); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "labeled" + 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 label", summary.Failed, summary.Total) + } + return nil +} + +func labelIssue(ctx *common.RuntimeContext, number string, addLabels, removeLabels []string) error { + var errs []string + for _, labelID := range addLabels { + body := map[string]interface{}{ + "tag_id": labelID, + } + if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/tags", v1RepoPath(ctx), number), body); err != nil { + errs = append(errs, fmt.Sprintf("add label %s: %v", labelID, err)) + } + } + for _, labelID := range removeLabels { + if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/tags/%s", v1RepoPath(ctx), number, labelID), nil); err != nil { + errs = append(errs, fmt.Sprintf("remove label %s: %v", labelID, err)) + } + } + if len(errs) > 0 { + return fmt.Errorf("%s", strings.Join(errs, "\n")) + } + return nil +} + +func newBatchMilestoneShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-milestone", + Description: "Set milestone on 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: "milestone", Short: "m", Usage: "Milestone ID", Required: true}, + {Name: "dry-run", Usage: "Preview the issues that would be updated without changing them", Bool: true, Default: "false"}, + }, + Run: runBatchMilestone, + } +} + +func runBatchMilestone(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + milestoneID, err := ctx.RequireArg("milestone") + 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), + DryRun: dryRun, + Total: len(numbers), + Results: make([]batchResult, 0, len(numbers)), + } + + for _, number := range numbers { + result := batchResult{Number: number, Action: "milestone"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + if err := setMilestone(ctx, number, milestoneID); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "milestoned" + 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 set milestone", summary.Failed, summary.Total) + } + return nil +} + +func setMilestone(ctx *common.RuntimeContext, number, milestoneID 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, + "fixed_version_id": milestoneID, + } + if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil { + return fmt.Errorf("set milestone: %w", err) + } + return nil +} + +func parseCommaSeparated(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + var result []string + for _, item := range strings.Split(value, ",") { + if trimmed := strings.TrimSpace(item); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) { numbers, err := parseIssueNumbers(numbersValue) if err != nil { diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 6ad3eb5..f640e2d 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -23,6 +23,9 @@ type existingIssue struct { func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ newBatchCloseShortcut(), + newBatchAssignShortcut(), + newBatchLabelShortcut(), + newBatchMilestoneShortcut(), { Name: "list", Description: "List issues", diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index 05f6756..2c84b4a 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -3,7 +3,6 @@ package release import ( "fmt" "io" - "net/http" "net/url" "os" "path/filepath" @@ -159,8 +158,13 @@ func Shortcuts() []*common.Shortcut { return fmt.Errorf("asset %q not found in release", assetName) } - // Download the file - resp, err := http.Get(downloadURL) + // Ensure target directory exists + if err := os.MkdirAll(dlDir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dlDir, err) + } + + // Download using authenticated client so private repos work + resp, err := ctx.Client.HTTP.Get(downloadURL) if err != nil { return fmt.Errorf("download failed: %w", err) } diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index fa47780..843105d 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -67,7 +67,9 @@ func Shortcuts() []*common.Shortcut { if len(parts) != 2 { return fmt.Errorf("invalid repository format %q: use owner/repo", rawURL) } - cloneURL = fmt.Sprintf("https://www.gitlink.org.cn/%s/%s.git", parts[0], parts[1]) + // Derive web base URL from API base URL (strip /api suffix) + webBase := strings.TrimSuffix(strings.TrimSuffix(ctx.Client.BaseURL, "/"), "/api") + cloneURL = fmt.Sprintf("%s/%s/%s.git", webBase, parts[0], parts[1]) repoName = parts[1] } diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index 0fa1331..e4acff3 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -310,7 +310,7 @@ func runRepoCloneShortcut(t *testing.T, fakeGitPath string, args map[string]stri ctx := &common.RuntimeContext{ Client: &client.Client{ HTTP: nil, - BaseURL: "", + BaseURL: "https://www.gitlink.org.cn/api", }, Owner: "owner", Repo: "repo",