feat(issue): support metadata fields and id alias
This commit is contained in:
parent
e3bb4d2711
commit
89d5321713
11
README.md
11
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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`(正常)**
|
||||
|
|
|
|||
Loading…
Reference in New Issue