gitlink-cli/shortcuts/issue/batch.go

341 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 将用户可见的项目编号映射为数据库主键 ID
// 用户输入的是 issue #1、#2项目级别编号但 GitLink 批量 API 需要数据库 ID如 143372
// 流程GET /issues 获取全量列表 → 构建 project_issues_index → id 映射表 → 按用户输入查找
// 仅 A 类命令batch-update/batch-destroy需要此映射B 类命令逐个调 API 时直接用项目编号
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")
}
// 构建 project_issues_index → 数据库ID 的映射表
// 例如: {"3": 143372, "4": 143373, "5": 143374}
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"] // 用户可见的编号(如 3
if !ok {
continue
}
id, ok := issue["id"] // 数据库主键 ID如 143372
if !ok {
continue
}
// 统一转为字符串作为 key确保数字类型匹配
indexToID[fmt.Sprintf("%v", idx)] = id
}
// 用户输入的编号 → 查找对应数据库 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{
// +batch-close按疑修编号或 CSV 文件批量关闭多个疑修
Name: "batch-close",
Description: "按疑修编号或 CSV 文件批量关闭疑修",
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,
}
}
// [B 类批量] 逐个关闭 issue——PATCH /issues/{n},每个请求独立
// 不需要 ID 映射,单 issue API 接受项目编号而非数据库主键
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 {
// 先获取 issue 当前数据——PATCH API 要求提交 subject 和 description不能为空
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, // 5 = closed
}
// PATCH /issues/{number} 只修改 status_id保留其他字段
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{
// +batch-update使用原生批量接口一次性更新多个疑修支持 --dry-run 预览)
Name: "batch-update",
Description: "通过原生批量接口批量更新多个疑修",
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,
}
}
// [A 类批量] 原生批量 API——一次 PATCH /issues/batch_update 请求操作多个 issue
// 需要先通过 resolveIssueDBIDs 将项目编号转为数据库 ID
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{
// +batch-destroy使用原生批量接口一次性永久删除多个疑修支持 --dry-run 与确认提示)
Name: "batch-destroy",
Description: "通过原生批量接口批量删除多个疑修",
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,
}
}
// [A 类批量] 原生批量 API——一次 DELETE /issues/batch_destroy 删除多个 issue不可逆
// 执行前调用 ConfirmAction 弹确认框
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)
}