forked from chroe/gitlink-cli
404 lines
12 KiB
Go
404 lines
12 KiB
Go
package issue
|
||
|
||
import (
|
||
"fmt"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||
)
|
||
|
||
// v1RepoPath returns the v1 API path prefix: /v1/{owner}/{repo}
|
||
func v1RepoPath(ctx *common.RuntimeContext) string {
|
||
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
|
||
}
|
||
|
||
type existingIssue struct {
|
||
Subject string
|
||
Description string
|
||
StatusID interface{}
|
||
}
|
||
|
||
func Shortcuts() []*common.Shortcut {
|
||
return []*common.Shortcut{
|
||
newBatchCloseShortcut(),
|
||
newBatchReopenShortcut(),
|
||
newBatchAssignShortcut(),
|
||
newBatchLabelShortcut(),
|
||
newBatchMilestoneShortcut(),
|
||
newBatchCommentShortcut(),
|
||
{
|
||
Name: "list",
|
||
Description: "List issues",
|
||
Flags: []common.Flag{
|
||
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
|
||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||
}
|
||
q := url.Values{}
|
||
q.Set("page", ctx.Arg("page"))
|
||
q.Set("limit", ctx.Arg("limit"))
|
||
if s := ctx.Arg("state"); s != "" {
|
||
q.Set("state", s)
|
||
}
|
||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
|
||
if err != nil {
|
||
return fmt.Errorf("获取 Issue 列表失败: %w", err)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
{
|
||
Name: "create",
|
||
Description: "Create a new issue",
|
||
Flags: []common.Flag{
|
||
{Name: "title", Short: "t", Usage: "Issue title", Required: true},
|
||
{Name: "body", Short: "b", Usage: "Issue description"},
|
||
{Name: "assignee", Short: "a", Usage: "Assignee login"},
|
||
{Name: "milestone", Short: "m", Usage: "Milestone ID"},
|
||
{Name: "label", Usage: "Label ID"},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||
}
|
||
title, err := ctx.RequireArg("title")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
body := map[string]interface{}{
|
||
"subject": title,
|
||
"status_id": 1, // 1 = open (required by v1 API)
|
||
"priority_id": 2, // 2 = normal
|
||
"done_ratio": 0,
|
||
}
|
||
if desc := ctx.Arg("body"); desc != "" {
|
||
body["description"] = desc
|
||
}
|
||
if a := ctx.Arg("assignee"); a != "" {
|
||
body["assigned_to_id"] = a
|
||
}
|
||
if m := ctx.Arg("milestone"); m != "" {
|
||
body["fixed_version_id"] = m
|
||
}
|
||
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
|
||
if err != nil {
|
||
return fmt.Errorf("创建 Issue 失败: %w", err)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
{
|
||
Name: "view",
|
||
Description: "View issue details",
|
||
Flags: []common.Flag{
|
||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||
}
|
||
number, err := ctx.RequireArg("number")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
|
||
if err != nil {
|
||
return fmt.Errorf("查看 Issue 详情失败: %w", err)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
{
|
||
Name: "close",
|
||
Description: "Close an issue",
|
||
Flags: []common.Flag{
|
||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||
}
|
||
number, err := ctx.RequireArg("number")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
current, err := fetchExistingIssue(ctx, number)
|
||
if err != nil {
|
||
return fmt.Errorf("关闭 Issue 失败: %w", err)
|
||
}
|
||
|
||
body := map[string]interface{}{
|
||
"subject": current.Subject,
|
||
"description": current.Description,
|
||
"status_id": 5, // 5 = closed
|
||
}
|
||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||
if err != nil {
|
||
return fmt.Errorf("关闭 Issue 失败: %w", err)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
{
|
||
Name: "reopen",
|
||
Description: "Reopen a closed issue",
|
||
Flags: []common.Flag{
|
||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||
}
|
||
number, err := ctx.RequireArg("number")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
current, err := fetchExistingIssue(ctx, number)
|
||
if err != nil {
|
||
return fmt.Errorf("重新打开 Issue 失败: %w", err)
|
||
}
|
||
|
||
body := map[string]interface{}{
|
||
"subject": current.Subject,
|
||
"description": current.Description,
|
||
"status_id": 1, // 1 = open
|
||
}
|
||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||
if err != nil {
|
||
return fmt.Errorf("重新打开 Issue 失败: %w", err)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
{
|
||
Name: "update",
|
||
Description: "Update an issue",
|
||
Flags: []common.Flag{
|
||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||
{Name: "title", Short: "t", Usage: "New title"},
|
||
{Name: "body", Short: "b", Usage: "New description"},
|
||
{Name: "state", Short: "s", Usage: "New state: open, closed, or numeric status_id"},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||
}
|
||
number, err := ctx.RequireArg("number")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
title := ctx.Arg("title")
|
||
description := ctx.Arg("body")
|
||
state := ctx.Arg("state")
|
||
if title == "" && description == "" && state == "" {
|
||
return fmt.Errorf("至少需要指定 --title、--body 或 --state 中的一个")
|
||
}
|
||
|
||
current, err := fetchExistingIssue(ctx, number)
|
||
if err != nil {
|
||
return fmt.Errorf("更新 Issue 失败: %w", err)
|
||
}
|
||
|
||
body := map[string]interface{}{
|
||
"subject": current.Subject,
|
||
"description": current.Description,
|
||
}
|
||
if t := ctx.Arg("title"); t != "" {
|
||
body["subject"] = t
|
||
}
|
||
if b := ctx.Arg("body"); b != "" {
|
||
body["description"] = b
|
||
}
|
||
if s := ctx.Arg("state"); s != "" {
|
||
statusID, err := normalizeIssueStatus(s)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
body["status_id"] = statusID
|
||
}
|
||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||
if err != nil {
|
||
return fmt.Errorf("更新 Issue 失败: %w", err)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
{
|
||
Name: "assign",
|
||
Description: "Assign an issue to a user",
|
||
Flags: []common.Flag{
|
||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||
{Name: "user", Short: "u", Usage: "Assignee user ID (numeric)", Required: true},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||
}
|
||
number, err := ctx.RequireArg("number")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
user, err := ctx.RequireArg("user")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
current, err := fetchExistingIssue(ctx, number)
|
||
if err != nil {
|
||
return fmt.Errorf("分配 Issue 失败: %w", err)
|
||
}
|
||
userID, err := strconv.Atoi(user)
|
||
if err != nil {
|
||
return fmt.Errorf("user 参数必须是数字 ID,而不是用户名")
|
||
}
|
||
body := map[string]interface{}{
|
||
"subject": current.Subject,
|
||
"description": current.Description,
|
||
"assigned_to_id": userID,
|
||
}
|
||
if current.StatusID != nil {
|
||
body["status_id"] = current.StatusID
|
||
}
|
||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
|
||
if err != nil {
|
||
return fmt.Errorf("分配 Issue 失败: %w", err)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
{
|
||
Name: "label",
|
||
Description: "Add or remove labels on an issue",
|
||
Flags: []common.Flag{
|
||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||
{Name: "add", Short: "a", Usage: "Comma-separated label IDs to add"},
|
||
{Name: "remove", Short: "r", Usage: "Comma-separated label IDs to remove"},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||
}
|
||
number, err := ctx.RequireArg("number")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
addIDs := ctx.Arg("add")
|
||
removeIDs := ctx.Arg("remove")
|
||
if addIDs == "" && removeIDs == "" {
|
||
return fmt.Errorf("至少需要指定 --add 或 --remove 中的一个")
|
||
}
|
||
|
||
var errs []string
|
||
if addIDs != "" {
|
||
for _, labelID := range strings.Split(addIDs, ",") {
|
||
labelID = strings.TrimSpace(labelID)
|
||
if labelID == "" {
|
||
continue
|
||
}
|
||
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("添加标签 %s: %v", labelID, err))
|
||
}
|
||
}
|
||
}
|
||
if removeIDs != "" {
|
||
for _, labelID := range strings.Split(removeIDs, ",") {
|
||
labelID = strings.TrimSpace(labelID)
|
||
if labelID == "" {
|
||
continue
|
||
}
|
||
if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/tags/%s", v1RepoPath(ctx), number, labelID), nil); err != nil {
|
||
errs = append(errs, fmt.Sprintf("移除标签 %s: %v", labelID, err))
|
||
}
|
||
}
|
||
}
|
||
if len(errs) > 0 {
|
||
return fmt.Errorf("标签操作失败:\n%s", strings.Join(errs, "\n"))
|
||
}
|
||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||
"message": "标签操作成功",
|
||
}, nil))
|
||
},
|
||
},
|
||
{
|
||
Name: "comment",
|
||
Description: "Add a comment to an issue",
|
||
Flags: []common.Flag{
|
||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||
}
|
||
number, err := ctx.RequireArg("number")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
body, err := ctx.RequireArg("body")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
payload := map[string]interface{}{
|
||
"notes": body,
|
||
}
|
||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload)
|
||
if err != nil {
|
||
return fmt.Errorf("添加评论失败: %w", err)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) {
|
||
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取 Issue 详情: %w", err)
|
||
}
|
||
issueData, ok := getEnv.Data.(map[string]interface{})
|
||
if !ok {
|
||
return nil, fmt.Errorf("解析 Issue 数据失败")
|
||
}
|
||
subject, _ := issueData["subject"].(string)
|
||
if subject == "" {
|
||
return nil, fmt.Errorf("解析 Issue 标题失败")
|
||
}
|
||
description, _ := issueData["description"].(string)
|
||
statusID := issueData["status_id"]
|
||
if statusID == nil {
|
||
// Try nested status object
|
||
if status, ok := issueData["status"].(map[string]interface{}); ok {
|
||
statusID = status["id"]
|
||
}
|
||
}
|
||
return &existingIssue{
|
||
Subject: subject,
|
||
Description: description,
|
||
StatusID: statusID,
|
||
}, nil
|
||
}
|
||
|
||
func normalizeIssueStatus(state string) (interface{}, error) {
|
||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||
case "open":
|
||
return 1, nil
|
||
case "closed":
|
||
return 5, nil
|
||
default:
|
||
if id, err := strconv.Atoi(state); err == nil {
|
||
return id, nil
|
||
}
|
||
return nil, fmt.Errorf("无效的 --state 值 %q: 请使用 open、closed 或数字 status_id", state)
|
||
}
|
||
}
|