gitlink-cli/shortcuts/issue/issue.go

669 lines
21 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/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
}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchCloseShortcut(),
newBatchUpdateShortcut(),
newBatchDestroyShortcut(),
// +list列出当前仓库的疑修支持按状态/里程碑/标签筛选)
{
Name: "list",
Description: "列出疑修",
Long: `List issues in the current repository.
Shows issue number, title, status, and other details.
Use --state to filter by status, --milestone, --assignee, --label,
--keyword for additional filtering, and --page and --limit for pagination.`,
Example: ` # List open issues (default)
gitlink issue +list
# List closed issues
gitlink issue +list --state closed
# Show 50 issues per page
gitlink issue +list --limit 50
# Filter by milestone and assignee
gitlink issue +list --milestone 3 --assignee 5
# Search by keyword
gitlink issue +list -k "login bug"
# Sort by created date
gitlink issue +list --sort created_on
# Sort by updated date descending
gitlink issue +list --sort updated_on --sort-direction desc`,
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"},
{Name: "milestone", Short: "m", Usage: "Filter by milestone ID"},
{Name: "assignee", Short: "a", Usage: "Filter by assignee user ID"},
{Name: "label", Usage: "Filter by label ID"},
{Name: "keyword", Short: "k", Usage: "Search by keyword"},
{Name: "sort", Usage: "Sort field (e.g. created_on, updated_on)"},
{Name: "sort-direction", Usage: "Sort direction: asc or desc"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return 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)
}
// [P2 参数补全] 以下 6 个查询参数原 CLI 未暴露,但 GitLink API 支持
// CLI 参数名 → API 字段名的映射(两者经常不一致)
if m := ctx.Arg("milestone"); m != "" {
q.Set("fixed_version_id", m) // CLI "milestone" → API "fixed_version_id"
}
if a := ctx.Arg("assignee"); a != "" {
q.Set("assigned_to_id", a) // CLI "assignee" → API "assigned_to_id"
}
if l := ctx.Arg("label"); l != "" {
q.Set("issue_tag_id", l) // CLI "label" → API "issue_tag_id"
}
if k := ctx.Arg("keyword"); k != "" {
q.Set("keyword", k) // 关键词搜索(名称一致,无需映射)
}
if s := ctx.Arg("sort"); s != "" {
q.Set("sort", s) // 排序字段(如 created_on, updated_on
}
if d := ctx.Arg("sort-direction"); d != "" {
q.Set("sort_direction", d) // 排序方向 asc/desc实测 API 用 sort+sort_direction不是 sort_by
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return err
}
normalizeIssueListIDs(env)
return ctx.Output(env)
},
},
// +create在当前仓库创建新疑修需 --title可附带描述/指派人/里程碑/标签)
{
Name: "create",
Description: "创建新疑修",
Long: `Create a new issue in the current repository.
Requires a title (--title). You may optionally set a description,
assignee, milestone, and label at creation time.`,
Example: ` # Create an issue with just a title
gitlink issue +create --title "Fix login bug"
# Create with description and assignee
gitlink issue +create --title "Fix login bug" --body "Steps to reproduce..." --assignee zhangsan
# Create with milestone and label
gitlink issue +create --title "New feature" --milestone 3 --label 5`,
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")
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 // CLI "assignee" → API "assigned_to_id"
}
if m := ctx.Arg("milestone"); m != "" {
body["fixed_version_id"] = m // CLI "milestone" → API "fixed_version_id"
}
// [P1 Bug#1 修复] 原代码只在 Flags 中定义了 label 参数Run 函数里没有取值写入请求体
// 导致用户传 --label 5 不报错,但创建的 issue 没有标签
// 修复:从 ctx.Arg("label") 取值,映射到 API 字段名 "issue_tag_id"
if l := ctx.Arg("label"); l != "" {
body["issue_tag_id"] = l // CLI "label" → API "issue_tag_id"
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +view查看指定疑修的详情标题/描述/状态/指派人等)
{
Name: "view",
Description: "查看疑修详情",
Long: `View detailed information about a specific issue.
Displays the issue title, description, status, assignee,
and other metadata. Use the issue number as shown in the web URL.`,
Example: ` # View issue #42
gitlink issue +view --number 42
# Short form
gitlink issue +view -n 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")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +close关闭疑修保留标题和描述可选附上说明性评论
{
Name: "close",
Description: "关闭疑修",
Long: `Close an existing issue by setting its status to closed.
The issue number is the one shown in the web URL (project-level index).
This preserves the existing title and description.
Use --comment to leave an explanation when closing.`,
Example: ` # Close issue #42
gitlink issue +close --number 42
# Short form
gitlink issue +close -n 42
# Close with a comment
gitlink issue +close -n 42 --comment "Fixed in commit abc123"`,
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "comment", Short: "c", Usage: "Comment to add when closing"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
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 err
}
// [P2 参数补全] 关闭PATCH和评论POST journals是两个独立 API代码串行调用
// 关闭 issue 本身是一个 PATCH 请求,评论是另一个 POST 请求到 journals 端点
if comment := ctx.Arg("comment"); comment != "" {
_, _ = ctx.CallAPI("POST",
fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number),
map[string]interface{}{"notes": comment})
}
return ctx.Output(env)
},
},
// +update更新疑修的字段标题/描述/状态/里程碑/指派人/标签/优先级等)
{
Name: "update",
Description: "更新疑修",
Long: `Update the title, description, status, or other fields of an existing issue.
At least one of --title, --body, --state, --milestone, --assignee,
--label, or --priority must be provided.
The --state flag accepts "open", "closed", or a numeric status_id.`,
Example: ` # Change the title
gitlink issue +update --number 42 --title "Updated title"
# Change description
gitlink issue +update -n 42 --body "New description"
# Reopen a closed issue
gitlink issue +update -n 42 --state open
# Update title and close at the same time
gitlink issue +update -n 42 --title "Fixed" --state closed
# Assign to a user and set milestone
gitlink issue +update -n 42 --assignee 5 --milestone 3
# Set label and priority
gitlink issue +update -n 42 --label 7 --priority 1`,
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"},
{Name: "milestone", Short: "m", Usage: "Milestone ID"},
{Name: "assignee", Short: "a", Usage: "Assignee user ID"},
{Name: "label", Usage: "Label ID"},
{Name: "priority", Usage: "Priority ID"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
title := ctx.Arg("title")
description := ctx.Arg("body")
state := ctx.Arg("state")
milestone := ctx.Arg("milestone")
assignee := ctx.Arg("assignee")
label := ctx.Arg("label")
priority := ctx.Arg("priority")
if title == "" && description == "" && state == "" &&
milestone == "" && assignee == "" && label == "" && priority == "" {
return fmt.Errorf("at least one of --title, --body, --state, --milestone, --assignee, --label, or --priority is required")
}
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return err
}
// [P2 参数补全] 保留原值,只修改用户指定的字段
// GitLink PATCH API 要求 subject 和 description 不能为空,所以必须先查出原值
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
}
// [P2 参数补全] 以下 4 个参数原 CLI 未暴露
if m := ctx.Arg("milestone"); m != "" {
body["fixed_version_id"] = m // CLI "milestone" → API "fixed_version_id"
}
if a := ctx.Arg("assignee"); a != "" {
body["assigned_to_id"] = a // CLI "assignee" → API "assigned_to_id"
}
if l := ctx.Arg("label"); l != "" {
body["issue_tag_id"] = l // CLI "label" → API "issue_tag_id"
}
if p := ctx.Arg("priority"); p != "" {
body["priority_id"] = p // CLI "priority" → API "priority_id"
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +comment在指定疑修下添加一条评论--number 和 --body 必填)
{
Name: "comment",
Description: "在疑修下添加评论",
Long: `Add a comment (journal entry) to an existing issue.
Both --number and --body are required.`,
Example: ` # Comment on issue #42
gitlink issue +comment --number 42 --body "This is now fixed."
# Short form
gitlink issue +comment -n 42 -b "LGTM"`,
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")
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 err
}
return ctx.Output(env)
},
},
// +comments列出指定疑修下的评论与动态记录
{
Name: "comments",
Description: "列出疑修的评论与动态",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", 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")
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", v1RepoPath(ctx), number), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +update-comment修改疑修下的某条评论内容
{
Name: "update-comment",
Description: "修改疑修下的评论",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "id", Usage: "Comment journal 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")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
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("PATCH", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +delete-comment删除疑修下的某条评论
{
Name: "delete-comment",
Description: "删除疑修下的评论",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "id", Usage: "Comment journal ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +reply-comment列出某条评论的子回复
{
Name: "reply-comment",
Description: "列出评论的子回复",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "id", Usage: "Parent comment journal ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s/journals/%s/children_journals", v1RepoPath(ctx), number, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +delete删除指定疑修
{
Name: "delete",
Description: "删除疑修",
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")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +batch-update通过原生批量接口批量更新多个疑修按数据库 ID
{
Name: "batch-update",
Description: "批量更新多个疑修",
Flags: []common.Flag{
{Name: "ids", Usage: "Comma-separated issue IDs", Required: true},
{Name: "state", Short: "s", Usage: "New state: open, closed"},
{Name: "assignee", Short: "a", Usage: "Assignee login"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
idsStr, err := ctx.RequireArg("ids")
if err != nil {
return err
}
body := map[string]interface{}{
"ids": parseIDList(idsStr),
}
if s := ctx.Arg("state"); s != "" {
statusID, err := normalizeIssueStatus(s)
if err != nil {
return err
}
body["status_id"] = statusID
}
if a := ctx.Arg("assignee"); a != "" {
body["assigned_to_id"] = a
}
env, err := ctx.CallAPI("PATCH", v1RepoPath(ctx)+"/issues/batch_update", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
// +batch-destroy通过原生批量接口批量删除多个疑修按数据库 ID
{
Name: "batch-destroy",
Description: "批量删除多个疑修",
Flags: []common.Flag{
{Name: "ids", Usage: "Comma-separated issue IDs", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
idsStr, err := ctx.RequireArg("ids")
if err != nil {
return err
}
body := map[string]interface{}{
"ids": parseIDList(idsStr),
}
env, err := ctx.CallAPI("DELETE", v1RepoPath(ctx)+"/issues/batch_destroy", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
// normalizeIssueListIDs adds "number" (project_issues_index) and renames
// "id" to "database_id" so the user-facing output uses the project-level
// issue number, not the global database primary key.
func normalizeIssueListIDs(env *output.Envelope) {
data, ok := env.Data.(map[string]interface{})
if !ok {
return
}
issues, ok := data["issues"].([]interface{})
if !ok {
return
}
for i, item := range issues {
issue, ok := item.(map[string]interface{})
if !ok {
continue
}
// Copy project_issues_index to top-level "number"
if num, ok := issue["project_issues_index"]; ok {
issue["number"] = num
}
// Rename "id" (global database PK) to "database_id"
if id, ok := issue["id"]; ok {
issue["database_id"] = id
delete(issue, "id")
}
issues[i] = issue
}
}
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, 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)
}
}
// parseIDList splits a comma-separated string into an int slice.
func parseIDList(s string) []int {
parts := strings.Split(s, ",")
ids := make([]int, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if id, err := strconv.Atoi(p); err == nil {
ids = append(ids, id)
}
}
return ids
}