feat(issue): add issue +reopen to reopen a closed issue

Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
This commit is contained in:
林晨 (Leo Cheng) 2026-07-08 12:11:47 +08:00
parent 9749a4c832
commit 8f5cc1637d
No known key found for this signature in database
GPG Key ID: 24FCF87A069356B9
5 changed files with 175 additions and 23 deletions

View File

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

View File

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

View File

@ -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": "查看议题详情",

View File

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

View File

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