diff --git a/README.md b/README.md index 26053f1..88ef4b4 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,13 @@ gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42 # View a patchset version diff gitlink-cli pr +version-diff --owner Gitlink --repo forgeplus -i 42 --version-id 16040 + +# List PR reviews +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" ``` ### Branch Management diff --git a/README.zh-CN.md b/README.zh-CN.md index ea37d6c..0b66995 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -220,6 +220,13 @@ gitlink-cli pr +versions --owner Gitlink --repo forgeplus -i 42 # 查看指定 patchset/version diff gitlink-cli pr +version-diff --owner Gitlink --repo forgeplus -i 42 --version-id 16040 + +# 查看 PR 审查记录 +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" ``` ### 发布管理 diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 15f3a17..7f9984c 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -225,6 +225,87 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "reviews", + Description: "List pull request reviews", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "status", Short: "s", Usage: "Filter review status: common, approved, rejected"}, + }, + Run: func(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{} + if status := ctx.Arg("status"); status != "" { + if err := validatePRReviewStatus(status); err != nil { + return err + } + q.Set("status", status) + } + env, err := ctx.CallAPIWithQuery("GET", prV1Path(ctx, id)+"/reviews", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "review", + Description: "Create a pull request review", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "status", Short: "s", Usage: "Review status: common, approved, rejected", Default: "common"}, + {Name: "content", Short: "c", Usage: "Review content", Required: true}, + {Name: "commit", Short: "m", Usage: "Commit SHA to attach the review to"}, + {Name: "dry-run", Usage: "Preview the review request without creating it", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + content, err := ctx.RequireArg("content") + if err != nil { + return err + } + status := ctx.Arg("status") + if status == "" { + status = "common" + } + if err := validatePRReviewStatus(status); err != nil { + return err + } + payload := map[string]interface{}{ + "content": content, + "status": status, + } + if commit := ctx.Arg("commit"); commit != "" { + payload["commit_id"] = commit + } + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "pull_request": id, + "dry_run": true, + "action": "create_review", + "payload": payload, + }) + } + env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/reviews", payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "comment", Description: "Add a comment to a pull request", @@ -265,6 +346,15 @@ func prV1Path(ctx *common.RuntimeContext, id string) string { return fmt.Sprintf("/v1/%s/%s/pulls/%s", ctx.Owner, ctx.Repo, id) } +func validatePRReviewStatus(status string) error { + switch status { + case "common", "approved", "rejected": + return nil + default: + return fmt.Errorf("invalid --status value %q: use common, approved, or rejected", status) + } +} + func extractIssueID(env *output.Envelope) (int64, error) { data, ok := env.Data.(map[string]interface{}) if !ok { diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go index 190ac63..db58cb6 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -164,6 +164,105 @@ func TestPRVersionDiffRequiresVersionID(t *testing.T) { } } +func TestPRReviewsUsesV1EndpointWithStatusFilter(t *testing.T) { + var calledPath string + var status string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls/13/reviews.json" { + t.Fatalf("unexpected request: %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery) + } + calledPath = r.URL.Path + status = r.URL.Query().Get("status") + writeJSON(t, w, map[string]interface{}{ + "total_count": float64(1), + "reviews": []map[string]interface{}{ + { + "id": float64(100), + "content": "LGTM", + "status": "approved", + }, + }, + }) + })) + defer server.Close() + + err := runPRShortcut(t, server, "reviews", map[string]string{ + "id": "13", + "status": "approved", + }) + if err != nil { + t.Fatalf("reviews shortcut failed: %v", err) + } + assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/reviews.json") + assertEqual(t, status, "approved") +} + +func TestPRReviewPostsReviewPayload(t *testing.T) { + var reviewPayload map[string]interface{} + var reviewPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/pulls/13/reviews.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + reviewPath = r.URL.Path + reviewPayload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{ + "id": float64(101), + "content": "Looks good", + "status": "approved", + "commit_id": "abc123", + }) + })) + defer server.Close() + + err := runPRShortcut(t, server, "review", map[string]string{ + "id": "13", + "status": "approved", + "content": "Looks good", + "commit": "abc123", + }) + if err != nil { + t.Fatalf("review shortcut failed: %v", err) + } + assertEqual(t, reviewPath, "/v1/owner/repo/pulls/13/reviews.json") + assertEqual(t, reviewPayload["content"], "Looks good") + assertEqual(t, reviewPayload["status"], "approved") + assertEqual(t, reviewPayload["commit_id"], "abc123") +} + +func TestPRReviewDryRunDoesNotCallAPI(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", map[string]string{ + "id": "13", + "status": "rejected", + "content": "Please fix the failing tests", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("review dry-run failed: %v", err) + } +} + +func TestPRReviewRejectsInvalidStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("server should not be called for invalid status: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runPRShortcut(t, server, "review", map[string]string{ + "id": "13", + "status": "approve", + "content": "LGTM", + }) + if err == nil { + t.Fatal("expected error for invalid review status, got nil") + } +} + func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { t.Helper() shortcut := findPRShortcut(t, name) diff --git a/skills/README.md b/skills/README.md index 4fc2789..972254b 100644 --- a/skills/README.md +++ b/skills/README.md @@ -123,7 +123,7 @@ skills/ | **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` | | **gitlink-repo** | 仓库管理 | `repo +list`, `repo +create`, `repo +info`, `repo +fork` | | **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` | +| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` | | **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` | | **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +view` | diff --git a/skills/gitlink-pr/SKILL.md b/skills/gitlink-pr/SKILL.md index 8a89719..1fb6beb 100644 --- a/skills/gitlink-pr/SKILL.md +++ b/skills/gitlink-pr/SKILL.md @@ -28,6 +28,8 @@ metadata: | `pr +diff` | 查看变更文件和 diff 内容 | 否 | | `pr +versions` | 查看 PR patchset/version 列表 | 否 | | `pr +version-diff` | 查看指定 patchset/version diff | 否 | +| `pr +reviews` | 查看 PR 审查记录,支持状态过滤 | 否 | +| `pr +review` | 创建 PR 审查(支持 dry-run 预览) | 是 | | `pr +comment` | 给 PR 添加评论 | 是 | ## 使用示例 @@ -59,6 +61,14 @@ gitlink-cli pr +versions --id 3 gitlink-cli pr +version-diff --id 3 --version-id 16040 gitlink-cli pr +version-diff --id 3 --version-id 16040 --file shortcuts/pr/pr.go +# 查看 PR 审查记录(支持按状态过滤) +gitlink-cli pr +reviews --id 3 +gitlink-cli pr +reviews --id 3 --status approved + +# 创建 PR 审查(common/approved/rejected,支持 --dry-run 预览) +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 添加评论 gitlink-cli pr +comment --id 3 --body "LGTM, ready to merge" ``` @@ -125,7 +135,11 @@ gitlink-cli api PUT /:owner/:repo/update_file --body '{"filepath":"file.md","con gitlink-cli api POST /:owner/:repo/pulls/check_can_merge --body '{"head":"dev","base":"main"}' # 创建 Review -gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"LGTM","event":"APPROVE"}' +gitlink-cli api POST /v1/:owner/:repo/pulls/:id/reviews --body '{"content":"LGTM","status":"approved"}' + +# 查看 Review 列表(支持 status 过滤) +gitlink-cli api GET /v1/:owner/:repo/pulls/:id/reviews +gitlink-cli api GET /v1/:owner/:repo/pulls/:id/reviews?status=approved # 获取可用分支 gitlink-cli api GET /:owner/:repo/pulls/get_branches diff --git a/skills/gitlink-pr/references/gitlink-pr-review.md b/skills/gitlink-pr/references/gitlink-pr-review.md new file mode 100644 index 0000000..32fb96d --- /dev/null +++ b/skills/gitlink-pr/references/gitlink-pr-review.md @@ -0,0 +1,73 @@ +# pr +review + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +创建 Pull Request 审查,可提交普通审查意见、通过(approve)或拒绝/请求修改(reject)。写入前建议先使用 `--dry-run` 预览请求内容。 + +## 命令 + +```bash +# 预览普通审查评论 +gitlink-cli pr +review --id 3 --status common --content "整体看起来可以" --dry-run + +# 提交普通审查评论 +gitlink-cli pr +review --id 3 --status common --content "整体看起来可以" + +# 通过 PR +gitlink-cli pr +review --id 3 --status approved --content "LGTM" + +# 请求修改 / 拒绝通过 +gitlink-cli pr +review --id 3 --status rejected --content "测试未通过,请修复后再合并" + +# 绑定到指定 commit +gitlink-cli pr +review --id 3 --status approved --content "LGTM" --commit +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--id` / `-i` | 是 | PR 序号(网页 URL `/pulls/N` 中的 `N`,即 `pull_request_number`) | +| `--status` / `-s` | 否 | 审查状态:`common`、`approved`、`rejected`,默认 `common` | +| `--content` / `-c` | 是 | 审查内容 | +| `--commit` / `-m` | 否 | 绑定审查的 commit SHA,对应 API 字段 `commit_id` | +| `--dry-run` | 否 | 只预览请求,不创建审查 | + +## API + +``` +POST /v1/{owner}/{repo}/pulls/{number}/reviews +``` + +请求体: + +```json +{ + "content": "LGTM", + "status": "approved", + "commit_id": "" +} +``` + +其中 `commit_id` 仅在传入 `--commit` 时发送。 + +## 安全流程 + +对写操作建议遵循: + +1. 先用 `pr +view` / `pr +files` / `pr +reviews` 获取上下文。 +2. 准备审查内容和状态。 +3. 执行 `pr +review --dry-run` 预览。 +4. 用户确认后去掉 `--dry-run` 执行真实写入。 + +## 注意事项 + +- `--status approved` 表示通过 PR;`--status rejected` 表示拒绝/请求修改;`--status common` 表示普通审查意见。 +- `--content` 必填,避免产生没有上下文的 approve/reject。 +- `--dry-run` 不会请求 GitLink API,适合 Agent 在执行写操作前展示计划。 + +## References + +- [gitlink-shared SKILL.md](../../gitlink-shared/SKILL.md) -- 认证与全局参数 +- [gitlink-pr SKILL.md](../SKILL.md) -- PR 操作总览 +- [pr +reviews](gitlink-pr-reviews.md) -- 查看 PR 审查记录 diff --git a/skills/gitlink-pr/references/gitlink-pr-reviews.md b/skills/gitlink-pr/references/gitlink-pr-reviews.md new file mode 100644 index 0000000..73c24c1 --- /dev/null +++ b/skills/gitlink-pr/references/gitlink-pr-reviews.md @@ -0,0 +1,61 @@ +# pr +reviews + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +查看 Pull Request 的审查记录,可按审查状态过滤。适合在合并前检查是否已有 approve、reject 或普通审查意见。 + +## 命令 + +```bash +# 查看 PR 审查记录 +gitlink-cli pr +reviews --id 3 + +# 简写 +gitlink-cli pr +reviews -i 3 + +# 按状态筛选 +gitlink-cli pr +reviews -i 3 --status approved +gitlink-cli pr +reviews -i 3 --status rejected +gitlink-cli pr +reviews -i 3 --status common + +# 指定仓库并输出 JSON +gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 3 --format json +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--id` / `-i` | 是 | PR 序号(网页 URL `/pulls/N` 中的 `N`,即 `pull_request_number`) | +| `--status` / `-s` | 否 | 审查状态:`common`、`approved`、`rejected` | + +## API + +``` +GET /v1/{owner}/{repo}/pulls/{number}/reviews +``` + +当传入 `--status` 时,会附加查询参数: + +``` +status= +``` + +## 典型流程 + +1. `pr +view --id ` 确认 PR 状态。 +2. `pr +files --id ` 查看变更文件。 +3. `pr +reviews --id ` 查看现有审查记录。 +4. 根据审查结果决定是否使用 `pr +review` 添加普通评论、approve 或 reject。 + +## 注意事项 + +- `--id` 不是数据库 ID,而是网页 URL `/pulls/N` 中的 PR 序号。 +- 这是只读查询命令,不会修改 PR。 +- `approved` 表示通过,`rejected` 表示拒绝/请求修改,`common` 表示普通审查评论。 + +## References + +- [gitlink-shared SKILL.md](../../gitlink-shared/SKILL.md) -- 认证与全局参数 +- [gitlink-pr SKILL.md](../SKILL.md) -- PR 操作总览 +- [pr +review](gitlink-pr-review.md) -- 创建 PR 审查