forked from Gitlink/gitlink-cli
341 lines
12 KiB
Go
341 lines
12 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 将用户可见的项目编号映射为数据库主键 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)
|
||
}
|