解决 issue #2: 新增 issue batch-assign、batch-label、batch-milestone 三个批量操作命令

This commit is contained in:
yetja 2026-05-25 11:18:57 +08:00
parent f024db9878
commit 1276744ee2
5 changed files with 298 additions and 16 deletions

View File

@ -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 {

View File

@ -23,6 +23,9 @@ type existingIssue struct {
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchCloseShortcut(),
newBatchAssignShortcut(),
newBatchLabelShortcut(),
newBatchMilestoneShortcut(),
{
Name: "list",
Description: "List issues",

View File

@ -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)
}

View File

@ -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]
}

View File

@ -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",