diff --git a/README.md b/README.md index 289eb16..21ba1f8 100644 --- a/README.md +++ b/README.md @@ -308,13 +308,13 @@ 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 # 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 @@ -323,7 +323,13 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12 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 # List issue assigners gitlink-cli issue +assigners --owner Gitlink --repo forgeplus @@ -401,6 +407,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 ce6f964..cf6cfc7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -319,13 +319,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 @@ -334,7 +334,13 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12 gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from 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 # 列出 Issue 负责人 gitlink-cli issue +assigners --owner Gitlink --repo forgeplus @@ -411,6 +417,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 9e5a117..0b5818b 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -272,29 +272,18 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Description: tr.T("cmd.issue.comment.short"), Flags: appendIssueNumberFlags( common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, + common.Flag{Name: "parent-id", Usage: "Parent comment ID when creating a child comment"}, + common.Flag{Name: "reply-id", Usage: "Reply target comment ID"}, + common.Flag{Name: "attachment-ids", Usage: "Comma-separated attachment IDs"}, + common.Flag{Name: "receivers", Short: "r", Usage: "Comma-separated @ receiver login names"}, + common.Flag{Name: "dry-run", Usage: "Preview the request body without creating the comment", Bool: true, Default: "false"}, ), - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, err := issueNumberArg(ctx) - if err != nil { - return err - } - body, err := ctx.RequireArg("body") - if err != nil { - return err - } - payload := map[string]interface{}{ - "notes": body, - } - env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload) - if err != nil { - return err - } - return ctx.Output(env) - }, + Run: runIssueComment, }, + newIssueCommentsShortcut(), + newIssueCommentUpdateShortcut(), + newIssueCommentDeleteShortcut(), + newIssueCommentChildrenShortcut(), { Name: "assigners", Description: "List issue assigners", diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 48be057..6996d73 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{} @@ -1127,3 +1129,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 03f537f..2ca10b4 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -398,6 +398,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 eece6d9..cdec8e8 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -531,3 +531,178 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) { t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) } } + +func TestPRReviewCommentsListBuildsQuery(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) + } + 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() + + 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 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 != "/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{}{"id": 200, "note": "please fix"}) + })) + defer server.Close() + + 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("review-comment shortcut failed: %v", err) + } + + 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 af78bca..e022aa0 100644 --- a/skills/README.md +++ b/skills/README.md @@ -135,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` | -| **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` | diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index c5a4aa2..be6b256 100644 --- a/skills/gitlink-issue/SKILL.md +++ b/skills/gitlink-issue/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-issue version: 2.0.0 -description: "Issue 管理:创建、查看、更新、关闭/批量关闭 Issue,添加评论。当用户需要操作 GitLink Issue 时触发。" +description: "Issue 管理:创建、查看、更新、关闭/批量关闭 Issue,管理评论/操作记录。当用户需要操作 GitLink Issue 时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -26,7 +26,11 @@ metadata: | `issue +update` | 更新 Issue | 是 | | `issue +close` | 关闭 Issue | 是 | | `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) | -| `issue +comment` | 添加评论 | 是 | +| `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 状态列表 | 否(公开项目) | @@ -63,6 +67,20 @@ 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 @@ -73,8 +91,7 @@ gitlink-cli issue +authors --owner Gitlink --repo forgeplus --keyword bob ## Raw API 补充 ```bash -# 获取 Issue 评论列表(使用 v1 API,按 issue number 查询) -gitlink-cli api GET /v1/:owner/:repo/issues/:number/journals +# 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"}' @@ -103,7 +120,7 @@ gitlink-cli api POST /:owner/:repo/issues/series_update --body '{"ids":[1,2,3]," - **Issue 编号(`--number`)是网页 URL 中看到的序号**(如 `issues/4` 中的 `4`),不是数据库内部 ID - `--id` / `-i` 仅作为 `--number` / `-n` 的兼容别名,传入的仍然是网页 URL 中的 Issue 编号 - **批量关闭使用 `--numbers`,同样传网页 URL 中的 Issue 编号**,不是数据库内部 ID -- Issue 操作使用 v1 API(`/api/v1/`),支持按 Issue 编号查询和操作 +- 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 已自动处理 diff --git a/skills/gitlink-pr/SKILL.md b/skills/gitlink-pr/SKILL.md index d00091d..a1644bb 100644 --- a/skills/gitlink-pr/SKILL.md +++ b/skills/gitlink-pr/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-pr version: 1.0.0 -description: "Pull Request 管理:创建、查看、合并、关闭 PR,查看变更文件、Diff 和 patchset/version。当用户需要操作 GitLink PR 时触发。" +description: "Pull Request 管理:创建、查看、合并、关闭 PR,查看变更文件、Diff、patchset/version、审查和 Review 评论。当用户需要操作 GitLink PR 时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -31,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 添加普通评论 | 是 | ## 使用示例 @@ -76,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" ``` @@ -148,6 +162,8 @@ 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 @@ -172,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` 向用户展示请求体。