From ef7929395655334efbb758748c9ea69b0b1f38a1 Mon Sep 17 00:00:00 2001 From: fsafasff <2624934035@qq.com> Date: Fri, 29 May 2026 12:03:36 +0800 Subject: [PATCH] fix: preserve issue metadata on update --- shortcuts/issue/issue.go | 84 +++++++++++++++++++++++++++++++++++ shortcuts/issue/issue_test.go | 49 ++++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 7c0bc68..5aa59e5 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -18,6 +18,7 @@ func v1RepoPath(ctx *common.RuntimeContext) string { type existingIssue struct { Subject string Description string + Metadata map[string]interface{} } func Shortcuts() []*common.Shortcut { @@ -134,6 +135,7 @@ func Shortcuts() []*common.Shortcut { "description": current.Description, "status_id": 5, // 5 = closed } + copyIssueMetadata(body, current.Metadata) env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) if err != nil { return err @@ -174,6 +176,7 @@ func Shortcuts() []*common.Shortcut { "subject": current.Subject, "description": current.Description, } + copyIssueMetadata(body, current.Metadata) if t := ctx.Arg("title"); t != "" { body["subject"] = t } @@ -315,9 +318,90 @@ func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIss return &existingIssue{ Subject: subject, Description: description, + Metadata: existingIssueMetadata(issueData), }, nil } +func existingIssueMetadata(issueData map[string]interface{}) map[string]interface{} { + metadata := map[string]interface{}{} + copyIDValue(metadata, "priority_id", issueData["priority_id"]) + copyNestedIDValue(metadata, "priority_id", issueData["priority"]) + copyIDValue(metadata, "tracker_id", issueData["tracker_id"]) + copyNestedIDValue(metadata, "tracker_id", issueData["tracker"]) + copyIDValue(metadata, "fixed_version_id", issueData["fixed_version_id"]) + copyNestedIDValue(metadata, "fixed_version_id", issueData["fixed_version"]) + copyIDValue(metadata, "assigned_to_id", issueData["assigned_to_id"]) + copyNestedIDValue(metadata, "assigned_to_id", issueData["assigned_to"]) + + if ids := issueTagIDs(issueData["issue_tags"]); len(ids) > 0 { + metadata["issue_tag_ids"] = ids + } + return metadata +} + +func copyIssueMetadata(body map[string]interface{}, metadata map[string]interface{}) { + for key, value := range metadata { + body[key] = value + } +} + +func copyNestedIDValue(dst map[string]interface{}, dstKey string, value interface{}) { + object, ok := value.(map[string]interface{}) + if !ok { + return + } + copyIDValue(dst, dstKey, object["id"]) +} + +func copyIDValue(dst map[string]interface{}, dstKey string, value interface{}) { + switch v := value.(type) { + case int: + dst[dstKey] = v + case int64: + dst[dstKey] = v + case float64: + dst[dstKey] = int(v) + case string: + if strings.TrimSpace(v) != "" { + dst[dstKey] = v + } + } +} + +func issueTagIDs(value interface{}) []interface{} { + tags, ok := value.([]interface{}) + if !ok { + return nil + } + ids := make([]interface{}, 0, len(tags)) + for _, tag := range tags { + tagData, ok := tag.(map[string]interface{}) + if !ok { + continue + } + if id, ok := normalizedIDValue(tagData["id"]); ok { + ids = append(ids, id) + } + } + return ids +} + +func normalizedIDValue(value interface{}) (interface{}, bool) { + switch v := value.(type) { + case int: + return v, true + case int64: + return v, true + case float64: + return int(v), true + case string: + if strings.TrimSpace(v) != "" { + return v, true + } + } + return nil, false +} + func normalizeIssueStatus(state string) (interface{}, error) { switch strings.ToLower(strings.TrimSpace(state)) { case "open": diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 661a4de..e234600 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -100,6 +100,55 @@ func TestIssueUpdatePreservesCurrentSubjectWhenChangingDescription(t *testing.T) assertEqual(t, updatePayload["description"], "New 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", + "priority": map[string]interface{}{"id": 2, "name": "normal"}, + "tracker": map[string]interface{}{"id": 1, "name": "bug"}, + "fixed_version": map[string]interface{}{"id": 9, "name": "v1"}, + "assigned_to_id": 7, + "issue_tags": []map[string]interface{}{ + {"id": 3, "name": "bug"}, + {"id": 4, "name": "cli"}, + }, + }) + 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 := runIssueShortcut(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["priority_id"], float64(2)) + assertEqual(t, updatePayload["tracker_id"], float64(1)) + assertEqual(t, updatePayload["fixed_version_id"], float64(9)) + assertEqual(t, updatePayload["assigned_to_id"], float64(7)) + + tagIDs, ok := updatePayload["issue_tag_ids"].([]interface{}) + if !ok { + t.Fatalf("issue_tag_ids = %T, want []interface{}", updatePayload["issue_tag_ids"]) + } + assertEqual(t, tagIDs[0], float64(3)) + assertEqual(t, tagIDs[1], float64(4)) +} + func TestBatchClosePreservesCurrentDescription(t *testing.T) { var updatePayload map[string]interface{} server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {