From 89d5321713ad486e645175c92cf4076c10826e29 Mon Sep 17 00:00:00 2001 From: wangyue789 Date: Tue, 2 Jun 2026 12:42:34 +0800 Subject: [PATCH] feat(issue): support metadata fields and id alias --- README.md | 11 + README.zh-CN.md | 10 + doc/changes/issue-id-alias.md | 24 ++ doc/changes/issue-metadata-fields.md | 36 ++ shortcuts/issue/issue.go | 233 +++++++++-- shortcuts/issue/issue_test.go | 593 ++++++++++++++++++++++----- skills/gitlink-issue/SKILL.md | 8 + 7 files changed, 772 insertions(+), 143 deletions(-) create mode 100644 doc/changes/issue-id-alias.md create mode 100644 doc/changes/issue-metadata-fields.md diff --git a/README.md b/README.md index 77ec2d6..d9e4c70 100644 --- a/README.md +++ b/README.md @@ -247,9 +247,15 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus # Create an issue gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..." +# Create an issue with metadata +gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" --priority-id 3 --tag-ids 4,5 --assigner-ids 7 + # View an issue gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123 +# Update issue metadata +gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15 + # Close an issue gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 @@ -269,6 +275,11 @@ gitlink-cli issue +assigners --owner Gitlink --repo forgeplus gitlink-cli issue +authors --owner Gitlink --repo forgeplus ``` +`issue +view`, `issue +update`, `issue +close`, and `issue +comment` prefer +`--number` / `-n` for the issue number shown in the web URL. `--id` / `-i` +is accepted as a compatibility alias for the same web issue number, not the +global database ID. + ### Label Management ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index db3547b..9dce55d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -258,9 +258,15 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus # 创建 Issue gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..." +# 创建带元数据的 Issue +gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" --priority-id 3 --tag-ids 4,5 --assigner-ids 7 + # 查看 Issue gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123 +# 更新 Issue 元数据 +gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15 + # 关闭 Issue gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 @@ -280,6 +286,10 @@ gitlink-cli issue +assigners --owner Gitlink --repo forgeplus gitlink-cli issue +authors --owner Gitlink --repo forgeplus ``` +`issue +view`、`issue +update`、`issue +close` 和 `issue +comment` 推荐使用 +`--number` / `-n` 传网页 URL 中的 Issue 编号。`--id` / `-i` 是同一网页 Issue +编号的兼容别名,不是数据库内部 ID。 + ### 标签管理 ```bash diff --git a/doc/changes/issue-id-alias.md b/doc/changes/issue-id-alias.md new file mode 100644 index 0000000..e0e2f94 --- /dev/null +++ b/doc/changes/issue-id-alias.md @@ -0,0 +1,24 @@ +# Issue ID Alias + +## Summary + +`issue +view`, `issue +close`, `issue +update`, and `issue +comment` now accept +`--id` / `-i` as a compatibility alias for `--number` / `-n`. + +The alias uses the same project-level issue number shown in the web URL, for +example `issues/123`. It is not the global database ID. + +`--number` remains the preferred flag and takes precedence when both flags are +provided. + +## Examples + +```bash +gitlink-cli issue +view --owner Gitlink --repo forgeplus --id 123 +gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 +gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 --body "Fixed" +``` + +## Submitter + +Wang Yue diff --git a/doc/changes/issue-metadata-fields.md b/doc/changes/issue-metadata-fields.md new file mode 100644 index 0000000..2246203 --- /dev/null +++ b/doc/changes/issue-metadata-fields.md @@ -0,0 +1,36 @@ +# Issue Metadata Fields + +## Summary + +`issue +create` and `issue +update` now support common GitLink Issue metadata fields. +When updating or closing an Issue, the shortcut also carries the current metadata +back to the API so unrelated fields are not reset by partial updates. + +## Added flags + +| Flag | API field | +|------|-----------| +| `--priority-id` | `priority_id` | +| `--tag-ids` | `issue_tag_ids` | +| `--assigner-ids` | `assigner_ids` | +| `--branch` | `branch_name` | +| `--start-date` | `start_date` | +| `--due-date` | `due_date` | + +`issue +create --label` is also mapped as a single tag ID for backward compatibility. + +## Examples + +```bash +gitlink-cli issue +create --owner Gitlink --repo forgeplus \ + --title "Bug: login failed" \ + --priority-id 3 \ + --tag-ids 4,5 \ + --assigner-ids 7 + +gitlink-cli issue +update --owner Gitlink --repo forgeplus \ + --number 123 \ + --priority-id 4 \ + --branch bugfix/login \ + --due-date 2026-06-15 +``` diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 0459921..c04a920 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -18,6 +18,13 @@ func v1RepoPath(ctx *common.RuntimeContext) string { type existingIssue struct { Subject string Description string + StatusID interface{} + PriorityID interface{} + TagIDs []interface{} + AssignerIDs []interface{} + BranchName string + StartDate string + DueDate string } func Shortcuts() []*common.Shortcut { @@ -58,6 +65,12 @@ func Shortcuts() []*common.Shortcut { {Name: "assignee", Short: "a", Usage: "Assignee login"}, {Name: "milestone", Short: "m", Usage: "Milestone ID"}, {Name: "label", Usage: "Label ID"}, + {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 { @@ -82,6 +95,9 @@ func Shortcuts() []*common.Shortcut { 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 @@ -92,10 +108,7 @@ func Shortcuts() []*common.Shortcut { { Name: "view", Description: "View issue details", - Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)"}, - {Name: "id", Usage: "Alias for --number; uses the issue number from the web URL"}, - }, + Flags: issueNumberFlags(), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -114,14 +127,12 @@ func Shortcuts() []*common.Shortcut { { Name: "close", Description: "Close an issue", - Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, - }, + Flags: issueNumberFlags(), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := ctx.RequireArg("number") + number, err := issueNumberArg(ctx) if err != nil { return err } @@ -133,8 +144,9 @@ func Shortcuts() []*common.Shortcut { body := map[string]interface{}{ "subject": current.Subject, "description": current.Description, - "status_id": 5, // 5 = closed } + 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 @@ -145,25 +157,30 @@ func Shortcuts() []*common.Shortcut { { 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"}, - }, + Flags: appendIssueNumberFlags( + common.Flag{Name: "title", Short: "t", Usage: "New title"}, + common.Flag{Name: "body", Short: "b", Usage: "New description"}, + common.Flag{Name: "state", Short: "s", Usage: "New state: open, closed, or numeric status_id"}, + 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 := ctx.RequireArg("number") + 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 == "" { - return fmt.Errorf("at least one of --title, --body, or --state is required") + if title == "" && description == "" && state == "" && !hasIssueMetadataArgs(ctx) { + return fmt.Errorf("at least one update field is required") } current, err := fetchExistingIssue(ctx, number) @@ -175,6 +192,7 @@ func Shortcuts() []*common.Shortcut { "subject": current.Subject, "description": current.Description, } + preserveIssueMetadata(body, current) if t := ctx.Arg("title"); t != "" { body["subject"] = t } @@ -188,6 +206,9 @@ func Shortcuts() []*common.Shortcut { } 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 @@ -198,15 +219,14 @@ func Shortcuts() []*common.Shortcut { { 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}, - }, + Flags: appendIssueNumberFlags( + common.Flag{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, err := issueNumberArg(ctx) if err != nil { return err } @@ -269,6 +289,27 @@ func Shortcuts() []*common.Shortcut { } } +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. @@ -316,9 +357,76 @@ func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIss return &existingIssue{ Subject: subject, Description: description, + StatusID: nestedIssueID(issueData, "status"), + PriorityID: nestedIssueID(issueData, "priority"), + TagIDs: issueObjectIDs(issueData, "tags", "issue_tags"), + AssignerIDs: issueObjectIDs(issueData, "assigners"), + BranchName: stringField(issueData, "branch_name"), + StartDate: stringField(issueData, "start_date"), + DueDate: stringField(issueData, "due_date"), }, nil } +func preserveIssueMetadata(body map[string]interface{}, issue *existingIssue) { + if issue.StatusID != nil { + body["status_id"] = issue.StatusID + } + if issue.PriorityID != nil { + body["priority_id"] = issue.PriorityID + } + if len(issue.TagIDs) > 0 { + body["issue_tag_ids"] = issue.TagIDs + } + 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": @@ -333,12 +441,77 @@ func normalizeIssueStatus(state string) (interface{}, error) { } } -func issueNumberArg(ctx *common.RuntimeContext) (string, error) { - if number := strings.TrimSpace(ctx.Arg("number")); number != "" { - return number, nil +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 + } } - if id := strings.TrimSpace(ctx.Arg("id")); id != "" { - return id, nil - } - return "", fmt.Errorf("required flag --number (or --id alias) not set") + 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 } diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index c821e54..db3471a 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" @@ -14,12 +15,18 @@ func runShortcut(t *testing.T, server *httptest.Server, name string, args map[st t.Helper() shortcut := findShortcut(t, name) ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, Owner: "owner", Repo: "repo", Format: "json", Args: args, } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } return shortcut.Run(ctx) } @@ -34,9 +41,25 @@ func findShortcut(t *testing.T, name string) *common.Shortcut { return nil } -func writeJSON(w http.ResponseWriter, v interface{}) { +func newIssueTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) { + t.Helper() w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} + +func writeText(t *testing.T, w http.ResponseWriter, status int, text string) { + t.Helper() + w.WriteHeader(status) + if _, err := w.Write([]byte(text)); err != nil { + t.Fatalf("write response: %v", err) + } } func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { @@ -55,10 +78,26 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) { } } +func assertNumberSlice(t *testing.T, got interface{}, want []float64) { + t.Helper() + values, ok := got.([]interface{}) + if !ok { + t.Fatalf("got %v (%T), want numeric slice", got, got) + } + if len(values) != len(want) { + t.Fatalf("got %v, want %v", values, want) + } + for i, value := range values { + if value != want[i] { + t.Fatalf("got %v, want %v", values, want) + } + } +} + // --- list --- func TestIssueList(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { t.Fatalf("expected GET, got %s", r.Method) } @@ -68,10 +107,10 @@ func TestIssueList(t *testing.T) { if r.URL.Query().Get("state") != "open" { t.Fatalf("expected state=open, got %s", r.URL.Query().Get("state")) } - writeJSON(w, []interface{}{ + writeJSON(t, w, []interface{}{ map[string]interface{}{"id": float64(1), "subject": "bug"}, }) - })) + }) defer server.Close() err := runShortcut(t, server, "list", map[string]string{"state": "open", "page": "1", "limit": "20"}) @@ -84,7 +123,7 @@ func TestIssueList(t *testing.T) { func TestIssueCreate(t *testing.T) { var payload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Fatalf("expected POST, got %s", r.Method) } @@ -92,8 +131,8 @@ func TestIssueCreate(t *testing.T) { t.Fatalf("unexpected path: %s", r.URL.Path) } payload = decodeJSON(t, r) - writeJSON(w, map[string]interface{}{"id": float64(1), "subject": "bug"}) - })) + writeJSON(t, w, map[string]interface{}{"id": float64(1), "subject": "bug"}) + }) defer server.Close() err := runShortcut(t, server, "create", map[string]string{ @@ -109,10 +148,45 @@ func TestIssueCreate(t *testing.T) { assertEqual(t, payload["assigned_to_id"], "alice") } +func TestIssueCreateSupportsMetadataFields(t *testing.T) { + var createPayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/issues.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + createPayload = decodeJSON(t, r) + writeJSON(t, w, createPayload) + }) + defer server.Close() + + err := runShortcut(t, server, "create", map[string]string{ + "title": "New issue", + "body": "With metadata", + "priority-id": "3", + "tag-ids": "4,5", + "assigner-ids": "7,8", + "branch": "feature/metadata", + "start-date": "2026-05-01", + "due-date": "2026-05-31", + }) + if err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + + assertEqual(t, createPayload["subject"], "New issue") + assertEqual(t, createPayload["description"], "With metadata") + assertEqual(t, createPayload["priority_id"], float64(3)) + assertNumberSlice(t, createPayload["issue_tag_ids"], []float64{4, 5}) + assertNumberSlice(t, createPayload["assigner_ids"], []float64{7, 8}) + assertEqual(t, createPayload["branch_name"], "feature/metadata") + assertEqual(t, createPayload["start_date"], "2026-05-01") + assertEqual(t, createPayload["due_date"], "2026-05-31") +} + func TestIssueCreateMissingTitle(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected") - })) + }) defer server.Close() err := runShortcut(t, server, "create", map[string]string{}) @@ -121,18 +195,18 @@ func TestIssueCreateMissingTitle(t *testing.T) { } } -// --- view --- +// --- view/id alias --- func TestIssueView(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { t.Fatalf("expected GET, got %s", r.Method) } if r.URL.Path != "/v1/owner/repo/issues/42.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{"id": float64(42), "subject": "bug"}) - })) + writeJSON(t, w, map[string]interface{}{"id": float64(42), "subject": "bug"}) + }) defer server.Close() err := runShortcut(t, server, "view", map[string]string{"number": "42"}) @@ -142,9 +216,9 @@ func TestIssueView(t *testing.T) { } func TestIssueViewMissingNumber(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected") - })) + }) defer server.Close() err := runShortcut(t, server, "view", map[string]string{}) @@ -153,47 +227,64 @@ func TestIssueViewMissingNumber(t *testing.T) { } } -func TestIssueViewAcceptsIDAsNumberAlias(t *testing.T) { +func TestIssueViewAcceptsIDAlias(t *testing.T) { var requestedPath string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { requestedPath = r.URL.Path - if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/29.json" { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42.json" { t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - writeJSON(w, map[string]interface{}{ - "project_issues_index": 29, + writeJSON(t, w, map[string]interface{}{ + "project_issues_index": 42, "subject": "Issue from web URL", }) - })) + }) defer server.Close() - err := runShortcut(t, server, "view", map[string]string{"id": "29"}) + err := runShortcut(t, server, "view", map[string]string{"id": "42"}) if err != nil { t.Fatalf("view shortcut failed: %v", err) } + assertEqual(t, requestedPath, "/v1/owner/repo/issues/42.json") +} - assertEqual(t, requestedPath, "/v1/owner/repo/issues/29.json") +func TestIssueNumberTakesPrecedenceOverIDAlias(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"subject": "Existing title"}) + }) + defer server.Close() + + err := runShortcut(t, server, "view", map[string]string{ + "number": "42", + "id": "99", + }) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } } // --- close --- func TestIssueClose(t *testing.T) { var patchPayload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "id": float64(42), "subject": "Existing title", "description": "Existing description", }) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": patchPayload = decodeJSON(t, r) - writeJSON(w, patchPayload) + writeJSON(t, w, patchPayload) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() err := runShortcut(t, server, "close", map[string]string{"number": "42"}) @@ -205,11 +296,67 @@ func TestIssueClose(t *testing.T) { assertEqual(t, patchPayload["status_id"], float64(5)) } +func TestIssueCloseAcceptsIDAlias(t *testing.T) { + var updatePayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": + writeJSON(t, w, map[string]interface{}{ + "subject": "Existing title", + "description": "Existing description", + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + updatePayload = decodeJSON(t, r) + writeJSON(t, w, updatePayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runShortcut(t, server, "close", map[string]string{"id": "42"}) + if err != nil { + t.Fatalf("close shortcut failed: %v", err) + } + assertEqual(t, updatePayload["status_id"], float64(5)) +} + +func TestIssueClosePreservesCurrentMetadata(t *testing.T) { + var updatePayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": + writeJSON(t, w, map[string]interface{}{ + "subject": "Existing title", + "priority": map[string]interface{}{"id": 3}, + "tags": []map[string]interface{}{ + {"id": 4}, + }, + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + updatePayload = decodeJSON(t, r) + writeJSON(t, w, updatePayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runShortcut(t, server, "close", map[string]string{"number": "42"}) + if err != nil { + t.Fatalf("close shortcut failed: %v", err) + } + assertEqual(t, updatePayload["subject"], "Existing title") + assertEqual(t, updatePayload["status_id"], float64(5)) + assertEqual(t, updatePayload["priority_id"], float64(3)) + assertNumberSlice(t, updatePayload["issue_tag_ids"], []float64{4}) +} + func TestIssueCloseFetchFails(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) - writeJSON(w, map[string]interface{}{"error": "not found"}) - })) + writeJSON(t, w, map[string]interface{}{"error": "not found"}) + }) defer server.Close() err := runShortcut(t, server, "close", map[string]string{"number": "999"}) @@ -222,21 +369,21 @@ func TestIssueCloseFetchFails(t *testing.T) { func TestIssueUpdateTitle(t *testing.T) { var patchPayload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "id": float64(42), "subject": "Existing title", "description": "Existing description", }) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": patchPayload = decodeJSON(t, r) - writeJSON(w, patchPayload) + writeJSON(t, w, patchPayload) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() err := runShortcut(t, server, "update", map[string]string{"number": "42", "title": "New title", "state": "closed"}) @@ -250,21 +397,21 @@ func TestIssueUpdateTitle(t *testing.T) { func TestIssueUpdateDescription(t *testing.T) { var patchPayload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "id": float64(42), "subject": "Existing title", "description": "Existing description", }) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": patchPayload = decodeJSON(t, r) - writeJSON(w, patchPayload) + writeJSON(t, w, patchPayload) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() err := runShortcut(t, server, "update", map[string]string{"number": "42", "body": "New description"}) @@ -277,21 +424,21 @@ func TestIssueUpdateDescription(t *testing.T) { func TestIssueUpdateNumericState(t *testing.T) { var patchPayload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "id": float64(42), "subject": "bug", "description": "desc", }) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": patchPayload = decodeJSON(t, r) - writeJSON(w, map[string]interface{}{"id": float64(42)}) + writeJSON(t, w, map[string]interface{}{"id": float64(42)}) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() err := runShortcut(t, server, "update", map[string]string{"number": "42", "state": "3"}) @@ -301,11 +448,128 @@ func TestIssueUpdateNumericState(t *testing.T) { assertEqual(t, patchPayload["status_id"], float64(3)) } -func TestIssueUpdateInvalidState(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +func TestIssueUpdateAcceptsIDAlias(t *testing.T) { + var updatePayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ + "subject": "Existing title", + "description": "Existing description", + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + updatePayload = decodeJSON(t, r) + writeJSON(t, w, updatePayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runShortcut(t, server, "update", map[string]string{ + "id": "42", + "title": "New title", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + assertEqual(t, updatePayload["subject"], "New title") + assertEqual(t, updatePayload["description"], "Existing description") +} + +func TestIssueUpdatePreservesCurrentMetadata(t *testing.T) { + var updatePayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": + writeJSON(t, w, map[string]interface{}{ + "subject": "Existing title", + "description": "Existing description", + "status": map[string]interface{}{"id": 1}, + "priority": map[string]interface{}{"id": 2}, + "tags": []map[string]interface{}{ + {"id": 7}, + {"id": 8}, + }, + "assigners": []map[string]interface{}{ + {"id": 9}, + }, + "branch_name": "main", + "start_date": "2026-05-01", + "due_date": "2026-05-31", + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + updatePayload = decodeJSON(t, r) + writeJSON(t, w, updatePayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runShortcut(t, server, "update", map[string]string{ + "number": "42", + "title": "New title", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + assertEqual(t, updatePayload["subject"], "New title") + assertEqual(t, updatePayload["description"], "Existing description") + assertEqual(t, updatePayload["status_id"], float64(1)) + assertEqual(t, updatePayload["priority_id"], float64(2)) + assertNumberSlice(t, updatePayload["issue_tag_ids"], []float64{7, 8}) + assertNumberSlice(t, updatePayload["assigner_ids"], []float64{9}) + assertEqual(t, updatePayload["branch_name"], "main") + assertEqual(t, updatePayload["start_date"], "2026-05-01") + assertEqual(t, updatePayload["due_date"], "2026-05-31") +} + +func TestIssueUpdateSupportsMetadataFields(t *testing.T) { + var updatePayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": + writeJSON(t, w, map[string]interface{}{ + "subject": "Existing title", + "description": "Existing description", + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + updatePayload = decodeJSON(t, r) + writeJSON(t, w, updatePayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runShortcut(t, server, "update", map[string]string{ + "number": "42", + "priority-id": "4", + "tag-ids": "6,7", + "assigner-ids": "8", + "branch": "bugfix/metadata", + "start-date": "2026-06-01", + "due-date": "2026-06-15", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + assertEqual(t, updatePayload["subject"], "Existing title") + assertEqual(t, updatePayload["description"], "Existing description") + assertEqual(t, updatePayload["priority_id"], float64(4)) + assertNumberSlice(t, updatePayload["issue_tag_ids"], []float64{6, 7}) + assertNumberSlice(t, updatePayload["assigner_ids"], []float64{8}) + assertEqual(t, updatePayload["branch_name"], "bugfix/metadata") + assertEqual(t, updatePayload["start_date"], "2026-06-01") + assertEqual(t, updatePayload["due_date"], "2026-06-15") +} + +func TestIssueUpdateInvalidState(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": + writeJSON(t, w, map[string]interface{}{ "id": float64(42), "subject": "bug", "description": "desc", @@ -313,7 +577,7 @@ func TestIssueUpdateInvalidState(t *testing.T) { default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() err := runShortcut(t, server, "update", map[string]string{"number": "42", "state": "invalid"}) @@ -323,9 +587,9 @@ func TestIssueUpdateInvalidState(t *testing.T) { } func TestIssueUpdateNoChanges(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected") - })) + }) defer server.Close() err := runShortcut(t, server, "update", map[string]string{"number": "42"}) @@ -334,11 +598,34 @@ func TestIssueUpdateNoChanges(t *testing.T) { } } +func TestIssueRejectsInvalidMetadataIDs(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid metadata should not call API, got %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + cases := []struct { + name string + args map[string]string + }{ + {name: "bad priority", args: map[string]string{"title": "x", "priority-id": "abc"}}, + {name: "empty tag", args: map[string]string{"title": "x", "tag-ids": "1,,2"}}, + {name: "label conflicts with tag ids", args: map[string]string{"title": "x", "label": "1", "tag-ids": "2"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := runShortcut(t, server, "create", tc.args); err == nil { + t.Fatal("expected metadata validation error") + } + }) + } +} + // --- comment --- func TestIssueComment(t *testing.T) { var payload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Fatalf("expected POST, got %s", r.Method) } @@ -346,8 +633,8 @@ func TestIssueComment(t *testing.T) { t.Fatalf("unexpected path: %s", r.URL.Path) } payload = decodeJSON(t, r) - writeJSON(w, map[string]interface{}{"id": float64(1), "message": "ok"}) - })) + writeJSON(t, w, map[string]interface{}{"id": float64(1), "message": "ok"}) + }) defer server.Close() err := runShortcut(t, server, "comment", map[string]string{"number": "42", "body": "test comment"}) @@ -357,10 +644,31 @@ func TestIssueComment(t *testing.T) { assertEqual(t, payload["notes"], "test comment") } +func TestIssueCommentAcceptsIDAlias(t *testing.T) { + var commentPayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/issues/42/journals.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + commentPayload = decodeJSON(t, r) + writeJSON(t, w, commentPayload) + }) + defer server.Close() + + err := runShortcut(t, server, "comment", map[string]string{ + "id": "42", + "body": "Fixed", + }) + if err != nil { + t.Fatalf("comment shortcut failed: %v", err) + } + assertEqual(t, commentPayload["notes"], "Fixed") +} + func TestIssueCommentMissingBody(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected") - })) + }) defer server.Close() err := runShortcut(t, server, "comment", map[string]string{"number": "42"}) @@ -369,24 +677,52 @@ func TestIssueCommentMissingBody(t *testing.T) { } } +func TestIssueNumberOrIDIsRequired(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + cases := []struct { + name string + args map[string]string + }{ + {name: "view", args: map[string]string{}}, + {name: "close", args: map[string]string{}}, + {name: "update", args: map[string]string{"title": "New title"}}, + {name: "comment", args: map[string]string{"body": "Fixed"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := runShortcut(t, server, tc.name, tc.args) + if err == nil { + t.Fatal("expected missing issue number error") + } + if !strings.Contains(err.Error(), "--number") || !strings.Contains(err.Error(), "--id") { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + // --- batch-close --- func TestBatchClosePreservesCurrentDescription(t *testing.T) { var updatePayload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "subject": "Existing title", "description": "Existing description", }) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": updatePayload = decodeJSON(t, r) - writeJSON(w, updatePayload) + writeJSON(t, w, updatePayload) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() err := runShortcut(t, server, "batch-close", map[string]string{ @@ -396,16 +732,15 @@ func TestBatchClosePreservesCurrentDescription(t *testing.T) { if err != nil { t.Fatalf("batch-close shortcut failed: %v", err) } - assertEqual(t, updatePayload["subject"], "Existing title") assertEqual(t, updatePayload["description"], "Existing description") assertEqual(t, updatePayload["status_id"], float64(5)) } func TestBatchCloseDryRun(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected in dry-run mode") - })) + }) defer server.Close() err := runShortcut(t, server, "batch-close", map[string]string{ @@ -418,9 +753,9 @@ func TestBatchCloseDryRun(t *testing.T) { } func TestBatchCloseNoNumbers(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected") - })) + }) defer server.Close() err := runShortcut(t, server, "batch-close", map[string]string{}) @@ -430,53 +765,90 @@ func TestBatchCloseNoNumbers(t *testing.T) { } func TestBatchCloseFetchFails(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - w.Write([]byte("not found")) - })) + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeText(t, w, http.StatusNotFound, "not found") + }) defer server.Close() - err := runShortcut(t, server, "batch-close", map[string]string{ - "numbers": "99", - }) + err := runShortcut(t, server, "batch-close", map[string]string{"numbers": "99"}) if err == nil { t.Fatal("expected error when fetch fails") } } func TestBatchCloseWithFailedClose(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/1.json": - writeJSON(w, map[string]interface{}{"subject": "Issue 1", "description": "desc1"}) + writeJSON(t, w, map[string]interface{}{"subject": "Issue 1", "description": "desc1"}) case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/2.json": - writeJSON(w, map[string]interface{}{"subject": "Issue 2", "description": "desc2"}) + writeJSON(t, w, map[string]interface{}{"subject": "Issue 2", "description": "desc2"}) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/1.json": - writeJSON(w, map[string]interface{}{"subject": "Issue 1", "description": "desc1", "status_id": float64(5)}) + writeJSON(t, w, map[string]interface{}{"subject": "Issue 1", "description": "desc1", "status_id": float64(5)}) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/2.json": - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() - err := runShortcut(t, server, "batch-close", map[string]string{ - "numbers": "1, 2", - }) + err := runShortcut(t, server, "batch-close", map[string]string{"numbers": "1, 2"}) if err == nil { t.Fatal("expected error when some issues fail to close") } } +// --- issue users --- + +func TestIssueAssignersShortcutWithKeyword(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_assigners.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + assertEqual(t, r.URL.Query().Get("keyword"), "alice") + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "assigners": []map[string]interface{}{ + {"id": 7, "name": "Alice", "login": "alice"}, + }, + }) + }) + defer server.Close() + + err := runShortcut(t, server, "assigners", map[string]string{"keyword": "alice"}) + if err != nil { + t.Fatalf("assigners shortcut failed: %v", err) + } +} + +func TestIssueAuthorsShortcutWithKeyword(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_authors.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + assertEqual(t, r.URL.Query().Get("keyword"), "bob") + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "authors": []map[string]interface{}{ + {"id": 8, "name": "Bob", "login": "bob"}, + }, + }) + }) + defer server.Close() + + err := runShortcut(t, server, "authors", map[string]string{"keyword": "bob"}) + if err != nil { + t.Fatalf("authors shortcut failed: %v", err) + } +} + // --- HTTP error paths --- func TestIssueListHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeText(t, w, http.StatusInternalServerError, "server error") + }) defer server.Close() err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}) @@ -486,10 +858,9 @@ func TestIssueListHTTPError(t *testing.T) { } func TestIssueCreateHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeText(t, w, http.StatusInternalServerError, "server error") + }) defer server.Close() err := runShortcut(t, server, "create", map[string]string{"title": "test"}) @@ -499,10 +870,9 @@ func TestIssueCreateHTTPError(t *testing.T) { } func TestIssueViewHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeText(t, w, http.StatusInternalServerError, "server error") + }) defer server.Close() err := runShortcut(t, server, "view", map[string]string{"number": "42"}) @@ -512,10 +882,9 @@ func TestIssueViewHTTPError(t *testing.T) { } func TestIssueCommentHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeText(t, w, http.StatusInternalServerError, "server error") + }) defer server.Close() err := runShortcut(t, server, "comment", map[string]string{"number": "42", "body": "test"}) @@ -525,19 +894,18 @@ func TestIssueCommentHTTPError(t *testing.T) { } func TestIssueUpdateHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "id": float64(42), "subject": "bug", "description": "desc", }) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() err := runShortcut(t, server, "update", map[string]string{"number": "42", "title": "new"}) @@ -547,19 +915,18 @@ func TestIssueUpdateHTTPError(t *testing.T) { } func TestIssueCloseHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "id": float64(42), "subject": "bug", "description": "desc", }) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() err := runShortcut(t, server, "close", map[string]string{"number": "42"}) @@ -569,9 +936,9 @@ func TestIssueCloseHTTPError(t *testing.T) { } func TestFetchExistingIssueBadData(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, "not a map") - })) + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, "not a map") + }) defer server.Close() ctx := &common.RuntimeContext{ @@ -586,9 +953,9 @@ func TestFetchExistingIssueBadData(t *testing.T) { } func TestFetchExistingIssueNoSubject(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, map[string]interface{}{"id": float64(1)}) - })) + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, map[string]interface{}{"id": float64(1)}) + }) defer server.Close() ctx := &common.RuntimeContext{ diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index 0928362..2857894 100644 --- a/skills/gitlink-issue/SKILL.md +++ b/skills/gitlink-issue/SKILL.md @@ -79,15 +79,23 @@ gitlink-cli api POST /:owner/:repo/issues/series_update --body '{"ids":[1,2,3]," | gitlink-cli 参数 | GitLink API 字段 | 说明 | |------------------|-----------------|------| | `--number` / `-n` | `project_issues_index` | Issue 编号(网页 URL 中的序号) | +| `--id` / `-i` | `project_issues_index` | `--number` 的兼容别名,不是数据库内部 ID | | `--title` | `subject` | Issue 标题 | | `--body` | `description` | Issue 描述 | | `--assignee` | `assigned_to_id` | 指派人 ID | | `--milestone` | `fixed_version_id` | 里程碑 ID | | `--state` | `status_id` | 状态(open=1,closed=5,也可直接传数字 ID) | +| `--priority-id` | `priority_id` | 优先级 ID | +| `--tag-ids` / `--label` | `issue_tag_ids` | Issue 标签 ID 数组 | +| `--assigner-ids` | `assigner_ids` | 负责人 ID 数组 | +| `--branch` | `branch_name` | 关联分支 | +| `--start-date` | `start_date` | 开始日期 | +| `--due-date` | `due_date` | 截止日期 | ## API 注意事项 - **Issue 编号(`--number`)是网页 URL 中看到的序号**(如 `issues/4` 中的 `4`),不是数据库内部 ID +- `--id` / `-i` 仅作为 `--number` / `-n` 的兼容别名,传入的仍然是网页 URL 中的 Issue 编号 - **批量关闭使用 `--numbers`,同样传网页 URL 中的 Issue 编号**,不是数据库内部 ID - Issue 操作使用 v1 API(`/api/v1/`),支持按 Issue 编号查询和操作 - **创建 Issue 时 CLI 会自动设置 `status_id: 1`(新增)和 `priority_id: 2`(正常)**