Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/permissions/rules.lex
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
94 changes: 94 additions & 0 deletions src/tools/github_pr_merge.lex
Original file line number Diff line number Diff line change
@@ -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")
}

4 changes: 3 additions & 1 deletion src/tools/index.lex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/tools/smoke.lex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading