gitlink-cli/shortcuts/issue/issue.go

325 lines
10 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"
"strconv" //字符串和数字转换
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Ctx
// 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)
}
//内部数据结构,标题+描述,保存从 API 取回来的 Issue 原始数据
type existingIssue struct {
Subject string
Description string
}
// 所有issue命令的注册入口返回所有issue命令
// 在终端敲入issue +list命令时遍历匹配到列表里的{Name: "list", ...}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchCloseShortcut(),
newBatchStatusShortcut(),
newBatchPriorityShortcut(),
newBatchAssignShortcut(),
newBatchLabelShortcut(),
newBatchCreateShortcut(),
newLabelAddShortcut(),
newLabelRemoveShortcut(),
newLabelListShortcut(),
{
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"},
},
//后面拼接 API 路径时,需要用到 ctx.Owner 和 ctx.Repo
// 如果不知道 owner 和 repo ,后续的 API 调用就不知道该往哪发请求,所以必须作为前置校验放在最前面。
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{} // 创建空的URL查询参数容器q后续通过set()添加键值对,最终拼接成形如 ?page=1&limit=20&state=open 的查询字符串
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit")) //从命令行参数中读取 page页码和 limit每页条数
if s := ctx.Arg("state"); s != "" { // 如果用户传入了state参数
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) //按照用户指定的格式json/html/table...)输出issue列表
},
},
{
Name: "create",
Description: "Create a new issue",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
title := ctx.Arg("title")
return fmt.Sprintf("Create issue: %s", title), nil
},
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 err
}
title, err := ctx.RequireArg("title", `--title "Bug: 登录页崩溃"`)
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 err
}
number, err := ctx.RequireArg("number", "--number 42")
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",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
number := ctx.Arg("number")
return fmt.Sprintf("Close issue #%s", number), nil
},
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 err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return 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 err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return 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 err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update an issue",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
number := ctx.Arg("number")
return fmt.Sprintf("Update issue #%s", number), nil
},
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 err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
title := ctx.Arg("title")
description := ctx.Arg("body")
state := ctx.Arg("state")
if title == "" && description == "" && state == "" {
return fmt.Errorf("at least one of --title, --body, or --state is required")
}
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return 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: "comment",
Description: "Add a comment to an issue",
DryRun: true,
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
number := ctx.Arg("number")
return fmt.Sprintf("Add comment to issue #%s", number), nil
},
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 err
}
number, err := ctx.RequireArg("number", "--number 42")
if err != nil {
return err
}
body, err := ctx.RequireArg("body", `--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("添加 Issue 评论失败: %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("failed to parse issue data")
}
subject, _ := issueData["subject"].(string)
if subject == "" {
return nil, fmt.Errorf("failed to parse issue subject")
}
description, _ := issueData["description"].(string)
return &existingIssue{
Subject: subject,
Description: description,
}, 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("invalid --state %q: use open, closed, or a numeric status_id", state)
}
}