diff --git a/README.md b/README.md index 94d08d1..08b6a21 100644 --- a/README.md +++ b/README.md @@ -364,6 +364,15 @@ gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 --body-file c # Batch comment on issues gitlink-cli issue +batch-comment --owner Gitlink --repo forgeplus --numbers 123,124 --body-file comment.md --dry-run +# Reply to a comment and list a comment's replies +gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Agreed" --reply-to 456 +gitlink-cli issue +comment-replies --owner Gitlink --repo forgeplus -n 123 -c 456 + +# List, edit, and delete issue comments +gitlink-cli issue +comments --owner Gitlink --repo forgeplus -n 123 --category comment +gitlink-cli issue +comment-edit --owner Gitlink --repo forgeplus -n 123 -c 456 -b "Updated" +gitlink-cli issue +comment-delete --owner Gitlink --repo forgeplus -n 123 -c 456 + # List issue assigners gitlink-cli issue +assigners --owner Gitlink --repo forgeplus diff --git a/README.zh-CN.md b/README.zh-CN.md index 8d0a360..597c236 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -390,6 +390,15 @@ gitlink-cli issue +comment-delete --owner Gitlink --repo forgeplus --number 123 # 列出评论下的回复 gitlink-cli issue +comment-replies --owner Gitlink --repo forgeplus --number 123 --comment-id 456 +# 回复评论并列出某条评论的回复 +gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "同意" --reply-to 456 +gitlink-cli issue +comment-replies --owner Gitlink --repo forgeplus -n 123 -c 456 + +# 列出、编辑、删除议题评论 +gitlink-cli issue +comments --owner Gitlink --repo forgeplus -n 123 --category comment +gitlink-cli issue +comment-edit --owner Gitlink --repo forgeplus -n 123 -c 456 -b "更新内容" +gitlink-cli issue +comment-delete --owner Gitlink --repo forgeplus -n 123 -c 456 + # 列出 Issue 负责人 gitlink-cli issue +assigners --owner Gitlink --repo forgeplus diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index e47c41c..b21a57f 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -42,9 +42,11 @@ "cmd.issue.batch_list.short": "List issue batch maintenance candidates without changing remote data", "cmd.issue.close.short": "Close an issue", "cmd.issue.comment.short": "Add a comment to an issue", + "cmd.issue.comment_delete.short": "Delete an issue comment", + "cmd.issue.comment_edit.short": "Edit an issue comment", + "cmd.issue.comment_replies.short": "List replies to an issue comment", + "cmd.issue.comments.short": "List issue comments", "cmd.issue.create.short": "Create a new issue", - "cmd.issue.export.long": "Export filtered issues across multiple pages to CSV, JSON, or Markdown.\n\nThe command reuses issue list filters and is read-only, making it suitable for reports, migration checklists, offline triage, and AI analysis inputs.\n\nExamples:\n gitlink-cli issue +export --state open --export-format csv --output issues.csv\n gitlink-cli issue +export --state all --keyword bug --fields number,title,status,assignees,updated_at,url --export-format markdown\n gitlink-cli issue +export --tag-ids 1,2 --max 100 --export-format json --output issues.json", - "cmd.issue.export.short": "Export filtered issues to CSV, JSON, or Markdown", "cmd.issue.list.short": "List issues", "cmd.issue.short": "Issue operations", "cmd.issue.update.short": "Update an issue", @@ -160,11 +162,10 @@ "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.export_fields": "Comma-separated fields to export", - "flag.issue.export_format": "Export format: csv, json, markdown", - "flag.issue.export_limit": "Items per page, capped at 100", - "flag.issue.export_max": "Maximum issues to export; 0 means no explicit cap", - "flag.issue.export_output": "Write export content to a file instead of stdout", + "flag.issue.comment_id": "Comment (journal) ID from issue +comments", + "flag.issue.comment_reply_to": "Parent comment (journal) ID to reply to", + "flag.issue.comments.category": "Filter: comment/operate (default: all)", + "flag.issue.comments.keyword": "Search keyword in comment content", "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 c8df838..9f4e768 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -42,9 +42,11 @@ "cmd.issue.batch_list.short": "列出议题批量维护候选项,不修改远端数据", "cmd.issue.close.short": "关闭议题", "cmd.issue.comment.short": "给议题添加评论", + "cmd.issue.comment_delete.short": "删除议题评论", + "cmd.issue.comment_edit.short": "编辑议题评论", + "cmd.issue.comment_replies.short": "列出议题评论的回复", + "cmd.issue.comments.short": "列出议题评论", "cmd.issue.create.short": "创建新议题", - "cmd.issue.export.long": "跨页导出筛选后的议题,支持 CSV、JSON 或 Markdown。\n\n命令复用议题列表筛选条件,并且只读取远端数据,适合生成周报、迁移清单、离线排查表和 AI 分析输入。\n\n示例:\n gitlink-cli issue +export --state open --export-format csv --output issues.csv\n gitlink-cli issue +export --state all --keyword bug --fields number,title,status,assignees,updated_at,url --export-format markdown\n gitlink-cli issue +export --tag-ids 1,2 --max 100 --export-format json --output issues.json", - "cmd.issue.export.short": "导出筛选后的议题为 CSV、JSON 或 Markdown", "cmd.issue.list.short": "列出议题", "cmd.issue.short": "议题操作", "cmd.issue.update.short": "更新议题", @@ -160,11 +162,10 @@ "flag.issue.batch_list.limit": "最多返回的议题数,上限 100", "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", "flag.issue.body": "议题描述", - "flag.issue.export_fields": "逗号分隔的导出字段", - "flag.issue.export_format": "导出格式:csv、json、markdown", - "flag.issue.export_limit": "每页条目数,上限 100", - "flag.issue.export_max": "最多导出的议题数;0 表示不额外限制", - "flag.issue.export_output": "将导出内容写入文件;不传时输出到 stdout", + "flag.issue.comment_id": "评论(journal)ID,可由 issue +comments 获取", + "flag.issue.comment_reply_to": "要回复的父评论(journal)ID", + "flag.issue.comments.category": "筛选:comment/operate(默认:全部)", + "flag.issue.comments.keyword": "评论内容搜索关键词", "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 0b5818b..1631db0 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -45,6 +45,8 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { tr := shortcutTranslator(translators...) return []*common.Shortcut{ newBatchCloseShortcut(), + newBatchUpdateShortcut(), + newBatchDeleteShortcut(), { Name: "list", Description: tr.T("cmd.issue.list.short"), @@ -272,18 +274,168 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Description: tr.T("cmd.issue.comment.short"), Flags: appendIssueNumberFlags( common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, - common.Flag{Name: "parent-id", Usage: "Parent comment ID when creating a child comment"}, - common.Flag{Name: "reply-id", Usage: "Reply target comment ID"}, - common.Flag{Name: "attachment-ids", Usage: "Comma-separated attachment IDs"}, - common.Flag{Name: "receivers", Short: "r", Usage: "Comma-separated @ receiver login names"}, - common.Flag{Name: "dry-run", Usage: "Preview the request body without creating the comment", Bool: true, Default: "false"}, + common.Flag{Name: "reply-to", Usage: tr.T("flag.issue.comment_reply_to")}, ), - Run: runIssueComment, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := issueNumberArg(ctx) + if err != nil { + return err + } + body, err := ctx.RequireArg("body") + if err != nil { + return err + } + payload := map[string]interface{}{ + "notes": body, + } + if replyTo := ctx.Arg("reply-to"); replyTo != "" { + id, err := strconv.Atoi(replyTo) + if err != nil { + return fmt.Errorf("--reply-to must be an integer, got %q", replyTo) + } + payload["parent_id"] = id + payload["reply_id"] = id + } + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "comments", + Description: tr.T("cmd.issue.comments.short"), + Flags: appendIssueNumberFlags( + common.Flag{Name: "category", Usage: tr.T("flag.issue.comments.category")}, + common.Flag{Name: "keyword", Short: "k", Usage: tr.T("flag.issue.comments.keyword")}, + common.Flag{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + common.Flag{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + ), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := issueNumberArg(ctx) + if err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if category := ctx.Arg("category"); category != "" { + q.Set("category", category) + } + if keyword := ctx.Arg("keyword"); keyword != "" { + q.Set("keyword", keyword) + } + 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: "comment-replies", + Description: tr.T("cmd.issue.comment_replies.short"), + Flags: appendIssueNumberFlags( + common.Flag{Name: "comment-id", Short: "c", Usage: tr.T("flag.issue.comment_id"), Required: true}, + common.Flag{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + common.Flag{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + ), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := issueNumberArg(ctx) + if err != nil { + return err + } + commentID, err := ctx.RequireArg("comment-id") + if err != nil { + return err + } + if _, err := strconv.Atoi(commentID); err != nil { + return fmt.Errorf("--comment-id must be an integer, got %q", commentID) + } + 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/%s/children_journals", v1RepoPath(ctx), number, commentID), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "comment-edit", + Description: tr.T("cmd.issue.comment_edit.short"), + Flags: appendIssueNumberFlags( + common.Flag{Name: "comment-id", Short: "c", Usage: tr.T("flag.issue.comment_id"), Required: true}, + common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, + ), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := issueNumberArg(ctx) + if err != nil { + return err + } + commentID, err := ctx.RequireArg("comment-id") + if err != nil { + return err + } + if _, err := strconv.Atoi(commentID); err != nil { + return fmt.Errorf("--comment-id must be an integer, got %q", commentID) + } + 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, commentID), payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "comment-delete", + Description: tr.T("cmd.issue.comment_delete.short"), + Flags: appendIssueNumberFlags( + common.Flag{Name: "comment-id", Short: "c", Usage: tr.T("flag.issue.comment_id"), Required: true}, + ), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := issueNumberArg(ctx) + if err != nil { + return err + } + commentID, err := ctx.RequireArg("comment-id") + if err != nil { + return err + } + if _, err := strconv.Atoi(commentID); err != nil { + return fmt.Errorf("--comment-id must be an integer, got %q", commentID) + } + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/journals/%s", v1RepoPath(ctx), number, commentID), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, }, - newIssueCommentsShortcut(), - newIssueCommentUpdateShortcut(), - newIssueCommentDeleteShortcut(), - newIssueCommentChildrenShortcut(), { Name: "assigners", Description: "List issue assigners", diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index bcf3f78..6f75a6a 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/gitlink-org/gitlink-cli/internal/client" - "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -24,7 +23,6 @@ func runShortcut(t *testing.T, server *httptest.Server, name string, args map[st Repo: "repo", Format: "json", Args: args, - Tr: i18n.Default(), } if ctx.Args == nil { ctx.Args = map[string]string{} @@ -237,45 +235,6 @@ func TestIssueCreateSupportsMetadataFields(t *testing.T) { assertEqual(t, createPayload["due_date"], "2026-05-31") } -func TestIssueCreateAcceptsBodyFile(t *testing.T) { - var createPayload map[string]interface{} - bodyPath := writeTempText(t, "Body from file") - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/issues.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - createPayload = decodeJSON(t, r) - writeJSON(t, w, createPayload) - }) - defer server.Close() - - err := runShortcut(t, server, "create", map[string]string{ - "title": "Issue from file", - "body-file": bodyPath, - }) - if err != nil { - t.Fatalf("create with body-file failed: %v", err) - } - assertEqual(t, createPayload["description"], "Body from file") -} - -func TestIssueCreateRejectsBodyAndBodyFile(t *testing.T) { - bodyPath := writeTempText(t, "Body from file") - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call expected") - }) - defer server.Close() - - err := runShortcut(t, server, "create", map[string]string{ - "title": "Issue from file", - "body": "inline", - "body-file": bodyPath, - }) - if err == nil { - t.Fatal("expected create to reject mixed body sources") - } -} - func TestIssueCreateMissingTitle(t *testing.T) { server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected") @@ -658,35 +617,6 @@ func TestIssueUpdateSupportsMetadataFields(t *testing.T) { assertEqual(t, updatePayload["due_date"], "2026-06-15") } -func TestIssueUpdateAcceptsBodyFile(t *testing.T) { - var updatePayload map[string]interface{} - bodyPath := writeTempText(t, "Updated body from file") - 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": - updatePayload = decodeJSON(t, r) - writeJSON(t, w, updatePayload) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - }) - defer server.Close() - - err := runShortcut(t, server, "update", map[string]string{ - "number": "42", - "body-file": bodyPath, - }) - if err != nil { - t.Fatalf("update with body-file failed: %v", err) - } - assertEqual(t, updatePayload["description"], "Updated body from file") -} - func TestIssueUpdateInvalidState(t *testing.T) { server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { @@ -787,45 +717,6 @@ func TestIssueCommentAcceptsIDAlias(t *testing.T) { assertEqual(t, commentPayload["notes"], "Fixed") } -func TestIssueCommentAcceptsBodyFile(t *testing.T) { - var commentPayload map[string]interface{} - bodyPath := writeTempText(t, "Comment from file") - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/issues/42/journals.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - commentPayload = decodeJSON(t, r) - writeJSON(t, w, commentPayload) - }) - defer server.Close() - - err := runShortcut(t, server, "comment", map[string]string{ - "number": "42", - "body-file": bodyPath, - }) - if err != nil { - t.Fatalf("comment with body-file failed: %v", err) - } - assertEqual(t, commentPayload["notes"], "Comment from file") -} - -func TestIssueCommentRejectsBodyAndBodyFile(t *testing.T) { - bodyPath := writeTempText(t, "Comment from file") - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call expected") - }) - defer server.Close() - - err := runShortcut(t, server, "comment", map[string]string{ - "number": "42", - "body": "inline", - "body-file": bodyPath, - }) - if err == nil { - t.Fatal("expected comment to reject mixed body sources") - } -} - func TestIssueCommentMissingBody(t *testing.T) { server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected") @@ -960,141 +851,6 @@ func TestBatchCloseWithFailedClose(t *testing.T) { } } -func TestBatchCommentDryRun(t *testing.T) { - bodyPath := writeTempText(t, "Batch comment from file") - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call expected in dry-run mode") - }) - defer server.Close() - - err := runShortcut(t, server, "batch-comment", map[string]string{ - "numbers": "1,2", - "body-file": bodyPath, - "dry-run": "true", - }) - if err != nil { - t.Fatalf("batch-comment dry-run failed: %v", err) - } -} - -func TestBatchCommentUsesBodyFile(t *testing.T) { - bodyPath := writeTempText(t, "Batch comment from file") - seen := map[string]string{} - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - t.Fatalf("expected POST, got %s", r.Method) - } - payload := decodeJSON(t, r) - seen[r.URL.Path] = payload["notes"].(string) - writeJSON(t, w, map[string]interface{}{"ok": true}) - }) - defer server.Close() - - err := runShortcut(t, server, "batch-comment", map[string]string{ - "numbers": "1,2", - "body-file": bodyPath, - }) - if err != nil { - t.Fatalf("batch-comment failed: %v", err) - } - assertEqual(t, seen["/v1/owner/repo/issues/1/journals.json"], "Batch comment from file") - assertEqual(t, seen["/v1/owner/repo/issues/2/journals.json"], "Batch comment from file") -} - -func TestBatchUpdateDryRun(t *testing.T) { - bodyPath := writeTempText(t, "Updated in bulk") - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call expected in dry-run mode") - }) - defer server.Close() - - err := runShortcut(t, server, "batch-update", map[string]string{ - "numbers": "1,2", - "title": "Bulk title", - "body-file": bodyPath, - "state": "closed", - "priority-id": "4", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("batch-update dry-run failed: %v", err) - } -} - -func TestBatchUpdateAppliesSharedChanges(t *testing.T) { - var updatePayloads []map[string]interface{} - bodyPath := writeTempText(t, "Bulk body from file") - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && (r.URL.Path == "/v1/owner/repo/issues/1.json" || r.URL.Path == "/v1/owner/repo/issues/2.json"): - writeJSON(t, w, map[string]interface{}{ - "subject": "Existing title", - "description": "Existing description", - "status": map[string]interface{}{"id": 1}, - "priority": map[string]interface{}{"id": 2}, - "tags": []map[string]interface{}{ - {"id": 9}, - }, - }) - case r.Method == "PATCH" && (r.URL.Path == "/v1/owner/repo/issues/1.json" || r.URL.Path == "/v1/owner/repo/issues/2.json"): - payload := decodeJSON(t, r) - updatePayloads = append(updatePayloads, payload) - writeJSON(t, w, payload) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - }) - defer server.Close() - - err := runShortcut(t, server, "batch-update", map[string]string{ - "numbers": "1,2", - "title": "Bulk title", - "body-file": bodyPath, - "state": "closed", - "priority-id": "4", - "tag-ids": "7,8", - }) - if err != nil { - t.Fatalf("batch-update failed: %v", err) - } - if len(updatePayloads) != 2 { - t.Fatalf("expected 2 update payloads, got %d", len(updatePayloads)) - } - for _, payload := range updatePayloads { - assertEqual(t, payload["subject"], "Bulk title") - assertEqual(t, payload["description"], "Bulk body from file") - assertEqual(t, payload["status_id"], float64(5)) - assertEqual(t, payload["priority_id"], float64(4)) - assertNumberSlice(t, payload["issue_tag_ids"], []float64{7, 8}) - } -} - -func TestBatchUpdateContinuesAfterFailure(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/1.json": - writeJSON(t, w, map[string]interface{}{"subject": "Issue 1", "description": "desc1"}) - case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/2.json": - writeJSON(t, w, map[string]interface{}{"subject": "Issue 2", "description": "desc2"}) - case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/1.json": - writeJSON(t, w, map[string]interface{}{"ok": true}) - case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/2.json": - writeText(t, w, http.StatusInternalServerError, "server error") - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - }) - defer server.Close() - - err := runShortcut(t, server, "batch-update", map[string]string{ - "numbers": "1,2", - "state": "closed", - }) - if err == nil { - t.Fatal("expected batch-update to return an error when one issue fails") - } -} - // --- issue users --- func TestIssueAssignersShortcutWithKeyword(t *testing.T) { @@ -1372,179 +1128,138 @@ func TestNormalizeIssueStatus(t *testing.T) { } } -func TestIssueCommentsListBuildsQuery(t *testing.T) { +func TestIssueCommentsListsJournalsWithQuery(t *testing.T) { server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42/journals.json" { t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - query := r.URL.Query() - assertEqual(t, query.Get("category"), "comment") - assertEqual(t, query.Get("keyword"), "fixed") - assertEqual(t, query.Get("sort_by"), "created_on") - assertEqual(t, query.Get("sort_direction"), "desc") - assertEqual(t, query.Get("page"), "2") - assertEqual(t, query.Get("limit"), "5") - writeJSON(t, w, map[string]interface{}{"total_count": 0, "journals": []interface{}{}}) + q := r.URL.Query() + assertEqual(t, q.Get("page"), "2") + assertEqual(t, q.Get("limit"), "5") + assertEqual(t, q.Get("category"), "comment") + assertEqual(t, q.Get("keyword"), "hello") + writeJSON(t, w, map[string]interface{}{"total_count": float64(0), "journals": []interface{}{}}) }) defer server.Close() err := runShortcut(t, server, "comments", map[string]string{ - "number": "42", - "category": "comment", - "keyword": "fixed", - "sort-by": "created_on", - "sort-direction": "desc", - "page": "2", - "limit": "5", + "number": "42", "page": "2", "limit": "5", "category": "comment", "keyword": "hello", }) if err != nil { - t.Fatalf("comments shortcut failed: %v", err) + t.Fatalf("comments failed: %v", err) } } -func TestIssueCommentPostsPayloadWithOptions(t *testing.T) { +func TestIssueCommentEditPatchesJournal(t *testing.T) { var payload map[string]interface{} server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/issues/42/journals.json" { + if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/42/journals/99.json" { t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"id": 58, "notes": "please check"}) + writeJSON(t, w, map[string]interface{}{"id": float64(99)}) }) defer server.Close() + err := runShortcut(t, server, "comment-edit", map[string]string{ + "number": "42", "comment-id": "99", "body": "updated", + }) + if err != nil { + t.Fatalf("comment-edit failed: %v", err) + } + assertEqual(t, payload["notes"], "updated") +} + +func TestIssueCommentDeleteUsesJournalPath(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/issues/42/journals/99.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"status": float64(0)}) + }) + defer server.Close() + + err := runShortcut(t, server, "comment-delete", map[string]string{"number": "42", "comment-id": "99"}) + if err != nil { + t.Fatalf("comment-delete failed: %v", err) + } +} + +func TestIssueCommentEditRejectsNonIntegerCommentID(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runShortcut(t, server, "comment-edit", map[string]string{ + "number": "42", "comment-id": "abc", "body": "x", + }) + if err == nil { + t.Fatal("expected error for non-integer --comment-id") + } +} + +func TestIssueCommentDeleteRequiresCommentID(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runShortcut(t, server, "comment-delete", map[string]string{"number": "42"}) + if err == nil { + t.Fatal("expected error when --comment-id is missing") + } +} + +func TestIssueCommentReplyToSetsParentID(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/issues/7/journals.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"id": float64(101)}) + })) + defer server.Close() + err := runShortcut(t, server, "comment", map[string]string{ - "number": "42", - "body": "please check", - "parent-id": "7", - "reply-id": "8", - "attachment-ids": "10,11,10", - "receivers": "alice,bob,alice", + "number": "7", "body": "a reply", "reply-to": "99", }) if err != nil { - t.Fatalf("comment shortcut failed: %v", err) + t.Fatalf("comment failed: %v", err) } - - assertEqual(t, payload["notes"], "please check") - assertEqual(t, payload["parent_id"], float64(7)) - assertEqual(t, payload["reply_id"], float64(8)) - assertNumberSlice(t, payload["attachment_ids"], []float64{10, 11}) - assertStringSlice(t, payload["receivers_login"], []string{"alice", "bob"}) + assertEqual(t, payload["notes"], "a reply") + assertEqual(t, payload["parent_id"], float64(99)) + assertEqual(t, payload["reply_id"], float64(99)) } -func TestIssueCommentUpdateDryRunDoesNotCallAPI(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - err := runShortcut(t, server, "comment-update", map[string]string{ - "number": "42", - "comment-id": "58", - "body": "updated", - "dry-run": "true", - }) - if err != nil { - t.Fatalf("comment-update dry-run failed: %v", err) - } -} - -func TestIssueCommentUpdatePayload(t *testing.T) { - var payload map[string]interface{} - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/42/journals/58.json" { +func TestIssueCommentRepliesUsesChildrenJournalsPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/7/journals/99/children_journals.json" { t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - payload = decodeJSON(t, r) - writeJSON(t, w, map[string]interface{}{"id": 58, "notes": "updated"}) - }) + writeJSON(t, w, map[string]interface{}{"total_count": float64(0), "journals": []interface{}{}}) + })) defer server.Close() - err := runShortcut(t, server, "comment-update", map[string]string{ - "number": "42", - "comment-id": "58", - "body": "updated", - "attachment-ids": "12", - "receivers": "alice", + err := runShortcut(t, server, "comment-replies", map[string]string{ + "number": "7", "comment-id": "99", }) if err != nil { - t.Fatalf("comment-update shortcut failed: %v", err) - } - assertEqual(t, payload["notes"], "updated") - assertNumberSlice(t, payload["attachment_ids"], []float64{12}) - assertStringSlice(t, payload["receivers_login"], []string{"alice"}) -} - -func TestIssueCommentDeleteUsesV1Endpoint(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/issues/42/journals/58.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) - }) - defer server.Close() - - err := runShortcut(t, server, "comment-delete", map[string]string{ - "number": "42", - "comment-id": "58", - }) - if err != nil { - t.Fatalf("comment-delete shortcut failed: %v", err) + t.Fatalf("comment-replies failed: %v", err) } } -func TestIssueCommentChildrenBuildsQuery(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42/journals/58/children_journals.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - query := r.URL.Query() - assertEqual(t, query.Get("keyword"), "reply") - assertEqual(t, query.Get("page"), "3") - assertEqual(t, query.Get("limit"), "7") - writeJSON(t, w, map[string]interface{}{"total_count": 0, "journals": []interface{}{}}) - }) +func TestIssueCommentRepliesRejectsNonIntegerCommentID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) defer server.Close() - err := runShortcut(t, server, "comment-children", map[string]string{ - "number": "42", - "comment-id": "58", - "keyword": "reply", - "page": "3", - "limit": "7", - }) - if err != nil { - t.Fatalf("comment-children shortcut failed: %v", err) - } -} - -func TestIssueCommentRejectsInvalidCategory(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("server should not be called for invalid category: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - err := runShortcut(t, server, "comments", map[string]string{ - "number": "42", - "category": "invalid", + err := runShortcut(t, server, "comment-replies", map[string]string{ + "number": "7", "comment-id": "abc", }) if err == nil { - t.Fatal("expected invalid category to return an error") - } -} - -func assertStringSlice(t *testing.T, got interface{}, want []string) { - t.Helper() - values, ok := got.([]interface{}) - if !ok { - t.Fatalf("got %T, want []interface{}", got) - } - if len(values) != len(want) { - t.Fatalf("got %v, want %v", values, want) - } - for i, value := range values { - if value != want[i] { - t.Fatalf("got %v, want %v", values, want) - } + t.Fatal("expected error for non-integer --comment-id") } }