feat(pr): add +comments, +comment-edit, +comment-delete

Completes the pull-journal comment lifecycle. The CLI could only
create PR comments (via the linked issue's journals); the dedicated
v1 pull journals endpoints also support list/update/destroy:

- pr +comments -i <num>: list a PR's journals
- pr +comment-edit -i <num> -c <journal-id> -b <text> [-s state]
- pr +comment-delete -i <num> -c <journal-id>

The server's UpdateService hard-requires state in
opened|resolved|disabled (omitting it fails with
'State不包含于列表中'), so +comment-edit always sends state,
defaulting to opened, with client-side validation of the enum.
--comment-id is integer-validated.

Production-verified full lifecycle on gitlink.org.cn:
create -> list -> edit (note updated) -> delete. 3 unit tests,
README examples, bilingual 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 14:43:26 +00:00
parent c09645da62
commit cf42b86f65
5 changed files with 157 additions and 0 deletions

View File

@ -420,6 +420,11 @@ gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42
# View changed files
gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42
# List, edit, and delete PR comments (pull journals)
gitlink-cli pr +comments --owner Gitlink --repo forgeplus -i 42
gitlink-cli pr +comment-edit --owner Gitlink --repo forgeplus -i 42 -c 484052 -b "Updated" -s resolved
gitlink-cli pr +comment-delete --owner Gitlink --repo forgeplus -i 42 -c 484052
# List PR patchset versions
gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42

View File

@ -54,6 +54,9 @@
"cmd.org.short": "Organization operations",
"cmd.pr.close.short": "Close a pull request",
"cmd.pr.comment.short": "Add a comment to a pull request",
"cmd.pr.comment_delete.short": "Delete a pull request comment",
"cmd.pr.comment_edit.short": "Edit a pull request comment",
"cmd.pr.comments.short": "List comments (journals) of a pull request",
"cmd.pr.create.short": "Create a pull request",
"cmd.pr.diff.short": "Show diff for a pull request",
"cmd.pr.files.short": "List changed files in a pull request",
@ -181,6 +184,8 @@
"flag.pr.assignee_id": "Assignee user ID",
"flag.pr.base": "Target branch",
"flag.pr.body": "PR description",
"flag.pr.comment_id": "Comment (journal) ID",
"flag.pr.comment_state": "Comment state: opened, resolved, or disabled (server requires one; default opened)",
"flag.pr.file": "Filter diff by file path",
"flag.pr.head": "Source branch",
"flag.pr.id": "PR number",

View File

@ -54,6 +54,9 @@
"cmd.org.short": "组织操作",
"cmd.pr.close.short": "关闭拉取请求",
"cmd.pr.comment.short": "给拉取请求添加评论",
"cmd.pr.comment_delete.short": "删除合并请求评论",
"cmd.pr.comment_edit.short": "编辑合并请求评论",
"cmd.pr.comments.short": "列出合并请求的评论journals",
"cmd.pr.create.short": "创建拉取请求",
"cmd.pr.diff.short": "显示拉取请求 diff",
"cmd.pr.files.short": "列出拉取请求中的变更文件",
@ -181,6 +184,8 @@
"flag.pr.assignee_id": "指派人用户 ID",
"flag.pr.base": "目标分支",
"flag.pr.body": "PR 描述",
"flag.pr.comment_id": "评论journalID",
"flag.pr.comment_state": "评论状态opened、resolved 或 disabled服务端必填默认 opened",
"flag.pr.file": "按文件路径筛选 diff",
"flag.pr.head": "源分支",
"flag.pr.id": "PR 编号",

View File

@ -3,6 +3,7 @@ package pr
import (
"fmt"
"net/url"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
@ -431,9 +432,93 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "comments",
Description: tr.T("cmd.pr.comments.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/journals", v1RepoPath(ctx), id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "comment-edit",
Description: tr.T("cmd.pr.comment_edit.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
{Name: "comment-id", Short: "c", Usage: tr.T("flag.pr.comment_id"), Required: true},
{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true},
{Name: "state", Short: "s", Usage: tr.T("flag.pr.comment_state"), Default: "opened"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
commentID, err := requireIntFlag(ctx, "comment-id")
if err != nil {
return err
}
body, _ := ctx.RequireArg("body")
state := ctx.Arg("state")
switch state {
case "opened", "resolved", "disabled":
default:
return fmt.Errorf("--state must be one of opened, resolved, disabled; got %q", state)
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/pulls/%s/journals/%s", v1RepoPath(ctx), id, commentID), map[string]interface{}{"note": body, "state": state})
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "comment-delete",
Description: tr.T("cmd.pr.comment_delete.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
{Name: "comment-id", Short: "c", Usage: tr.T("flag.pr.comment_id"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
commentID, err := requireIntFlag(ctx, "comment-id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/pulls/%s/journals/%s", v1RepoPath(ctx), id, commentID), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
func requireIntFlag(ctx *common.RuntimeContext, name string) (string, error) {
value, err := ctx.RequireArg(name)
if err != nil {
return "", err
}
if _, err := strconv.Atoi(value); err != nil {
return "", fmt.Errorf("--%s must be an integer, got %q", name, value)
}
return value, nil
}
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]

View File

@ -531,3 +531,60 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
func TestPRCommentsUsesPullJournalsEndpoint(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/pulls/382/journals.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{"journals": []interface{}{}, "total_count": float64(0)})
}))
defer server.Close()
if err := runPRShortcut(t, server, "comments", map[string]string{"id": "382"}); err != nil {
t.Fatalf("comments failed: %v", err)
}
}
func TestPRCommentEditSendsNoteAndState(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/pulls/382/journals/484052.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"id": float64(484052)})
}))
defer server.Close()
args := map[string]string{"id": "382", "comment-id": "484052", "body": "edited", "state": "resolved"}
if err := runPRShortcut(t, server, "comment-edit", args); err != nil {
t.Fatalf("comment-edit failed: %v", err)
}
assertEqual(t, payload["note"], "edited")
assertEqual(t, payload["state"], "resolved")
args["state"] = "bogus"
if err := runPRShortcut(t, server, "comment-edit", args); err == nil {
t.Fatal("expected error for invalid --state")
}
args["state"] = "opened"
args["comment-id"] = "abc"
if err := runPRShortcut(t, server, "comment-edit", args); err == nil {
t.Fatal("expected error for non-integer --comment-id")
}
}
func TestPRCommentDeleteUsesPullJournalsEndpoint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/pulls/382/journals/484052.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{"status": float64(0)})
}))
defer server.Close()
if err := runPRShortcut(t, server, "comment-delete", map[string]string{"id": "382", "comment-id": "484052"}); err != nil {
t.Fatalf("comment-delete failed: %v", err)
}
}