SLOPSHOPPER

merge-gate

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

guardtoaststatusprocesstimer
v0.1.0no licenseupdated 2026-10-25Tatendaz/T-mods/merge-gate
A shopper browsing a rack in a slop shop
README

merge-gate

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.

What it checks

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:

  • Every CI check run is green. Only the newest run of each check counts, so a cancelled first run that a re-run replaced does not block. A head with no CI check at all is refused, because right after a push the checks have not reported yet.
  • 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.
  • 0 unresolved review threads.
  • Not a draft, and no merge conflicts.
  • Not a Night Shift PR (its body links ideas-inbox#17).
  • On repos listed in 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).

Status line

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.

Options

OptionDefaultWhat it does
astraReposTatendaz/builtbyComma-separated repos that need the Astra line.
budgetStatustrueShows the CodeRabbit budget in the status line.

Usage cost

None. It runs gh and two local Python scripts, and makes no model call.

Tests

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-gate
Source 1 files
hooks/register.ts 296 lines
1import 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