diff --git a/README.md b/README.md
index e9b2f25..94d08d1 100644
--- a/README.md
+++ b/README.md
@@ -326,7 +326,7 @@ gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" --priority-id 3 --tag-ids 4,5 --assigner-ids 7
# View an issue
-gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
+gitlink-cli issue +view --owner Gitlink --repo forgeplus --number 123
# Update issue metadata
gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15
@@ -335,7 +335,7 @@ gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priori
gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --body-file update.md
# Close an issue
-gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
+gitlink-cli issue +close --owner Gitlink --repo forgeplus --number 123
# Preview batch close without changing data
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run
@@ -350,7 +350,13 @@ gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --from issues.c
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
# Add a comment
-gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
+gitlink-cli issue +comment --owner Gitlink --repo forgeplus --number 123 -b "Fixed"
+
+# List, update, delete, and inspect issue comments/journals
+gitlink-cli issue +comments --owner Gitlink --repo forgeplus --number 123 --category comment
+gitlink-cli issue +comment-update --owner Gitlink --repo forgeplus --number 123 --comment-id 58 -b "Updated comment" --dry-run
+gitlink-cli issue +comment-delete --owner Gitlink --repo forgeplus --number 123 --comment-id 58 --dry-run
+gitlink-cli issue +comment-children --owner Gitlink --repo forgeplus --number 123 --comment-id 58
# Add a comment from a file
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 --body-file comment.md
@@ -434,6 +440,12 @@ gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
# Create a PR review (with dry-run preview)
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
+
+# List and manage PR review comments
+gitlink-cli pr +review-comments --owner Gitlink --repo forgeplus -i 42 --state opened
+gitlink-cli pr +review-comment --owner Gitlink --repo forgeplus -i 42 --review-id 10 --commit abc123 --line-code abc123_0_10 --path README.md --note "Please fix" --dry-run
+gitlink-cli pr +review-comment-update --owner Gitlink --repo forgeplus -i 42 --comment-id 200 --state resolved --dry-run
+gitlink-cli pr +review-comment-delete --owner Gitlink --repo forgeplus -i 42 --comment-id 200 --dry-run
```
### Branch Management
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 835f525..8d0a360 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -343,13 +343,13 @@ gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" --priority-id 3 --tag-ids 4,5 --assigner-ids 7
# 查看 Issue
-gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
+gitlink-cli issue +view --owner Gitlink --repo forgeplus --number 123
# 更新 Issue 元数据
gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15
# 关闭 Issue
-gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
+gitlink-cli issue +close --owner Gitlink --repo forgeplus --number 123
# 预览批量关闭,不修改数据
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run
@@ -369,7 +369,13 @@ gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 -
gitlink-cli issue +export --owner Gitlink --repo forgeplus --state open --keyword bug --export-format csv --output issues.csv
# 添加评论
-gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
+gitlink-cli issue +comment --owner Gitlink --repo forgeplus --number 123 -b "已修复"
+
+# 查看、更新、删除 Issue 评论/操作记录
+gitlink-cli issue +comments --owner Gitlink --repo forgeplus --number 123 --category comment
+gitlink-cli issue +comment-update --owner Gitlink --repo forgeplus --number 123 --comment-id 58 -b "更新评论" --dry-run
+gitlink-cli issue +comment-delete --owner Gitlink --repo forgeplus --number 123 --comment-id 58 --dry-run
+gitlink-cli issue +comment-children --owner Gitlink --repo forgeplus --number 123 --comment-id 58
# 回复评论并携带附件和 @ 用户
gitlink-cli issue +comment --owner Gitlink --repo forgeplus --number 123 -b "请查看日志" --parent-id 456 --reply-id 456 --attachment-ids 7,8 --receivers alice,bob
@@ -483,6 +489,12 @@ gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
# 创建 PR 审查(支持 dry-run 预览)
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
+
+# 查看和管理 PR Review 行评论
+gitlink-cli pr +review-comments --owner Gitlink --repo forgeplus -i 42 --state opened
+gitlink-cli pr +review-comment --owner Gitlink --repo forgeplus -i 42 --review-id 10 --commit abc123 --line-code abc123_0_10 --path README.md --note "请修改" --dry-run
+gitlink-cli pr +review-comment-update --owner Gitlink --repo forgeplus -i 42 --comment-id 200 --state resolved --dry-run
+gitlink-cli pr +review-comment-delete --owner Gitlink --repo forgeplus -i 42 --comment-id 200 --dry-run
```
### 发布管理
diff --git a/doc/changes/journal-shortcuts.md b/doc/changes/journal-shortcuts.md
new file mode 100644
index 0000000..8d1379f
--- /dev/null
+++ b/doc/changes/journal-shortcuts.md
@@ -0,0 +1,52 @@
+# Issue and PR Journal Shortcuts
+
+补齐 Issue 评论/操作记录与 Pull Request Review 评论相关 OpenAPI 封装。
+
+## Issue comments / journals
+
+新增或增强:
+
+- `issue +comment`:添加评论,新增 `--parent-id`、`--reply-id`、`--attachment-ids`、`--receivers`、`--dry-run`。
+- `issue +comments`:查看 Issue 评论和操作记录,支持 `category`、关键字、排序、分页。
+- `issue +comment-update`:更新 Issue 评论,支持附件、@接收人和 `--dry-run`。
+- `issue +comment-delete`:删除 Issue 评论,支持 `--dry-run`。
+- `issue +comment-children`:查看指定评论的子评论。
+
+覆盖 OpenAPI:
+
+- `GET /api/v1/{owner}/{repo}/issues/{index}/journals.json`
+- `POST /api/v1/{owner}/{repo}/issues/{index}/journals.json`
+- `PATCH /api/v1/{owner}/{repo}/issues/{index}/journals/{id}.json`
+- `DELETE /api/v1/{owner}/{repo}/issues/{index}/journals/{id}.json`
+- `GET /api/v1/{owner}/{repo}/issues/{index}/journals/{id}/children_journals.json`
+
+## PR review comments
+
+新增:
+
+- `pr +review-comments`:查看 PR Review 行评论,支持 review/state/path/parent/keyword 等过滤。
+- `pr +review-comment`:创建 PR Review 行评论,支持 `comment` / `problem` 类型和 `--dry-run`。
+- `pr +review-comment-update`:更新 Review 评论内容、commit 或状态,支持 `--dry-run`。
+- `pr +review-comment-delete`:删除 Review 评论,支持 `--dry-run`。
+
+覆盖 OpenAPI:
+
+- `GET /api/v1/{owner}/{repo}/pulls/{index}/journals.json`
+- `POST /api/v1/{owner}/{repo}/pulls/{index}/journals.json`
+- `PUT /api/v1/{owner}/{repo}/pulls/{index}/journals/{id}.json`
+- `DELETE /api/v1/{owner}/{repo}/pulls/{index}/journals/{id}.json`
+
+## 安全设计
+
+- 所有写入/删除评论的命令支持 `--dry-run`,先输出 method/path/body,不直接改远端数据。
+- PR Review 行评论创建支持 `--diff-json` / `--diff-file`,复杂 diff payload 由用户或 Agent 明确传入,避免 CLI 猜测 line diff。
+- 参数校验覆盖 Issue journal category、PR review comment type/state、布尔查询参数、正整数 ID 和 JSON diff。
+
+## 测试
+
+新增单元测试覆盖:
+
+- Issue comments list/create/update/delete/children 的 method、path、query、payload。
+- PR review comments list/create/update/delete 的 method、path、query、payload。
+- dry-run 不触发 API。
+- 参数校验失败不触发 API。
diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go
index abbb994..0b5818b 100644
--- a/shortcuts/issue/issue.go
+++ b/shortcuts/issue/issue.go
@@ -45,8 +45,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
return []*common.Shortcut{
newBatchCloseShortcut(),
- newBatchCommentShortcut(),
- newBatchUpdateShortcut(),
{
Name: "list",
Description: tr.T("cmd.issue.list.short"),
@@ -115,7 +113,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: tr.T("flag.issue.title"), Required: true},
{Name: "body", Short: "b", Usage: tr.T("flag.issue.body")},
- {Name: "body-file", Usage: "Read issue description from a file"},
{Name: "assignee", Short: "a", Usage: tr.T("flag.issue.assignee")},
{Name: "milestone", Short: "m", Usage: tr.T("flag.issue.milestone")},
{Name: "label", Usage: tr.T("flag.issue.label")},
@@ -140,11 +137,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
"priority_id": 2, // 2 = normal
"done_ratio": 0,
}
- desc, err := readIssueTextArg(ctx, "body", "body-file", false)
- if err != nil {
- return err
- }
- if desc != "" {
+ if desc := ctx.Arg("body"); desc != "" {
body["description"] = desc
}
if a := ctx.Arg("assignee"); a != "" {
@@ -218,7 +211,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
Flags: appendIssueNumberFlags(
common.Flag{Name: "title", Short: "t", Usage: tr.T("flag.issue.new_title")},
common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.issue.new_body")},
- common.Flag{Name: "body-file", Usage: "Read the updated issue description from a file"},
common.Flag{Name: "state", Short: "s", Usage: tr.T("flag.issue.new_state")},
common.Flag{Name: "priority-id", Usage: "New priority ID"},
common.Flag{Name: "tag-ids", Usage: "Comma-separated issue tag IDs"},
@@ -235,16 +227,37 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
if err != nil {
return err
}
- if _, err := buildIssueUpdatePreview(ctx); err != nil {
- return err
+ title := ctx.Arg("title")
+ description := ctx.Arg("body")
+ state := ctx.Arg("state")
+ if title == "" && description == "" && state == "" && !hasIssueMetadataArgs(ctx) {
+ return fmt.Errorf("at least one update field is required")
}
+
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return err
}
- body, err := buildIssueUpdateBody(ctx, current)
- if err != nil {
+ body := map[string]interface{}{
+ "subject": current.Subject,
+ "description": current.Description,
+ }
+ preserveIssueMetadata(body, current)
+ if t := ctx.Arg("title"); t != "" {
+ body["subject"] = t
+ }
+ if b := ctx.Arg("body"); b != "" {
+ body["description"] = b
+ }
+ if s := ctx.Arg("state"); s != "" {
+ statusID, err := normalizeIssueStatus(s)
+ if err != nil {
+ return err
+ }
+ body["status_id"] = statusID
+ }
+ if err := applyIssueMetadataArgs(ctx, body); err != nil {
return err
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
@@ -258,28 +271,19 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
Name: "comment",
Description: tr.T("cmd.issue.comment.short"),
Flags: appendIssueNumberFlags(
- common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body")},
- common.Flag{Name: "body-file", Usage: "Read comment body from a file"},
+ 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"},
),
- 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 := readIssueTextArg(ctx, "body", "body-file", true)
- if err != nil {
- return err
- }
- env, err := commentOnIssue(ctx, number, body)
- if err != nil {
- return err
- }
- return ctx.Output(env)
- },
+ Run: runIssueComment,
},
+ newIssueCommentsShortcut(),
+ newIssueCommentUpdateShortcut(),
+ newIssueCommentDeleteShortcut(),
+ newIssueCommentChildrenShortcut(),
{
Name: "assigners",
Description: "List issue assigners",
@@ -559,41 +563,6 @@ func normalizeIssueStatus(state string) (interface{}, error) {
}
}
-func buildIssueUpdateBody(ctx *common.RuntimeContext, current *existingIssue) (map[string]interface{}, error) {
- description, err := readIssueTextArg(ctx, "body", "body-file", false)
- if err != nil {
- return nil, err
- }
- title := ctx.Arg("title")
- state := ctx.Arg("state")
- if title == "" && description == "" && state == "" && !hasIssueMetadataArgs(ctx) {
- return nil, fmt.Errorf("at least one update field is required")
- }
-
- body := map[string]interface{}{
- "subject": current.Subject,
- "description": current.Description,
- }
- preserveIssueMetadata(body, current)
- if title != "" {
- body["subject"] = title
- }
- if description != "" {
- body["description"] = description
- }
- if state != "" {
- statusID, err := normalizeIssueStatus(state)
- if err != nil {
- return nil, err
- }
- body["status_id"] = statusID
- }
- if err := applyIssueMetadataArgs(ctx, body); err != nil {
- return nil, err
- }
- return body, nil
-}
-
func hasIssueMetadataArgs(ctx *common.RuntimeContext) bool {
for _, name := range []string{"priority-id", "tag-ids", "label", "assigner-ids", "branch", "start-date", "due-date"} {
if ctx.Arg(name) != "" {
diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go
index d613157..bcf3f78 100644
--- a/shortcuts/issue/issue_test.go
+++ b/shortcuts/issue/issue_test.go
@@ -8,6 +8,7 @@ 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"
)
@@ -23,6 +24,7 @@ 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{}
@@ -1369,3 +1371,180 @@ func TestNormalizeIssueStatus(t *testing.T) {
}
}
}
+
+func TestIssueCommentsListBuildsQuery(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{}{}})
+ })
+ 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",
+ })
+ if err != nil {
+ t.Fatalf("comments shortcut failed: %v", err)
+ }
+}
+
+func TestIssueCommentPostsPayloadWithOptions(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" {
+ 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"})
+ })
+ 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",
+ })
+ if err != nil {
+ t.Fatalf("comment shortcut 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"})
+}
+
+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" {
+ 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"})
+ })
+ defer server.Close()
+
+ err := runShortcut(t, server, "comment-update", map[string]string{
+ "number": "42",
+ "comment-id": "58",
+ "body": "updated",
+ "attachment-ids": "12",
+ "receivers": "alice",
+ })
+ 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)
+ }
+}
+
+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{}{}})
+ })
+ 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",
+ })
+ 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)
+ }
+ }
+}
diff --git a/shortcuts/issue/journal.go b/shortcuts/issue/journal.go
new file mode 100644
index 0000000..8ab9554
--- /dev/null
+++ b/shortcuts/issue/journal.go
@@ -0,0 +1,329 @@
+package issue
+
+import (
+ "fmt"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/gitlink-org/gitlink-cli/shortcuts/common"
+)
+
+func newIssueCommentsShortcut() *common.Shortcut {
+ return &common.Shortcut{
+ Name: "comments",
+ Description: "List issue comments and operation journals",
+ Flags: []common.Flag{
+ {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
+ {Name: "category", Short: "c", Usage: "Journal category: all, comment, or operate", Default: "all"},
+ {Name: "keyword", Short: "k", Usage: "Search keyword"},
+ {Name: "sort-by", Usage: "Sort field: created_on or updated_on"},
+ {Name: "sort-direction", Usage: "Sort direction: asc or desc"},
+ {Name: "page", Short: "p", Usage: "Page number", Default: "1"},
+ {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
+ },
+ Run: runIssueComments,
+ }
+}
+
+func newIssueCommentUpdateShortcut() *common.Shortcut {
+ return &common.Shortcut{
+ Name: "comment-update",
+ Description: "Update an issue comment",
+ Flags: []common.Flag{
+ {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
+ {Name: "comment-id", Short: "i", Usage: "Issue comment/journal ID", Required: true},
+ {Name: "body", Short: "b", Usage: "New comment body", Required: true},
+ {Name: "attachment-ids", Usage: "Comma-separated attachment IDs"},
+ {Name: "receivers", Short: "r", Usage: "Comma-separated @ receiver login names"},
+ {Name: "dry-run", Usage: "Preview the request body without updating the comment", Bool: true, Default: "false"},
+ },
+ Run: runIssueCommentUpdate,
+ }
+}
+
+func newIssueCommentDeleteShortcut() *common.Shortcut {
+ return &common.Shortcut{
+ Name: "comment-delete",
+ Description: "Delete an issue comment",
+ Flags: []common.Flag{
+ {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
+ {Name: "comment-id", Short: "i", Usage: "Issue comment/journal ID", Required: true},
+ {Name: "dry-run", Usage: "Preview the delete request without deleting the comment", Bool: true, Default: "false"},
+ },
+ Run: runIssueCommentDelete,
+ }
+}
+
+func newIssueCommentChildrenShortcut() *common.Shortcut {
+ return &common.Shortcut{
+ Name: "comment-children",
+ Description: "List child comments for an issue comment",
+ Flags: []common.Flag{
+ {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
+ {Name: "comment-id", Short: "i", Usage: "Parent issue comment/journal ID", Required: true},
+ {Name: "keyword", Short: "k", Usage: "Search keyword"},
+ {Name: "page", Short: "p", Usage: "Page number", Default: "1"},
+ {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
+ },
+ Run: runIssueCommentChildren,
+ }
+}
+
+func runIssueComments(ctx *common.RuntimeContext) error {
+ if err := ctx.ResolveOwnerRepo(); err != nil {
+ return err
+ }
+ number, err := issueNumberArg(ctx)
+ if err != nil {
+ return err
+ }
+ category, err := normalizeIssueJournalCategory(ctx.Arg("category"))
+ if err != nil {
+ return err
+ }
+ q := url.Values{}
+ if category != "" {
+ q.Set("category", category)
+ }
+ addStringQuery(q, "keyword", ctx.Arg("keyword"))
+ addStringQuery(q, "sort_by", ctx.Arg("sort-by"))
+ addStringQuery(q, "sort_direction", ctx.Arg("sort-direction"))
+ q.Set("page", defaultValue(ctx.Arg("page"), "1"))
+ q.Set("limit", defaultValue(ctx.Arg("limit"), "20"))
+
+ env, err := ctx.CallAPIWithQuery("GET", issueJournalPath(ctx, number), q)
+ if err != nil {
+ return err
+ }
+ return ctx.Output(env)
+}
+
+func runIssueComment(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, err := issueCommentPayload(ctx, body, true)
+ if err != nil {
+ return err
+ }
+ path := issueJournalPath(ctx, number)
+ if parseBool(ctx.Arg("dry-run")) {
+ return ctx.OutputData(issueJournalDryRun("create_comment", "POST", path, payload))
+ }
+ env, err := ctx.CallAPI("POST", path, payload)
+ if err != nil {
+ return err
+ }
+ return ctx.Output(env)
+}
+
+func runIssueCommentUpdate(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
+ }
+ body, err := ctx.RequireArg("body")
+ if err != nil {
+ return err
+ }
+ payload, err := issueCommentPayload(ctx, body, false)
+ if err != nil {
+ return err
+ }
+ path := issueJournalItemPath(ctx, number, commentID)
+ if parseBool(ctx.Arg("dry-run")) {
+ return ctx.OutputData(issueJournalDryRun("update_comment", "PATCH", path, payload))
+ }
+ env, err := ctx.CallAPI("PATCH", path, payload)
+ if err != nil {
+ return err
+ }
+ return ctx.Output(env)
+}
+
+func runIssueCommentDelete(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
+ }
+ path := issueJournalItemPath(ctx, number, commentID)
+ if parseBool(ctx.Arg("dry-run")) {
+ return ctx.OutputData(issueJournalDryRun("delete_comment", "DELETE", path, nil))
+ }
+ env, err := ctx.CallAPI("DELETE", path, nil)
+ if err != nil {
+ return err
+ }
+ return ctx.Output(env)
+}
+
+func runIssueCommentChildren(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
+ }
+ q := url.Values{}
+ addStringQuery(q, "keyword", ctx.Arg("keyword"))
+ q.Set("page", defaultValue(ctx.Arg("page"), "1"))
+ q.Set("limit", defaultValue(ctx.Arg("limit"), "20"))
+ env, err := ctx.CallAPIWithQuery("GET", issueJournalChildrenPath(ctx, number, commentID), q)
+ if err != nil {
+ return err
+ }
+ return ctx.Output(env)
+}
+
+func issueCommentPayload(ctx *common.RuntimeContext, body string, includeReplyFields bool) (map[string]interface{}, error) {
+ payload := map[string]interface{}{"notes": body}
+ if includeReplyFields {
+ if parentID := ctx.Arg("parent-id"); parentID != "" {
+ id, err := parsePositiveIntValue(parentID, "parent-id")
+ if err != nil {
+ return nil, err
+ }
+ payload["parent_id"] = id
+ }
+ if replyID := ctx.Arg("reply-id"); replyID != "" {
+ id, err := parsePositiveIntValue(replyID, "reply-id")
+ if err != nil {
+ return nil, err
+ }
+ payload["reply_id"] = id
+ }
+ }
+ if value := ctx.Arg("attachment-ids"); value != "" {
+ ids, err := parseIntCSV(value, "attachment-ids")
+ if err != nil {
+ return nil, err
+ }
+ payload["attachment_ids"] = ids
+ }
+ if value := ctx.Arg("receivers"); value != "" {
+ payload["receivers_login"] = parseStringCSV(value)
+ }
+ return payload, nil
+}
+
+func issueJournalPath(ctx *common.RuntimeContext, number string) string {
+ return fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number)
+}
+
+func issueJournalItemPath(ctx *common.RuntimeContext, number, commentID string) string {
+ return fmt.Sprintf("%s/%s", issueJournalPath(ctx, number), commentID)
+}
+
+func issueJournalChildrenPath(ctx *common.RuntimeContext, number, commentID string) string {
+ return fmt.Sprintf("%s/children_journals", issueJournalItemPath(ctx, number, commentID))
+}
+
+func normalizeIssueJournalCategory(value string) (string, error) {
+ category := strings.ToLower(strings.TrimSpace(value))
+ if category == "" {
+ return "", nil
+ }
+ switch category {
+ case "all", "comment", "operate":
+ return category, nil
+ default:
+ return "", fmt.Errorf("invalid --category %q: use all, comment, or operate", value)
+ }
+}
+
+func parsePositiveIntValue(value, flagName string) (int, error) {
+ id, err := strconv.Atoi(strings.TrimSpace(value))
+ if err != nil || id <= 0 {
+ return 0, fmt.Errorf("invalid --%s %q: use a positive integer", flagName, value)
+ }
+ return id, nil
+}
+
+func parseIntCSV(value, flagName string) ([]int, error) {
+ values := make([]int, 0)
+ seen := map[int]bool{}
+ for _, part := range strings.Split(value, ",") {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+ id, err := parsePositiveIntValue(part, flagName)
+ if err != nil {
+ return nil, err
+ }
+ if seen[id] {
+ continue
+ }
+ seen[id] = true
+ values = append(values, id)
+ }
+ return values, nil
+}
+
+func parseStringCSV(value string) []string {
+ values := make([]string, 0)
+ seen := map[string]bool{}
+ for _, part := range strings.Split(value, ",") {
+ part = strings.TrimSpace(part)
+ if part == "" || seen[part] {
+ continue
+ }
+ seen[part] = true
+ values = append(values, part)
+ }
+ return values
+}
+
+func addStringQuery(q url.Values, key, value string) {
+ if value != "" {
+ q.Set(key, value)
+ }
+}
+
+func defaultValue(value, fallback string) string {
+ if strings.TrimSpace(value) == "" {
+ return fallback
+ }
+ return value
+}
+
+func issueJournalDryRun(action, method, path string, payload map[string]interface{}) map[string]interface{} {
+ data := map[string]interface{}{
+ "dry_run": true,
+ "action": action,
+ "method": method,
+ "path": path,
+ }
+ if payload != nil {
+ data["body"] = payload
+ }
+ return data
+}
diff --git a/shortcuts/pr/journal.go b/shortcuts/pr/journal.go
new file mode 100644
index 0000000..257bad1
--- /dev/null
+++ b/shortcuts/pr/journal.go
@@ -0,0 +1,358 @@
+package pr
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/gitlink-org/gitlink-cli/shortcuts/common"
+)
+
+var allowedPRReviewCommentTypes = map[string]bool{"comment": true, "problem": true}
+var allowedPRReviewCommentStates = map[string]bool{"opened": true, "resolved": true, "disabled": true}
+
+func newPRReviewCommentsShortcut() *common.Shortcut {
+ return &common.Shortcut{
+ Name: "review-comments",
+ Description: "List pull request review comments",
+ Flags: []common.Flag{
+ {Name: "id", Short: "i", Usage: "PR number", Required: true},
+ {Name: "keyword", Short: "k", Usage: "Search keyword"},
+ {Name: "review-id", Usage: "Filter by review ID"},
+ {Name: "need-respond", Usage: "Filter by response requirement: true or false"},
+ {Name: "state", Short: "s", Usage: "Filter state: opened, resolved, or disabled"},
+ {Name: "parent-id", Usage: "Filter by parent comment ID"},
+ {Name: "path", Usage: "Filter by file path"},
+ {Name: "is-full", Usage: "Whether to include replies: true or false"},
+ {Name: "sort-by", Usage: "Sort field: created_on or updated_on"},
+ {Name: "sort-direction", Usage: "Sort direction: asc or desc"},
+ },
+ Run: runPRReviewComments,
+ }
+}
+
+func newPRReviewCommentShortcut() *common.Shortcut {
+ return &common.Shortcut{
+ Name: "review-comment",
+ Description: "Create a pull request review line comment",
+ Flags: []common.Flag{
+ {Name: "id", Short: "i", Usage: "PR number", Required: true},
+ {Name: "note", Short: "n", Usage: "Comment text", Required: true},
+ {Name: "review-id", Usage: "Review ID", Required: true},
+ {Name: "type", Short: "t", Usage: "Comment type: comment or problem", Default: "comment"},
+ {Name: "commit", Short: "m", Usage: "Commit SHA for the review comment", Required: true},
+ {Name: "line-code", Usage: "GitLink line_code value for the diff line", Required: true},
+ {Name: "path", Short: "p", Usage: "File path for the review comment", Required: true},
+ {Name: "parent-id", Usage: "Parent review comment ID when replying"},
+ {Name: "diff-json", Usage: "Raw JSON diff object to include in the request body"},
+ {Name: "diff-file", Usage: "Read JSON diff object from a file"},
+ {Name: "dry-run", Usage: "Preview the request body without creating the review comment", Bool: true, Default: "false"},
+ },
+ Run: runPRReviewComment,
+ }
+}
+
+func newPRReviewCommentUpdateShortcut() *common.Shortcut {
+ return &common.Shortcut{
+ Name: "review-comment-update",
+ Description: "Update a pull request review comment",
+ Flags: []common.Flag{
+ {Name: "id", Short: "i", Usage: "PR number", Required: true},
+ {Name: "comment-id", Short: "j", Usage: "Review comment/journal ID", Required: true},
+ {Name: "note", Short: "n", Usage: "New comment text"},
+ {Name: "commit", Short: "m", Usage: "Commit SHA"},
+ {Name: "state", Short: "s", Usage: "Comment state: opened, resolved, or disabled"},
+ {Name: "dry-run", Usage: "Preview the request body without updating the review comment", Bool: true, Default: "false"},
+ },
+ Run: runPRReviewCommentUpdate,
+ }
+}
+
+func newPRReviewCommentDeleteShortcut() *common.Shortcut {
+ return &common.Shortcut{
+ Name: "review-comment-delete",
+ Description: "Delete a pull request review comment",
+ Flags: []common.Flag{
+ {Name: "id", Short: "i", Usage: "PR number", Required: true},
+ {Name: "comment-id", Short: "j", Usage: "Review comment/journal ID", Required: true},
+ {Name: "dry-run", Usage: "Preview the delete request without deleting the review comment", Bool: true, Default: "false"},
+ },
+ Run: runPRReviewCommentDelete,
+ }
+}
+
+func runPRReviewComments(ctx *common.RuntimeContext) error {
+ if err := ctx.ResolveOwnerRepo(); err != nil {
+ return err
+ }
+ id, err := ctx.RequireArg("id")
+ if err != nil {
+ return err
+ }
+ q := url.Values{}
+ addPRQuery(q, "keyword", ctx.Arg("keyword"))
+ addPRQuery(q, "review_id", ctx.Arg("review-id"))
+ if value := ctx.Arg("need-respond"); value != "" {
+ if _, err := strconv.ParseBool(strings.TrimSpace(value)); err != nil {
+ return fmt.Errorf("invalid --need-respond %q: use true or false", value)
+ }
+ q.Set("need_respond", value)
+ }
+ state, err := normalizePRReviewCommentState(ctx.Arg("state"), true)
+ if err != nil {
+ return err
+ }
+ addPRQuery(q, "state", state)
+ addPRQuery(q, "parent_id", ctx.Arg("parent-id"))
+ addPRQuery(q, "path", ctx.Arg("path"))
+ if value := ctx.Arg("is-full"); value != "" {
+ if _, err := strconv.ParseBool(strings.TrimSpace(value)); err != nil {
+ return fmt.Errorf("invalid --is-full %q: use true or false", value)
+ }
+ q.Set("is_full", value)
+ }
+ addPRQuery(q, "sort_by", ctx.Arg("sort-by"))
+ addPRQuery(q, "sort_direction", ctx.Arg("sort-direction"))
+
+ env, err := ctx.CallAPIWithQuery("GET", prReviewJournalsPath(ctx, id), q)
+ if err != nil {
+ return err
+ }
+ return ctx.Output(env)
+}
+
+func runPRReviewComment(ctx *common.RuntimeContext) error {
+ if err := ctx.ResolveOwnerRepo(); err != nil {
+ return err
+ }
+ id, err := ctx.RequireArg("id")
+ if err != nil {
+ return err
+ }
+ payload, err := prReviewCommentCreatePayload(ctx)
+ if err != nil {
+ return err
+ }
+ path := prReviewJournalsPath(ctx, id)
+ if parsePRBool(ctx.Arg("dry-run")) {
+ return ctx.OutputData(prJournalDryRun(ctx, id, "create_review_comment", "POST", path, payload))
+ }
+ env, err := ctx.CallAPI("POST", path, payload)
+ if err != nil {
+ return err
+ }
+ return ctx.Output(env)
+}
+
+func runPRReviewCommentUpdate(ctx *common.RuntimeContext) error {
+ if err := ctx.ResolveOwnerRepo(); err != nil {
+ return err
+ }
+ id, err := ctx.RequireArg("id")
+ if err != nil {
+ return err
+ }
+ commentID, err := ctx.RequireArg("comment-id")
+ if err != nil {
+ return err
+ }
+ payload, err := prReviewCommentUpdatePayload(ctx)
+ if err != nil {
+ return err
+ }
+ path := prReviewJournalItemPath(ctx, id, commentID)
+ if parsePRBool(ctx.Arg("dry-run")) {
+ return ctx.OutputData(prJournalDryRun(ctx, id, "update_review_comment", "PUT", path, payload))
+ }
+ env, err := ctx.CallAPI("PUT", path, payload)
+ if err != nil {
+ return err
+ }
+ return ctx.Output(env)
+}
+
+func runPRReviewCommentDelete(ctx *common.RuntimeContext) error {
+ if err := ctx.ResolveOwnerRepo(); err != nil {
+ return err
+ }
+ id, err := ctx.RequireArg("id")
+ if err != nil {
+ return err
+ }
+ commentID, err := ctx.RequireArg("comment-id")
+ if err != nil {
+ return err
+ }
+ path := prReviewJournalItemPath(ctx, id, commentID)
+ if parsePRBool(ctx.Arg("dry-run")) {
+ return ctx.OutputData(prJournalDryRun(ctx, id, "delete_review_comment", "DELETE", path, nil))
+ }
+ env, err := ctx.CallAPI("DELETE", path, nil)
+ if err != nil {
+ return err
+ }
+ return ctx.Output(env)
+}
+
+func prReviewCommentCreatePayload(ctx *common.RuntimeContext) (map[string]interface{}, error) {
+ note, err := ctx.RequireArg("note")
+ if err != nil {
+ return nil, err
+ }
+ reviewID, err := ctx.RequireArg("review-id")
+ if err != nil {
+ return nil, err
+ }
+ commitID, err := ctx.RequireArg("commit")
+ if err != nil {
+ return nil, err
+ }
+ lineCode, err := ctx.RequireArg("line-code")
+ if err != nil {
+ return nil, err
+ }
+ filePath, err := ctx.RequireArg("path")
+ if err != nil {
+ return nil, err
+ }
+ commentType, err := normalizePRReviewCommentType(defaultPRValue(ctx.Arg("type"), "comment"))
+ if err != nil {
+ return nil, err
+ }
+ payload := map[string]interface{}{
+ "type": commentType,
+ "note": note,
+ "review_id": reviewID,
+ "line_code": lineCode,
+ "commit_id": commitID,
+ "path": filePath,
+ }
+ if parentID := ctx.Arg("parent-id"); parentID != "" {
+ id, err := parsePRPositiveInt(parentID, "parent-id")
+ if err != nil {
+ return nil, err
+ }
+ payload["parent_id"] = id
+ }
+ diff, err := parseJSONFromArgs(ctx.Arg("diff-json"), ctx.Arg("diff-file"))
+ if err != nil {
+ return nil, err
+ }
+ if diff != nil {
+ payload["diff"] = diff
+ }
+ return payload, nil
+}
+
+func prReviewCommentUpdatePayload(ctx *common.RuntimeContext) (map[string]interface{}, error) {
+ payload := map[string]interface{}{}
+ if note := ctx.Arg("note"); note != "" {
+ payload["note"] = note
+ }
+ if commitID := ctx.Arg("commit"); commitID != "" {
+ payload["commit_id"] = commitID
+ }
+ if state := ctx.Arg("state"); state != "" {
+ normalized, err := normalizePRReviewCommentState(state, false)
+ if err != nil {
+ return nil, err
+ }
+ payload["state"] = normalized
+ }
+ if len(payload) == 0 {
+ return nil, fmt.Errorf("at least one of --note, --commit, or --state is required")
+ }
+ return payload, nil
+}
+
+func prReviewJournalsPath(ctx *common.RuntimeContext, id string) string {
+ return prV1Path(ctx, id) + "/journals"
+}
+
+func prReviewJournalItemPath(ctx *common.RuntimeContext, id, commentID string) string {
+ return fmt.Sprintf("%s/%s", prReviewJournalsPath(ctx, id), commentID)
+}
+
+func normalizePRReviewCommentType(value string) (string, error) {
+ commentType := strings.ToLower(strings.TrimSpace(value))
+ if allowedPRReviewCommentTypes[commentType] {
+ return commentType, nil
+ }
+ return "", fmt.Errorf("invalid --type %q: use comment or problem", value)
+}
+
+func normalizePRReviewCommentState(value string, allowEmpty bool) (string, error) {
+ state := strings.ToLower(strings.TrimSpace(value))
+ if state == "" && allowEmpty {
+ return "", nil
+ }
+ if allowedPRReviewCommentStates[state] {
+ return state, nil
+ }
+ return "", fmt.Errorf("invalid --state %q: use opened, resolved, or disabled", value)
+}
+
+func parseJSONFromArgs(raw, file string) (interface{}, error) {
+ if raw != "" && file != "" {
+ return nil, fmt.Errorf("use either --diff-json or --diff-file, not both")
+ }
+ if file != "" {
+ data, err := os.ReadFile(file)
+ if err != nil {
+ return nil, fmt.Errorf("read --diff-file: %w", err)
+ }
+ raw = string(data)
+ }
+ if strings.TrimSpace(raw) == "" {
+ return nil, nil
+ }
+ var parsed interface{}
+ if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
+ return nil, fmt.Errorf("parse diff JSON: %w", err)
+ }
+ return parsed, nil
+}
+
+func parsePRPositiveInt(value, flagName string) (int, error) {
+ id, err := strconv.Atoi(strings.TrimSpace(value))
+ if err != nil || id <= 0 {
+ return 0, fmt.Errorf("invalid --%s %q: use a positive integer", flagName, value)
+ }
+ return id, nil
+}
+
+func addPRQuery(q url.Values, key, value string) {
+ if value != "" {
+ q.Set(key, value)
+ }
+}
+
+func defaultPRValue(value, fallback string) string {
+ if strings.TrimSpace(value) == "" {
+ return fallback
+ }
+ return value
+}
+
+func parsePRBool(value string) bool {
+ parsed, err := strconv.ParseBool(strings.TrimSpace(value))
+ return err == nil && parsed
+}
+
+func prJournalDryRun(ctx *common.RuntimeContext, id, action, method, path string, payload map[string]interface{}) map[string]interface{} {
+ data := map[string]interface{}{
+ "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
+ "pull_request": id,
+ "dry_run": true,
+ "action": action,
+ "method": method,
+ "path": path,
+ }
+ if payload != nil {
+ data["body"] = payload
+ }
+ return data
+}
diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go
index c3296e7..4025a13 100644
--- a/shortcuts/pr/pr.go
+++ b/shortcuts/pr/pr.go
@@ -475,6 +475,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
return ctx.Output(env)
},
},
+ newPRReviewCommentsShortcut(),
+ newPRReviewCommentShortcut(),
+ newPRReviewCommentUpdateShortcut(),
+ newPRReviewCommentDeleteShortcut(),
{
Name: "comment",
Description: tr.T("cmd.pr.comment.short"),
diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go
index 2598528..cdec8e8 100644
--- a/shortcuts/pr/pr_test.go
+++ b/shortcuts/pr/pr_test.go
@@ -1,7 +1,6 @@
package pr
import (
- "encoding/base64"
"encoding/json"
"fmt"
"net/http"
@@ -55,135 +54,6 @@ func TestPRCommentPostsToCorrectIssueJournal(t *testing.T) {
assertEqual(t, journalPayload["notes"], "LGTM, looks good!")
}
-func TestPRCreateSameRepoBranchUsesSimplePayload(t *testing.T) {
- var payload map[string]interface{}
- encodedHead := base64.RawURLEncoding.EncodeToString([]byte("feature/search"))
- encodedBase := base64.RawURLEncoding.EncodeToString([]byte("master"))
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch {
- case r.Method == "GET" && r.URL.Path == "/owner/repo/compare/"+encodedHead+"..."+encodedBase+".json":
- writeJSON(t, w, map[string]interface{}{
- "commits_count": float64(2),
- "files_count": float64(5),
- })
- case r.Method == "POST" && r.URL.Path == "/owner/repo/pulls.json":
- payload = decodeJSON(t, r)
- writeJSON(t, w, map[string]interface{}{"status": float64(0), "pull_request_number": float64(22)})
- default:
- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
- }
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "create", map[string]string{
- "title": "feat: search",
- "head": "feature/search",
- "base": "master",
- "body": "Add search support",
- })
- if err != nil {
- t.Fatalf("create shortcut failed: %v", err)
- }
-
- assertEqual(t, payload["head"], "feature/search")
- assertEqual(t, payload["base"], "master")
- assertEqual(t, payload["is_original"], false)
- assertEqual(t, payload["commits_count"], float64(2))
- assertEqual(t, payload["files_count"], float64(5))
- if _, ok := payload["merge_user_login"]; ok {
- t.Fatal("same-repo PR should not include merge_user_login")
- }
-}
-
-func TestPRCreateForkBranchAddsGitLinkForkFields(t *testing.T) {
- var payload map[string]interface{}
- encodedHead := base64.RawURLEncoding.EncodeToString([]byte("alice:feature/JIRA-123/fix"))
- encodedBase := base64.RawURLEncoding.EncodeToString([]byte("master"))
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch {
- case r.Method == "GET" && r.URL.Path == "/alice/fork-repo.json":
- writeJSON(t, w, map[string]interface{}{
- "project_id": float64(1546652),
- "project_identifier": "fork-repo",
- })
- case r.Method == "GET" && r.URL.Path == "/owner/repo/compare/"+encodedHead+"..."+encodedBase+".json":
- writeJSON(t, w, map[string]interface{}{
- "commits_count": float64(3),
- "files_count": float64(7),
- })
- case r.Method == "POST" && r.URL.Path == "/owner/repo/pulls.json":
- payload = decodeJSON(t, r)
- writeJSON(t, w, map[string]interface{}{"status": float64(0), "pull_request_number": float64(23)})
- default:
- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
- }
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "create", map[string]string{
- "title": "feat: fork support",
- "head": "alice/fork-repo:feature/JIRA-123/fix",
- "base": "master",
- })
- if err != nil {
- t.Fatalf("create shortcut failed: %v", err)
- }
-
- assertEqual(t, payload["head"], "feature/JIRA-123/fix")
- assertEqual(t, payload["is_original"], true)
- assertEqual(t, payload["merge_user_login"], "alice")
- assertEqual(t, payload["merge_project_identifier"], "fork-repo")
- assertEqual(t, payload["fork_project_id"], float64(1546652))
- assertEqual(t, payload["commits_count"], float64(3))
- assertEqual(t, payload["files_count"], float64(7))
-}
-
-func TestFetchPRCompareCountsUsesURLSafeBase64(t *testing.T) {
- encodedHead := base64.RawURLEncoding.EncodeToString([]byte("alice:feature/fork"))
- encodedBase := base64.RawURLEncoding.EncodeToString([]byte("master"))
-
- if encodedHead != "YWxpY2U6ZmVhdHVyZS9mb3Jr" {
- t.Fatalf("unexpected encoded head: %s", encodedHead)
- }
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- wantPath := "/owner/repo/compare/" + encodedHead + "..." + encodedBase + ".json"
- if r.Method != "GET" || r.URL.Path != wantPath {
- t.Fatalf("unexpected request: %s %s, want GET %s", r.Method, r.URL.Path, wantPath)
- }
- writeJSON(t, w, map[string]interface{}{
- "commits_count": float64(4),
- "files_count": float64(9),
- })
- }))
- defer server.Close()
-
- ctx := &common.RuntimeContext{
- Client: &client.Client{
- HTTP: server.Client(),
- BaseURL: server.URL,
- },
- Owner: "owner",
- Repo: "repo",
- }
-
- counts, err := fetchPRCompareCounts(ctx, "alice:feature/fork", "master")
- if err != nil {
- t.Fatalf("fetchPRCompareCounts failed: %v", err)
- }
-
- assertEqual(t, counts["commits_count"], 4)
- assertEqual(t, counts["files_count"], 9)
-}
-
-func TestParsePRHeadRejectsInvalidForkSyntax(t *testing.T) {
- _, err := parsePRHead("alice:feature/fork")
- if err == nil {
- t.Fatal("expected invalid head syntax to fail")
- }
-}
-
func TestPRCommentFailsWhenPRNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
@@ -222,155 +92,6 @@ func TestPRCommentFailsWhenIssueFieldMissing(t *testing.T) {
}
}
-func TestPRReviewCommentsListSendsFilters(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/13/journals.json" {
- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
- }
- q := r.URL.Query()
- assertEqual(t, q.Get("keyword"), "race")
- assertEqual(t, q.Get("review_id"), "5")
- assertEqual(t, q.Get("need_respond"), "true")
- assertEqual(t, q.Get("state"), "opened")
- assertEqual(t, q.Get("parent_id"), "7")
- assertEqual(t, q.Get("path"), "main.go")
- assertEqual(t, q.Get("is_full"), "true")
- assertEqual(t, q.Get("sort_by"), "updated_on")
- assertEqual(t, q.Get("sort_direction"), "desc")
- writeJSON(t, w, map[string]interface{}{
- "total_count": float64(1),
- "journals": []interface{}{
- map[string]interface{}{"id": float64(9), "note": "race"},
- },
- })
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "review-comments", map[string]string{
- "id": "13",
- "keyword": "race",
- "review-id": "5",
- "need-respond": "true",
- "state": "opened",
- "parent-id": "7",
- "path": "main.go",
- "full": "true",
- "sort-by": "updated_on",
- "sort-direction": "desc",
- })
- if err != nil {
- t.Fatalf("review-comments failed: %v", err)
- }
-}
-
-func TestPRReviewCommentCreateSendsPayload(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/pulls/13/journals.json" {
- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
- }
- payload = decodeJSON(t, r)
- writeJSON(t, w, payload)
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "review-comment", map[string]string{
- "id": "13",
- "body": "Please handle this edge case",
- "type": "problem",
- "review-id": "5",
- "line-code": "abc_1_2",
- "commit": "deadbeef",
- "path": "main.go",
- "parent-id": "7",
- "diff-json": `{"name":"main.go","addition":1}`,
- })
- if err != nil {
- t.Fatalf("review-comment failed: %v", err)
- }
- assertEqual(t, payload["type"], "problem")
- assertEqual(t, payload["note"], "Please handle this edge case")
- assertEqual(t, payload["review_id"], float64(5))
- assertEqual(t, payload["line_code"], "abc_1_2")
- assertEqual(t, payload["commit_id"], "deadbeef")
- assertEqual(t, payload["path"], "main.go")
- assertEqual(t, payload["parent_id"], float64(7))
- diff, ok := payload["diff"].(map[string]interface{})
- if !ok {
- t.Fatalf("diff = %v, want object", payload["diff"])
- }
- assertEqual(t, diff["name"], "main.go")
- assertEqual(t, diff["addition"], float64(1))
-}
-
-func TestPRReviewCommentUpdateSendsPayload(t *testing.T) {
- var payload map[string]interface{}
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != "PUT" || r.URL.Path != "/v1/owner/repo/pulls/13/journals/9.json" {
- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
- }
- payload = decodeJSON(t, r)
- writeJSON(t, w, payload)
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "review-comment-update", map[string]string{
- "id": "13",
- "comment-id": "9",
- "body": "Resolved after follow-up",
- "commit": "cafebabe",
- "state": "resolved",
- })
- if err != nil {
- t.Fatalf("review-comment-update failed: %v", err)
- }
- assertEqual(t, payload["note"], "Resolved after follow-up")
- assertEqual(t, payload["commit_id"], "cafebabe")
- assertEqual(t, payload["state"], "resolved")
-}
-
-func TestPRReviewCommentDelete(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/13/journals/9.json" {
- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
- }
- writeJSON(t, w, map[string]interface{}{"status": float64(0), "message": "success"})
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "review-comment-delete", map[string]string{"id": "13", "comment-id": "9"})
- if err != nil {
- t.Fatalf("review-comment-delete failed: %v", err)
- }
-}
-
-func TestPRReviewCommentRejectsInvalidArgs(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- t.Fatalf("invalid args should not call API, got %s %s", r.Method, r.URL.Path)
- }))
- defer server.Close()
-
- cases := []struct {
- name string
- cmd string
- args map[string]string
- }{
- {name: "bad list state", cmd: "review-comments", args: map[string]string{"id": "13", "state": "done"}},
- {name: "bad bool", cmd: "review-comments", args: map[string]string{"id": "13", "need-respond": "maybe"}},
- {name: "bad type", cmd: "review-comment", args: map[string]string{"id": "13", "body": "x", "type": "note"}},
- {name: "bad diff json", cmd: "review-comment", args: map[string]string{"id": "13", "body": "x", "diff-json": "{"}},
- {name: "missing update fields", cmd: "review-comment-update", args: map[string]string{"id": "13", "comment-id": "9"}},
- {name: "bad comment id", cmd: "review-comment-delete", args: map[string]string{"id": "13", "comment-id": "abc"}},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- if err := runPRShortcut(t, server, tc.cmd, tc.args); err == nil {
- t.Fatal("expected validation error")
- }
- })
- }
-}
-
// --- list ---
func TestPRList(t *testing.T) {
@@ -458,18 +179,15 @@ func TestPRListStateAllOmitsStatus(t *testing.T) {
func TestPRCreate(t *testing.T) {
var payload map[string]interface{}
- encodedHead := base64.RawURLEncoding.EncodeToString([]byte("feature/x"))
- encodedBase := base64.RawURLEncoding.EncodeToString([]byte("master"))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch {
- case r.Method == "GET" && r.URL.Path == "/owner/repo/compare/"+encodedHead+"..."+encodedBase+".json":
- writeJSON(t, w, map[string]interface{}{"commits_count": float64(1), "files_count": float64(2)})
- case r.Method == "POST" && r.URL.Path == "/owner/repo/pulls.json":
- payload = decodeJSON(t, r)
- writeJSON(t, w, map[string]interface{}{"id": float64(42), "title": "feat: new"})
- default:
- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ if r.Method != "POST" {
+ t.Fatalf("expected POST, got %s", r.Method)
}
+ if r.URL.Path != "/owner/repo/pulls.json" {
+ t.Fatalf("unexpected path: %s", r.URL.Path)
+ }
+ payload = decodeJSON(t, r)
+ writeJSON(t, w, map[string]interface{}{"id": float64(42), "title": "feat: new"})
}))
defer server.Close()
@@ -490,18 +208,9 @@ func TestPRCreate(t *testing.T) {
func TestPRCreateNoBody(t *testing.T) {
var payload map[string]interface{}
- encodedHead := base64.RawURLEncoding.EncodeToString([]byte("feature/y"))
- encodedBase := base64.RawURLEncoding.EncodeToString([]byte("master"))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- switch {
- case r.Method == "GET" && r.URL.Path == "/owner/repo/compare/"+encodedHead+"..."+encodedBase+".json":
- writeJSON(t, w, map[string]interface{}{"commits_count": float64(0), "files_count": float64(0)})
- case r.Method == "POST" && r.URL.Path == "/owner/repo/pulls.json":
- payload = decodeJSON(t, r)
- writeJSON(t, w, map[string]interface{}{"id": float64(43), "title": "feat: nob"})
- default:
- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
- }
+ payload = decodeJSON(t, r)
+ writeJSON(t, w, map[string]interface{}{"id": float64(43), "title": "feat: nob"})
}))
defer server.Close()
@@ -517,103 +226,6 @@ func TestPRCreateNoBody(t *testing.T) {
}
}
-// --- branches / check-can-merge ---
-
-func TestPRBranches(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != "GET" {
- t.Fatalf("expected GET, got %s", r.Method)
- }
- if r.URL.Path != "/owner/repo/pulls/get_branches.json" {
- t.Fatalf("unexpected path: %s", r.URL.Path)
- }
- writeJSON(t, w, map[string]interface{}{
- "branches": []interface{}{"master", "feature/search"},
- })
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "branches", nil)
- if err != nil {
- t.Fatalf("branches failed: %v", err)
- }
-}
-
-func TestPRCheckCanMergeDryRunDoesNotCallAPI(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- t.Fatalf("check-can-merge dry-run should not call API, got: %s %s", r.Method, r.URL.Path)
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "check-can-merge", map[string]string{
- "head": "feature/search",
- "base": "master",
- "dry-run": "true",
- })
- if err != nil {
- t.Fatalf("check-can-merge dry-run failed: %v", err)
- }
-}
-
-func TestPRCheckCanMergeRequiresYes(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- t.Fatalf("check-can-merge without --yes should not call API, got: %s %s", r.Method, r.URL.Path)
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "check-can-merge", map[string]string{
- "head": "feature/search",
- "base": "master",
- })
- if err == nil {
- t.Fatal("expected check-can-merge to require --yes")
- }
-}
-
-func TestPRCheckCanMergeWithYesCallsEndpoint(t *testing.T) {
- var payload map[string]interface{}
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != "POST" {
- t.Fatalf("expected POST, got %s", r.Method)
- }
- if r.URL.Path != "/owner/repo/pulls/check_can_merge.json" {
- t.Fatalf("unexpected path: %s", r.URL.Path)
- }
- payload = decodeJSON(t, r)
- writeJSON(t, w, map[string]interface{}{"can_merge": true})
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "check-can-merge", map[string]string{
- "head": "feature/search",
- "base": "master",
- "yes": "true",
- })
- if err != nil {
- t.Fatalf("check-can-merge failed: %v", err)
- }
- assertEqual(t, payload["head"], "feature/search")
- assertEqual(t, payload["base"], "master")
-}
-
-func TestPRCheckCanMergeDefaultsBaseToMaster(t *testing.T) {
- var payload map[string]interface{}
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- payload = decodeJSON(t, r)
- writeJSON(t, w, map[string]interface{}{"can_merge": true})
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "check-can-merge", map[string]string{
- "head": "feature/search",
- "yes": "true",
- })
- if err != nil {
- t.Fatalf("check-can-merge failed: %v", err)
- }
- assertEqual(t, payload["base"], "master")
-}
-
// --- view ---
func TestPRView(t *testing.T) {
@@ -637,201 +249,6 @@ func TestPRView(t *testing.T) {
}
}
-func TestEnrichPullRequestTimestampsPromotesMergedAndClosedAt(t *testing.T) {
- var journalCalls int
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != "GET" {
- t.Fatalf("expected GET, got %s", r.Method)
- }
- if r.URL.Path != "/v1/owner/repo/issues/142349/journals.json" {
- t.Fatalf("unexpected path: %s", r.URL.Path)
- }
- journalCalls++
- writeJSON(t, w, map[string]interface{}{"journals": []interface{}{}})
- }))
- defer server.Close()
-
- ctx := &common.RuntimeContext{
- Client: &client.Client{
- HTTP: server.Client(),
- BaseURL: server.URL,
- },
- Owner: "owner",
- Repo: "repo",
- }
- env := &output.Envelope{Data: map[string]interface{}{
- "pull_request": map[string]interface{}{
- "pull_request_status": float64(1),
- "merged_at": "2026-05-14T14:26:27+08:00",
- "created_at": "2026-05-10T09:00:00+08:00",
- },
- "issue": map[string]interface{}{
- "id": float64(142349),
- "created_at": "2026-05-10T09:00:00+08:00",
- },
- }}
-
- if err := enrichPullRequestTimestamps(ctx, env); err != nil {
- t.Fatalf("enrichPullRequestTimestamps returned error: %v", err)
- }
-
- data := env.Data.(map[string]interface{})
- pr := data["pull_request"].(map[string]interface{})
- issue := data["issue"].(map[string]interface{})
- assertEqual(t, data["created_at"], "2026-05-10T09:00:00+08:00")
- assertEqual(t, data["merged_at"], "2026-05-14T14:26:27+08:00")
- assertEqual(t, data["closed_at"], "2026-05-14T14:26:27+08:00")
- assertEqual(t, data["closed_on"], "2026-05-14T14:26:27+08:00")
- assertEqual(t, pr["merged_at"], "2026-05-14T14:26:27+08:00")
- assertEqual(t, pr["closed_at"], "2026-05-14T14:26:27+08:00")
- assertEqual(t, issue["closed_on"], "2026-05-14T14:26:27+08:00")
- if journalCalls != 1 {
- t.Fatalf("journalCalls = %d, want 1", journalCalls)
- }
-}
-
-func TestEnrichPullRequestTimestampsReadsMergeTimeFromJournals(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != "GET" {
- t.Fatalf("expected GET, got %s", r.Method)
- }
- if r.URL.Path != "/v1/owner/repo/issues/142349/journals.json" {
- t.Fatalf("unexpected path: %s", r.URL.Path)
- }
- writeJSON(t, w, map[string]interface{}{
- "journals": []interface{}{
- map[string]interface{}{
- "operate_category": "status",
- "operate_content": "合并了 合并请求",
- "updated_at": "2026-05-14T14:26:27+08:00",
- },
- },
- })
- }))
- defer server.Close()
-
- ctx := &common.RuntimeContext{
- Client: &client.Client{
- HTTP: server.Client(),
- BaseURL: server.URL,
- },
- Owner: "owner",
- Repo: "repo",
- }
- env := &output.Envelope{Data: map[string]interface{}{
- "pull_request": map[string]interface{}{
- "pull_request_staus": "merged",
- },
- "issue": map[string]interface{}{
- "id": float64(142349),
- },
- }}
-
- if err := enrichPullRequestTimestamps(ctx, env); err != nil {
- t.Fatalf("enrichPullRequestTimestamps returned error: %v", err)
- }
-
- data := env.Data.(map[string]interface{})
- pr := data["pull_request"].(map[string]interface{})
- issue := data["issue"].(map[string]interface{})
- assertEqual(t, data["merged_at"], "2026-05-14T14:26:27+08:00")
- assertEqual(t, data["closed_at"], "2026-05-14T14:26:27+08:00")
- assertEqual(t, data["closed_on"], "2026-05-14T14:26:27+08:00")
- assertEqual(t, pr["merged_at"], "2026-05-14T14:26:27+08:00")
- assertEqual(t, pr["closed_at"], "2026-05-14T14:26:27+08:00")
- assertEqual(t, issue["closed_on"], "2026-05-14T14:26:27+08:00")
-}
-
-func TestEnrichPullRequestTimestampsReadsClosedTimeFromJournals(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != "GET" {
- t.Fatalf("expected GET, got %s", r.Method)
- }
- if r.URL.Path != "/v1/owner/repo/issues/142350/journals.json" {
- t.Fatalf("unexpected path: %s", r.URL.Path)
- }
- writeJSON(t, w, map[string]interface{}{
- "journals": []interface{}{
- map[string]interface{}{
- "operate_category": "status",
- "operate_content": "关闭了合并请求",
- "created_at": "2026-05-15T10:30:00+08:00",
- },
- },
- })
- }))
- defer server.Close()
-
- ctx := &common.RuntimeContext{
- Client: &client.Client{
- HTTP: server.Client(),
- BaseURL: server.URL,
- },
- Owner: "owner",
- Repo: "repo",
- }
- env := &output.Envelope{Data: map[string]interface{}{
- "pull_request": map[string]interface{}{
- "pull_request_status": float64(2),
- },
- "issue": map[string]interface{}{
- "id": float64(142350),
- },
- }}
-
- if err := enrichPullRequestTimestamps(ctx, env); err != nil {
- t.Fatalf("enrichPullRequestTimestamps returned error: %v", err)
- }
-
- data := env.Data.(map[string]interface{})
- pr := data["pull_request"].(map[string]interface{})
- issue := data["issue"].(map[string]interface{})
- assertEqual(t, data["closed_at"], "2026-05-15T10:30:00+08:00")
- assertEqual(t, data["closed_on"], "2026-05-15T10:30:00+08:00")
- assertEqual(t, pr["closed_at"], "2026-05-15T10:30:00+08:00")
- assertEqual(t, issue["closed_on"], "2026-05-15T10:30:00+08:00")
- if _, ok := data["merged_at"]; ok {
- t.Fatalf("merged_at should stay empty for closed pull requests, got %v", data["merged_at"])
- }
-}
-
-func TestEnrichPullRequestTimestampsSkipsJournalsForOpenPR(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)
- return
- }))
- defer server.Close()
-
- ctx := &common.RuntimeContext{
- Client: &client.Client{
- HTTP: server.Client(),
- BaseURL: server.URL,
- },
- Owner: "owner",
- Repo: "repo",
- }
- env := &output.Envelope{Data: map[string]interface{}{
- "pull_request": map[string]interface{}{
- "pull_request_status": float64(0),
- },
- "issue": map[string]interface{}{
- "id": float64(142351),
- },
- }}
-
- if err := enrichPullRequestTimestamps(ctx, env); err != nil {
- t.Fatalf("enrichPullRequestTimestamps returned error: %v", err)
- }
-
- data := env.Data.(map[string]interface{})
- if _, ok := data["closed_at"]; ok {
- t.Fatalf("closed_at should not be set for open pull requests, got %v", data["closed_at"])
- }
- if _, ok := data["merged_at"]; ok {
- t.Fatalf("merged_at should not be set for open pull requests, got %v", data["merged_at"])
- }
-}
-
// --- merge ---
func TestPRMerge(t *testing.T) {
@@ -999,36 +416,6 @@ func TestPRCreateHTTPError(t *testing.T) {
}
}
-func TestPRBranchesHTTPError(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- w.Write([]byte("server error"))
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "branches", nil)
- if err == nil {
- t.Fatal("expected error for HTTP 500")
- }
-}
-
-func TestPRCheckCanMergeHTTPError(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- w.Write([]byte("server error"))
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "check-can-merge", map[string]string{
- "head": "feature/search",
- "base": "master",
- "yes": "true",
- })
- if err == nil {
- t.Fatal("expected error for HTTP 500")
- }
-}
-
func TestPRViewHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
@@ -1094,120 +481,6 @@ func TestPRDiffHTTPError(t *testing.T) {
}
}
-// --- commits ---
-
-func TestPRCommits(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != "GET" {
- t.Fatalf("expected GET, got %s", r.Method)
- }
- if r.URL.Path != "/v1/owner/repo/pulls/42/commits.json" {
- t.Fatalf("unexpected path: %s", r.URL.Path)
- }
- writeJSON(t, w, []interface{}{
- map[string]interface{}{"sha": "abc1234", "message": "fix: bug"},
- })
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "commits", map[string]string{"id": "42"})
- if err != nil {
- t.Fatalf("commits failed: %v", err)
- }
-}
-
-func TestPRCommitsHTTPError(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- w.Write([]byte("server error"))
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "commits", map[string]string{"id": "42"})
- if err == nil {
- t.Fatal("expected error for HTTP 500")
- }
-}
-
-// --- branches ---
-
-func TestPRBranches(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != "GET" {
- t.Fatalf("expected GET, got %s", r.Method)
- }
- if r.URL.Path != "/owner/repo/pulls/get_branches.json" {
- t.Fatalf("unexpected path: %s", r.URL.Path)
- }
- writeJSON(t, w, []interface{}{
- map[string]interface{}{"name": "master"},
- map[string]interface{}{"name": "develop"},
- })
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "branches", map[string]string{})
- if err != nil {
- t.Fatalf("branches failed: %v", err)
- }
-}
-
-func TestPRBranchesHTTPError(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- w.Write([]byte("server error"))
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "branches", map[string]string{})
- if err == nil {
- t.Fatal("expected error for HTTP 500")
- }
-}
-
-// --- check-merge ---
-
-func TestPRCheckMerge(t *testing.T) {
- var payload map[string]interface{}
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != "POST" {
- t.Fatalf("expected POST, got %s", r.Method)
- }
- if r.URL.Path != "/owner/repo/pulls/check_can_merge.json" {
- t.Fatalf("unexpected path: %s", r.URL.Path)
- }
- payload = decodeJSON(t, r)
- writeJSON(t, w, map[string]interface{}{"can_merge": true})
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "check-merge", map[string]string{
- "head": "feature/x",
- "base": "master",
- })
- if err != nil {
- t.Fatalf("check-merge failed: %v", err)
- }
- assertEqual(t, payload["head"], "feature/x")
- assertEqual(t, payload["base"], "master")
-}
-
-func TestPRCheckMergeHTTPError(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- w.Write([]byte("server error"))
- }))
- defer server.Close()
-
- err := runPRShortcut(t, server, "check-merge", map[string]string{
- "head": "feature/x",
- "base": "master",
- })
- if err == nil {
- t.Fatal("expected error for HTTP 500")
- }
-}
-
func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findPRShortcut(t, name)
@@ -1259,45 +532,177 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
}
}
-func TestPRCommitsUsesCommitsEndpoint(t *testing.T) {
+func TestPRReviewCommentsListBuildsQuery(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != "GET" || r.URL.Path != "/owner/repo/pulls/355/commits.json" {
+ if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls/13/journals.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
- writeJSON(t, w, map[string]interface{}{"commits_count": float64(1), "commits": []interface{}{}})
+ query := r.URL.Query()
+ assertEqual(t, query.Get("keyword"), "todo")
+ assertEqual(t, query.Get("review_id"), "10")
+ assertEqual(t, query.Get("need_respond"), "true")
+ assertEqual(t, query.Get("state"), "opened")
+ assertEqual(t, query.Get("parent_id"), "200")
+ assertEqual(t, query.Get("path"), "README.md")
+ assertEqual(t, query.Get("is_full"), "true")
+ assertEqual(t, query.Get("sort_by"), "created_on")
+ assertEqual(t, query.Get("sort_direction"), "desc")
+ writeJSON(t, w, map[string]interface{}{"total_count": 0, "journals": []interface{}{}})
}))
defer server.Close()
- if err := runPRShortcut(t, server, "commits", map[string]string{"id": "355"}); err != nil {
- t.Fatalf("commits failed: %v", err)
+ err := runPRShortcut(t, server, "review-comments", map[string]string{
+ "id": "13",
+ "keyword": "todo",
+ "review-id": "10",
+ "need-respond": "true",
+ "state": "opened",
+ "parent-id": "200",
+ "path": "README.md",
+ "is-full": "true",
+ "sort-by": "created_on",
+ "sort-direction": "desc",
+ })
+ if err != nil {
+ t.Fatalf("review-comments shortcut failed: %v", err)
}
}
-func TestPRCheckMergePostsBranchesAndForkTuple(t *testing.T) {
+func TestPRReviewCommentPostsPayload(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 != "/owner/repo/pulls/check_can_merge.json" {
+ if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/pulls/13/journals.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
payload = decodeJSON(t, r)
- writeJSON(t, w, map[string]interface{}{"status": float64(0), "message": "可以合并"})
+ writeJSON(t, w, map[string]interface{}{"id": 200, "note": "please fix"})
}))
defer server.Close()
- err := runPRShortcut(t, server, "check-merge", map[string]string{
- "head": "feat/x", "base": "master", "fork-project-id": "1549132",
+ err := runPRShortcut(t, server, "review-comment", map[string]string{
+ "id": "13",
+ "note": "please fix",
+ "review-id": "10",
+ "type": "problem",
+ "commit": "abc123",
+ "line-code": "abc123_0_10",
+ "path": "README.md",
+ "parent-id": "199",
+ "diff-json": `{"name":"README.md","addition":1}`,
})
if err != nil {
- t.Fatalf("check-merge failed: %v", err)
+ t.Fatalf("review-comment shortcut failed: %v", err)
}
- assertEqual(t, payload["head"], "feat/x")
- assertEqual(t, payload["base"], "master")
- assertEqual(t, payload["fork_project_id"], float64(1549132))
- assertEqual(t, payload["is_original"], true)
- if err := runPRShortcut(t, server, "check-merge", map[string]string{
- "head": "a", "base": "b", "fork-project-id": "abc",
- }); err == nil {
- t.Fatal("expected error for non-integer --fork-project-id")
+ assertEqual(t, payload["type"], "problem")
+ assertEqual(t, payload["note"], "please fix")
+ assertEqual(t, payload["review_id"], "10")
+ assertEqual(t, payload["commit_id"], "abc123")
+ assertEqual(t, payload["line_code"], "abc123_0_10")
+ assertEqual(t, payload["path"], "README.md")
+ assertEqual(t, payload["parent_id"], float64(199))
+ diff, ok := payload["diff"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("diff type = %T, want map", payload["diff"])
+ }
+ assertEqual(t, diff["name"], "README.md")
+ assertEqual(t, diff["addition"], float64(1))
+}
+
+func TestPRReviewCommentDryRunDoesNotCallAPI(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatalf("server should not be called during dry-run: %s %s", r.Method, r.URL.Path)
+ }))
+ defer server.Close()
+
+ err := runPRShortcut(t, server, "review-comment", map[string]string{
+ "id": "13",
+ "note": "please fix",
+ "review-id": "10",
+ "commit": "abc123",
+ "line-code": "abc123_0_10",
+ "path": "README.md",
+ "dry-run": "true",
+ })
+ if err != nil {
+ t.Fatalf("review-comment dry-run failed: %v", err)
+ }
+}
+
+func TestPRReviewCommentUpdatePayload(t *testing.T) {
+ var payload map[string]interface{}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "PUT" || r.URL.Path != "/v1/owner/repo/pulls/13/journals/200.json" {
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ payload = decodeJSON(t, r)
+ writeJSON(t, w, map[string]interface{}{"id": 200, "note": "fixed", "state": "resolved"})
+ }))
+ defer server.Close()
+
+ err := runPRShortcut(t, server, "review-comment-update", map[string]string{
+ "id": "13",
+ "comment-id": "200",
+ "note": "fixed",
+ "commit": "def456",
+ "state": "resolved",
+ })
+ if err != nil {
+ t.Fatalf("review-comment-update shortcut failed: %v", err)
+ }
+ assertEqual(t, payload["note"], "fixed")
+ assertEqual(t, payload["commit_id"], "def456")
+ assertEqual(t, payload["state"], "resolved")
+}
+
+func TestPRReviewCommentDeleteUsesV1Endpoint(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/13/journals/200.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 := runPRShortcut(t, server, "review-comment-delete", map[string]string{
+ "id": "13",
+ "comment-id": "200",
+ })
+ if err != nil {
+ t.Fatalf("review-comment-delete shortcut failed: %v", err)
+ }
+}
+
+func TestPRReviewCommentRejectsInvalidInputs(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatalf("server should not be called for invalid input: %s %s", r.Method, r.URL.Path)
+ }))
+ defer server.Close()
+
+ if err := runPRShortcut(t, server, "review-comment", map[string]string{
+ "id": "13",
+ "note": "please fix",
+ "review-id": "10",
+ "type": "todo",
+ "commit": "abc123",
+ "line-code": "abc123_0_10",
+ "path": "README.md",
+ }); err == nil {
+ t.Fatal("expected invalid review comment type to fail")
+ }
+
+ if err := runPRShortcut(t, server, "review-comment-update", map[string]string{
+ "id": "13",
+ "comment-id": "200",
+ "state": "done",
+ }); err == nil {
+ t.Fatal("expected invalid review comment state to fail")
+ }
+
+ if err := runPRShortcut(t, server, "review-comments", map[string]string{
+ "id": "13",
+ "need-respond": "maybe",
+ }); err == nil {
+ t.Fatal("expected invalid need-respond to fail")
}
}
diff --git a/skills/README.md b/skills/README.md
index 908d763..e022aa0 100644
--- a/skills/README.md
+++ b/skills/README.md
@@ -32,7 +32,7 @@ gitlink-cli auth status
gitlink-cli user +me
```
-详见: [gitlink-shared/SKILL.md](gitlink-shared/SKILL.md)
+详见: [gitlink-shared/examples/auth-workflow.md](gitlink-shared/examples/auth-workflow.md)
### 2. 查看可用命令
@@ -64,19 +64,25 @@ skills/
├── README.md # 本文件
├── gitlink-shared/ # 共享基础规则
│ ├── SKILL.md # 认证、全局参数、安全规则、分支约定
-│ └── references/
-│ ├── api-reference.md # API 详细参考、错误处理
-│ ├── raw-api-batch.md # 批量 Raw API 调用参考
-│ └── troubleshooting.md # 常见问题排查
+│ ├── REFERENCE.md # API 详细参考、错误处理
+│ ├── TROUBLESHOOTING.md # 常见问题排查
+│ └── examples/
+│ └── auth-workflow.md # 认证工作流示例
├── gitlink-repo/ # 仓库管理
│ ├── SKILL.md # 仓库操作指南
-│ └── references/ # 仓库命令参考文档
+│ ├── REFERENCE.md # 仓库 API 参考
+│ └── examples/
+│ └── repo-workflow.md # 仓库管理工作流
├── gitlink-issue/ # Issue 管理
│ ├── SKILL.md # Issue 操作指南
-│ └── references/ # Issue 命令参考文档
+│ ├── REFERENCE.md # Issue API 参考
+│ └── examples/
+│ └── issue-workflow.md # Issue 全流程工作流
├── gitlink-pr/ # Pull Request
│ ├── SKILL.md # PR 操作指南
-│ └── references/ # PR 命令参考文档
+│ ├── REFERENCE.md # PR API 参考
+│ └── examples/
+│ └── pr-workflow.md # PR 工作流
├── gitlink-member/ # 仓库成员管理
│ └── SKILL.md # 成员与邀请链接操作指南
├── gitlink-branch/ # 分支管理
@@ -85,26 +91,25 @@ skills/
│ └── branch-workflow.md # 分支工作流
├── gitlink-release/ # 版本发布
│ ├── SKILL.md # Release 操作指南
-│ └── references/ # Release 命令参考文档
-├── gitlink-release-auto/ # 自动化 Release 管理
-│ └── SKILL.md # 自动发版、版本号推荐、Release Notes 生成
+│ ├── REFERENCE.md # Release API 参考
+│ └── examples/
+│ └── release-workflow.md # Release 工作流
├── gitlink-search/ # 搜索功能
│ ├── SKILL.md # 搜索操作指南
-│ └── references/ # 搜索命令参考文档
+│ └── examples/
+│ └── search-workflow.md # 搜索工作流
├── gitlink-user/ # 用户管理
-│ ├── SKILL.md # 用户操作指南
-│ └── references/ # 用户命令参考文档
-├── gitlink-notification/ # 通知与消息管理
-│ └── SKILL.md # 消息查看、标记已读、删除和 @ 提及
+│ └── SKILL.md # 用户操作指南
├── gitlink-org/ # 组织管理
│ ├── SKILL.md # 组织操作指南
-│ └── references/ # 组织命令参考文档
+│ └── examples/
+│ └── org-workflow.md # 组织工作流
├── gitlink-ci/ # CI/CD
-│ └── SKILL.md # CI 操作指南
+│ ├── SKILL.md # CI 操作指南
+│ └── examples/
+│ └── ci-workflow.md # CI 工作流
├── gitlink-pipeline/ # 流水线工作流
│ └── SKILL.md # Pipeline 操作指南
-├── gitlink-wiki/ # Wiki 页面管理
-│ └── SKILL.md # Wiki 操作指南
├── gitlink-pm/ # 项目管理
│ └── SKILL.md # PM 操作指南
├── gitlink-health/ # 项目健康度分析
@@ -130,8 +135,8 @@ skills/
|-------|------|----------|
| **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` |
| **gitlink-repo** | 仓库管理与洞察 | `repo +list`, `repo +info`, `repo +languages`, `repo +contributors`, `repo +code-stats`, `repo +follow`, `repo +like` |
-| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close`, `issue +batch-update`, `issue +batch-delete`, `issue +export` |
-| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` |
+| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close`, `issue +comments` |
+| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review`, `pr +review-comments` |
| **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` |
| **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` |
| **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +edit`, `release +update`, `release +view` |
@@ -142,14 +147,12 @@ skills/
|-------|------|----------|
| **gitlink-search** | 搜索功能 | `search +repos`, `search +users` |
| **gitlink-user** | 用户管理 | `user +me`, `user +info` |
-| **gitlink-notification** | 通知与消息管理 | `notification +list`, `notification +read`, `notification +delete` |
| **gitlink-org** | 组织管理 | `org +list`, `org +info`, `org +members` |
| **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` |
| **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` |
-| **gitlink-wiki** | Wiki 页面管理 | `wiki +list`, `wiki +view`, `wiki +create`, `wiki +update`, `wiki +delete` |
| **gitlink-pm** | 项目管理 | 通过 Raw API 访问 |
| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes |
-| **gitlink-health** | 项目健康度诊断 | `health diagnose` (5 维度评分:文档、许可证、社区、成熟度、CI/CD) |
+| **gitlink-health** | 开源项目健康度 | 详情见SKILL.md |
---
@@ -166,7 +169,7 @@ gitlink-cli repo +info
gitlink-cli repo +info --owner wbtiger --repo gitlink-cli
```
-详见: [gitlink-repo/SKILL.md](gitlink-repo/SKILL.md)
+详见: [gitlink-repo/examples/repo-workflow.md](gitlink-repo/examples/repo-workflow.md)
### 场景 2:创建和管理 Issue
@@ -187,7 +190,7 @@ gitlink-cli issue +close -i 123
gitlink-cli issue +batch-close --numbers 123,124 --dry-run
```
-详见: [gitlink-issue/SKILL.md](gitlink-issue/SKILL.md)
+详见: [gitlink-issue/examples/issue-workflow.md](gitlink-issue/examples/issue-workflow.md)
### 场景 3:管理分支和发布
@@ -205,7 +208,7 @@ gitlink-cli release +create -t v1.0.0 -n "v1.0.0 正式版" -b "更新内容..."
gitlink-cli release +view -i
```
-详见: [gitlink-release/SKILL.md](gitlink-release/SKILL.md)
+详见: [gitlink-release/examples/release-workflow.md](gitlink-release/examples/release-workflow.md)
### 场景 4:搜索和发现
@@ -221,7 +224,7 @@ gitlink-cli org +list
gitlink-cli org +info -i Gitlink
```
-详见: [gitlink-search/SKILL.md](gitlink-search/SKILL.md)
+详见: [gitlink-search/examples/search-workflow.md](gitlink-search/examples/search-workflow.md)
---
@@ -230,8 +233,8 @@ gitlink-cli org +info -i Gitlink
### 快速查找
- **我想了解认证**: [gitlink-shared/SKILL.md](gitlink-shared/SKILL.md)
-- **我想查看 API 细节**: [gitlink-shared/references/api-reference.md](gitlink-shared/references/api-reference.md)
-- **我遇到了错误**: [gitlink-shared/references/troubleshooting.md](gitlink-shared/references/troubleshooting.md)
+- **我想查看 API 细节**: [gitlink-shared/REFERENCE.md](gitlink-shared/REFERENCE.md)
+- **我遇到了错误**: [gitlink-shared/TROUBLESHOOTING.md](gitlink-shared/TROUBLESHOOTING.md)
- **我想看工作流示例**: 查看各 Skill 下的 `examples/` 目录
### 按功能分类
@@ -239,12 +242,12 @@ gitlink-cli org +info -i Gitlink
**仓库操作**:
- [gitlink-repo/SKILL.md](gitlink-repo/SKILL.md) - 仓库命令
- [gitlink-branch/SKILL.md](gitlink-branch/SKILL.md) - 分支命令
-- [gitlink-repo/SKILL.md](gitlink-repo/SKILL.md) - 完整工作流
+- [gitlink-repo/examples/repo-workflow.md](gitlink-repo/examples/repo-workflow.md) - 完整工作流
**Issue 和 PR**:
- [gitlink-issue/SKILL.md](gitlink-issue/SKILL.md) - Issue 命令
- [gitlink-pr/SKILL.md](gitlink-pr/SKILL.md) - PR 命令
-- [gitlink-issue/SKILL.md](gitlink-issue/SKILL.md) - Issue 工作流
+- [gitlink-issue/examples/issue-workflow.md](gitlink-issue/examples/issue-workflow.md) - Issue 工作流
**发布和搜索**:
- [gitlink-release/SKILL.md](gitlink-release/SKILL.md) - Release 命令
@@ -286,11 +289,11 @@ gitlink-cli auth login
### Q: 如何查看完整的 API 参考?
-A: 查看 [gitlink-shared/references/api-reference.md](gitlink-shared/references/api-reference.md)
+A: 查看 [gitlink-shared/REFERENCE.md](gitlink-shared/REFERENCE.md)
### Q: 遇到错误怎么办?
-A: 查看 [gitlink-shared/references/troubleshooting.md](gitlink-shared/references/troubleshooting.md)
+A: 查看 [gitlink-shared/TROUBLESHOOTING.md](gitlink-shared/TROUBLESHOOTING.md)
---
@@ -326,7 +329,7 @@ AI 代理可以:
- 所有边界情况处理正确
- 完整的文档和示例
-详见: [../doc/design.md](../doc/design.md)
+详见: [../doc/SKILLS_TEST_REPORT_2026-04-02.md](../doc/SKILLS_TEST_REPORT_2026-04-02.md)
---
@@ -334,7 +337,8 @@ AI 代理可以:
- [主项目 README](../README.md) - gitlink-cli 项目说明
- [设计文档](../doc/design.md) - 架构设计和开发计划
-- [API 参考文档](../doc/gitlink_api_reference.md) - GitLink API 参考文档
+- [测试报告](../doc/SKILLS_TEST_REPORT_2026-04-02.md) - 功能测试报告
+- [代码同步方案](../doc/CODE_SYNC_STRATEGY_FINAL.md) - GitHub ↔ GitLink 同步设计
- [gitlink-bisync](https://www.gitlink.org.cn/wbtiger/gitlink-bisync) - 代码双向同步系统
---
@@ -342,8 +346,8 @@ AI 代理可以:
## 📞 获取帮助
- **命令帮助**: `gitlink-cli --help`
-- **故障排查**: [gitlink-shared/references/troubleshooting.md](gitlink-shared/references/troubleshooting.md)
-- **API 参考**: [gitlink-shared/references/api-reference.md](gitlink-shared/references/api-reference.md)
+- **故障排查**: [gitlink-shared/TROUBLESHOOTING.md](gitlink-shared/TROUBLESHOOTING.md)
+- **API 参考**: [gitlink-shared/REFERENCE.md](gitlink-shared/REFERENCE.md)
- **工作流示例**: 查看各 Skill 下的 `examples/` 目录
---
@@ -351,7 +355,7 @@ AI 代理可以:
## 🎓 下一步
1. 阅读 [gitlink-shared/SKILL.md](gitlink-shared/SKILL.md) 了解基础
-2. 查看 [gitlink-shared/SKILL.md](gitlink-shared/SKILL.md) 完成认证
+2. 查看 [gitlink-shared/examples/auth-workflow.md](gitlink-shared/examples/auth-workflow.md) 完成认证
3. 根据需求选择相应的 Skill 文档
4. 参考 `examples/` 目录中的工作流示例
5. 使用 AI 代理自动化你的工作流
diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md
index 39a2c67..be6b256 100644
--- a/skills/gitlink-issue/SKILL.md
+++ b/skills/gitlink-issue/SKILL.md
@@ -1,95 +1,135 @@
---
name: gitlink-issue
-version: 2.1.0
-description: "GitLink Issue 管理:创建、查看、更新、关闭、评论,以及批量关闭、批量评论、批量更新。"
+version: 2.0.0
+description: "Issue 管理:创建、查看、更新、关闭/批量关闭 Issue,管理评论/操作记录。当用户需要操作 GitLink Issue 时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli issue --help"
---
-# gitlink-issue
+# gitlink-issue(Issue 操作)
-> 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),确认认证方式、全局参数和安全约束。
+**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
+**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
+**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
-## 适用场景
+> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
-- 维护者需要快速创建、更新、关闭或评论 Issue。
-- Agent 需要基于仓库内的 Issue 批量做运营动作,例如统一补评论、统一更新优先级、统一补截止日期。
-- 需要从 Markdown 文件读取长文本,避免把大段内容直接塞进命令行参数。
+## Shortcuts
-## 常用命令
+| Shortcut | 说明 | 需要认证 |
+|----------|------|----------|
+| `issue +list` | Issue 列表 | 否(公开项目) |
+| `issue +create` | 创建 Issue | 是 |
+| `issue +view` | Issue 详情 | 否(公开项目) |
+| `issue +update` | 更新 Issue | 是 |
+| `issue +close` | 关闭 Issue | 是 |
+| `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) |
+| `issue +comment` | 添加评论,支持回复、附件、@接收人和 dry-run | 是 |
+| `issue +comments` | 查看 Issue 评论和操作记录 | 否(公开项目) |
+| `issue +comment-update` | 更新 Issue 评论,支持 dry-run | 是 |
+| `issue +comment-delete` | 删除 Issue 评论,支持 dry-run | 是 |
+| `issue +comment-children` | 查看评论的子评论 | 否(公开项目) |
+| `issue +assigners` | 查询 Issue 负责人列表 | 否(公开项目) |
+| `issue +authors` | 查询 Issue 发布人列表 | 否(公开项目) |
+| `issue +statuses` | 查询 Issue 状态列表 | 否(公开项目) |
+| `issue +tags` | 查询 Issue 标签列表 | 否(公开项目) |
+| `issue +priorities` | 查询 Issue 优先级列表 | 否(公开项目) |
-| Shortcut | 用途 | 是否写操作 |
-| --- | --- | --- |
-| `issue +list` | 列出 Issue | 否 |
-| `issue +view` | 查看单个 Issue 详情 | 否 |
-| `issue +create` | 创建 Issue,支持 `--body-file` | 是 |
-| `issue +update` | 更新单个 Issue,支持 `--body-file` | 是 |
-| `issue +close` | 关闭单个 Issue | 是 |
-| `issue +comment` | 给单个 Issue 添加评论,支持 `--body-file` | 是 |
-| `issue +batch-close` | 按编号或 CSV 批量关闭 Issue | 是 |
-| `issue +batch-comment` | 按编号或 CSV 批量评论 | 是 |
-| `issue +batch-update` | 按编号或 CSV 批量更新元数据 | 是 |
-| `issue +assigners` | 列出可分配负责人 | 否 |
-| `issue +authors` | 列出 Issue 作者 | 否 |
-| `issue +priorities` | 列出优先级 | 否 |
-| `issue +tags` | 列出标签 | 否 |
-| `issue +statuses` | 列出状态 | 否 |
-
-## 使用方式
+## 使用示例
```bash
-# 创建一个 Issue
-gitlink-cli issue +create --owner Gitlink --repo forgeplus \
- --title "Bug: 登录失败" \
- --body-file issue.md
+# 列出 Issue
+gitlink-cli issue +list --owner Gitlink --repo forgeplus --state open
-# 更新单个 Issue 的优先级和截止日期
-gitlink-cli issue +update --owner Gitlink --repo forgeplus \
- --number 42 \
- --priority-id 4 \
- --due-date 2026-06-15
+# 搜索并排序 Issue
+gitlink-cli issue +list --owner Gitlink --repo forgeplus --state open --keyword 登录 --sort-by issues.updated_on --sort-direction desc
-# 通过文件给单个 Issue 添加长评论
-gitlink-cli issue +comment --owner Gitlink --repo forgeplus \
- --number 42 \
- --body-file comment.md
+# 创建 Issue
+gitlink-cli issue +create --owner myuser --repo myrepo --title "Bug: 登录失败" --body "复现步骤:..."
-# 预览批量评论
-gitlink-cli issue +batch-comment --owner Gitlink --repo forgeplus \
- --numbers 42,43,44 \
- --body-file comment.md \
- --dry-run
+# 查看 Issue 详情(使用网页可见的 Issue 编号)
+gitlink-cli issue +view --owner Gitlink --repo forgeplus --number 4
-# 从 CSV 批量更新 Issue
-gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus \
- --from issues.csv \
- --state closed \
- --priority-id 4 \
- --due-date 2026-06-15
+# 更新 Issue
+gitlink-cli issue +update --number 4 --title "新标题" --body "更新描述"
+
+# 关闭 Issue
+gitlink-cli issue +close --number 4
+
+# 预览批量关闭 Issue,不修改数据
+gitlink-cli issue +batch-close --owner myuser --repo myrepo --numbers 123,124 --dry-run
+
+# 从 CSV 文件批量关闭 Issue
+gitlink-cli issue +batch-close --owner myuser --repo myrepo --from issues.csv
+
+# 添加评论
+gitlink-cli issue +comment --number 4 --body "已修复,请验证"
+
+# 回复评论 / @用户 / 携带附件(写操作先 dry-run)
+gitlink-cli issue +comment --number 4 --body "请 @alice 复核" --receivers alice --attachment-ids 10,11 --dry-run
+
+# 查看评论和操作记录
+gitlink-cli issue +comments --number 4 --category all --page 1 --limit 20
+gitlink-cli issue +comments --number 4 --category comment --keyword "复现"
+
+# 更新/删除评论(先 dry-run)
+gitlink-cli issue +comment-update --number 4 --comment-id 58 --body "更新后的评论" --dry-run
+gitlink-cli issue +comment-delete --number 4 --comment-id 58 --dry-run
+
+# 查看子评论
+gitlink-cli issue +comment-children --number 4 --comment-id 58
+
+# 查询 Issue 负责人
+gitlink-cli issue +assigners --owner Gitlink --repo forgeplus --keyword alice
+
+# 查询 Issue 发布人
+gitlink-cli issue +authors --owner Gitlink --repo forgeplus --keyword bob
```
-## 批量操作约定
+## Raw API 补充
-- `--numbers` 使用网页 URL 中可见的 Issue 编号,不是数据库内部 ID。
-- `--from` 支持 CSV 文件,优先识别 `number`、`issue_number`、`project_issues_index` 列;如果没有表头,则默认第一列为 Issue 编号。
-- `issue +batch-comment` 和 `issue +batch-update` 会逐条执行,并输出每条 Issue 的结果汇总。
-- 批量命令支持 `--dry-run`,推荐先预览再真实执行。
+```bash
+# Issue 评论相关接口已由 issue +comments / +comment-update / +comment-delete / +comment-children 覆盖。
-## 文本输入约定
+# 批量更新 Issue(仍使用旧版 API,需传数据库 ID)
+gitlink-cli api POST /:owner/:repo/issues/series_update --body '{"ids":[1,2,3],"status_id":"closed"}'
+```
-- `issue +create`、`issue +update`、`issue +comment`、`issue +batch-comment`、`issue +batch-update` 都支持 `--body-file`。
-- `--body` 和 `--body-file` 互斥,避免正文来源不明确。
-- 长文本优先使用 `--body-file`,便于保留换行和 Markdown 格式。
+## GitLink Issue 字段映射
-## 安全建议
+| gitlink-cli 参数 | GitLink API 字段 | 说明 |
+|------------------|-----------------|------|
+| `--number` / `-n` | `project_issues_index` | Issue 编号(网页 URL 中的序号) |
+| `--id` / `-i` | `project_issues_index` | `--number` 的兼容别名,不是数据库内部 ID |
+| `--title` | `subject` | Issue 标题 |
+| `--body` | `description` | Issue 描述 |
+| `--assignee` | `assigned_to_id` | 指派人 ID |
+| `--milestone` | `fixed_version_id` | 里程碑 ID |
+| `--state` | `status_id` | 状态(open=1,closed=5,也可直接传数字 ID) |
+| `--priority-id` | `priority_id` | 优先级 ID |
+| `--tag-ids` / `--label` | `issue_tag_ids` | Issue 标签 ID 数组 |
+| `--assigner-ids` | `assigner_ids` | 负责人 ID 数组 |
+| `--branch` | `branch_name` | 关联分支 |
+| `--start-date` | `start_date` | 开始日期 |
+| `--due-date` | `due_date` | 截止日期 |
-- 对写操作先确认仓库、Issue 编号和目标字段。
-- 批量命令先跑 `--dry-run`,确认数量和目标无误后再执行真实写入。
-- `issue +update` 和 `issue +batch-update` 会先读取当前 Issue,再带上现有标题/描述/元数据发起 PATCH,避免误清空字段。
+## API 注意事项
-## 输出与自动化
+- **Issue 编号(`--number`)是网页 URL 中看到的序号**(如 `issues/4` 中的 `4`),不是数据库内部 ID
+- `--id` / `-i` 仅作为 `--number` / `-n` 的兼容别名,传入的仍然是网页 URL 中的 Issue 编号
+- **批量关闭使用 `--numbers`,同样传网页 URL 中的 Issue 编号**,不是数据库内部 ID
+- Issue 操作和 Issue 评论操作使用 v1 API(`/api/v1/`),支持按 Issue 编号查询和操作
+- **创建 Issue 时 CLI 会自动设置 `status_id: 1`(新增)和 `priority_id: 2`(正常)**
+- **更新/关闭 Issue 时必须保留当前 `subject` 和 `description`**,即使只修改状态(CLI 会先读取当前 Issue 并自动带回)
+- v1 API 写操作必须使用 `access_token`(非 `token`)认证,CLI 已自动处理
-- 所有命令都支持全局 `--format json|table|yaml`。
-- 批量命令输出统一包含 `repository`、`action`、`dry_run`、`total`、`succeeded`、`failed` 和逐条 `results`,适合脚本和 Agent 继续处理。
+## Issue 状态映射(status_id)
+
+| status_id | 名称 | 说明 |
+|-----------|------|------|
+| 1 | 新增 | 新建 Issue 的默认状态 |
+| 2 | 正在解决 | 处理中 |
+| 3 | 已解决 | 已修复 |
+| 5 | 关闭 | 关闭(`+close` 命令使用此值) |
diff --git a/skills/gitlink-pr/SKILL.md b/skills/gitlink-pr/SKILL.md
index 57033a0..a1644bb 100644
--- a/skills/gitlink-pr/SKILL.md
+++ b/skills/gitlink-pr/SKILL.md
@@ -1,7 +1,7 @@
---
name: gitlink-pr
-version: 2.0.0
-description: "Pull Request 管理:创建、查看、合并、关闭 PR,查看变更文件、Diff、patchset/version、可用分支和合并准备状态。当用户需要操作 GitLink PR 时触发。"
+version: 1.0.0
+description: "Pull Request 管理:创建、查看、合并、关闭 PR,查看变更文件、Diff、patchset/version、审查和 Review 评论。当用户需要操作 GitLink PR 时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
@@ -21,8 +21,6 @@ metadata:
|----------|------|----------|
| `pr +list` | PR 列表 | 否(公开项目) |
| `pr +create` | 创建 PR | 是 |
-| `pr +branches` | 获取 PR 可用源分支和目标分支 | 否 |
-| `pr +check-can-merge` | 检查源/目标分支是否可合并,先 dry-run | 是 |
| `pr +view` | PR 详情 | 否(公开项目) |
| `pr +merge` | 合并 PR | 是 |
| `pr +refuse` | 拒绝并关闭 PR | 是 |
@@ -33,7 +31,11 @@ metadata:
| `pr +version-diff` | 查看指定 patchset/version diff | 否 |
| `pr +reviews` | 查看 PR 审查记录,支持状态过滤 | 否 |
| `pr +review` | 创建 PR 审查(支持 dry-run 预览) | 是 |
-| `pr +comment` | 给 PR 添加评论 | 是 |
+| `pr +review-comments` | 查看 PR Review 行评论 | 否 |
+| `pr +review-comment` | 创建 PR Review 行评论(支持 dry-run 预览) | 是 |
+| `pr +review-comment-update` | 更新 PR Review 行评论(支持 dry-run 预览) | 是 |
+| `pr +review-comment-delete` | 删除 PR Review 行评论(支持 dry-run 预览) | 是 |
+| `pr +comment` | 给 PR 添加普通评论 | 是 |
## 使用示例
@@ -47,13 +49,6 @@ gitlink-cli pr +list --owner Gitlink --repo forgeplus --state merged --keyword r
# 创建 PR(源分支必须有实际代码变更)
gitlink-cli pr +create --title "feat: 新增搜索功能" --head feature/search --base master --body "实现了全文搜索"
-# 获取可用分支
-gitlink-cli pr +branches --owner Gitlink --repo forgeplus
-
-# 检查源分支是否可合并到目标分支,先 dry-run 再执行
-gitlink-cli pr +check-can-merge --owner Gitlink --repo forgeplus --head feature/search --base master --dry-run
-gitlink-cli pr +check-can-merge --owner Gitlink --repo forgeplus --head feature/search --base master --yes
-
# 查看 PR 详情(使用 pull_request_number,即网页 URL 中的序号)
gitlink-cli pr +view --id 3
@@ -85,7 +80,17 @@ gitlink-cli pr +reviews --id 3 --status approved
gitlink-cli pr +review --id 3 --status approved --content "LGTM, ready to merge"
gitlink-cli pr +review --id 3 --status approved --content "LGTM" --dry-run
-# 给 PR 添加评论
+# 查看 PR Review 行评论
+gitlink-cli pr +review-comments --id 3 --state opened --path README.md
+
+# 创建 PR Review 行评论(需要 review-id、commit、line-code 和 path,先 dry-run)
+gitlink-cli pr +review-comment --id 3 --review-id 10 --commit abc123 --line-code abc123_0_10 --path README.md --note "这里需要修改" --type problem --dry-run
+
+# 更新/解决/删除 PR Review 行评论(先 dry-run)
+gitlink-cli pr +review-comment-update --id 3 --comment-id 200 --state resolved --dry-run
+gitlink-cli pr +review-comment-delete --id 3 --comment-id 200 --dry-run
+
+# 给 PR 添加普通评论
gitlink-cli pr +comment --id 3 --body "LGTM, ready to merge"
```
@@ -147,6 +152,9 @@ gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=file.md&ref=dev'
# 从 entries.sha 获取 SHA,然后:
gitlink-cli api PUT /:owner/:repo/update_file --body '{"filepath":"file.md","content":"","sha":"","branch":"dev","message":"update file"}'
+# 检查是否可合并
+gitlink-cli api POST /:owner/:repo/pulls/check_can_merge --body '{"head":"dev","base":"main"}'
+
# 创建 Review
gitlink-cli api POST /v1/:owner/:repo/pulls/:id/reviews --body '{"content":"LGTM","status":"approved"}'
@@ -154,6 +162,11 @@ gitlink-cli api POST /v1/:owner/:repo/pulls/:id/reviews --body '{"content":"LGTM
gitlink-cli api GET /v1/:owner/:repo/pulls/:id/reviews
gitlink-cli api GET /v1/:owner/:repo/pulls/:id/reviews?status=approved
+# PR Review 评论接口已由 pr +review-comments / +review-comment / +review-comment-update / +review-comment-delete 覆盖。
+
+# 获取可用分支
+gitlink-cli api GET /:owner/:repo/pulls/get_branches
+
# 查看 PR patchset/version 列表(v1 API)
gitlink-cli api GET /v1/:owner/:repo/pulls/:id/versions
@@ -165,8 +178,6 @@ gitlink-cli api GET /v1/:owner/:repo/pulls/:id/versions/:version_id/diff
- ⛔ **GitLink 的 PR 操作必须用 `gitlink-cli pr`,不能用 `gh pr`。** `gh` 是 GitHub CLI,无法操作 GitLink 平台。详见 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 的「工具使用边界」章节。
- GitLink 的默认分支通常是 `master`(非 `main`),创建 PR 时注意 `--base` 参数
-- 创建或检查 PR 前可用 `pr +branches` 获取可选源/目标分支
-- `pr +check-can-merge` 使用远端 POST 检查接口,Agent 应先执行 `--dry-run`,确认后再加 `--yes`
- 合并 PR 前建议先用 `pr +view` 确认状态
- **PR 创建要求源分支与目标分支有实际代码差异**,否则返回"分支内容相同,无需创建合并请求"
- PR 查看/合并/关闭/重开需要使用 `pull_request_number`(即网页 URL `/pulls/N` 中的序号,从 `pr +list` 返回)
@@ -177,3 +188,14 @@ gitlink-cli api GET /v1/:owner/:repo/pulls/:id/versions/:version_id/diff
- `pr +list` 的 `--state` 参数会映射为 GitLink API 的 `status` 筛选;需要查看所有 PR 时传 `--state all`
- PR 状态值:`pull_request_status` 0=open, 1=merged, 2=closed
- 关联已有 Issue 时,把 Issue 编号或 URL 写入 PR `--body`,或使用 `issue +comment` 留痕;不要用 Raw API 对 Issue 做不完整更新,否则可能清空 Issue 描述
+
+
+## PR Review 评论注意事项
+
+- `pr +review-comment` 面向代码行级 Review 评论,不等同于 `pr +comment` 普通评论。
+- `--id` 是网页 URL `/pulls/N` 中的 PR 序号。
+- `--review-id` 可通过 `pr +reviews --id N` 获取。
+- `--line-code`、`--commit`、`--path` 应来自 GitLink diff / review 上下文;不确定时先用 `pr +files`、`pr +versions`、`pr +version-diff` 获取上下文。
+- `--type comment` 表示普通行评论,`--type problem` 表示需要回应的问题评论。
+- `--state opened|resolved|disabled` 用于筛选或更新 Review 评论状态。
+- 创建、更新、删除 Review 评论前必须先使用 `--dry-run` 向用户展示请求体。