Merge pull request #412: feat(issue): 新增 issue +delete 单议题删除

# Conflicts:
#	shortcuts/issue/issue_test.go
This commit is contained in:
wbtiger 2026-07-14 21:57:26 +08:00
commit 4e6cc86343
6 changed files with 48 additions and 110 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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