feat(issue): support metadata fields and id alias

This commit is contained in:
wangyue789 2026-06-02 12:42:34 +08:00
parent e3bb4d2711
commit 89d5321713
7 changed files with 772 additions and 143 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
```

View File

@ -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
}

View File

@ -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{

View File

@ -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=1closed=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`(正常)**