forked from Gitlink/gitlink-cli
516 lines
17 KiB
Go
516 lines
17 KiB
Go
package issue
|
||
|
||
import (
|
||
"fmt" //格式化字符串
|
||
"net/url"
|
||
"strconv" //字符串和数字转换
|
||
"strings"
|
||
|
||
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
|
||
"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(),
|
||
newBatchDestroyShortcut(),
|
||
newLabelAddShortcut(),
|
||
newLabelRemoveShortcut(),
|
||
newLabelListShortcut(),
|
||
{
|
||
Name: "list",
|
||
Description: "List issues",
|
||
Long: "List issues in a repository with optional filtering by state and pagination.",
|
||
Example: " gitlink-cli issue +list --state open\n gitlink-cli issue +list --state closed --page 1 --limit 50\n gitlink-cli issue +list --columns id,subject,status,priority --state all",
|
||
Flags: []common.Flag{
|
||
{Name: "state", Short: "s", Usage: "Filter by state", Default: "open", Choices: []string{"open", "closed", "all"}},
|
||
{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 clierrors.OpError(clierrors.KindServer, "list", "issues", err).WithCommand(ctx.CommandName)
|
||
}
|
||
return ctx.Output(env) //按照用户指定的格式(json/html/table...)输出issue列表
|
||
},
|
||
},
|
||
{
|
||
Name: "create",
|
||
Description: "Create a new issue",
|
||
Long: "Create a new issue in the repository. Requires --title. Supports --body, --assignee, --milestone, and --label.",
|
||
Example: " gitlink-cli issue +create --title \"Bug: login crash\" --body \"Steps to reproduce...\"\n gitlink-cli issue +create --title \"Feature request\" --assignee zhangsan --label 3",
|
||
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["assigner_ids"] = []interface{}{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 clierrors.OpError(clierrors.KindServer, "create", "issue", err).WithCommand(ctx.CommandName)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
{
|
||
Name: "view",
|
||
Description: "View issue details",
|
||
Example: " gitlink-cli issue +view --number 42",
|
||
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 clierrors.OpError(clierrors.KindNotFound, "view", "issue", err).WithCommand(ctx.CommandName)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
{
|
||
Name: "close",
|
||
Description: "Close an issue",
|
||
Example: " gitlink-cli issue +close --number 42\n gitlink-cli issue +close --number 42 --dry-run",
|
||
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 clierrors.OpError(clierrors.KindServer, "close", "issue", err).WithCommand(ctx.CommandName)
|
||
}
|
||
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",
|
||
Example: " gitlink-cli issue +update --number 42 --title \"Updated title\"\n gitlink-cli issue +update --number 42 --state closed",
|
||
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", Validate: validateIssueState},
|
||
},
|
||
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 clierrors.InputError(
|
||
"at least one of --title, --body, or --state is required",
|
||
"至少需要提供 --title、--body 或 --state 中的一个参数",
|
||
).WithCommand(ctx.CommandName)
|
||
}
|
||
|
||
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 := issueStateToStatusID(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 clierrors.OpError(clierrors.KindServer, "update", "issue", err).WithCommand(ctx.CommandName)
|
||
}
|
||
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 clierrors.OpError(clierrors.KindServer, "comment", "issue", err).WithCommand(ctx.CommandName)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
// === 元数据查询 ===
|
||
{
|
||
Name: "statuses",
|
||
Description: "List issue statuses",
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return err
|
||
}
|
||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_statues", nil)
|
||
if err != nil {
|
||
return clierrors.OpError(clierrors.KindServer, "list", "issue statuses", err).WithCommand(ctx.CommandName)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
{
|
||
Name: "authors",
|
||
Description: "List issue authors",
|
||
Flags: []common.Flag{
|
||
{Name: "keyword", Short: "k", Usage: "Search keyword"},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return err
|
||
}
|
||
q := url.Values{}
|
||
if kw := ctx.Arg("keyword"); kw != "" {
|
||
q.Set("keyword", kw)
|
||
}
|
||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_authors", q)
|
||
if err != nil {
|
||
return clierrors.OpError(clierrors.KindServer, "list", "issue authors", err).WithCommand(ctx.CommandName)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
{
|
||
Name: "assigners",
|
||
Description: "List issue assignees",
|
||
Flags: []common.Flag{
|
||
{Name: "keyword", Short: "k", Usage: "Search keyword"},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return err
|
||
}
|
||
q := url.Values{}
|
||
if kw := ctx.Arg("keyword"); kw != "" {
|
||
q.Set("keyword", kw)
|
||
}
|
||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_assigners", q)
|
||
if err != nil {
|
||
return clierrors.OpError(clierrors.KindServer, "list", "issue assignees", err).WithCommand(ctx.CommandName)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
{
|
||
Name: "priorities",
|
||
Description: "List issue priorities",
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return err
|
||
}
|
||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_priorities", nil)
|
||
if err != nil {
|
||
return clierrors.OpError(clierrors.KindServer, "list", "issue priorities", err).WithCommand(ctx.CommandName)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
// === 评论管理 ===
|
||
{
|
||
Name: "comment-edit",
|
||
Description: "Edit an issue comment",
|
||
Flags: []common.Flag{
|
||
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
|
||
{Name: "comment-id", Short: "c", Usage: "Comment ID", Required: true},
|
||
{Name: "body", Short: "b", Usage: "New 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
|
||
}
|
||
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
body, err := ctx.RequireArg("body", `--body "updated comment"`)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
payload := map[string]interface{}{
|
||
"notes": body,
|
||
"attachment_ids": []int{},
|
||
}
|
||
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, commentID), payload)
|
||
if err != nil {
|
||
return clierrors.OpError(clierrors.KindServer, "edit", "comment", err).WithCommand(ctx.CommandName)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
{
|
||
Name: "comment-delete",
|
||
Description: "Delete an issue comment",
|
||
DryRun: true,
|
||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||
return fmt.Sprintf("删除 Issue #%s 的评论 #%s", ctx.Arg("number"), ctx.Arg("comment-id")), nil
|
||
},
|
||
Flags: []common.Flag{
|
||
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
|
||
{Name: "comment-id", Short: "c", Usage: "Comment ID", 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
|
||
}
|
||
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, commentID), nil)
|
||
if err != nil {
|
||
return clierrors.OpError(clierrors.KindServer, "delete", "comment", err).WithCommand(ctx.CommandName)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
{
|
||
Name: "replies",
|
||
Description: "List replies to a comment",
|
||
Flags: []common.Flag{
|
||
{Name: "number", Short: "n", Usage: "Issue number", Required: true},
|
||
{Name: "comment-id", Short: "c", Usage: "Parent comment ID", Required: true},
|
||
{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 err
|
||
}
|
||
number, err := ctx.RequireArg("number", "--number 42")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
commentID, err := ctx.RequireArg("comment-id", "--comment-id 123")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
q := url.Values{}
|
||
q.Set("page", ctx.Arg("page"))
|
||
q.Set("limit", ctx.Arg("limit"))
|
||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/issues/%s/journals/%s/children_journals", v1RepoPath(ctx), number, commentID), q)
|
||
if err != nil {
|
||
return clierrors.OpError(clierrors.KindServer, "list", "replies", err).WithCommand(ctx.CommandName)
|
||
}
|
||
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, clierrors.OpError(clierrors.KindNotFound, "view", "issue", err).WithCommand(ctx.CommandName)
|
||
}
|
||
issueData, ok := getEnv.Data.(map[string]interface{})
|
||
if !ok {
|
||
return nil, clierrors.InputError("failed to parse issue data", "API 返回格式异常,请稍后重试").WithCommand(ctx.CommandName)
|
||
}
|
||
subject, _ := issueData["subject"].(string)
|
||
if subject == "" {
|
||
return nil, clierrors.InputError("failed to parse issue subject", "API 返回数据中缺少 subject 字段,请稍后重试").WithCommand(ctx.CommandName)
|
||
}
|
||
description, _ := issueData["description"].(string)
|
||
return &existingIssue{
|
||
Subject: subject,
|
||
Description: description,
|
||
}, nil
|
||
}
|
||
|
||
func validateIssueState(state string) error {
|
||
state = strings.ToLower(strings.TrimSpace(state))
|
||
if state == "open" || state == "closed" {
|
||
return nil
|
||
}
|
||
if _, err := strconv.Atoi(state); err == nil {
|
||
return nil
|
||
}
|
||
return fmt.Errorf("must be \"open\", \"closed\", or a numeric status_id, got %q", state)
|
||
}
|
||
|
||
func issueStateToStatusID(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: %s", state)
|
||
}
|
||
}
|