Stops gh pr merge until the head is clean: CI green, a real review, no open threads, and the Astra line where a repo needs it

Stops gh pr merge until the PR's current head meets the merge-when-clean rule. A refused merge comes back to Claude as a tool error that lists each problem.
Before any Bash command that runs gh pr merge, it reads the PR with gh pr view and the review state with ~/.claude/skills/tatendaz-github/scripts/coderabbit_status.py. The merge goes through only when all of these hold:
coderabbit_status.py says action: done, looks_clean: true and clean_is_fresh: true for this exact head SHA. That means a real CodeRabbit review of this head or a review-failover success on it, not a clean review of an earlier push and not the bare stamp @coderabbitai ignore leaves. The gate runs the probe with --since <now>, so it judges the PR as it stands. Without that, the probe counted every bot comment since the PR opened as new, including its replies on resolved threads, and refused any PR that ever had a finding.ideas-inbox#17).astraRepos, the body or a comment holds Astra review: <n> findings.It accepts one form of merge only, and refuses every other command that mentions a merge:
cd <dir> && gh pr merge <number or PR URL> --repo <owner/repo> --squash --delete-branch
The cd, the PR, --repo, one of --squash/--merge/--rebase (or -s/-m/-r) and --delete-branch (-d) are optional. Anything else is refused: --admin in any form, --auto, a body or subject flag, a second merge, a pipe, quotes, $, backslashes, an environment assignment such as GH_REPO=, a merge inside bash -c, the REST pulls/<n>/merge and merges endpoints, GraphQL mergePullRequest, mergeBranch, enablePullRequestAutoMerge and enqueuePullRequest, and gh alias. The check runs on the text with quotes and backslashes removed, so gh "pr" merge is caught too. A commit message or echo that only mentions a merge is refused as well; write such text with a file tool.
When the PR is clean, the hook does not run the command as typed. It runs one it builds from the checked PR: gh pr merge <number> --repo <owner/repo> --match-head-commit <checked sha> <method> [--delete-branch]. So gh merges exactly the PR and head that were checked, and refuses if a push landed in between.
A command that runs gh, or whose command word expands ($GH), is refused when its first three words hold $, a backtick, a quote, a backslash or a glob, so gh pr m$(echo erge) 5 is caught. A literal tool path such as "$HOME/bin/tool" is not affected.
Limits. This is a guardrail against Claude merging by mistake, not a wall against a shell that wants to merge. A script file that runs gh pr merge, or an existing gh alias that expands to pr merge, is not caught (on 2026-10-19 gh alias list held only co: pr checkout). The hard stop is a GitHub ruleset that requires the CI checks on main.
When the review is missing, the refusal includes the coderabbit_budget.py run command to request one and the failover command to run on exit 75.
If gh or the status script fails, the merge is refused (the hook fails closed).
Every 5 minutes it runs coderabbit_budget.py status and shows CodeRabbit free · 1/5 this hour or CodeRabbit on hold until 21:40 · use budget failover.
| Option | Default | What it does |
|---|---|---|
astraRepos | Tatendaz/builtby | Comma-separated repos that need the Astra line. |
budgetStatus | true | Shows the CodeRabbit budget in the status line. |
None. It runs gh and two local Python scripts, and makes no model call.
tests/merge-gate.test.ts covers the allowed form, 29 refused forms, the built merge command, the superseded-run rule, each refusal reason, and the Bash hook end to end with a fake gh. The live merge path has not been run: the auto-mode classifier refused a test merge of an already-merged PR.
claude plugin test ~/.claude/mods/merge-gatehooks/register.ts 296 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Stops `gh pr merge` until the PR's head is clean by the owner's merge rule:
4// every CI check green, a real review on this head (CodeRabbit or the
5// review-failover backup), no unresolved threads, not a draft, no conflicts,
6// not a Night Shift PR, and the "Astra review:" line on repos that need it.
7
8const SCRIPTS = '.claude/skills/tatendaz-github/scripts'
9const NIGHT_SHIFT = /ideas-inbox(#|\/issues\/)17\b/
10const ASTRA_LINE = /Astra review:\s*\d+\s+findings?/i
11const GREEN = new Set(['SUCCESS', 'SKIPPED', 'NEUTRAL'])
12
13export type MergeTarget = {
14 /** The PR as typed: a number or a PR URL; absent for the current branch's PR. */
15 pr?: string
16 repo?: string
17 /** The directory a `cd` before the merge moved to. */
18 cwd?: string
19 method: '--squash' | '--merge' | '--rebase'
20 deleteBranch: boolean
21}
22
23export type MergeScan = { kind: 'none' } | { kind: 'refuse'; reason: string } | { kind: 'merge'; target: MergeTarget }
24
25// Anything that looks like a merge once quotes and backslashes are gone, including the API
26// routes that merge without `gh pr merge`: REST /merges, GraphQL mergeBranch, auto-merge and the merge queue.
27const MERGE_TEXT = /\bpr\s+merge\b|mergePullRequest|enablePullRequestAutoMerge|enqueuePullRequest|mergeBranch|\/merges?\b|\bgh\s+alias\b/i
28// What a plain merge command may hold: no expansion, substitution, quoting or redirection.
29const PLAIN = /^[A-Za-z0-9_./~:@=,+\- \n&;]*$/
30
31const PLAIN_FORM =
32 'merge-gate: run the merge on its own in the plain form, `gh pr merge <number> --repo <owner/repo> --squash --delete-branch` ' +
33 '(an optional `cd <dir> &&` before it). The gate checks that form and runs it pinned to the checked head. ' +
34 'If the command only mentions a merge in text, write that text with a file tool.'
35
36const EXPANDS = /[$`\\"'*?[{]/
37
38/**
39 * True when shell expansion could build a `gh` subcommand the text check
40 * cannot see (`gh pr m$(echo erge) 5`, `$GH pr merge`): a segment that runs
41 * gh, or whose command word itself expands, with an expanding character in
42 * its first three words.
43 */
44export function hidesGhVerb(command: string): boolean {
45 for (const segment of command.split(/&&|\|\||;|\n|\|/)) {
46 const w = segment.trim().split(/\s+/).filter(Boolean)
47 while (w.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0]!)) w.shift()
48 const head = w.slice(0, 3)
49 const exe = (w[0] ?? '').replace(/["']/g, '').split('/').pop()
50 // "$HOME/bin/tool" names a literal tool; only an expanding last part could be gh.
51 const exeExpands = /[$`]/.test(exe ?? '')
52 if ((exe === 'gh' || exeExpands) && head.slice(exe === 'gh' ? 1 : 0).some(word => EXPANDS.test(word)) && (exe === 'gh' || head.length > 1)) return true
53 }
54 return false
55}
56
57/**
58 * Accepts one form of merge only: optional `cd <dir> &&` segments, then
59 * `gh pr merge [<number>|<url>] [--repo <owner/repo>] [--squash|--merge|--rebase]
60 * [--delete-branch]`, nothing else. Any other command that mentions a merge is
61 * refused. The hook then runs a merge it builds itself from the checked PR,
62 * so nothing the command says beyond these fields reaches gh.
63 */
64export function scanMerge(command: string): MergeScan {
65 if (hidesGhVerb(command)) return { kind: 'refuse', reason: PLAIN_FORM }
66 if (!MERGE_TEXT.test(command.replace(/["'\\]/g, ''))) return { kind: 'none' }
67 if (!PLAIN.test(command)) return { kind: 'refuse', reason: PLAIN_FORM }
68
69 const segments = command.split(/&&|;|\n/).map(seg => seg.trim()).filter(Boolean)
70 const merge = segments.pop()!.split(/\s+/)
71 let cwd: string | undefined
72 for (const seg of segments) {
73 const w = seg.split(/\s+/)
74 if (w.length !== 2 || w[0] !== 'cd') return { kind: 'refuse', reason: PLAIN_FORM }
75 cwd = w[1]
76 }
77 if (merge[0] !== 'gh' || merge[1] !== 'pr' || merge[2] !== 'merge') return { kind: 'refuse', reason: PLAIN_FORM }
78
79 const target: MergeTarget = { method: '--squash', deleteBranch: false, ...(cwd ? { cwd } : {}) }
80 let methods = 0
81 for (let i = 3; i < merge.length; i++) {
82 const word = merge[i]!
83 if (word === '--repo' || word === '-R') target.repo = merge[++i]
84 else if (word.startsWith('--repo=')) target.repo = word.slice(7)
85 else if (word === '--squash' || word === '-s') (target.method = '--squash'), methods++
86 else if (word === '--merge' || word === '-m') (target.method = '--merge'), methods++
87 else if (word === '--rebase' || word === '-r') (target.method = '--rebase'), methods++
88 else if (word === '--delete-branch' || word === '-d') target.deleteBranch = true
89 else if (/^\d+$/.test(word) && target.pr === undefined) target.pr = word
90 else {
91 const url = word.match(/^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)$/)
92 if (!url || target.pr !== undefined) {
93 return { kind: 'refuse', reason: `merge-gate: \`${word}\` is not allowed in a merge. ${word.startsWith('--admin') ? 'The owner never allows --admin.' : PLAIN_FORM}` }
94 }
95 target.repo = url[1]
96 target.pr = url[2]
97 }
98 }
99 if (methods > 1) return { kind: 'refuse', reason: 'merge-gate: pick one of --squash, --merge or --rebase.' }
100 if (target.repo !== undefined && !/^[\w.-]+\/[\w.-]+$/.test(target.repo)) return { kind: 'refuse', reason: PLAIN_FORM }
101 return { kind: 'merge', target }
102}
103
104/** The merge the hook runs: built from the checked PR, pinned to the checked head. */
105export function mergeCommand(repo: string, number: number, sha: string, target: MergeTarget): string {
106 return ['gh', 'pr', 'merge', String(number), '--repo', repo, '--match-head-commit', sha, target.method, ...(target.deleteBranch ? ['--delete-branch'] : [])].join(' ')
107}
108
109type Check = {
110 __typename?: string
111 name?: string
112 workflowName?: string
113 context?: string
114 status?: string
115 conclusion?: string | null
116 state?: string
117 startedAt?: string
118}
119
120/** Keeps the newest run of each check: a re-run leaves the cancelled first run in the rollup. */
121export function latestChecks(rollup: readonly Check[]): Check[] {
122 const newest = new Map<string, Check>()
123 for (const c of rollup) {
124 const key = c.context !== undefined ? `status:${c.context}` : `check:${c.workflowName ?? ''}/${c.name ?? ''}`
125 const seen = newest.get(key)
126 if (!seen || String(c.startedAt ?? '') >= String(seen.startedAt ?? '')) newest.set(key, c)
127 }
128 return [...newest.values()]
129}
130
131/** Names every check on the head that is not green, and says whether any CI check ran at all. */
132export function redChecks(rollup: readonly Check[]): { red: string[]; hasCi: boolean } {
133 const red: string[] = []
134 let hasCi = false
135 for (const c of latestChecks(rollup)) {
136 if (c.__typename === 'StatusContext' || c.state !== undefined) {
137 if (c.state !== 'SUCCESS') red.push(`${c.context} ${String(c.state).toLowerCase()}`)
138 continue
139 }
140 hasCi = true
141 if (c.status !== 'COMPLETED') red.push(`${c.name} ${String(c.status).toLowerCase()}`)
142 else if (!GREEN.has(String(c.conclusion))) red.push(`${c.name} ${String(c.conclusion).toLowerCase()}`)
143 }
144 return { red, hasCi }
145}
146
147type Review = {
148 action?: string
149 reason?: string
150 looks_clean?: boolean
151 clean_is_fresh?: boolean
152 unresolved_threads?: number | null
153 head_sha?: string | null
154 rate_limited?: boolean
155}
156
157type Pr = {
158 number: number
159 url: string
160 headRefOid: string
161 isDraft: boolean
162 mergeable: string
163 body: string
164 statusCheckRollup: Check[]
165 comments: { body: string }[]
166}
167
168export type Verdict = { isClean: true; summary: string; headSha: string; number: number; repo: string } | { isClean: false; problems: string[]; hint?: string }
169
170/** Judges a PR against the merge rule from what gh and coderabbit_status.py reported. */
171export function judge(pr: Pr, review: Review, needsAstra: boolean): Verdict {
172 const problems: string[] = []
173 const repo = pr.url.match(/github\.com\/([^/]+\/[^/]+)\/pull/)?.[1] ?? '<owner/repo>'
174 let hint: string | undefined
175
176 if (NIGHT_SHIFT.test(pr.body ?? '')) {
177 return { isClean: false, problems: ['this is a Night Shift PR: the owner reads and merges those'] }
178 }
179 if (pr.isDraft) problems.push('it is a draft')
180 if (pr.mergeable === 'CONFLICTING') problems.push('it has merge conflicts')
181
182 const { red, hasCi } = redChecks(pr.statusCheckRollup ?? [])
183 if (!hasCi) problems.push('no CI check has reported on this head yet')
184 if (red.length > 0) problems.push(`not green: ${red.slice(0, 5).join(', ')}${red.length > 5 ? ` and ${red.length - 5} more` : ''}`)
185
186 // clean_is_fresh = the clean verdict covers this head (CodeRabbit reviewed it, or review-failover passed on it),
187 // not a clean review of an earlier push.
188 const reviewedHead = review.head_sha === pr.headRefOid && review.clean_is_fresh === true
189 if (!(review.action === 'done' && review.looks_clean === true && reviewedHead)) {
190 problems.push(`no clean review on head ${pr.headRefOid.slice(0, 7)}: ${review.reason ?? review.action ?? 'unknown'}`)
191 hint =
192 `Request one through the budget: python3 ~/${SCRIPTS}/coderabbit_budget.py run --kind pr --repo ${repo} --pr ${pr.number} -- ` +
193 `gh pr comment ${pr.number} --repo ${repo} --body '@coderabbitai review'. On exit 75 run ` +
194 `python3 ~/${SCRIPTS}/coderabbit_budget.py failover --repo ${repo} --pr ${pr.number}.`
195 }
196 if ((review.unresolved_threads ?? 0) > 0) problems.push(`${review.unresolved_threads} review threads are unresolved`)
197
198 if (needsAstra && ![pr.body ?? '', ...(pr.comments ?? []).map(c => c.body)].some(t => ASTRA_LINE.test(t))) {
199 problems.push('no "Astra review: <n> findings, <n> fixed" line in the PR')
200 }
201
202 if (problems.length > 0) return { isClean: false, problems, ...(hint ? { hint } : {}) }
203 return { isClean: true, headSha: pr.headRefOid, number: pr.number, repo, summary: `#${pr.number} at ${pr.headRefOid.slice(0, 7)}: CI green, reviewed, 0 open threads` }
204}
205
206async function home($: EngineInterface): Promise<string> {
207 return (await $.env.get('HOME')) ?? '~'
208}
209
210async function resolveCwd($: EngineInterface, cd: string | undefined): Promise<string> {
211 const base = await $.session.cwd()
212 if (!cd) return base
213 if (cd === '~' || cd.startsWith('~/')) return (await home($)) + cd.slice(1)
214 return cd.startsWith('/') ? cd : `${base}/${cd}`
215}
216
217async function json<T>($: EngineInterface, argv: string[], cwd: string): Promise<T> {
218 const { exitCode, stdout, stderr } = await $.process.run(argv, { cwd, timeoutMs: 60_000 })
219 if (exitCode !== 0) throw new Error(`${argv.slice(0, 3).join(' ')} failed: ${(stderr || stdout).trim().slice(0, 300)}`)
220 return JSON.parse(stdout) as T
221}
222
223async function check($: EngineInterface, target: MergeTarget, astraRepos: string[]): Promise<Verdict> {
224 const cwd = await resolveCwd($, target.cwd)
225 const fields = 'number,url,headRefOid,isDraft,mergeable,body,statusCheckRollup,comments'
226 const pr = await json<Pr>(
227 $,
228 ['gh', 'pr', 'view', ...(target.pr ? [target.pr] : []), ...(target.repo ? ['--repo', target.repo] : []), '--json', fields],
229 cwd,
230 )
231 const repo = pr.url.match(/github\.com\/([^/]+\/[^/]+)\/pull/)?.[1] ?? target.repo ?? ''
232 // --since now: the gate judges the PR as it stands (open threads, a fresh verdict on this head). Without it the
233 // probe counts every bot comment since the PR opened as new, including its replies on threads already resolved,
234 // and answers `address` forever on any PR that ever had a finding.
235 const review = await json<Review>(
236 $,
237 ['python3', `${await home($)}/${SCRIPTS}/coderabbit_status.py`, '--repo', repo, '--pr', String(pr.number), '--since', new Date().toISOString()],
238 cwd,
239 )
240 return judge(pr, review, astraRepos.includes(repo.toLowerCase()))
241}
242
243function budgetLine(status: { can_trigger_pr?: boolean; pr_triggers_last_hour?: number; hourly_limit_pr?: number; pr_cooldown_until?: string | null }): string {
244 if (status.can_trigger_pr) return `CodeRabbit free · ${status.pr_triggers_last_hour ?? 0}/${status.hourly_limit_pr ?? '?'} this hour`
245 const until = status.pr_cooldown_until ? new Date(status.pr_cooldown_until) : null
246 const at = until && !Number.isNaN(until.getTime()) ? ` until ${String(until.getHours()).padStart(2, '0')}:${String(until.getMinutes()).padStart(2, '0')}` : ''
247 return `CodeRabbit on hold${at} · use budget failover`
248}
249
250export const register: Register = (on, options) => {
251 const astraRepos = String((options as Record<string, unknown>).astraRepos ?? '')
252 .split(',')
253 .map(s => s.trim().toLowerCase())
254 .filter(Boolean)
255 const showBudget = (options as Record<string, unknown>).budgetStatus !== false
256
257 on('session.start', async ($, e, next) => {
258 if (showBudget) {
259 const refresh = async () => {
260 try {
261 const status = await json<Parameters<typeof budgetLine>[0]>(
262 $,
263 ['python3', `${await home($)}/${SCRIPTS}/coderabbit_budget.py`, 'status'],
264 await $.session.cwd(),
265 )
266 $.ui.status(budgetLine(status))
267 } catch {
268 $.ui.status(undefined)
269 }
270 }
271 void refresh()
272 $.clock.every(5 * 60_000, () => void refresh())
273 }
274 return next(e)
275 })
276
277 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
278 const scan = scanMerge(e.command)
279 if (scan.kind === 'none') return next(e)
280 if (scan.kind === 'refuse') return { deny: scan.reason }
281 const target = scan.target
282
283 const verdict = await check($, target, astraRepos)
284 if (!verdict.isClean) {
285 const list = verdict.problems.map(p => `- ${p}`).join('\n')
286 return { deny: `merge-gate: not clean, so not merged.\n${list}${verdict.hint ? `\n${verdict.hint}` : ''}` }
287 }
288 $.ui.toast(`merge-gate: ${verdict.summary}`)
289 return next({ ...e, command: mergeCommand(verdict.repo, verdict.number, verdict.headSha, target) })
290 }).catch(($, e, next) =>
291 next.called
292 ? next(e)
293 : { deny: 'merge-gate: could not check this merge (gh or coderabbit_status.py failed). Check the PR by hand, or ask the owner.' },
294 )
295}
296