diff --git a/README.md b/README.md index 785c6ac..5ade3e5 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,9 @@ gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priori # Close an issue gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 +# Delete an issue (destructive; requires --yes) +gitlink-cli issue +delete --owner Gitlink --repo forgeplus --number 123 --yes + # Preview batch close without changing data gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run diff --git a/README.zh-CN.md b/README.zh-CN.md index 92cf696..d62f47f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -361,6 +361,9 @@ gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priori # 关闭 Issue gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 +# 删除 Issue(破坏性操作,需 --yes 确认) +gitlink-cli issue +delete --owner Gitlink --repo forgeplus --number 123 --yes + # 预览批量关闭,不修改数据 gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index ab9a529..bd726a8 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -45,6 +45,7 @@ "cmd.issue.close.short": "Close an issue", "cmd.issue.comment.short": "Add a comment to an issue", "cmd.issue.create.short": "Create a new issue", + "cmd.issue.delete.short": "Delete an issue", "cmd.issue.list.short": "List issues", "cmd.issue.reopen.short": "Reopen a closed issue", "cmd.issue.short": "Issue operations", @@ -168,6 +169,7 @@ "flag.issue.batch_list.limit": "Maximum issues to return, capped at 100", "flag.issue.batch_process.limit": "Maximum issues to process, capped at 100", "flag.issue.body": "Issue description", + "flag.issue.delete.yes": "Confirm issue deletion", "flag.issue.label": "Label ID", "flag.issue.label_filter": "Filter by existing label", "flag.issue.milestone": "Milestone ID", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 147fd4a..38ddc2d 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -45,6 +45,7 @@ "cmd.issue.close.short": "关闭议题", "cmd.issue.comment.short": "给议题添加评论", "cmd.issue.create.short": "创建新议题", + "cmd.issue.delete.short": "删除议题", "cmd.issue.list.short": "列出议题", "cmd.issue.reopen.short": "重新打开已关闭的议题", "cmd.issue.short": "议题操作", @@ -168,6 +169,7 @@ "flag.issue.batch_list.limit": "最多返回的议题数,上限 100", "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", "flag.issue.body": "议题描述", + "flag.issue.delete.yes": "确认删除议题", "flag.issue.label": "标签 ID", "flag.issue.label_filter": "按已有标签筛选", "flag.issue.milestone": "里程碑 ID", diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 2c22e1e..9f0c256 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -195,6 +195,30 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return setIssueStatus(ctx, 1) // 1 = open }, }, + { + Name: "delete", + Description: tr.T("cmd.issue.delete.short"), + Flags: appendIssueNumberFlags( + common.Flag{Name: "yes", Usage: tr.T("flag.issue.delete.yes"), Bool: true, Default: "false"}, + ), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := issueNumberArg(ctx) + if err != nil { + return err + } + if !parseBool(ctx.Arg("yes")) { + return fmt.Errorf("delete is destructive; pass --yes to confirm deleting issue #%s", number) + } + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "update", Description: tr.T("cmd.issue.update.short"), diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 9581abe..0112c0d 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -417,114 +417,35 @@ func TestIssueCloseFetchFails(t *testing.T) { } } -// --- reopen --- +// --- delete --- -func TestIssueReopen(t *testing.T) { - var patchPayload map[string]interface{} +func TestIssueDelete(t *testing.T) { + var deletedPath string 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: + if r.Method != "DELETE" { t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } + deletedPath = r.URL.Path + writeJSON(t, w, map[string]interface{}{"status": float64(0), "message": "success"}) }) defer server.Close() - err := runShortcut(t, server, "reopen", map[string]string{"number": "42"}) + err := runShortcut(t, server, "delete", map[string]string{"number": "42", "yes": "true"}) if err != nil { - t.Fatalf("reopen failed: %v", err) + t.Fatalf("delete failed: %v", err) } - assertEqual(t, patchPayload["subject"], "Existing title") - assertEqual(t, patchPayload["description"], "Existing description") - assertEqual(t, patchPayload["status_id"], float64(1)) + assertEqual(t, deletedPath, "/v1/owner/repo/issues/42.json") } -func TestIssueReopenPreservesCurrentMetadata(t *testing.T) { - var patchPayload map[string]interface{} +func TestIssueDeleteRequiresConfirmation(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/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) - } + t.Fatalf("unexpected request without --yes: %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"}) + err := runShortcut(t, server, "delete", map[string]string{"number": "42"}) if err == nil { - t.Fatal("expected error when issue not found") + t.Fatal("expected error without --yes confirmation") } } @@ -852,7 +773,6 @@ 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"}}, } @@ -871,7 +791,7 @@ func TestIssueNumberOrIDIsRequired(t *testing.T) { // --- batch-close --- -func TestBatchClosePreservesCurrentMetadata(t *testing.T) { +func TestBatchClosePreservesCurrentDescription(t *testing.T) { var updatePayload map[string]interface{} server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { @@ -879,16 +799,6 @@ func TestBatchClosePreservesCurrentMetadata(t *testing.T) { writeJSON(t, w, map[string]interface{}{ "subject": "Existing title", "description": "Existing description", - "priority": map[string]interface{}{"id": 3}, - "tags": []map[string]interface{}{ - {"id": 4}, - }, - "assigners": []map[string]interface{}{ - {"id": 5}, - }, - "branch_name": "release/next", - "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) @@ -909,12 +819,6 @@ func TestBatchClosePreservesCurrentMetadata(t *testing.T) { assertEqual(t, updatePayload["subject"], "Existing title") assertEqual(t, updatePayload["description"], "Existing description") assertEqual(t, updatePayload["status_id"], float64(5)) - assertEqual(t, updatePayload["priority_id"], float64(3)) - assertNumberSlice(t, updatePayload["issue_tag_ids"], []float64{4}) - assertNumberSlice(t, updatePayload["assigner_ids"], []float64{5}) - assertEqual(t, updatePayload["branch_name"], "release/next") - assertEqual(t, updatePayload["start_date"], "2026-05-01") - assertEqual(t, updatePayload["due_date"], "2026-05-31") } func TestBatchCloseDryRun(t *testing.T) {