forked from Gitlink/gitlink-cli
feat(issue): add issue +reopen to reopen a closed issue
Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
This commit is contained in:
parent
9749a4c832
commit
8f5cc1637d
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "查看议题详情",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"}},
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue