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>
This commit is contained in:
1os21ka23r9navae6mrro 2026-07-08 13:43:32 +00:00
parent c09645da62
commit 029622ff52
6 changed files with 202 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@ -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": "评论journalID可由 issue +comments 获取",
"flag.issue.comments.category": "筛选comment/operate默认全部",
"flag.issue.comments.keyword": "评论内容搜索关键词",
"flag.issue.label": "标签 ID",
"flag.issue.label_filter": "按已有标签筛选",
"flag.issue.milestone": "里程碑 ID",

View File

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

View File

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