From 8f5cc1637df155ba67d5cdddf09f9dccab95c45b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E6=99=A8=20=28Leo=20Cheng=29?= Date: Wed, 8 Jul 2026 12:11:47 +0800 Subject: [PATCH] feat(issue): add issue +reopen to reopen a closed issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 林晨 (Leo Cheng) --- doc/changes/issue-reopen.md | 24 +++++++ internal/i18n/locales/en-US.json | 1 + internal/i18n/locales/zh-CN.json | 1 + shortcuts/issue/issue.go | 60 ++++++++++------- shortcuts/issue/issue_test.go | 112 +++++++++++++++++++++++++++++++ 5 files changed, 175 insertions(+), 23 deletions(-) create mode 100644 doc/changes/issue-reopen.md diff --git a/doc/changes/issue-reopen.md b/doc/changes/issue-reopen.md new file mode 100644 index 0000000..1005b7e --- /dev/null +++ b/doc/changes/issue-reopen.md @@ -0,0 +1,24 @@ +# Issue Reopen + +## Summary + +`issue +reopen` reopens a closed issue, mirroring `issue +close`. It brings the +issue shortcut group to parity with `milestone +reopen` and `pr +reopen`, which +already had the counterpart to their close command. + +Like `issue +close`, the v1 PATCH is read-modify-write, so the current issue is +fetched first and its metadata (priority, tags, assigners, linked branch, dates) +is replayed alongside the new status so unrelated fields are not reset. Only +`status_id` is flipped: `1` (open) for reopen, `5` (closed) for close. Both +commands share the same helper, so `+reopen` preserves exactly the fields +`+close` already does. + +## Examples + +```bash +gitlink-cli issue +reopen --owner Gitlink --repo forgeplus --number 123 +gitlink-cli issue +reopen --owner Gitlink --repo forgeplus -i 123 +``` + +`--number` / `-n` is the project-level issue number from the web URL; `--id` / +`-i` is accepted as a compatibility alias. diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..de3c6e6 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -44,6 +44,7 @@ "cmd.issue.comment.short": "Add a comment to an issue", "cmd.issue.create.short": "Create a new issue", "cmd.issue.list.short": "List issues", + "cmd.issue.reopen.short": "Reopen a closed issue", "cmd.issue.short": "Issue operations", "cmd.issue.update.short": "Update an issue", "cmd.issue.view.short": "View issue details", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..6f467f1 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -44,6 +44,7 @@ "cmd.issue.comment.short": "给议题添加评论", "cmd.issue.create.short": "创建新议题", "cmd.issue.list.short": "列出议题", + "cmd.issue.reopen.short": "重新打开已关闭的议题", "cmd.issue.short": "议题操作", "cmd.issue.update.short": "更新议题", "cmd.issue.view.short": "查看议题详情", diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index b19027e..0d683f3 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -182,29 +182,15 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Description: tr.T("cmd.issue.close.short"), Flags: issueNumberFlags(), Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, err := issueNumberArg(ctx) - if err != nil { - return err - } - current, err := fetchExistingIssue(ctx, number) - if err != nil { - return err - } - - body := map[string]interface{}{ - "subject": current.Subject, - "description": current.Description, - } - 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 - } - return ctx.Output(env) + return setIssueStatus(ctx, 5) // 5 = closed + }, + }, + { + Name: "reopen", + Description: tr.T("cmd.issue.reopen.short"), + Flags: issueNumberFlags(), + Run: func(ctx *common.RuntimeContext) error { + return setIssueStatus(ctx, 1) // 1 = open }, }, { @@ -475,6 +461,34 @@ func normalizeIssueListIDs(env *output.Envelope) { } } +// setIssueStatus flips an issue to statusID. The v1 PATCH is read-modify-write, +// so the current issue is fetched and its metadata replayed to avoid clearing +// fields that were not part of the status change. +func setIssueStatus(ctx *common.RuntimeContext, statusID int) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := issueNumberArg(ctx) + if err != nil { + return err + } + current, err := fetchExistingIssue(ctx, number) + if err != nil { + return err + } + body := map[string]interface{}{ + "subject": current.Subject, + "description": current.Description, + } + preserveIssueMetadata(body, current) + body["status_id"] = statusID + env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) + if err != nil { + return err + } + return ctx.Output(env) +} + func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) { getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil) if err != nil { diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 48be057..06c31b4 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -417,6 +417,117 @@ func TestIssueCloseFetchFails(t *testing.T) { } } +// --- reopen --- + +func TestIssueReopen(t *testing.T) { + var patchPayload 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{}{ + "id": float64(42), + "subject": "Existing title", + "description": "Existing description", + "status": map[string]interface{}{"id": 5}, + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + patchPayload = decodeJSON(t, r) + writeJSON(t, w, patchPayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runShortcut(t, server, "reopen", map[string]string{"number": "42"}) + if err != nil { + t.Fatalf("reopen failed: %v", err) + } + assertEqual(t, patchPayload["subject"], "Existing title") + assertEqual(t, patchPayload["description"], "Existing description") + assertEqual(t, patchPayload["status_id"], float64(1)) +} + +func TestIssueReopenPreservesCurrentMetadata(t *testing.T) { + var patchPayload 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", + "status": map[string]interface{}{"id": 5}, + "priority": map[string]interface{}{"id": 3}, + "tags": []map[string]interface{}{ + {"id": 4}, + }, + "assigners": []map[string]interface{}{ + {"id": 7}, + }, + "branch_name": "feature/x", + "start_date": "2026-01-01", + "due_date": "2026-02-01", + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + patchPayload = decodeJSON(t, r) + writeJSON(t, w, patchPayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runShortcut(t, server, "reopen", map[string]string{"number": "42"}) + if err != nil { + t.Fatalf("reopen shortcut failed: %v", err) + } + assertEqual(t, patchPayload["subject"], "Existing title") + assertEqual(t, patchPayload["status_id"], float64(1)) + assertEqual(t, patchPayload["priority_id"], float64(3)) + assertNumberSlice(t, patchPayload["issue_tag_ids"], []float64{4}) + assertNumberSlice(t, patchPayload["assigner_ids"], []float64{7}) + assertEqual(t, patchPayload["branch_name"], "feature/x") + assertEqual(t, patchPayload["start_date"], "2026-01-01") + assertEqual(t, patchPayload["due_date"], "2026-02-01") +} + +func TestIssueReopenAcceptsIDAlias(t *testing.T) { + var patchPayload 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": + patchPayload = decodeJSON(t, r) + writeJSON(t, w, patchPayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runShortcut(t, server, "reopen", map[string]string{"id": "42"}) + if err != nil { + t.Fatalf("reopen shortcut failed: %v", err) + } + assertEqual(t, patchPayload["status_id"], float64(1)) +} + +func TestIssueReopenFetchFails(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + writeJSON(t, w, map[string]interface{}{"error": "not found"}) + }) + defer server.Close() + + err := runShortcut(t, server, "reopen", map[string]string{"number": "999"}) + if err == nil { + t.Fatal("expected error when issue not found") + } +} + // --- update --- func TestIssueUpdateTitle(t *testing.T) { @@ -741,6 +852,7 @@ func TestIssueNumberOrIDIsRequired(t *testing.T) { }{ {name: "view", args: map[string]string{}}, {name: "close", args: map[string]string{}}, + {name: "reopen", args: map[string]string{}}, {name: "update", args: map[string]string{"title": "New title"}}, {name: "comment", args: map[string]string{"body": "Fixed"}}, } -- 2.34.1