SLOPSHOPPER

merge-gate

Holds gh pr merge until CI passes on the PR head; /gate runs the project CI steps locally without a Claude turn

newguardcommandtoaststatusprocess
v0.4.5no licenseupdated 2026-10-12KZ-HQ/god-mods/merge-gate
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · merge-gate
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /gate ⎿ merge-gate: gate: 3 steps from package.json ⎿ merge-gate: ✔ npm run lint (0s) ⎿ merge-gate: ✔ npm run test (0s) ⎿ merge-gate: ✔ npm run build (0s) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

god-mods

Claude Code mods: hooks modules that change Claude's interface and behaviour, published as the god-mods plugin marketplace.

Install

In Claude Code:

/plugin marketplace add KZ-HQ/god-mods
/plugin install danger-guard@god-mods

Install the mods you want one by one. Restart Claude Code (a full restart; /reload-plugins does not load new mod code) for them to take effect.

Mods

ModWhat it does
agent-spend/spend [days] [here]: pane of subagent spend by model and agent type, with the costliest agents, from subagent transcripts
context-gaugeBand above the prompt: context fill, last turn's cost, session cost and rate limits; /gauge toggles it
danger-guardHolds risky shell commands and writes to protected data, previews what they would change, and asks before they run
long-turn-notifymacOS notification when a long turn finishes or Claude needs your input, in terminal sessions
merge-gateHolds gh pr merge until CI passes on the PR head; /gate runs the project's CI steps locally
mod-stats/mod-stats [days]: what each mod did, cost and saved, and how often it faulted
mod-faultsPassive: records any god-mod hook that fails, times out or is refused, and toasts at a mod's third fault in a session; /mod-faults. It sees only mods beneath it: put mod-faults@god-mods first in enabledPlugins (that order is the chain order)
next-stepsSuggests 2–3 likely next prompts after each turn; press 1/2/3 to draft one; /next off
prompt-shaperExpands your shorthand and attaches git state to each prompt; adds a project-toolchain note to the system prompt
secret-scrubberMasks API keys and tokens in tool output, and refuses to write them into source files
test-on-editWhen Claude's answer ends, runs the tests of the files it edited; failures go to Claude with your next prompt

Plugin order

Installed mods nest in the order of enabledPlugins in ~/.claude/settings.json, first outermost; a plugin can't choose its place. mod-faults only sees faults in mods beneath it, so keep it first:

"enabledPlugins": {
  "mod-faults@god-mods": true,
  "danger-guard@god-mods": true,
  ...
}

Plugins installed later are appended, so they land beneath it. Reinstalling mod-faults moves it to the end again; its start-up toast and /mod-faults name any mods it can't see.

Metrics

Every mod appends local JSON lines to ~/.claude/mods/metrics.jsonl; nothing leaves your machine. Rows (schema v: 2) carry t, mod, event, ver, session and project:

  • loaded: once per session start, with the mod's version
  • turn: once per turn, counting each hook's calls, e.g. {"tool.call": 3, "command.run{goal}": 1}
  • anything else: an outcome the mod recorded, e.g. merge-gate's verdict

Rows hold counts, kinds and lengths only, never prompt, command or tool-input text. /mod-stats summarises them. mod-faults adds a fault row per failed hook, naming the mod and event; it no longer writes ~/.claude/logs/god-mods-faults.jsonl, which can be deleted.

Development

Each mod is a folder with .claude-plugin/plugin.json and hooks/register.tsx, plus tests in tests/.

The metrics code lives once in shared/metrics.ts and is inlined into every register.tsx between // <metrics> markers, because the engine only lets $ into functions declared in the same file. Edit the shared file, then:

shared/sync.sh          # inline into every mod
shared/sync.sh --check  # fail if any copy is stale

Check a mod before committing:

claude plugin validate <mod>
claude plugin test <mod>

Bump the mod's version in its plugin.json and in .claude-plugin/marketplace.json together.

