forked from Gitlink/gitlink-cli
356 lines
9.9 KiB
Go
356 lines
9.9 KiB
Go
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,元素类型兼容 float64(JSON 数字)。
|
||
// 类型不匹配或缺失时返回 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)
|
||
}
|
||
}
|