gitlink-cli/shortcuts/issue/issue.go

356 lines
9.9 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"
)
// 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 构造 PATCH body
// 其余字段为后续扩展保留。
type IssueData struct {
Subject string
Description string
StatusID int
AssignedToID int
FixedVersionID int
PriorityID int
LabelIDs []int
}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchCreateShortcut(),
newBatchCloseShortcut(),
newBatchOpenShortcut(),
newBatchAssignShortcut(),
newBatchLabelShortcut(),
newBatchUpdateShortcut(),
newBatchDeleteShortcut(),
{
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 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 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 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 != "" {
id, err := ResolveUserID(ctx, a)
if err != nil {
return fmt.Errorf("assignee %q: %w", a, err)
}
body["assigner_ids"] = []int{id}
}
if m := ctx.Arg("milestone"); m != "" {
body["milestone_id"] = m
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil {
return 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")
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: "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 err
}
number, err := ctx.RequireArg("number")
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,
"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: "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 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 := fetchIssueData(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 err
}
return ctx.Output(env)
},
},
{
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 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)
},
},
}
}
// 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: getMapInt(issueMap, "status_id"),
AssignedToID: getMapInt(issueMap, "assigned_to_id"),
FixedVersionID: getNestedMapInt(issueMap, "milestone", "id"),
PriorityID: getMapInt(issueMap, "priority_id"),
LabelIDs: getTagIDs(issueMap, "tags"),
}
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 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)
}
}