Source 2 files
hooks/register.tsx 307 lines
1import type { EngineInterface, On, Register } from 'claude-code'
2
3import { MCP_MERGE, judge, mergeFromMcp, parseMerge, stepsFromMakefile, stepsFromPackage, stepsFromWorkflow } from './steps.ts'
4import type { Check, Merge, Step } from './steps.ts'
5
6const STEP_TIMEOUT_MS = 10 * 60 * 1000
7const FAIL_TAIL_LINES = 25
8const MERGE = 'Merge anyway'
9const HOLD = "Don't merge"
10
11const join = (root: string, rel: string) => (rel === '.' || rel === '' ? root : rel.startsWith('/') ? rel : `${root}/${rel.replace(/^\.\//, '')}`)
12
13// Where the steps come from, first match wins: .claude/gate.json, CI workflows, Makefile, package.json.
14async function findSteps($: EngineInterface, root: string): Promise<{ source: string; steps: Step[] }> {
15  const read = (p: string) => $.fs.read(`${root}/${p}`).catch(() => undefined)
16
17  const custom = await read('.claude/gate.json')
18  if (custom) {
19    const parsed = JSON.parse(custom) as { steps?: Step[] }
20
21    return { source: '.claude/gate.json', steps: (parsed.steps ?? []).map(s => ({ ...s, cwd: s.cwd ?? '.' })) }
22  }
23
24  const flows = (await $.fs.list(`${root}/.github/workflows`).catch(() => [])).filter(f => /\.ya?ml$/.test(f.name)).map(f => f.name).sort()
25  const fromFlows: Step[] = []
26  for (const f of flows) fromFlows.push(...stepsFromWorkflow((await read(`.github/workflows/${f}`)) ?? ''))
27  if (fromFlows.length) return { source: `.github/workflows (${flows.join(', ')})`, steps: fromFlows }
28
29  const make = await read('Makefile')
30  if (make && stepsFromMakefile(make).length) return { source: 'Makefile', steps: stepsFromMakefile(make) }
31
32  const pkg = await read('package.json')
33  if (pkg) return { source: 'package.json', steps: stepsFromPackage(JSON.parse(pkg) as { scripts?: Record<string, string> }, '.') }
34
35  return { source: 'nothing found', steps: [] }
36}
37
38const tail = (text: string) => text.trimEnd().split('\n').slice(-FAIL_TAIL_LINES).join('\n')
39
40async function runGate($: EngineInterface): Promise<string> {
41  const root = await repoRoot($)
42  if (!root) return 'gate: not in a git repository.'
43  const { source, steps } = await findSteps($, root)
44  if (steps.length === 0) return 'gate: no checks found. Add .claude/gate.json: { "steps": [{ "name": "test", "run": "npm test", "cwd": "." }] }'
45
46  const lines: string[] = [`gate: ${steps.length} steps from ${source}`]
47  for (const [i, step] of steps.entries()) {
48    $.ui.status(`gate: ${i + 1}/${steps.length} ${step.name}…`)
49    const started = await $.clock.now()
50    try {
51      const r = await $.process.run(['/bin/zsh', '-lc', step.run], { cwd: join(root, step.cwd), timeoutMs: STEP_TIMEOUT_MS })
52      const secs = Math.round(((await $.clock.now()) - started) / 1000)
53      if (r.exitCode === 0) {
54        lines.push(`✔ ${step.name} (${secs}s)`)
55        continue
56      }
57      lines.push(`✘ ${step.name} (exit ${r.exitCode}, ${secs}s)`, tail(`${r.stdout}\n${r.stderr}`))
58    } catch (err) {
59      lines.push(`✘ ${step.name}: ${err instanceof Error ? err.message : String(err)}`)
60    }
61    const skipped = steps.slice(i + 1).map(s => s.name)
62    if (skipped.length) lines.push(`skipped: ${skipped.join(', ')}`)
63    break
64  }
65  $.ui.status(undefined)
66
67  return lines.join('\n')
68}
69
70async function ask($: EngineInterface, question: string): Promise<boolean> {
71  try {
72    const merged = (await $.ui.ask(question, { header: 'Merge gate', options: [MERGE, HOLD] })) === MERGE
73    track($, 'merge-gate', 'asked', { answer: merged ? 'merge' : 'hold' })
74
75    return merged
76  } catch {
77    return false
78  }
79}
80
81// The desktop app's PATH has no Homebrew, so `gh` alone isn't found there: ask a login shell once
82async function gh($: EngineInterface, args: string[], cwd: string | undefined) {
83  const r = await $.process.run([await findGh($), ...args], { cwd, timeoutMs: 30000 })
84
85  return { code: r.exitCode, out: r.stdout.trim(), err: r.stderr.trim() }
86}
87
88const heldOnError = (error: unknown) => ({
89  deny: `merge-gate could not check this merge (${message(error)}), so it was held.`,
90})
91
92// The folder the merge command runs in: its leading `cd`, with ~ and $HOME expanded, against the session's folder.
93// A missing folder makes spawning gh fail as ENOENT naming gh itself, so callers check it exists.
94async function mergeCwd($: EngineInterface, dir: string | undefined): Promise<string> {
95  const sessionCwd = await $.session.cwd()
96  if (!dir) return sessionCwd
97  const home = (await $.env.get('HOME')) ?? ''
98  const expanded = home ? dir.replace(/^(~|\$HOME|\$\{HOME\})(?=\/|$)/, home) : dir
99
100  return join(sessionCwd, expanded)
101}
102
103// Undefined lets the merge go ahead; a string is why it is held.
104async function gate($: EngineInterface, merge: Merge): Promise<string | undefined> {
105  const cwd = await mergeCwd($, merge.cwd)
106  if (!(await $.fs.exists(cwd))) return `merge-gate: the merge runs in ${cwd}, which does not exist, so CI could not be checked.`
107  const target = [...(merge.selector ? [merge.selector] : []), ...(merge.repo ? ['--repo', merge.repo] : [])]
108  const label = merge.selector ? `PR ${merge.selector}` : 'the PR for this branch'
109
110  let checks: Check[]
111  let draft = false
112  try {
113    const view = await gh($, ['pr', 'view', ...target, '--json', 'number,isDraft,state'], cwd)
114    if (view.code !== 0) throw new Error(view.err.split('\n')[0] || `gh exited ${view.code}`)
115    draft = (JSON.parse(view.out) as { isDraft: boolean }).isDraft
116    // Exits non-zero while checks fail or run, and still prints them
117    const r = await gh($, ['pr', 'checks', ...target, '--json', 'name,bucket,state'], cwd)
118    checks = r.out.startsWith('[') ? (JSON.parse(r.out) as Check[]) : []
119    if (!r.out.startsWith('[') && !/no checks reported/i.test(r.err)) throw new Error(r.err.split('\n')[0] || `gh exited ${r.code}`)
120  } catch (err) {
121    const why = err instanceof Error ? err.message : String(err)
122
123    return (await ask($, `merge-gate couldn't read CI for ${label} (${why}). Merge anyway?`))
124      ? undefined
125      : `merge-gate: couldn't verify CI for ${label} (${why}), and the user didn't approve merging blind.`
126  }
127
128  if (draft) return `merge-gate: ${label} is a draft. Mark it ready for review first.`
129
130  const verdict = judge(checks)
131  track($, 'merge-gate', 'verdict', { kind: verdict.kind })
132  switch (verdict.kind) {
133    case 'pass':
134      $.ui.toast(`merge-gate: ${verdict.count} checks passed on ${label}`)
135
136      return undefined
137    case 'fail':
138      return `merge-gate: CI failed on ${label}: ${verdict.names.join(', ')}. Fix and push, then merge once checks pass.`
139    case 'pending':
140      return `merge-gate: CI is still running on ${label} (${verdict.names.join(', ')}). Wait with \`gh pr checks ${merge.selector ?? ''} --watch\`, or queue it with \`gh pr merge --auto\`.`
141    case 'none':
142      return (await ask($, `${label} has no CI checks. Merge anyway?`))
143        ? undefined
144        : `merge-gate: ${label} has no CI checks and the user didn't approve merging without them.`
145  }
146}
147
148// <metrics: generated from shared/metrics.ts by shared/sync.sh>
149// Shared mod metrics (schema v2). Source of truth: ~/.claude/mods/shared/metrics.ts.
150// shared/sync.sh inlines it between the metrics markers in each mod's register.tsx (the engine only lets $
151// into functions declared in the same file). Edit here, run sync.sh, never edit an inlined copy.
152//
153// Rows appended to ~/.claude/mods/metrics.jsonl, all with { v: 2, t, mod, event, ver, session, project }:
154//   loaded   once per load (logLoaded, or count() in the mod's session.start hook), at session.start: proves which version of the mod is running
155//   turn     once per turn with activity (logTurns, or count() in the mod's turn.complete hook): { hooks: { <event>: calls } }
156//   <other>  outcome rows from track(), e.g. merge-gate 'verdict'
157// Privacy: never put prompt, command, tool-input or matched-secret text in a row; lengths, kinds and counts only.
158
159const APPEND = 'mkdir -p "$HOME/.claude/mods" && printf "%s\\n" "$1" >> "$HOME/.claude/mods/metrics.jsonl"'
160
161
162const ctx = { ver: '', session: '', project: '' }
163const stats = new Map<string, number>()
164let writeFailed = false
165
166const message = (err: unknown) => (err instanceof Error ? err.message : String(err)).slice(0, 300)
167
168function write($: EngineInterface, mod: string, event: string, data: Record<string, unknown>): void {
169  void (async () => {
170    const t = new Date(await $.clock.now()).toISOString()
171    if (!ctx.project) ctx.project = (await $.session.cwd()).split('/').filter(Boolean).pop() ?? ''
172    const line = JSON.stringify({ v: 2, t, mod, event, ver: ctx.ver, session: ctx.session, project: ctx.project, ...data })
173    const { exitCode, stderr } = await $.process.run(['/bin/sh', '-c', APPEND, 'mod-metrics', line], { timeoutMs: 3000 })
174    if (exitCode !== 0) throw new Error(stderr.trim() || `exit ${exitCode}`)
175  })().catch((err: unknown) => {
176    if (writeFailed) return
177    writeFailed = true
178    $.ui.toast(`${mod}: metrics write failed (${message(err)})`)
179  })
180}
181
182// An outcome row: something the mod decided or did. Fire-and-forget.
183function track($: EngineInterface, mod: string, event: string, data: Record<string, unknown> = {}): void {
184  write($, mod, event, data)
185}
186
187async function loaded($: EngineInterface): Promise<void> {
188  const mod = $.plugin.name
189  ctx.session = await $.session.id().catch(() => '')
190  ctx.project = ''
191  try {
192    const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: string }
193    ctx.ver = manifest.version ?? ''
194  } catch {
195    ctx.ver = '?'
196  }
197  write($, mod, 'loaded', {})
198}
199
200function flush($: EngineInterface): void {
201  const mod = $.plugin.name
202  if (!stats.size) return
203  const hooks = Object.fromEntries(stats)
204  stats.clear()
205  write($, mod, 'turn', { hooks })
206}
207
208// The engine allows one hook per event per plugin, and counts them by spelling. A mod that hooks
209// session.start or turn.complete itself calls count() in that hook, which writes the row; otherwise
210// it calls the matching helper once in register.
211function logLoaded(on: On): void {
212  on('session.start', async ($, e, next) => {
213    await loaded($)
214
215    return next(e)
216  })
217}
218
219function logTurns(on: On): void {
220  on('turn.complete', async ($, e, next) => {
221    flush($)
222
223    return next(e)
224  })
225}
226
227// First statement of every hook: counts the call into this turn's row. The engine only accepts
228// function literals as hooks, so calls are counted from inside rather than by wrapping.
229// In a session.start hook it also writes the loaded row; in a turn.complete hook it writes the turn row.
230function count($: EngineInterface, event: string): void {
231  stats.set(event, (stats.get(event) ?? 0) + 1)
232  if (event === 'session.start') void loaded($)
233  if (event === 'turn.complete') flush($)
234}
235// </metrics>
236
237// <gh: generated from shared/gh.ts by shared/sync.sh>
238let ghPath: string | undefined
239
240// gh's absolute path, found through a login shell: the desktop app's PATH lacks Homebrew. Falls back to plain gh.
241async function findGh($: EngineInterface): Promise<string> {
242  if (ghPath) return ghPath
243  try {
244    const r = await $.process.run(['/bin/zsh', '-lc', 'command -v gh'], { timeoutMs: 5000 })
245    const found = r.stdout.trim().split('\n').pop()
246    if (r.exitCode === 0 && found?.startsWith('/')) return (ghPath = found)
247  } catch {
248    // No zsh: fall back to PATH
249  }
250
251  return 'gh'
252}
253// </gh>
254
255// <git: generated from shared/git.ts by shared/sync.sh>
256// The git top level of cwd (the session's folder when omitted); undefined outside a repository or when git fails
257async function repoRoot($: EngineInterface, cwd?: string): Promise<string | undefined> {
258  const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { ...(cwd ? { cwd } : {}), timeoutMs: 5000 }).catch(() => undefined)
259
260  return r && r.exitCode === 0 ? r.stdout.trim() || undefined : undefined
261}
262// </git>
263
264export const register: Register = on => {
265  logTurns(on)
266
267  on('session.start', async ($, e, next) => {
268    count($, 'session.start')
269    await $.command.register({ name: 'gate', description: "Run the project's CI steps locally (no Claude turn); /gate list shows them" })
270
271    return next(e)
272  })
273
274  on('command.run', { command: 'gate' }, async ($, e) => {
275    count($, 'command.run{gate}')
276    if (e.args.trim() === 'list') {
277      const root = await repoRoot($)
278      if (!root) return { text: 'gate: not in a git repository.' }
279      const { source, steps } = await findSteps($, root)
280
281      return { text: [`From ${source}:`, ...steps.map(s => `  [${s.cwd}] ${s.run.replace(/\n/g, ' && ')}`)].join('\n') }
282    }
283
284    return { text: await runGate($) }
285  })
286
287  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
288    count($, 'tool.call{Bash}')
289    const merge = parseMerge(e.command)
290    if (!merge) return next(e)
291    const deny = await gate($, merge)
292
293    return deny === undefined ? next(e) : { deny }
294  }).catch(($, e, next) => (next.called ? next(e) : heldOnError(next.error)))
295
296  // The same gate for a merge through an MCP server's tool
297  on('tool.call', async ($, e, next) => {
298    count($, 'tool.call')
299    if (!MCP_MERGE.test(String(e.tool)) || !String(e.tool).startsWith('mcp__')) return next(e)
300    const merge = mergeFromMcp(e as unknown as Record<string, unknown>)
301    if (!merge) return next(e)
302    const deny = await gate($, merge)
303
304    return deny === undefined ? next(e) : { deny }
305  }).catch(($, e, next) => (next.called ? next(e) : heldOnError(next.error)))
306}
307
hooks/steps.ts 165 lines
1// Pure helpers: no `$`, so tests call them directly.
2
3export type Step = { name: string; run: string; cwd: string }
4
5// Dependency installs: CI needs them on a fresh runner, a local checkout already has them.
6const INSTALL = /^(npm (ci|install)|pnpm (i|install)|yarn( install)?|bun install|pip install|uv sync|poetry install|bundle install|npm run install:ci)\b/
7
8const indentOf = (line: string) => line.length - line.trimStart().length
9
10// The `run:` steps of a GitHub Actions workflow, in order, with their working
11// directory. Line-based: enough for ordinary workflows, not a YAML parser.
12export function stepsFromWorkflow(yaml: string): Step[] {
13  const lines = yaml.split('\n')
14  const steps: Step[] = []
15  let defaultCwd = '.'
16  let current: { name?: string; run?: string; cwd?: string; indent: number } | undefined
17  let inSteps = false
18  let stepsIndent = -1
19
20  const flush = () => {
21    if (current?.run && !INSTALL.test(current.run.trim())) {
22      steps.push({ name: current.name ?? current.run.split('\n')[0]!, run: current.run, cwd: current.cwd ?? defaultCwd })
23    }
24    current = undefined
25  }
26
27  for (let i = 0; i < lines.length; i++) {
28    const line = lines[i]!
29    const text = line.trim()
30    if (text === '' || text.startsWith('#')) continue
31    const indent = indentOf(line)
32
33    if (inSteps && indent <= stepsIndent && !text.startsWith('-')) {
34      flush()
35      inSteps = false
36    }
37    if (/^steps:\s*$/.test(text)) {
38      flush()
39      inSteps = true
40      stepsIndent = indent
41      continue
42    }
43    const wd = text.match(/^working-directory:\s*(.+)$/)
44    if (!inSteps) {
45      if (wd) defaultCwd = wd[1]!.replace(/^['"]|['"]$/g, '')
46      continue
47    }
48
49    const body = text.startsWith('- ') ? text.slice(2).trim() : text
50    if (text.startsWith('- ')) {
51      flush()
52      current = { indent }
53    }
54    if (!current) continue
55
56    const kv = body.match(/^([\w-]+):\s*(.*)$/)
57    if (!kv) continue
58    const [, key, value = ''] = kv
59    if (key === 'name') current.name = value.replace(/^['"]|['"]$/g, '')
60    if (key === 'working-directory') current.cwd = value.replace(/^['"]|['"]$/g, '')
61    if (key === 'run') {
62      if (value === '|' || value === '>' || value === '|-' || value === '>-') {
63        const block: string[] = []
64        const keyIndent = indentOf(line) + (text.startsWith('- ') ? 2 : 0)
65        while (i + 1 < lines.length && (lines[i + 1]!.trim() === '' || indentOf(lines[i + 1]!) > keyIndent)) {
66          block.push(lines[++i]!.trim())
67        }
68        current.run = block.filter(Boolean).join(value.startsWith('>') ? ' ' : '\n')
69      } else {
70        current.run = value.replace(/^['"]|['"]$/g, '')
71      }
72    }
73  }
74  flush()
75
76  // Same command in two jobs runs once
77  const seen = new Set<string>()
78
79  return steps.filter(s => !seen.has(`${s.cwd}\0${s.run}`) && seen.add(`${s.cwd}\0${s.run}`))
80}
81
82// Fallback when there is no workflow: the usual check targets.
83export function stepsFromMakefile(makefile: string): Step[] {
84  const targets = new Set([...makefile.matchAll(/^([A-Za-z][\w-]*):/gm)].map(m => m[1]!))
85
86  return ['lint', 'typecheck', 'check', 'test'].filter(t => targets.has(t)).map(t => ({ name: `make ${t}`, run: `make ${t}`, cwd: '.' }))
87}
88
89export function stepsFromPackage(pkg: { scripts?: Record<string, string> }, cwd: string): Step[] {
90  const scripts = pkg.scripts ?? {}
91
92  return ['lint', 'typecheck', 'test', 'build'].filter(s => s in scripts).map(s => ({ name: `npm run ${s}`, run: `npm run ${s}`, cwd }))
93}
94
95export type Check = { name: string; bucket: string; state?: string }
96
97export type Verdict =
98  | { kind: 'pass'; count: number }
99  | { kind: 'none' }
100  | { kind: 'pending'; names: string[] }
101  | { kind: 'fail'; names: string[] }
102
103export function judge(checks: readonly Check[]): Verdict {
104  if (checks.length === 0) return { kind: 'none' }
105  const failed = checks.filter(c => c.bucket === 'fail' || c.bucket === 'cancel').map(c => c.name)
106  if (failed.length) return { kind: 'fail', names: failed }
107  const pending = checks.filter(c => c.bucket === 'pending').map(c => c.name)
108  if (pending.length) return { kind: 'pending', names: pending }
109
110  return { kind: 'pass', count: checks.length }
111}
112
113// Quoted text (commit messages, echo arguments) is data, never a command.
114// Heredoc bodies are text, not commands: drop each from its << line to its terminator line
115const withoutHeredocs = (command: string) =>
116  command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?^\s*\2\s*$/gm, '')
117// Blanks quoted text but keeps its length, so offsets in the result index the original
118const unquoted = (command: string) => command.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, q => `"${' '.repeat(q.length - 2)}"`)
119
120export type Merge = { selector?: string; repo?: string; cwd?: string }
121
122// The PR a `gh pr merge` names (number, URL or branch), its repo flag, and a leading `cd`.
123// Undefined when there is no merge to gate: no such command, `--help`, or `--auto`
124// (which queues the merge for GitHub to perform once its own requirements pass).
125export function parseMerge(full: string): Merge | undefined {
126  const command = withoutHeredocs(full)
127  // Only as a command of its own: at the start, or after ; && || |
128  const m = unquoted(command).match(/(?:^|[;&|]\s*)\s*gh\s+pr\s+merge(?=\s|$|[;&|])([^;&|]*)/)
129  if (!m) return undefined
130  // Read the arguments from the original text, so quoted values stay whole
131  const at = unquoted(command).indexOf(m[0]) + m[0].length - m[1]!.length
132  const raw = command.slice(at).match(/^((?:"(?:[^"\\]|\\.)*"|'[^']*'|[^;&|"'])*)/)?.[1] ?? ''
133  const words = [...raw.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(w => w[1] ?? w[2] ?? w[3]!)
134  if (words.some(w => w === '--help' || w === '-h' || w === '--auto' || w === '--disable-auto')) return undefined
135
136  let selector: string | undefined
137  let repo: string | undefined
138  const VALUED = new Set(['-R', '--repo', '-t', '--subject', '-b', '--body', '-F', '--body-file', '-A', '--author-email', '--match-head-commit'])
139  for (let i = 0; i < words.length; i++) {
140    const w = words[i]!
141    if (w === '-R' || w === '--repo') repo = words[++i]
142    else if (w.startsWith('--repo=')) repo = w.slice(7)
143    else if (VALUED.has(w)) i++
144    else if (!w.startsWith('-') && selector === undefined) selector = w
145  }
146  const cd = command.match(/^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*&&/)
147
148  return { selector, repo, cwd: cd?.[1]?.replace(/^['"]|['"]$/g, '') }
149}
150
151// A merge tool from an MCP server (the GitHub connector's merge_pull_request).
152export const MCP_MERGE = /(^|__)(merge_pull_request|merge_pr|pull_request_merge)$/i
153
154export function mergeFromMcp(input: Record<string, unknown>): Merge | undefined {
155  const number = input.pull_number ?? input.pullNumber ?? input.number ?? input.pr_number
156  const owner = input.owner
157  const repo = input.repo ?? input.repository
158  if (number === undefined) return undefined
159
160  return {
161    selector: String(number),
162    repo: typeof owner === 'string' && typeof repo === 'string' ? `${owner}/${repo}` : typeof repo === 'string' && repo.includes('/') ? repo : undefined,
163  }
164}
165