From e5dc802e2f5932137bcc5e99702d1e547f2f0321 Mon Sep 17 00:00:00 2001 From: Alfonso Sastre Date: Wed, 9 Sep 2026 09:56:02 +0200 Subject: [PATCH] tools: add github_pr_merge, wait for CI then land a PR github_pr_create (#152) got a change as far as "a PR exists". Landing it always meant a human (or an agent driving `gh` by hand outside this codebase) polling `gh pr checks`, then running `gh pr merge` once green -- the exact pattern GitHub Copilot's "Agent Merge" and Codex's automatic pre-merge approval reviews both shipped this year, and exactly what a real dogfooding session did by hand for six PRs in a row before this tool existed. Approval-gated for the same reason github_pr_create is: merging is visible to everyone with repo access and cannot be un-merged. The approval prompt covers the whole conditional intent ("merge this once checks pass"), asked before the outcome is known -- the same shape as a human reviewer saying "LGTM, merge when CI is green". Verifies checks itself (`gh pr checks --watch --fail-fast`) rather than trusting the caller's own judgment, and refuses to call `gh pr merge` at all unless that reports success -- a CI failure's own output becomes the tool's error detail, so the failure can actually be diagnosed rather than just reported as "didn't merge". `pr` accepts anything `gh` itself accepts (number, URL, branch name). Wired into refactor_permission's allowlist and all_tools_for_mode, same as github_pr_create; added to smoke.lex's skip list (a smoke run must never trigger a real merge). Co-Authored-By: Claude Sonnet 5 --- src/permissions/rules.lex | 2 +- src/tools/github_pr_merge.lex | 94 +++++++++++++++++++++++++++++++++++ src/tools/index.lex | 4 +- src/tools/smoke.lex | 4 +- 4 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 src/tools/github_pr_merge.lex diff --git a/src/permissions/rules.lex b/src/permissions/rules.lex index 33b9657..21cbcec 100644 --- a/src/permissions/rules.lex +++ b/src/permissions/rules.lex @@ -80,7 +80,7 @@ fn spec_permission() -> sp.Spec { } fn refactor_permission() -> sp.Spec { - allow_tools("refactor_tools", list.concat(["read", "write", "edit", "grep", "glob", "bash", "remember", "lex_check", "os_check", "lex_audit", "sigid_lookup", "effects_of", "lex_store_merge", "propagate_effect", "github_pr_create"], list.concat(vcs_read_names(), vcs_write_names()))) + allow_tools("refactor_tools", list.concat(["read", "write", "edit", "grep", "glob", "bash", "remember", "lex_check", "os_check", "lex_audit", "sigid_lookup", "effects_of", "lex_store_merge", "propagate_effect", "github_pr_create", "github_pr_merge"], list.concat(vcs_read_names(), vcs_write_names()))) } fn test_permission() -> sp.Spec { diff --git a/src/tools/github_pr_merge.lex b/src/tools/github_pr_merge.lex new file mode 100644 index 0000000..2fc9954 --- /dev/null +++ b/src/tools/github_pr_merge.lex @@ -0,0 +1,94 @@ +# lex-code — wait for a pull request's checks, then merge it +# +# github_pr_create (lex-code#152) got a change as far as "a PR exists". +# Landing it always meant a human (or Claude, driving `gh` by hand outside +# this codebase) polling `gh pr checks`, then running `gh pr merge` once +# green -- exactly the pattern GitHub Copilot's "Agent Merge" and Codex's +# automatic pre-merge approval reviews both shipped in 2026, and exactly +# what a real dogfooding session ended up doing by hand for six PRs in a +# row before this tool existed. +# +# Approval-gated for the same reason github_pr_create is: merging is +# visible to everyone with repo access and cannot be un-merged the way a +# local edit can be un-written. The approval prompt covers the whole +# conditional intent ("merge this once checks pass"), asked BEFORE the +# outcome is known -- the same shape as a human reviewer saying "LGTM, +# merge when CI is green" without re-approving after the fact. +# +# Deliberately verifies checks itself rather than trusting the caller's +# own judgment that they're green: `gh pr checks --watch --fail-fast` +# blocks until resolution and this tool refuses to call `gh pr merge` at +# all unless that reports success, regardless of whether the repo's own +# branch protection would have caught a bad merge anyway. A CI failure's +# output is returned as the error detail (not swallowed) so the failure +# can actually be diagnosed and fixed, not just reported as "didn't +# merge". +# +# `pr` accepts anything `gh` itself accepts for a PR -- a number, a URL, +# or a branch name (github_pr_create's own stdout is the PR's URL) -- +# rather than forcing this tool to parse one shape out of another. + +import "std.process" as proc + +import "std.list" as list + +import "lex-llm/tool" as t + +import "lex-schema/json_value" as jv + +import "lex-schema/error" as e + +import "lex-schema/schema" as s + +import "./util" as util + +fn params() -> s.ModelSchema { + { title: "GithubPrMergeArgs", description: "Wait for a pull request's checks to finish, then merge it if (and only if) they pass. Requires operator approval before it runs.", fields: [s.required_str("pr", []), s.optional(s.required_str("merge_method", []))] } +} + +fn merge_flag(method :: Str) -> Str + examples { + merge_flag("squash") => "--squash", + merge_flag("rebase") => "--rebase", + merge_flag("merge") => "--merge", + merge_flag("") => "--squash", + merge_flag("bogus") => "--squash" + } +{ + if method == "rebase" { + "--rebase" + } else { + if method == "merge" { + "--merge" + } else { + "--squash" + } + } +} + +fn execute(args :: jv.Json) -> [net, io, proc] Result[jv.Json, e.Errors] { + match util.field_str(args, "pr") { + None => Err(e.single("", "missing_field", "pr is required (a PR number, URL, or branch name)")), + Some(pr) => match proc.run("gh", ["pr", "checks", pr, "--watch", "--fail-fast"]) { + Err(msg) => Err(e.single("", "proc_error", msg)), + Ok(checks_out) => match util.cli_result(checks_out) { + Err(detail) => Err(e.single("", "checks_failed", detail)), + Ok(_) => { + let method := merge_flag(util.field_str_or(args, "merge_method", "squash")) + match proc.run("gh", ["pr", "merge", pr, method, "--delete-branch"]) { + Err(msg) => Err(e.single("", "proc_error", msg)), + Ok(merge_out) => match util.cli_result(merge_out) { + Err(detail) => Err(e.single("", "gh_pr_merge_failed", detail)), + Ok(body_out) => Ok(JStr(body_out)), + }, + } + }, + }, + }, + } +} + +fn tool() -> t.Tool { + t.with_approval(t.define("github_pr_merge", "Wait for a pull request's CI checks to finish (gh pr checks --watch), then merge it only if they pass, deleting the branch afterward. Requires operator approval before it runs.", params(), execute), "github_pr_merge") +} + diff --git a/src/tools/index.lex b/src/tools/index.lex index 0996394..07c07de 100644 --- a/src/tools/index.lex +++ b/src/tools/index.lex @@ -48,6 +48,8 @@ import "./remember" as remember_tool import "./github_pr_create" as github_pr_tool +import "./github_pr_merge" as github_pr_merge_tool + import "./lex_store_merge" as store_merge_tool import "./vcs/ast_diff" as vcs_ast_diff_tool @@ -124,7 +126,7 @@ fn vcs_tools() -> List[t.Tool] { # calling it — it compares a file's effects against that mode's grant — # so the toolset has to be built per mode rather than shared. fn all_tools_for_mode(mode :: Str) -> List[t.Tool] { - list.concat([read_tool.tool(), write_tool.tool(), edit_tool.tool(), grep_tool.tool(), glob_tool.tool(), bash_tool.tool(), todo_tool.tool(), remember_tool.tool(), check_tool.tool(), os_check_tool.tool_for_mode(mode), audit_tool.tool(), semantic_search_tool.tool(), run_tool.tool(), test_tool.tool(), spec_check_tool.tool(), spec_smt_tool.tool(), sigid_tool.tool(), attest_tool.tool(), effects_tool.tool(), store_merge_tool.tool(), propagate_tool.tool(), guidelines_tool.tool(), bar_check_tool.tool(), github_pr_tool.tool()], vcs_tools()) + list.concat([read_tool.tool(), write_tool.tool(), edit_tool.tool(), grep_tool.tool(), glob_tool.tool(), bash_tool.tool(), todo_tool.tool(), remember_tool.tool(), check_tool.tool(), os_check_tool.tool_for_mode(mode), audit_tool.tool(), semantic_search_tool.tool(), run_tool.tool(), test_tool.tool(), spec_check_tool.tool(), spec_smt_tool.tool(), sigid_tool.tool(), attest_tool.tool(), effects_tool.tool(), store_merge_tool.tool(), propagate_tool.tool(), guidelines_tool.tool(), bar_check_tool.tool(), github_pr_tool.tool(), github_pr_merge_tool.tool()], vcs_tools()) } # The build agent's own toolset: build's grant forbids nothing, so this diff --git a/src/tools/smoke.lex b/src/tools/smoke.lex index a88fe31..2176c48 100644 --- a/src/tools/smoke.lex +++ b/src/tools/smoke.lex @@ -143,10 +143,10 @@ fn render(o :: Outcome) -> Str { # one anybody kept. fn skipped() -> List[Str] examples { - skipped() => ["read", "write", "edit", "grep", "glob", "bash", "todowrite", "remember", "semantic_search", "load_toolset", "vcs_branch_create", "vcs_branch_use", "vcs_merge_start", "vcs_merge_resolve", "vcs_merge_resolve_one", "vcs_merge_defer", "vcs_merge_commit", "vcs_op_push", "vcs_op_pull", "github_pr_create"] + skipped() => ["read", "write", "edit", "grep", "glob", "bash", "todowrite", "remember", "semantic_search", "load_toolset", "vcs_branch_create", "vcs_branch_use", "vcs_merge_start", "vcs_merge_resolve", "vcs_merge_resolve_one", "vcs_merge_defer", "vcs_merge_commit", "vcs_op_push", "vcs_op_pull", "github_pr_create", "github_pr_merge"] } { - ["read", "write", "edit", "grep", "glob", "bash", "todowrite", "remember", "semantic_search", "load_toolset", "vcs_branch_create", "vcs_branch_use", "vcs_merge_start", "vcs_merge_resolve", "vcs_merge_resolve_one", "vcs_merge_defer", "vcs_merge_commit", "vcs_op_push", "vcs_op_pull", "github_pr_create"] + ["read", "write", "edit", "grep", "glob", "bash", "todowrite", "remember", "semantic_search", "load_toolset", "vcs_branch_create", "vcs_branch_use", "vcs_merge_start", "vcs_merge_resolve", "vcs_merge_resolve_one", "vcs_merge_defer", "vcs_merge_commit", "vcs_op_push", "vcs_op_pull", "github_pr_create", "github_pr_merge"] } fn is_skipped(name :: Str) -> Bool