diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 902fda8..a12d0ad 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -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 +} diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 088f82c..9a812d5 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -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)) +}