gitlink-cli/shortcuts/issue/issue.go

404 lines
12 KiB
Go
Raw Permalink 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"
"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)
}
}