From 029622ff5211995fb5281b3b1c45a52e2880359f Mon Sep 17 00:00:00 2001 From: 1os21ka23r9navae6mrro <1os21ka23r9navae6mrro@gmail.com> Date: Wed, 8 Jul 2026 13:43:32 +0000 Subject: [PATCH 1/2] feat(issue): add +comments, +comment-edit, +comment-delete Completes issue comment CRUD on top of the existing issue +comment by covering the v1 journals endpoints (index/update/destroy): - issue +comments: list comments with --category/--keyword filters and --page/--limit pagination - issue +comment-edit: PATCH a journal's notes by --comment-id - issue +comment-delete: DELETE a journal by --comment-id --comment-id is validated as an integer before any API call. Production-verified against gitlink.org.cn: full create -> list -> edit -> delete round-trip on a real issue. Includes 6 unit tests, bilingual README examples, and en-US/zh-CN i18n keys. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 5 ++ README.zh-CN.md | 5 ++ internal/i18n/locales/en-US.json | 6 ++ internal/i18n/locales/zh-CN.json | 6 ++ shortcuts/issue/issue.go | 97 ++++++++++++++++++++++++++++++++ shortcuts/issue/issue_test.go | 83 +++++++++++++++++++++++++++ 6 files changed, 202 insertions(+) diff --git a/README.md b/README.md index e5e4318..ae520d7 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,11 @@ gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 - # Add a comment gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed" +# 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 6a8879d..09a3086 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -367,6 +367,11 @@ gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 - # 添加评论 gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复" +# 列出、编辑、删除议题评论 +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 0739395..165a346 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -42,6 +42,9 @@ "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.comments.short": "List issue comments", "cmd.issue.create.short": "Create a new issue", "cmd.issue.list.short": "List issues", "cmd.issue.short": "Issue operations", @@ -158,6 +161,9 @@ "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.comment_id": "Comment (journal) ID from issue +comments", + "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 2e6fc4d..c479c71 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -42,6 +42,9 @@ "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.comments.short": "列出议题评论", "cmd.issue.create.short": "创建新议题", "cmd.issue.list.short": "列出议题", "cmd.issue.short": "议题操作", @@ -158,6 +161,9 @@ "flag.issue.batch_list.limit": "最多返回的议题数,上限 100", "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", "flag.issue.body": "议题描述", + "flag.issue.comment_id": "评论(journal)ID,可由 issue +comments 获取", + "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 b19027e..ddc4a17 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -297,6 +297,103 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { 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-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) + }, + }, { Name: "assigners", Description: "List issue assigners", diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 48be057..47737c1 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -1127,3 +1127,86 @@ func TestNormalizeIssueStatus(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) + } + 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", "page": "2", "limit": "5", "category": "comment", "keyword": "hello", + }) + if err != nil { + t.Fatalf("comments failed: %v", err) + } +} + +func TestIssueCommentEditPatchesJournal(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/99.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + payload = decodeJSON(t, r) + 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") + } +} From b08d3d04a8e741c7b4b65333524b2c522c7bd768 Mon Sep 17 00:00:00 2001 From: 1os21ka23r9navae6mrro <1os21ka23r9navae6mrro@gmail.com> Date: Wed, 8 Jul 2026 13:56:44 +0000 Subject: [PATCH 2/2] feat(issue): support threaded replies via --reply-to and +comment-replies - issue +comment --reply-to : reply to an existing comment; the server requires both parent_id and reply_id, so both are set - issue +comment-replies: list a comment's replies via the children_journals endpoint with --page/--limit Production-verified: parent -> reply -> list replies -> delete round-trip on a real issue. 3 new unit tests and bilingual docs. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 4 +++ README.zh-CN.md | 4 +++ internal/i18n/locales/en-US.json | 2 ++ internal/i18n/locales/zh-CN.json | 2 ++ shortcuts/issue/issue.go | 42 +++++++++++++++++++++++++ shortcuts/issue/issue_test.go | 53 ++++++++++++++++++++++++++++++++ 6 files changed, 107 insertions(+) diff --git a/README.md b/README.md index ae520d7..cbd075b 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,10 @@ gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 - # Add a comment gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed" +# 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" diff --git a/README.zh-CN.md b/README.zh-CN.md index 09a3086..6e1e3db 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -367,6 +367,10 @@ gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 - # 添加评论 gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复" +# 回复评论并列出某条评论的回复 +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 "更新内容" diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 165a346..b21a57f 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -44,6 +44,7 @@ "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.list.short": "List issues", @@ -162,6 +163,7 @@ "flag.issue.batch_process.limit": "Maximum issues to process, capped at 100", "flag.issue.body": "Issue description", "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", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index c479c71..9f4e768 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -44,6 +44,7 @@ "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.list.short": "列出议题", @@ -162,6 +163,7 @@ "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", "flag.issue.body": "议题描述", "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", diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index ddc4a17..1631db0 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -274,6 +274,7 @@ 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: "reply-to", Usage: tr.T("flag.issue.comment_reply_to")}, ), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -290,6 +291,14 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { 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 @@ -330,6 +339,39 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { 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"), diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 47737c1..6f75a6a 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -1210,3 +1210,56 @@ func TestIssueCommentDeleteRequiresCommentID(t *testing.T) { 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": "7", "body": "a reply", "reply-to": "99", + }) + if err != nil { + t.Fatalf("comment failed: %v", err) + } + assertEqual(t, payload["notes"], "a reply") + assertEqual(t, payload["parent_id"], float64(99)) + assertEqual(t, payload["reply_id"], float64(99)) +} + +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) + } + writeJSON(t, w, map[string]interface{}{"total_count": float64(0), "journals": []interface{}{}}) + })) + defer server.Close() + + err := runShortcut(t, server, "comment-replies", map[string]string{ + "number": "7", "comment-id": "99", + }) + if err != nil { + t.Fatalf("comment-replies failed: %v", err) + } +} + +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-replies", map[string]string{ + "number": "7", "comment-id": "abc", + }) + if err == nil { + t.Fatal("expected error for non-integer --comment-id") + } +}