gitlink-cli/shortcuts/issue/issue.go

738 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/i18n"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// v1RepoPath 返回 v1 API 路径前缀:/v1/{owner}/{repo}。
// issue 相关端点都走 v1 前缀,与其它资源(如 label、pr的 /v0 路径不同。
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
// IssueData 记录从 issue 接口读出的全部字段。
// batch 流程使用 Subject、Description、LabelIDsclose/update 命令使用全部字段
// 来构造保留现有 metadata 的 PATCH body。
// StatusID/PriorityID 为 interface{} 以兼容 API 返回的嵌套对象 id如 status.id
type IssueData struct {
Subject string
Description string
StatusID interface{}
AssignedToID int
FixedVersionID int
PriorityID interface{}
LabelIDs []int
AssignerIDs []interface{}
BranchName string
StartDate string
DueDate string
}
func normalizeIssueListState(state string) string {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open", "opened":
return "opened"
case "closed":
return "closed"
case "all", "":
return "all"
default:
return state
}
}
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
return []*common.Shortcut{
newBatchCreateShortcut(tr),
newBatchCloseShortcut(tr),
newBatchOpenShortcut(tr),
newBatchAssignShortcut(tr),
newBatchLabelShortcut(tr),
newBatchUpdateShortcut(tr),
newBatchDeleteShortcut(tr),
{
Name: "list",
Description: tr.T("cmd.issue.list.short"),
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: tr.T("flag.issue.state"), Default: "open"},
{Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword")},
{Name: "participant", Usage: tr.T("flag.issue.participant")},
{Name: "author-id", Usage: tr.T("flag.issue.author_id")},
{Name: "assignee-id", Usage: tr.T("flag.issue.assignee_id")},
{Name: "milestone-id", Usage: tr.T("flag.issue.milestone")},
{Name: "status-id", Usage: tr.T("flag.issue.status_id")},
{Name: "tag-ids", Usage: tr.T("flag.issue.tag_ids")},
{Name: "sort-by", Usage: tr.T("flag.sort_by")},
{Name: "sort-direction", Usage: tr.T("flag.sort_direction")},
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
},
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("category", normalizeIssueListState(s))
}
if keyword := ctx.Arg("keyword"); keyword != "" {
q.Set("keyword", keyword)
}
if participant := ctx.Arg("participant"); participant != "" {
q.Set("participant_category", participant)
}
if authorID := ctx.Arg("author-id"); authorID != "" {
q.Set("author_id", authorID)
}
if assigneeID := ctx.Arg("assignee-id"); assigneeID != "" {
q.Set("assigner_id", assigneeID)
}
if milestoneID := ctx.Arg("milestone-id"); milestoneID != "" {
q.Set("milestone_id", milestoneID)
}
if statusID := ctx.Arg("status-id"); statusID != "" {
q.Set("status_id", statusID)
}
if tagIDs := ctx.Arg("tag-ids"); tagIDs != "" {
q.Set("issue_tag_ids", tagIDs)
}
if sortBy := ctx.Arg("sort-by"); sortBy != "" {
q.Set("sort_by", sortBy)
}
if sortDirection := ctx.Arg("sort-direction"); sortDirection != "" {
q.Set("sort_direction", sortDirection)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return err
}
normalizeIssueListIDs(env)
return ctx.Output(env)
},
},
{
Name: "create",
Description: tr.T("cmd.issue.create.short"),
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: tr.T("flag.issue.title"), Required: true},
{Name: "body", Short: "b", Usage: tr.T("flag.issue.body")},
{Name: "assignee", Short: "a", Usage: tr.T("flag.issue.assignee")},
{Name: "milestone", Short: "m", Usage: tr.T("flag.issue.milestone")},
{Name: "label", Usage: tr.T("flag.issue.label")},
{Name: "priority-id", Usage: "Priority ID", Default: "2"},
{Name: "tag-ids", Usage: "Comma-separated issue tag IDs"},
{Name: "assigner-ids", Usage: "Comma-separated issue assigner IDs"},
{Name: "branch", Usage: "Linked branch name"},
{Name: "start-date", Usage: "Start date (YYYY-MM-DD)"},
{Name: "due-date", Usage: "Due date (YYYY-MM-DD)"},
},
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
}
if m := ctx.Arg("milestone"); m != "" {
body["fixed_version_id"] = m
}
if err := applyIssueMetadataArgs(ctx, body); err != nil {
return err
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: tr.T("cmd.issue.view.short"),
Flags: issueNumberFlags(),
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := issueNumberArg(ctx)
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)
},
},
{
Name: "close",
Description: tr.T("cmd.issue.close.short"),
Flags: issueNumberFlags(),
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := issueNumberArg(ctx)
if err != nil {
return err
}
current, err := fetchIssueData(ctx, number)
if err != nil {
return err
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
}
preserveIssueMetadata(body, current)
body["status_id"] = 5 // 5 = closed
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: tr.T("cmd.issue.update.short"),
Flags: appendIssueNumberFlags(
common.Flag{Name: "title", Short: "t", Usage: tr.T("flag.issue.new_title")},
common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.issue.new_body")},
common.Flag{Name: "state", Short: "s", Usage: tr.T("flag.issue.new_state")},
common.Flag{Name: "priority-id", Usage: "New priority ID"},
common.Flag{Name: "tag-ids", Usage: "Comma-separated issue tag IDs"},
common.Flag{Name: "assigner-ids", Usage: "Comma-separated issue assigner IDs"},
common.Flag{Name: "branch", Usage: "Linked branch name"},
common.Flag{Name: "start-date", Usage: "Start date (YYYY-MM-DD)"},
common.Flag{Name: "due-date", Usage: "Due date (YYYY-MM-DD)"},
),
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := issueNumberArg(ctx)
if err != nil {
return err
}
title := ctx.Arg("title")
description := ctx.Arg("body")
state := ctx.Arg("state")
if title == "" && description == "" && state == "" && !hasIssueMetadataArgs(ctx) {
return fmt.Errorf("at least one update field is required")
}
current, err := fetchIssueData(ctx, number)
if err != nil {
return err
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
}
preserveIssueMetadata(body, current)
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
}
if err := applyIssueMetadataArgs(ctx, body); err != nil {
return err
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "comment",
Description: tr.T("cmd.issue.comment.short"),
Flags: appendIssueNumberFlags(
common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true},
),
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := issueNumberArg(ctx)
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)
},
},
{
Name: "assigners",
Description: "List issue assigners",
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 keyword := ctx.Arg("keyword"); keyword != "" {
q.Set("keyword", keyword)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_assigners", q)
if err != nil {
return err
}
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 keyword := ctx.Arg("keyword"); keyword != "" {
q.Set("keyword", keyword)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_authors", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "priorities",
Description: "List issue priorities",
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 keyword := ctx.Arg("keyword"); keyword != "" {
q.Set("keyword", keyword)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_priorities", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "tags",
Description: "List issue tags",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword"},
{Name: "only-name", Usage: "Only return tag names and IDs", Bool: true, Default: "false"},
{Name: "order-by", Usage: "Order by: updated_on, created_on, issues_count"},
{Name: "order-direction", Usage: "Order direction: asc or desc"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if keyword := ctx.Arg("keyword"); keyword != "" {
q.Set("keyword", keyword)
}
if parseBool(ctx.Arg("only-name")) {
q.Set("only_name", "true")
}
if orderBy := ctx.Arg("order-by"); orderBy != "" {
q.Set("order_by", orderBy)
}
if orderDirection := ctx.Arg("order-direction"); orderDirection != "" {
q.Set("order_direction", orderDirection)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_tags", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "statuses",
Description: "List issue statuses",
Flags: []common.Flag{
{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
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_statues", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
}
return i18n.Default()
}
func issueNumberFlags() []common.Flag {
return []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number from the web URL (preferred)"},
{Name: "id", Short: "i", Usage: "Compatibility alias for --number; this is not the database ID"},
}
}
func appendIssueNumberFlags(flags ...common.Flag) []common.Flag {
return append(issueNumberFlags(), flags...)
}
func issueNumberArg(ctx *common.RuntimeContext) (string, error) {
if number := strings.TrimSpace(ctx.Arg("number")); number != "" {
return number, nil
}
if id := strings.TrimSpace(ctx.Arg("id")); id != "" {
return id, nil
}
return "", fmt.Errorf("required flag --number is missing (or use --id as a compatibility alias)")
}
// 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
}
}
// fetchIssueData 从 API 读取指定 issue 的完整数据。
// JSON 反序列化得到的 float64 / []interface{} 会被规范化为 int / []int。
func fetchIssueData(ctx *common.RuntimeContext, number string) (*IssueData, error) {
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
if err != nil {
return nil, err
}
issueMap, ok := getEnv.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("failed to parse issue data")
}
subject, _ := issueMap["subject"].(string)
if subject == "" {
return nil, fmt.Errorf("failed to parse issue subject")
}
data := &IssueData{
Subject: subject,
Description: getMapString(issueMap, "description"),
StatusID: nestedIssueID(issueMap, "status"),
AssignedToID: getMapInt(issueMap, "assigned_to_id"),
FixedVersionID: getNestedMapInt(issueMap, "milestone", "id"),
PriorityID: nestedIssueID(issueMap, "priority"),
LabelIDs: getTagIDs(issueMap, "tags"),
AssignerIDs: issueObjectIDs(issueMap, "assigners"),
BranchName: getMapString(issueMap, "branch_name"),
StartDate: getMapString(issueMap, "start_date"),
DueDate: getMapString(issueMap, "due_date"),
}
return data, nil
}
// getMapString 从 map 中安全提取 string 值,类型不匹配时返回空串。
func getMapString(m map[string]interface{}, key string) string {
s, _ := m[key].(string)
return s
}
// getMapInt 从 map 中提取 int 值,兼容 JSON 反序列化得到的 float64。
// 类型不匹配或缺失时返回 0。
func getMapInt(m map[string]interface{}, key string) int {
switch v := m[key].(type) {
case float64:
return int(v)
case int:
return v
}
return 0
}
// getNestedMapInt 从 map 的嵌套对象字段中提取 int 类型的值。
// 例如 issueMap["milestone"] 是 {id: 2764, name: "v1.0"}
// getNestedMapInt(issueMap, "milestone", "id") 返回 2764。
// 字段缺失或类型不匹配时返回 0。
func getNestedMapInt(m map[string]interface{}, outerKey, innerKey string) int {
outer, ok := m[outerKey].(map[string]interface{})
if !ok {
return 0
}
return getMapInt(outer, innerKey)
}
// getMapIntSlice 从 map 中提取 []int元素类型兼容 float64JSON 数字)。
// 类型不匹配或缺失时返回 nil。
func getMapIntSlice(m map[string]interface{}, key string) []int {
raw, ok := m[key].([]interface{})
if !ok {
return nil
}
ids := make([]int, 0, len(raw))
for _, item := range raw {
switch v := item.(type) {
case float64:
ids = append(ids, int(v))
case int:
ids = append(ids, v)
}
}
return ids
}
// getTagIDs 从 map 中提取 tag 对象数组中每个对象的 id 字段。
// API 返回 tags: [{id: 1, name: "bug"}, ...],需要遍历对象提取 id。
// 类型不匹配或缺失时返回 nil。
func getTagIDs(m map[string]interface{}, key string) []int {
raw, ok := m[key].([]interface{})
if !ok {
return nil
}
ids := make([]int, 0, len(raw))
for _, item := range raw {
if tag, ok := item.(map[string]interface{}); ok {
id := getMapInt(tag, "id")
if id > 0 {
ids = append(ids, id)
}
}
}
return ids
}
func preserveIssueMetadata(body map[string]interface{}, issue *IssueData) {
if issue.StatusID != nil {
body["status_id"] = issue.StatusID
}
if issue.PriorityID != nil {
body["priority_id"] = issue.PriorityID
}
if len(issue.LabelIDs) > 0 {
body["issue_tag_ids"] = issue.LabelIDs
}
if len(issue.AssignerIDs) > 0 {
body["assigner_ids"] = issue.AssignerIDs
}
if issue.BranchName != "" {
body["branch_name"] = issue.BranchName
}
if issue.StartDate != "" {
body["start_date"] = issue.StartDate
}
if issue.DueDate != "" {
body["due_date"] = issue.DueDate
}
}
func nestedIssueID(data map[string]interface{}, key string) interface{} {
item, ok := data[key].(map[string]interface{})
if !ok {
return nil
}
return item["id"]
}
func issueObjectIDs(data map[string]interface{}, keys ...string) []interface{} {
for _, key := range keys {
items, ok := data[key].([]interface{})
if !ok {
continue
}
ids := make([]interface{}, 0, len(items))
for _, item := range items {
obj, ok := item.(map[string]interface{})
if !ok {
continue
}
if id, ok := obj["id"]; ok {
ids = append(ids, id)
}
}
if len(ids) > 0 {
return ids
}
}
return nil
}
func stringField(data map[string]interface{}, key string) string {
value, _ := data[key].(string)
return value
}
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)
}
}
func hasIssueMetadataArgs(ctx *common.RuntimeContext) bool {
for _, name := range []string{"priority-id", "tag-ids", "label", "assigner-ids", "branch", "start-date", "due-date"} {
if ctx.Arg(name) != "" {
return true
}
}
return false
}
func applyIssueMetadataArgs(ctx *common.RuntimeContext, body map[string]interface{}) error {
if priority := ctx.Arg("priority-id"); priority != "" {
priorityID, err := parseIssueID(priority, "priority-id")
if err != nil {
return err
}
body["priority_id"] = priorityID
}
tagIDs := ctx.Arg("tag-ids")
if label := ctx.Arg("label"); label != "" {
if tagIDs != "" {
return fmt.Errorf("--label cannot be used with --tag-ids")
}
tagIDs = label
}
if tagIDs != "" {
ids, err := parseIssueIDList(tagIDs, "tag-ids")
if err != nil {
return err
}
body["issue_tag_ids"] = ids
}
if assignerIDs := ctx.Arg("assigner-ids"); assignerIDs != "" {
ids, err := parseIssueIDList(assignerIDs, "assigner-ids")
if err != nil {
return err
}
body["assigner_ids"] = ids
}
if branch := ctx.Arg("branch"); branch != "" {
body["branch_name"] = branch
}
if startDate := ctx.Arg("start-date"); startDate != "" {
body["start_date"] = startDate
}
if dueDate := ctx.Arg("due-date"); dueDate != "" {
body["due_date"] = dueDate
}
return nil
}
func parseIssueIDList(value, flagName string) ([]int, error) {
parts := strings.Split(value, ",")
ids := make([]int, 0, len(parts))
for _, part := range parts {
id, err := parseIssueID(part, flagName)
if err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, nil
}
func parseIssueID(value, flagName string) (int, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return 0, fmt.Errorf("--%s contains an empty ID", flagName)
}
id, err := strconv.Atoi(trimmed)
if err != nil || id <= 0 {
return 0, fmt.Errorf("--%s must contain positive numeric IDs", flagName)
}
return id, nil
}