feat(issue): add comments CRUD, delete, batch-update, batch-destroy shortcuts

This commit is contained in:
刘焱 2026-06-02 08:16:59 +08:00
parent 94661010ac
commit a94088e4ee
2 changed files with 388 additions and 0 deletions

View File

@ -223,6 +223,199 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "comments",
Description: "List comments and activity on an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update-comment",
Description: "Update a comment on an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "id", Usage: "Comment journal ID", Required: true},
{Name: "body", Short: "b", Usage: "New comment body", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
body, err := ctx.RequireArg("body")
if err != nil {
return err
}
payload := map[string]interface{}{
"notes": body,
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete-comment",
Description: "Delete a comment on an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "id", Usage: "Comment journal ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "reply-comment",
Description: "List child replies of a comment",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "id", Usage: "Parent comment journal ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s/journals/%s/children_journals", v1RepoPath(ctx), number, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "batch-update",
Description: "Batch update multiple issues",
Flags: []common.Flag{
{Name: "ids", Usage: "Comma-separated issue IDs", Required: true},
{Name: "state", Short: "s", Usage: "New state: open, closed"},
{Name: "assignee", Short: "a", Usage: "Assignee login"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
idsStr, err := ctx.RequireArg("ids")
if err != nil {
return err
}
body := map[string]interface{}{
"ids": parseIDList(idsStr),
}
if s := ctx.Arg("state"); s != "" {
statusID, err := normalizeIssueStatus(s)
if err != nil {
return err
}
body["status_id"] = statusID
}
if a := ctx.Arg("assignee"); a != "" {
body["assigned_to_id"] = a
}
env, err := ctx.CallAPI("PATCH", v1RepoPath(ctx)+"/issues/batch_update", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "batch-destroy",
Description: "Batch delete multiple issues",
Flags: []common.Flag{
{Name: "ids", Usage: "Comma-separated issue IDs", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
idsStr, err := ctx.RequireArg("ids")
if err != nil {
return err
}
body := map[string]interface{}{
"ids": parseIDList(idsStr),
}
env, err := ctx.CallAPI("DELETE", v1RepoPath(ctx)+"/issues/batch_destroy", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
@ -289,3 +482,19 @@ func normalizeIssueStatus(state string) (interface{}, error) {
return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state)
}
}
// parseIDList splits a comma-separated string into an int slice.
func parseIDList(s string) []int {
parts := strings.Split(s, ",")
ids := make([]int, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if id, err := strconv.Atoi(p); err == nil {
ids = append(ids, id)
}
}
return ids
}

View File

@ -186,3 +186,182 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
func TestIssueDelete(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/42.json":
writeJSON(t, w, map[string]interface{}{"ok": true})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "delete", map[string]string{"number": "42"})
if err != nil {
t.Fatalf("delete shortcut failed: %v", err)
}
}
func TestIssueComments(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/journals.json":
if r.URL.Query().Get("page") != "2" || r.URL.Query().Get("limit") != "10" {
t.Fatalf("unexpected query params: %v", r.URL.Query())
}
writeJSON(t, w, map[string]interface{}{
"journals": []interface{}{
map[string]interface{}{"id": 1, "notes": "first comment"},
map[string]interface{}{"id": 2, "notes": "second comment"},
},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "comments", map[string]string{
"number": "42",
"page": "2",
"limit": "10",
})
if err != nil {
t.Fatalf("comments shortcut failed: %v", err)
}
}
func TestIssueUpdateComment(t *testing.T) {
var updatePayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42/journals/99.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "update-comment", map[string]string{
"number": "42",
"id": "99",
"body": "updated comment text",
})
if err != nil {
t.Fatalf("update-comment shortcut failed: %v", err)
}
assertEqual(t, updatePayload["notes"], "updated comment text")
}
func TestIssueDeleteComment(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/42/journals/99.json":
writeJSON(t, w, map[string]interface{}{"ok": true})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "delete-comment", map[string]string{
"number": "42",
"id": "99",
})
if err != nil {
t.Fatalf("delete-comment shortcut failed: %v", err)
}
}
func TestIssueReplyComment(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/journals/99/children_journals.json":
writeJSON(t, w, map[string]interface{}{
"journals": []interface{}{
map[string]interface{}{"id": 100, "notes": "reply"},
},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "reply-comment", map[string]string{
"number": "42",
"id": "99",
})
if err != nil {
t.Fatalf("reply-comment shortcut failed: %v", err)
}
}
func TestIssueBatchUpdate(t *testing.T) {
var updatePayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/batch_update.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-update", map[string]string{
"ids": "1,2,3",
"state": "closed",
"assignee": "someone",
})
if err != nil {
t.Fatalf("batch-update shortcut failed: %v", err)
}
ids, ok := updatePayload["ids"].([]interface{})
if !ok {
t.Fatalf("expected ids to be a slice, got %T", updatePayload["ids"])
}
assertEqual(t, len(ids), 3)
assertEqual(t, ids[0], float64(1))
assertEqual(t, ids[1], float64(2))
assertEqual(t, ids[2], float64(3))
assertEqual(t, updatePayload["status_id"], float64(5))
assertEqual(t, updatePayload["assigned_to_id"], "someone")
}
func TestIssueBatchDestroy(t *testing.T) {
var updatePayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/batch_destroy.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, updatePayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-destroy", map[string]string{
"ids": "10,20,30",
})
if err != nil {
t.Fatalf("batch-destroy shortcut failed: %v", err)
}
ids, ok := updatePayload["ids"].([]interface{})
if !ok {
t.Fatalf("expected ids to be a slice, got %T", updatePayload["ids"])
}
assertEqual(t, len(ids), 3)
assertEqual(t, ids[0], float64(10))
assertEqual(t, ids[1], float64(20))
assertEqual(t, ids[2], float64(30))
}