fix(issue): 修复详情缺失并保护更新元数据

This commit is contained in:
Mengz 2026-06-10 19:33:56 +08:00
parent 52b7093846
commit 0a5bad01c8
5 changed files with 574 additions and 37 deletions

View File

@ -336,6 +336,14 @@ gitlink-cli issue +statuses --owner Gitlink --repo forgeplus
is accepted as a compatibility alias for the same web issue number, not the
global database ID.
`issue +view` now returns both `number` and `database_id`, and it enriches the
response with tracker, priority, status, and issue tag metadata when the legacy
issue detail endpoints provide those fields.
`issue +update`, `issue +close`, and `issue +batch-close` preserve existing
tracker, version, assignee, tag, and schedule metadata before sending updates,
which avoids clearing required fields on the server by accident.
### Label Management
```bash

View File

@ -0,0 +1,9 @@
# issue 详情增强与更新保护
这个变更聚焦修复 `issue` 快捷命令里两个容易影响实际使用的问题。
`issue +view` 之前只读取 v1 详情接口,返回结果里缺少网页端常见的状态、优先级、跟踪器和标签信息,用户很难直接把 CLI 输出和网页上的 issue 页面对应起来。这次调整后,命令会继续以 v1 接口为主,再补充读取旧版详情与编辑接口,在不影响主流程可用性的前提下,把 `number`、`database_id`、`tracker_id`、`issue_type`、`issue_tag_ids`、`issue_tag_names` 等信息一起带出来。
`issue +update`、`issue +close` 和 `issue +batch-close` 之前只保留了部分字段,更新时可能把现有 issue 的 `tracker_id`、`fixed_version_id`、`assigned_to_id`、`issue_type` 等服务端依赖字段丢掉,导致网页上出现状态异常或字段被误清空。现在这些命令会先读取 issue 的编辑元数据,再把关键字段一并回写;如果编辑元数据拉取失败,就直接终止更新,避免发送不完整的 PATCH 请求。
为了防止这类问题回归,这次补充了 `shortcuts/issue` 的单元测试,覆盖了详情增强、元数据保留、编辑元数据失败时停止写入,以及批量关闭复用同一套保护逻辑的场景。

View File

@ -102,6 +102,8 @@ func closeIssue(ctx *common.RuntimeContext, number string) error {
"description": current.Description,
"status_id": closedIssueStatusID,
}
preserveIssueMetadata(body, current)
body["status_id"] = closedIssueStatusID
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
return fmt.Errorf("close issue: %w", err)
}

View File

@ -1,6 +1,7 @@
package issue
import (
"errors"
"fmt"
"net/url"
"strconv"
@ -30,15 +31,27 @@ func normalizeIssueListState(state string) string {
}
type existingIssue struct {
Subject string
Description string
StatusID interface{}
PriorityID interface{}
TagIDs []interface{}
AssignerIDs []interface{}
BranchName string
StartDate string
DueDate string
Subject string
Description string
StatusID interface{}
PriorityID interface{}
TagIDs []interface{}
AssignerIDs []interface{}
AssignedToID interface{}
FixedVersionID interface{}
TrackerID interface{}
IssueType interface{}
BranchName string
StartDate string
DueDate string
}
func legacyIssuePath(ctx *common.RuntimeContext, number string) string {
return fmt.Sprintf("%s/issues/%s", ctx.RepoPath(), number)
}
func legacyIssueEditPath(ctx *common.RuntimeContext, number string) string {
return legacyIssuePath(ctx, number) + "/edit"
}
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
@ -172,6 +185,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
if err != nil {
return err
}
enrichIssueView(ctx, number, env)
return ctx.Output(env)
},
},
@ -487,16 +501,24 @@ func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIss
return nil, fmt.Errorf("failed to parse issue subject")
}
description, _ := issueData["description"].(string)
editData, err := fetchLegacyIssueEdit(ctx, number)
if err != nil {
return nil, fmt.Errorf("fetch issue edit metadata: %w", err)
}
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"),
Subject: subject,
Description: description,
StatusID: firstNonNil(nestedIssueID(issueData, "status"), editData["status_id"]),
PriorityID: firstNonNil(nestedIssueID(issueData, "priority"), editData["priority_id"]),
TagIDs: firstNonEmptyIDs(issueObjectIDs(issueData, "tags", "issue_tags"), issueValueIDs(editData, "issue_tags")),
AssignerIDs: issueObjectIDs(issueData, "assigners"),
AssignedToID: firstNonNil(issueData["assigned_to_id"], editData["assigned_to_id"]),
FixedVersionID: firstNonNil(issueData["fixed_version_id"], editData["fixed_version_id"]),
TrackerID: firstNonNil(issueData["tracker_id"], editData["tracker_id"], nestedIssueID(issueData, "tracker")),
IssueType: firstNonNil(issueData["issue_type"], editData["issue_type"]),
BranchName: firstNonEmptyString(stringField(issueData, "branch_name"), stringField(editData, "branch_name")),
StartDate: firstNonEmptyString(stringField(issueData, "start_date"), stringField(editData, "start_date")),
DueDate: firstNonEmptyString(stringField(issueData, "due_date"), stringField(editData, "due_date")),
}, nil
}
@ -513,6 +535,18 @@ func preserveIssueMetadata(body map[string]interface{}, issue *existingIssue) {
if len(issue.AssignerIDs) > 0 {
body["assigner_ids"] = issue.AssignerIDs
}
if issue.AssignedToID != nil {
body["assigned_to_id"] = issue.AssignedToID
}
if issue.FixedVersionID != nil {
body["fixed_version_id"] = issue.FixedVersionID
}
if issue.TrackerID != nil {
body["tracker_id"] = issue.TrackerID
}
if issue.IssueType != nil {
body["issue_type"] = issue.IssueType
}
if issue.BranchName != "" {
body["branch_name"] = issue.BranchName
}
@ -534,12 +568,17 @@ func nestedIssueID(data map[string]interface{}, key string) interface{} {
func issueObjectIDs(data map[string]interface{}, keys ...string) []interface{} {
for _, key := range keys {
items, ok := data[key].([]interface{})
items, ok := interfaceSlice(data[key])
if !ok {
continue
}
ids := make([]interface{}, 0, len(items))
for _, item := range items {
switch value := item.(type) {
case float64, int, int64, string:
ids = append(ids, value)
continue
}
obj, ok := item.(map[string]interface{})
if !ok {
continue
@ -555,11 +594,199 @@ func issueObjectIDs(data map[string]interface{}, keys ...string) []interface{} {
return nil
}
func issueObjectNames(data map[string]interface{}, key string) []string {
items, ok := interfaceSlice(data[key])
if !ok {
return nil
}
names := make([]string, 0, len(items))
for _, item := range items {
obj, ok := item.(map[string]interface{})
if !ok {
continue
}
if name, ok := obj["name"].(string); ok && name != "" {
names = append(names, name)
}
}
if len(names) == 0 {
return nil
}
return names
}
func stringField(data map[string]interface{}, key string) string {
value, _ := data[key].(string)
return value
}
func mapField(data map[string]interface{}, key string) map[string]interface{} {
item, _ := data[key].(map[string]interface{})
return item
}
func interfaceSlice(value interface{}) ([]interface{}, bool) {
items, ok := value.([]interface{})
if ok {
return items, true
}
switch typed := value.(type) {
case []map[string]interface{}:
items = make([]interface{}, 0, len(typed))
for _, item := range typed {
items = append(items, item)
}
return items, true
}
return nil, false
}
func issueValueIDs(data map[string]interface{}, keys ...string) []interface{} {
return issueObjectIDs(data, keys...)
}
func firstNonNil(values ...interface{}) interface{} {
for _, value := range values {
if !isNilValue(value) {
return value
}
}
return nil
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func firstNonEmptyIDs(values ...[]interface{}) []interface{} {
for _, ids := range values {
if len(ids) > 0 {
return ids
}
}
return nil
}
func isNilValue(value interface{}) bool {
if value == nil {
return true
}
switch typed := value.(type) {
case map[string]interface{}:
return len(typed) == 0
}
return false
}
func fetchLegacyIssueDetail(ctx *common.RuntimeContext, number string) (map[string]interface{}, error) {
return fetchIssueMap(ctx, legacyIssuePath(ctx, number), "failed to parse legacy issue detail")
}
func fetchLegacyIssueEdit(ctx *common.RuntimeContext, number string) (map[string]interface{}, error) {
return fetchIssueMap(ctx, legacyIssueEditPath(ctx, number), "failed to parse legacy issue edit data")
}
func fetchIssueMap(ctx *common.RuntimeContext, path, parseErr string) (map[string]interface{}, error) {
env, err := ctx.CallAPI("GET", path, nil)
if err != nil {
return nil, err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, errors.New(parseErr)
}
return data, nil
}
func enrichIssueView(ctx *common.RuntimeContext, number string, env *output.Envelope) {
if env == nil {
return
}
issueData, ok := env.Data.(map[string]interface{})
if !ok {
return
}
legacyDetail, _ := fetchLegacyIssueDetail(ctx, number)
legacyEdit, _ := fetchLegacyIssueEdit(ctx, number)
env.Data = mergeIssueViewData(issueData, legacyDetail, legacyEdit)
}
func mergeIssueViewData(v1Data, legacyDetail, legacyEdit map[string]interface{}) map[string]interface{} {
issue := cloneIssueMap(v1Data)
if issue == nil {
return v1Data
}
if number := firstNonNil(issue["number"], issue["project_issues_index"], legacyDetail["project_issues_index"]); number != nil {
issue["number"] = number
}
if databaseID := firstNonNil(issue["id"], legacyDetail["id"]); databaseID != nil {
issue["database_id"] = databaseID
delete(issue, "id")
}
status := firstNonNil(mapField(issue, "status"), mapField(legacyDetail, "issue_status"))
if status != nil {
issue["status"] = status
if statusMap, ok := status.(map[string]interface{}); ok {
if name := stringField(statusMap, "name"); name != "" {
issue["status_name"] = name
}
}
}
if priority := firstNonNil(mapField(issue, "priority"), mapField(legacyDetail, "priority")); priority != nil {
issue["priority"] = priority
if priorityMap, ok := priority.(map[string]interface{}); ok {
if name := stringField(priorityMap, "name"); name != "" {
issue["priority_name"] = name
}
}
}
if tracker := firstNonNil(mapField(issue, "tracker"), mapField(legacyDetail, "tracker")); tracker != nil {
issue["tracker"] = tracker
}
if trackerID := firstNonNil(issue["tracker_id"], nestedIssueID(issue, "tracker"), nestedIssueID(legacyDetail, "tracker"), legacyEdit["tracker_id"]); trackerID != nil {
issue["tracker_id"] = trackerID
}
if issueType := firstNonNil(issue["issue_type"], legacyDetail["issue_type"], legacyEdit["issue_type"]); issueType != nil {
issue["issue_type"] = issueType
}
if assignedToID := firstNonNil(issue["assigned_to_id"], legacyDetail["assigned_to_id"], legacyEdit["assigned_to_id"]); assignedToID != nil {
issue["assigned_to_id"] = assignedToID
}
if fixedVersionID := firstNonNil(issue["fixed_version_id"], legacyDetail["fixed_version_id"], legacyDetail["version_id"], legacyEdit["fixed_version_id"]); fixedVersionID != nil {
issue["fixed_version_id"] = fixedVersionID
}
if versionID := firstNonNil(issue["version_id"], legacyDetail["version_id"], legacyEdit["fixed_version_id"]); versionID != nil {
issue["version_id"] = versionID
}
if tagIDs := firstNonEmptyIDs(issueObjectIDs(issue, "tags", "issue_tags"), issueObjectIDs(legacyDetail, "issue_tags"), issueValueIDs(legacyEdit, "issue_tags")); len(tagIDs) > 0 {
issue["issue_tag_ids"] = tagIDs
}
if tagNames := issueObjectNames(legacyDetail, "issue_tags"); len(tagNames) > 0 {
issue["issue_tag_names"] = tagNames
}
return issue
}
func cloneIssueMap(data map[string]interface{}) map[string]interface{} {
if data == nil {
return nil
}
cloned := make(map[string]interface{}, len(data))
for key, value := range data {
cloned[key] = value
}
return cloned
}
func normalizeIssueStatus(state string) (interface{}, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open":
@ -611,6 +838,9 @@ func applyIssueMetadataArgs(ctx *common.RuntimeContext, body map[string]interfac
return err
}
body["assigner_ids"] = ids
if len(ids) == 1 {
body["assigned_to_id"] = ids[0]
}
}
if branch := ctx.Arg("branch"); branch != "" {
body["branch_name"] = branch

View File

@ -80,8 +80,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 {
var values []float64
switch typed := got.(type) {
case []interface{}:
values = make([]float64, 0, len(typed))
for _, value := range typed {
switch number := value.(type) {
case float64:
values = append(values, number)
case int:
values = append(values, float64(number))
default:
t.Fatalf("got %v (%T), want numeric slice", got, got)
}
}
case []int:
values = make([]float64, 0, len(typed))
for _, value := range typed {
values = append(values, float64(value))
}
default:
t.Fatalf("got %v (%T), want numeric slice", got, got)
}
if len(values) != len(want) {
@ -94,6 +112,48 @@ func assertNumberSlice(t *testing.T, got interface{}, want []float64) {
}
}
func issueLegacyPath(number string) string {
return "/owner/repo/issues/" + number + ".json"
}
func issueLegacyEditPath(number string) string {
return "/owner/repo/issues/" + number + "/edit.json"
}
func writeLegacyIssueDetail(t *testing.T, w http.ResponseWriter, number string) {
t.Helper()
writeJSON(t, w, map[string]interface{}{
"id": 9001,
"project_issues_index": 42,
"tracker": map[string]interface{}{"id": 11, "name": "Bug"},
"priority": map[string]interface{}{"id": 2, "name": "Normal"},
"issue_status": map[string]interface{}{"id": 1, "name": "Open"},
"issue_tags": []map[string]interface{}{
{"id": 7, "name": "backend"},
},
"version_id": 13,
"branch_name": "main",
"start_date": "2026-05-01",
"due_date": "2026-05-31",
})
}
func writeLegacyIssueEdit(t *testing.T, w http.ResponseWriter) {
t.Helper()
writeJSON(t, w, map[string]interface{}{
"status_id": 1,
"priority_id": 2,
"tracker_id": 11,
"issue_type": "bug",
"issue_tags": []interface{}{7, 8},
"assigned_to_id": 9,
"fixed_version_id": 13,
"branch_name": "main",
"start_date": "2026-05-01",
"due_date": "2026-05-31",
})
}
// --- list ---
func TestIssueList(t *testing.T) {
@ -251,13 +311,21 @@ func TestIssueCreateMissingTitle(t *testing.T) {
func TestIssueView(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("expected GET, got %s", r.Method)
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{
"id": float64(42),
"project_issues_index": float64(42),
"subject": "bug",
"status": nil,
})
case r.Method == "GET" && r.URL.Path == issueLegacyPath("42"):
writeLegacyIssueDetail(t, w, "42")
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeLegacyIssueEdit(t, w)
default:
t.Fatalf("unexpected path: %s %s", r.Method, r.URL.Path)
}
if r.URL.Path != "/v1/owner/repo/issues/42.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{"id": float64(42), "subject": "bug"})
})
defer server.Close()
@ -282,14 +350,20 @@ func TestIssueViewMissingNumber(t *testing.T) {
func TestIssueViewAcceptsIDAlias(t *testing.T) {
var requestedPath string
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/42.json" {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
requestedPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"project_issues_index": 42,
"subject": "Issue from web URL",
})
case r.Method == "GET" && r.URL.Path == issueLegacyPath("42"):
writeLegacyIssueDetail(t, w, "42")
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeLegacyIssueEdit(t, w)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"project_issues_index": 42,
"subject": "Issue from web URL",
})
})
defer server.Close()
@ -302,10 +376,16 @@ func TestIssueViewAcceptsIDAlias(t *testing.T) {
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" {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{"subject": "Existing title"})
case r.Method == "GET" && r.URL.Path == issueLegacyPath("42"):
writeLegacyIssueDetail(t, w, "42")
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeLegacyIssueEdit(t, w)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{"subject": "Existing title"})
})
defer server.Close()
@ -330,6 +410,8 @@ func TestIssueClose(t *testing.T) {
"subject": "Existing title",
"description": "Existing description",
})
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeLegacyIssueEdit(t, w)
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
patchPayload = decodeJSON(t, r)
writeJSON(t, w, patchPayload)
@ -357,6 +439,8 @@ func TestIssueCloseAcceptsIDAlias(t *testing.T) {
"subject": "Existing title",
"description": "Existing description",
})
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeLegacyIssueEdit(t, w)
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
@ -385,6 +469,19 @@ func TestIssueClosePreservesCurrentMetadata(t *testing.T) {
{"id": 4},
},
})
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeJSON(t, w, map[string]interface{}{
"status_id": 1,
"priority_id": 3,
"tracker_id": 11,
"issue_type": "bug",
"issue_tags": []interface{}{4},
"assigned_to_id": 9,
"fixed_version_id": 13,
"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)
@ -402,6 +499,10 @@ func TestIssueClosePreservesCurrentMetadata(t *testing.T) {
assertEqual(t, updatePayload["status_id"], float64(5))
assertEqual(t, updatePayload["priority_id"], float64(3))
assertNumberSlice(t, updatePayload["issue_tag_ids"], []float64{4})
assertEqual(t, updatePayload["tracker_id"], float64(11))
assertEqual(t, updatePayload["issue_type"], "bug")
assertEqual(t, updatePayload["assigned_to_id"], float64(9))
assertEqual(t, updatePayload["fixed_version_id"], float64(13))
}
func TestIssueCloseFetchFails(t *testing.T) {
@ -429,6 +530,8 @@ func TestIssueUpdateTitle(t *testing.T) {
"subject": "Existing title",
"description": "Existing description",
})
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeLegacyIssueEdit(t, w)
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
patchPayload = decodeJSON(t, r)
writeJSON(t, w, patchPayload)
@ -457,6 +560,8 @@ func TestIssueUpdateDescription(t *testing.T) {
"subject": "Existing title",
"description": "Existing description",
})
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeLegacyIssueEdit(t, w)
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
patchPayload = decodeJSON(t, r)
writeJSON(t, w, patchPayload)
@ -484,6 +589,8 @@ func TestIssueUpdateNumericState(t *testing.T) {
"subject": "bug",
"description": "desc",
})
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeLegacyIssueEdit(t, w)
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
patchPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
@ -509,6 +616,8 @@ func TestIssueUpdateAcceptsIDAlias(t *testing.T) {
"subject": "Existing title",
"description": "Existing description",
})
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeLegacyIssueEdit(t, w)
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
@ -550,6 +659,19 @@ func TestIssueUpdatePreservesCurrentMetadata(t *testing.T) {
"start_date": "2026-05-01",
"due_date": "2026-05-31",
})
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeJSON(t, w, map[string]interface{}{
"status_id": 1,
"priority_id": 2,
"tracker_id": 11,
"issue_type": "bug",
"issue_tags": []interface{}{7, 8},
"assigned_to_id": 9,
"fixed_version_id": 13,
"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)
@ -575,6 +697,10 @@ func TestIssueUpdatePreservesCurrentMetadata(t *testing.T) {
assertEqual(t, updatePayload["branch_name"], "main")
assertEqual(t, updatePayload["start_date"], "2026-05-01")
assertEqual(t, updatePayload["due_date"], "2026-05-31")
assertEqual(t, updatePayload["tracker_id"], float64(11))
assertEqual(t, updatePayload["issue_type"], "bug")
assertEqual(t, updatePayload["assigned_to_id"], float64(9))
assertEqual(t, updatePayload["fixed_version_id"], float64(13))
}
func TestIssueUpdateSupportsMetadataFields(t *testing.T) {
@ -586,6 +712,8 @@ func TestIssueUpdateSupportsMetadataFields(t *testing.T) {
"subject": "Existing title",
"description": "Existing description",
})
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeLegacyIssueEdit(t, w)
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
@ -612,6 +740,7 @@ func TestIssueUpdateSupportsMetadataFields(t *testing.T) {
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["assigned_to_id"], 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")
@ -626,6 +755,8 @@ func TestIssueUpdateInvalidState(t *testing.T) {
"subject": "bug",
"description": "desc",
})
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeLegacyIssueEdit(t, w)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
@ -768,6 +899,8 @@ func TestBatchClosePreservesCurrentDescription(t *testing.T) {
"subject": "Existing title",
"description": "Existing description",
})
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeLegacyIssueEdit(t, w)
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
@ -833,8 +966,12 @@ func TestBatchCloseWithFailedClose(t *testing.T) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/1.json":
writeJSON(t, w, map[string]interface{}{"subject": "Issue 1", "description": "desc1"})
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("1"):
writeLegacyIssueEdit(t, w)
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/2.json":
writeJSON(t, w, map[string]interface{}{"subject": "Issue 2", "description": "desc2"})
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("2"):
writeLegacyIssueEdit(t, w)
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/1.json":
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":
@ -1005,6 +1142,54 @@ func TestIssueViewHTTPError(t *testing.T) {
}
}
func TestMergeIssueViewDataAddsLegacyFields(t *testing.T) {
merged := mergeIssueViewData(
map[string]interface{}{
"id": float64(42),
"project_issues_index": float64(18),
"subject": "Issue from v1",
"status": nil,
},
map[string]interface{}{
"id": float64(2048),
"project_issues_index": float64(18),
"tracker": map[string]interface{}{"id": 5, "name": "Feature"},
"priority": map[string]interface{}{"id": 2, "name": "Normal"},
"issue_status": map[string]interface{}{"id": 1, "name": "Open"},
"issue_tags": []map[string]interface{}{
{"id": 7, "name": "backend"},
{"id": 8, "name": "urgent"},
},
"version_id": 13,
},
map[string]interface{}{
"tracker_id": 5,
"issue_type": "feature",
"assigned_to_id": 9,
"fixed_version_id": 13,
"issue_tags": []interface{}{7, 8},
},
)
assertEqual(t, merged["number"], float64(18))
assertEqual(t, merged["database_id"], float64(42))
assertEqual(t, merged["tracker_id"], 5)
assertEqual(t, merged["issue_type"], "feature")
assertEqual(t, merged["assigned_to_id"], 9)
assertEqual(t, merged["fixed_version_id"], 13)
assertEqual(t, merged["version_id"], 13)
assertEqual(t, merged["status_name"], "Open")
assertEqual(t, merged["priority_name"], "Normal")
assertNumberSlice(t, merged["issue_tag_ids"], []float64{7, 8})
tagNames, ok := merged["issue_tag_names"].([]string)
if !ok {
t.Fatalf("expected issue_tag_names, got %T", merged["issue_tag_names"])
}
if len(tagNames) != 2 || tagNames[0] != "backend" || tagNames[1] != "urgent" {
t.Fatalf("unexpected issue_tag_names: %v", tagNames)
}
}
func TestIssueCommentHTTPError(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeText(t, w, http.StatusInternalServerError, "server error")
@ -1024,6 +1209,8 @@ func TestIssueUpdateHTTPError(t *testing.T) {
writeJSON(t, w, map[string]interface{}{
"id": float64(42), "subject": "bug", "description": "desc",
})
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeLegacyIssueEdit(t, w)
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeText(t, w, http.StatusInternalServerError, "server error")
default:
@ -1045,6 +1232,8 @@ func TestIssueCloseHTTPError(t *testing.T) {
writeJSON(t, w, map[string]interface{}{
"id": float64(42), "subject": "bug", "description": "desc",
})
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeLegacyIssueEdit(t, w)
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeText(t, w, http.StatusInternalServerError, "server error")
default:
@ -1061,7 +1250,14 @@ func TestIssueCloseHTTPError(t *testing.T) {
func TestFetchExistingIssueBadData(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, "not a map")
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/1.json":
writeJSON(t, w, "not a map")
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("1"):
writeLegacyIssueEdit(t, w)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
@ -1078,7 +1274,14 @@ func TestFetchExistingIssueBadData(t *testing.T) {
func TestFetchExistingIssueNoSubject(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, map[string]interface{}{"id": float64(1)})
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/1.json":
writeJSON(t, w, map[string]interface{}{"id": float64(1)})
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("1"):
writeLegacyIssueEdit(t, w)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
@ -1093,6 +1296,91 @@ func TestFetchExistingIssueNoSubject(t *testing.T) {
}
}
func TestFetchExistingIssueRequiresEditMetadata(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/1.json":
writeJSON(t, w, map[string]interface{}{
"subject": "issue",
"description": "desc",
})
case r.Method == "GET" && r.URL.Path == issueLegacyEditPath("1"):
writeText(t, w, http.StatusInternalServerError, "boom")
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner",
Repo: "repo",
}
_, err := fetchExistingIssue(ctx, "1")
if err == nil || !strings.Contains(err.Error(), "edit metadata") {
t.Fatalf("expected edit metadata error, got %v", err)
}
}
func TestIssueUpdateStopsWhenEditMetadataFails(t *testing.T) {
patchCalled := false
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 == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeText(t, w, http.StatusInternalServerError, "server error")
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
patchCalled = true
t.Fatal("PATCH should not be sent when edit metadata fetch fails")
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.Fatal("expected error when edit metadata fetch fails")
}
if patchCalled {
t.Fatal("patch should not have been called")
}
}
func TestIssueCloseStopsWhenEditMetadataFails(t *testing.T) {
patchCalled := false
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 == "GET" && r.URL.Path == issueLegacyEditPath("42"):
writeText(t, w, http.StatusInternalServerError, "server error")
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json":
patchCalled = true
t.Fatal("PATCH should not be sent when edit metadata fetch fails")
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.Fatal("expected error when edit metadata fetch fails")
}
if patchCalled {
t.Fatal("patch should not have been called")
}
}
// --- normalizeIssueStatus ---
func TestNormalizeIssueStatus(t *testing.T) {