diff --git a/README.md b/README.md index e5e4318..30e4a49 100644 --- a/README.md +++ b/README.md @@ -408,6 +408,11 @@ gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: Search feature # Create a PR (from a fork) gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: New feature" --head your_username/forgeplus:feature/my-feature --base master +# List PR branch candidates and check merge readiness +gitlink-cli pr +branches --owner Gitlink --repo forgeplus +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 + # View a PR gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42 diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..8bc7c67 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -418,6 +418,11 @@ gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: 搜索功能" # 创建 PR(从 Fork 仓库) gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: 新功能" --head your_username/forgeplus:feature/my-feature --base master +# 列出 PR 可用分支并检查合并准备状态 +gitlink-cli pr +branches --owner Gitlink --repo forgeplus +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 gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42 diff --git a/doc/changes/pr-readiness-shortcuts.md b/doc/changes/pr-readiness-shortcuts.md new file mode 100644 index 0000000..59abcd8 --- /dev/null +++ b/doc/changes/pr-readiness-shortcuts.md @@ -0,0 +1,65 @@ +# PR readiness shortcuts + +## Background + +Pull request workflows already support list, create, view, merge, review, +comments, changed files, and patchset/version inspection. Two helper endpoints +were still documented as Raw API calls: + +- `GET /:owner/:repo/pulls/get_branches` +- `POST /:owner/:repo/pulls/check_can_merge` + +This change adds first-class shortcuts for those PR preparation workflows. + +## New shortcuts + +- `pr +branches` lists PR source/target branch candidates. +- `pr +check-can-merge` checks whether a source branch can merge into a target + branch. + +## Safety model + +`pr +branches` is read-only and can run directly: + +```bash +gitlink-cli pr +branches --owner Gitlink --repo forgeplus +``` + +`pr +check-can-merge` uses a remote POST endpoint, so it supports dry-run and +requires explicit confirmation for real execution: + +```bash +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 +``` + +## Documentation updates + +- README and README.zh-CN include branch helper and merge readiness examples. +- `skills/gitlink-pr` now prefers `pr +branches` and `pr +check-can-merge` + over Raw API calls. +- `skills/gitlink-gatekeeper` includes merge readiness as a PR preflight signal. + +## Tests + +Unit tests cover: + +- endpoint method/path mapping for `pr +branches`; +- `pr +check-can-merge` dry-run behavior; +- `--yes` confirmation guard; +- request payload and default `--base master`; +- HTTP error propagation for both shortcuts. + +Suggested verification: + +```bash +go test ./shortcuts/pr ./shortcuts +``` + +Full project verification: + +```bash +go test ./... +``` diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..6239005 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -52,6 +52,8 @@ "cmd.org.list.short": "List organizations", "cmd.org.members.short": "List organization members", "cmd.org.short": "Organization operations", + "cmd.pr.branches.short": "List pull request source and target branches", + "cmd.pr.check_can_merge.short": "Check whether pull request branches can merge", "cmd.pr.close.short": "Close a pull request", "cmd.pr.comment.short": "Add a comment to a pull request", "cmd.pr.create.short": "Create a pull request", @@ -181,6 +183,7 @@ "flag.pr.assignee_id": "Assignee user ID", "flag.pr.base": "Target branch", "flag.pr.body": "PR description", + "flag.pr.check_yes": "Confirm the remote merge readiness check", "flag.pr.file": "Filter diff by file path", "flag.pr.head": "Source branch", "flag.pr.id": "PR number", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..984623c 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -52,6 +52,8 @@ "cmd.org.list.short": "列出组织", "cmd.org.members.short": "列出组织成员", "cmd.org.short": "组织操作", + "cmd.pr.branches.short": "列出拉取请求可用源分支和目标分支", + "cmd.pr.check_can_merge.short": "检查拉取请求分支是否可合并", "cmd.pr.close.short": "关闭拉取请求", "cmd.pr.comment.short": "给拉取请求添加评论", "cmd.pr.create.short": "创建拉取请求", @@ -181,6 +183,7 @@ "flag.pr.assignee_id": "指派人用户 ID", "flag.pr.base": "目标分支", "flag.pr.body": "PR 描述", + "flag.pr.check_yes": "确认执行远端可合并检查", "flag.pr.file": "按文件路径筛选 diff", "flag.pr.head": "源分支", "flag.pr.id": "PR 编号", diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 03f537f..49f364b 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -123,6 +123,31 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "branches", + Description: tr.T("cmd.pr.branches.short"), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/pulls/get_branches", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "check-can-merge", + Description: tr.T("cmd.pr.check_can_merge.short"), + Flags: []common.Flag{ + {Name: "head", Usage: tr.T("flag.pr.head"), Required: true}, + {Name: "base", Usage: tr.T("flag.pr.base"), Default: "master"}, + {Name: "dry-run", Usage: tr.T("flag.dry_run"), Bool: true, Default: "false"}, + {Name: "yes", Usage: tr.T("flag.pr.check_yes"), Bool: true, Default: "false"}, + }, + Run: runCheckCanMerge, + }, { Name: "view", Description: tr.T("cmd.pr.view.short"), @@ -434,6 +459,43 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { } } +func runCheckCanMerge(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + head, err := ctx.RequireArg("head") + if err != nil { + return err + } + base := ctx.Arg("base") + if base == "" { + base = "master" + } + payload := map[string]interface{}{ + "head": head, + "base": base, + } + path := ctx.RepoPath() + "/pulls/check_can_merge" + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "dry_run": true, + "action": "check_can_merge", + "method": "POST", + "path": path, + "payload": payload, + }) + } + if ctx.Arg("yes") != "true" { + return fmt.Errorf("check-can-merge calls a remote POST endpoint; run with --dry-run first, then pass --yes to execute") + } + env, err := ctx.CallAPI("POST", path, payload) + if err != nil { + return err + } + return ctx.Output(env) +} + func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { if len(translators) > 0 && translators[0] != nil { return translators[0] diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go index eece6d9..12b2a5c 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -226,6 +226,103 @@ 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) { @@ -416,6 +513,36 @@ 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) diff --git a/skills/gitlink-gatekeeper/SKILL.md b/skills/gitlink-gatekeeper/SKILL.md index ce31ddb..9c54c41 100644 --- a/skills/gitlink-gatekeeper/SKILL.md +++ b/skills/gitlink-gatekeeper/SKILL.md @@ -37,7 +37,7 @@ gitlink-gatekeeper 是一个**可复现的 PR 合并门禁**:团队把合并 | 阶段 | 操作 | AI Agent 角色 | |------|------|--------------| | ① 加载策略 | 读 `gatekeeper.yaml`,找不到则回退内置默认策略 | 解析 / 校验 / 回退 | -| ② 采集上下文 | 拉 PR 元信息、变更文件、diff、commits、CI 状态 | 执行 CLI 命令采集数据 | +| ② 采集上下文 | 拉 PR 元信息、分支可合并性、变更文件、diff、commits、CI 状态 | 执行 CLI 命令采集数据 | | ③ 产出发现 | 逐文件审查,按 severity 分级标记问题 | AI 分析,输出发现列表 | | ④ 逐维评分 | 五维各算 `0..weight` 得分,相加得 `total` | 确定性计算(非主观) | | ⑤ 硬门禁 | 逐项判定 `hard_gates`,命中即拦截 | 布尔判定 | @@ -97,6 +97,7 @@ behavior: | 步骤 | 数据 | 命令 | |------|------|------| | PR 元信息 | 标题/描述/作者/关联 issue | `gitlink-cli pr +view -i --format json` | +| 合并准备状态 | 源/目标分支是否可合并 | `gitlink-cli pr +check-can-merge --head --base --dry-run`,确认后 `--yes` | | 变更文件 | 文件路径列表 | `gitlink-cli pr +files -i --format json` | | Diff | 变更内容供 AI 审查 | `gitlink-cli pr +diff -i --format json` | | commits | commit 列表(消息供 commit_quality) | `gitlink-cli api GET /:owner/:repo/pulls/:id/commits --format json` | @@ -107,6 +108,7 @@ behavior: ```bash PR=42 gitlink-cli pr +view -i "$PR" --format json # title / body / 关联 issue +gitlink-cli pr +check-can-merge --head feature/pr --base master --dry-run gitlink-cli pr +files -i "$PR" --format json # changed files gitlink-cli pr +diff -i "$PR" --format json # diff(供 AI 审查) gitlink-cli api GET /:owner/:repo/pulls/$PR/commits --format json @@ -293,6 +295,7 @@ gitlink-cli api POST /:owner/:repo/issues/$ISSUE_ID --body '{ # 在目标仓库目录下,对 PR #42 跑门禁,仅预览评分卡 PR=42 gitlink-cli pr +view -i "$PR" --format json +gitlink-cli pr +check-can-merge --head feature/pr --base master --dry-run gitlink-cli pr +files -i "$PR" --format json gitlink-cli pr +diff -i "$PR" --format json gitlink-cli api GET /:owner/:repo/pulls/$PR/commits --format json diff --git a/skills/gitlink-pr/SKILL.md b/skills/gitlink-pr/SKILL.md index d00091d..57033a0 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 时触发。" +version: 2.0.0 +description: "Pull Request 管理:创建、查看、合并、关闭 PR,查看变更文件、Diff、patchset/version、可用分支和合并准备状态。当用户需要操作 GitLink PR 时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -21,6 +21,8 @@ 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 | 是 | @@ -45,6 +47,13 @@ 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 @@ -138,9 +147,6 @@ 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"}' @@ -148,9 +154,6 @@ 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 -# 获取可用分支 -gitlink-cli api GET /:owner/:repo/pulls/get_branches - # 查看 PR patchset/version 列表(v1 API) gitlink-cli api GET /v1/:owner/:repo/pulls/:id/versions @@ -162,6 +165,8 @@ 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` 返回)