@lemoncode/lemony 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,699 @@
1
+ #!/usr/bin/env bash
2
+ # Lemony — agent-executed merge with a checks precondition.
3
+ #
4
+ # The one thing the harness must never do is land a PR nobody decided to land.
5
+ # A human's "merge it" silently assumes CI is green, and the closeout record PR
6
+ # merges with no human in the loop at all — so this executor makes that
7
+ # assumption explicit: it verifies the check status the platform reports for
8
+ # the PR and merges ONLY on green. Anything else — red, still pending after a
9
+ # bounded wait, or no checks at all — exits with a distinct code so the calling
10
+ # contract surfaces the real state to a human instead of merging by omission.
11
+ # Branch protection is the user's belt; the harness executes merges and cannot
12
+ # assume it is configured, so it carries its own precondition. (`gh` is the
13
+ # GitHub rendering of the platform-neutral rule "verify the check status the
14
+ # platform reports for the PR".)
15
+ #
16
+ # Usage:
17
+ # .claude/hooks/lib/merge-pr.sh <pr-number|branch|url> [gh-pr-merge flags…] \
18
+ # [--timeout-secs N] [--grace-secs N] [--poll-secs N] \
19
+ # [--approve-issue N] [--force]
20
+ #
21
+ # The PR ref MUST be the first argument (enforced) — that way a value-bearing
22
+ # pass-through flag can never be misread as the PR. Flags this script consumes
23
+ # (anything else is forwarded verbatim to `gh pr merge`, e.g. --squash
24
+ # --delete-branch; `-R`/`--repo` is forwarded to the checks query TOO, so the
25
+ # PR that is verified is always the PR that is merged):
26
+ # --timeout-secs N max total wait for pending checks. Default: the
27
+ # `merge.checks_timeout_secs` key in harness.config.yml,
28
+ # else 600 (~10 min). Clamped up to --grace-secs.
29
+ # --grace-secs N window for check state to settle after a push before a
30
+ # verdict is trusted: zero checks at 2s means nothing, and
31
+ # an early green with a slow workflow still registering is
32
+ # just as misleading — so "no checks" (exit 20) and a green
33
+ # merge both wait out the grace window. Default 30.
34
+ # --poll-secs N poll interval while waiting (min 1). Default 10.
35
+ # --approve-issue N stale-approve guard: before merging, read the
36
+ # latest APPROVE record on issue N (the Reviewer writes
37
+ # `Reviewed-tree:` + `Diff-fingerprint:` lines into its
38
+ # APPROVE comment) and verify the PR's current content is
39
+ # what that APPROVE reviewed. Three outcomes: tree equal →
40
+ # merge; tree differs but diff fingerprint equal → the
41
+ # base advanced (update-branch) → merge, informing on
42
+ # stderr; fingerprint differs → exit 40, never merge. The
43
+ # merge call is then pinned with --match-head-commit to
44
+ # the exact head the guard verified, so a push racing the
45
+ # merge is rejected by the platform, not missed by luck
46
+ # (a caller-supplied --match-head-commit is rejected as
47
+ # ambiguous). Needs to run inside a clone whose `origin`
48
+ # serves the PR's repo (`refs/pull/<n>/head`) — for the
49
+ # same reason it refuses to combine with -R/--repo: the
50
+ # recompute needs origin to BE the PR's repo. Any read or
51
+ # recompute failure refuses the merge (exit 1). The
52
+ # record is read from the platform and the hashes are
53
+ # recomputed locally — nothing is relayed by the calling
54
+ # agent (a relayed hash would be one confabulation away
55
+ # from neutralizing the guard). Trust boundary: the
56
+ # record is accepted only from a comment whose author
57
+ # association is OWNER, MEMBER, or COLLABORATOR —
58
+ # GitHub's own trust rings — so an outside commenter on a
59
+ # public repo cannot forge a verdict (the hashes are
60
+ # computable from the public diff). Within that ring the
61
+ # guard defends against accident and confabulation, not
62
+ # a malicious insider: every harness agent acts as the
63
+ # same authenticated user, so authorship cannot
64
+ # distinguish the Reviewer from another agent.
65
+ # --force skip the preconditions (checks AND stale-approve) and
66
+ # merge now. Reserved for a human's informed decision
67
+ # ("merge anyway") relayed by the calling contract — never
68
+ # the agent's own choice.
69
+ #
70
+ # Exit codes:
71
+ # 0 merged — verified against the platform after the merge call, never
72
+ # inferred from gh's exit code (precondition green, or --force)
73
+ # 10 checks red — never merge on red by omission; surface the failure
74
+ # 20 no checks reported after the grace window — ask the human (a standing
75
+ # "this repo has no CI" answer lives in `merge.allow_no_checks`)
76
+ # 30 checks still pending when the timeout elapsed — re-present, don't merge
77
+ # 40 stale approve — the PR content no longer matches what the APPROVE on
78
+ # --approve-issue reviewed (diff fingerprint differs): re-present the
79
+ # gate and route to re-review; a fresh APPROVE re-records the hashes
80
+ # 1 not merged, no verdict: usage error, the check status could not be
81
+ # read (gh/auth/network failure, or a gh too old for `pr checks
82
+ # --json` — needs gh >= 2.50), `gh pr merge` itself was rejected
83
+ # (branch protection, conflict), or the merge call succeeded but the
84
+ # platform did not confirm MERGED (auto-merge/merge queue pending, or
85
+ # the state could not be read — the PR may in fact be merged) —
86
+ # stderr says which
87
+ # 127 gh not installed — fail closed, never merge unverified
88
+ # 129/130/143 interrupted (SIGHUP/SIGINT/SIGTERM) while waiting — not
89
+ # merged. During the `gh pr merge` call itself SIGINT/SIGHUP are
90
+ # masked (gh inherits the ignore); SIGTERM can still kill gh (Go
91
+ # re-arms its own handler), so a signal-killed merge re-queries the
92
+ # platform and reports the real state — merged (0) or UNKNOWN (1),
93
+ # never a false "not merged" on a PR that actually landed.
94
+ #
95
+ # Not a lifecycle hook (no stdin envelope, no exit-2 block) — an executor the
96
+ # agent contracts call. It fails CLOSED: on any doubt it does not merge.
97
+ set -u
98
+
99
+ # The verdict blocks print to stdout before `fail`: with a dead reader
100
+ # (`merge-pr.sh 123 | head -1`), the default SIGPIPE would kill the shell with
101
+ # 141, replacing the typed verdict (10/20/30) the calling contracts are built
102
+ # on and losing the (Hook:) self-identification. Ignoring PIPE turns those
103
+ # writes into plain EPIPE failures — the script runs `set -u` without `-e`,
104
+ # so execution still reaches `fail` and the distinct exit code survives.
105
+ trap '' PIPE
106
+
107
+ # Bash seeds SECONDS from an exported ancestor value (3.2 and 5.x alike); an
108
+ # inherited clock would void the grace window and merge an unsettled green.
109
+ SECONDS=0
110
+
111
+ warn() {
112
+ printf '[merge-pr] %s\n' "$*" >&2
113
+ }
114
+
115
+ # Every no-merge exit self-identifies (bash-hooks playbook): the transcript
116
+ # will show "refusing to merge" — the reader must see which script decided.
117
+ fail() {
118
+ printf '(Hook: %s)\n' "${BASH_SOURCE[0]}" >&2
119
+ exit "$1"
120
+ }
121
+
122
+ # Baked default for the pending-checks wait; `merge.checks_timeout_secs` in
123
+ # harness.config.yml overrides it. A guard test asserts this literal equals the
124
+ # canonical TS constant, so the duplication can never silently drift.
125
+ DEFAULT_TIMEOUT_SECS=600
126
+
127
+ # Repo root from the script's own location (.claude/hooks/lib/ → up three), so
128
+ # resolution is independent of the caller's cwd — same recipe as lemony.sh.
129
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
130
+
131
+ USAGE='usage: merge-pr.sh <pr-number|branch|url> [gh-pr-merge flags…] [--timeout-secs N] [--grace-secs N] [--poll-secs N] [--force]'
132
+
133
+ require_value() {
134
+ if [ "$2" -lt 2 ]; then
135
+ warn "$1 requires a value — $USAGE"
136
+ fail 1
137
+ fi
138
+ }
139
+
140
+ require_number() {
141
+ local value="$2"
142
+ case "$value" in
143
+ '' | *[!0-9]*)
144
+ warn "$1 must be a non-negative integer (got '$value')"
145
+ fail 1
146
+ ;;
147
+ esac
148
+ # Beyond int range, `[ -lt ]` errors out and every clock comparison goes
149
+ # false forever — the poll loop would hang instead of reaching a verdict.
150
+ if [ "${#value}" -gt 9 ]; then
151
+ warn "$1 is out of range (got '$value', max 9 digits)"
152
+ fail 1
153
+ fi
154
+ }
155
+
156
+ # The PR ref is positionally enforced as $1 so a pass-through flag's value
157
+ # (--subject "…", --body "…") can never be swallowed as the PR. An empty ref
158
+ # is rejected too: gh resolves an empty selector to the CURRENT BRANCH's PR —
159
+ # an unset caller variable would verify and merge a PR nobody named.
160
+ if [ $# -eq 0 ] || [ "${1#-}" != "$1" ]; then
161
+ warn "the PR ref must be the first argument — $USAGE"
162
+ fail 1
163
+ fi
164
+ case "$1" in
165
+ '' | *[[:space:]]*)
166
+ warn "the PR ref must be a non-empty, whitespace-free token — $USAGE"
167
+ fail 1
168
+ ;;
169
+ esac
170
+ PR="$1"
171
+ shift
172
+
173
+ FORCE=0
174
+ TIMEOUT_SECS=""
175
+ GRACE_SECS=30
176
+ POLL_SECS=10
177
+ APPROVE_ISSUE=""
178
+ CALLER_MATCH_HEAD=0
179
+ MERGE_ARGS=()
180
+ REPO_ARGS=()
181
+ GUARD_ARGS=()
182
+
183
+ while [ $# -gt 0 ]; do
184
+ case "$1" in
185
+ --timeout-secs)
186
+ require_value "$1" $#
187
+ require_number "$1" "$2"
188
+ TIMEOUT_SECS="$2"
189
+ shift 2
190
+ ;;
191
+ --grace-secs)
192
+ require_value "$1" $#
193
+ require_number "$1" "$2"
194
+ GRACE_SECS="$2"
195
+ shift 2
196
+ ;;
197
+ --poll-secs)
198
+ require_value "$1" $#
199
+ require_number "$1" "$2"
200
+ if [ "$2" -lt 1 ]; then
201
+ warn '--poll-secs must be >= 1 (a zero interval would hammer the API)'
202
+ fail 1
203
+ fi
204
+ POLL_SECS="$2"
205
+ shift 2
206
+ ;;
207
+ --approve-issue)
208
+ require_value "$1" $#
209
+ require_number "$1" "$2"
210
+ APPROVE_ISSUE="$2"
211
+ shift 2
212
+ ;;
213
+ --force)
214
+ FORCE=1
215
+ shift
216
+ ;;
217
+ # Repo selection must reach BOTH gh invocations — checks verified on one
218
+ # repo while merging another would defeat the whole precondition.
219
+ -R | --repo)
220
+ require_value "$1" $#
221
+ REPO_ARGS=(--repo "$2")
222
+ shift 2
223
+ ;;
224
+ --repo=*)
225
+ REPO_ARGS=(--repo "${1#--repo=}")
226
+ shift
227
+ ;;
228
+ # gh is pflag-based, so the attached shorthand forms are just as valid —
229
+ # falling through to MERGE_ARGS would merge one repo's PR on another
230
+ # repo's checks. `-R=*` must precede `-R?*` (it matches it too).
231
+ -R=*)
232
+ REPO_ARGS=(--repo "${1#-R=}")
233
+ shift
234
+ ;;
235
+ -R?*)
236
+ REPO_ARGS=(--repo "${1#-R}")
237
+ shift
238
+ ;;
239
+ # Value-bearing gh-pr-merge flags: consume flag AND value together so a
240
+ # value that happens to spell a script keyword is never parsed as one — a
241
+ # PR subject of "--force" must not disarm the precondition.
242
+ -t | --subject | -b | --body | -F | --body-file | -A | --author-email)
243
+ require_value "$1" $#
244
+ MERGE_ARGS+=("$1" "$2")
245
+ shift 2
246
+ ;;
247
+ # Same pass-through, but tracked: the stale-approve guard pins the merge
248
+ # to the head IT verified — two pins with different owners is ambiguous.
249
+ --match-head-commit)
250
+ require_value "$1" $#
251
+ CALLER_MATCH_HEAD=1
252
+ MERGE_ARGS+=("$1" "$2")
253
+ shift 2
254
+ ;;
255
+ # gh's pflag grammar also accepts grouped/attached single-dash forms
256
+ # (-dR owner/repo, -st "subject", -tSubject) where IT decides which argv
257
+ # is a flag, a value, or a repo switch — re-parsing that grammar here is
258
+ # how a keyword or a repo override could still slip through. Refuse the
259
+ # whole class, fail closed. (Attached -R…, handled above, is the one
260
+ # exception — it feeds BOTH gh invocations.)
261
+ -[!-]?*)
262
+ warn "grouped/attached single-dash flags are not supported (got '$1') — pass flags separately; repo selection as -R/--repo"
263
+ fail 1
264
+ ;;
265
+ # Flags under which `gh pr merge` exits 0 WITHOUT merging (usage text,
266
+ # auto-merge toggles): forwarding them would fabricate a "merged" verdict.
267
+ # --auto in particular is the exact bypass this executor replaces.
268
+ -h | --help | --help=* | --auto | --auto=* | --disable-auto | --disable-auto=*)
269
+ warn "$1 is not supported through the executor — gh reports success without merging. $USAGE"
270
+ fail 1
271
+ ;;
272
+ # pflag stops flag parsing at `--`; mirror that, or "-- --force" (the
273
+ # defensive make-it-inert idiom) would be re-scanned as a script keyword.
274
+ --)
275
+ MERGE_ARGS+=("$1")
276
+ shift
277
+ while [ $# -gt 0 ]; do
278
+ MERGE_ARGS+=("$1")
279
+ shift
280
+ done
281
+ ;;
282
+ *)
283
+ MERGE_ARGS+=("$1")
284
+ shift
285
+ ;;
286
+ esac
287
+ done
288
+
289
+ if [ -n "$APPROVE_ISSUE" ] && [ "$CALLER_MATCH_HEAD" = "1" ]; then
290
+ warn '--approve-issue pins the merge to the head the guard verified — a caller-supplied --match-head-commit is ambiguous; drop one of the two.'
291
+ fail 1
292
+ fi
293
+
294
+ # The guard recomputes hashes from a local clone of `origin`; a -R/--repo
295
+ # target lives elsewhere, so the combination can never verify (the head
296
+ # cross-check would fail with a misleading "racing push" message). Refuse it
297
+ # honestly up front instead.
298
+ if [ -n "$APPROVE_ISSUE" ] && [ ${#REPO_ARGS[@]} -gt 0 ]; then
299
+ warn "the stale-approve guard recomputes hashes from a local clone of origin and cannot verify a -R/--repo target — run it from a clone of the PR's repo."
300
+ fail 1
301
+ fi
302
+
303
+ if ! command -v gh >/dev/null 2>&1; then
304
+ warn 'gh not found — cannot verify the check status, refusing to merge.'
305
+ fail 127
306
+ fi
307
+
308
+ # Read the `merge:` block of harness.config.yml with one awk pass (node-free,
309
+ # same recipe as playbook-scan.sh): emit `<key>\t<value>` for the uncommented
310
+ # `checks_timeout_secs` / `allow_no_checks` entries — inline comments stripped
311
+ # (on the block header too) and surrounding quotes removed. Any column-0 line
312
+ # ends the block. Absent/unreadable config → baked defaults.
313
+ CONFIG_TIMEOUT=""
314
+ CONFIG_ALLOW_NO_CHECKS=""
315
+ read_merge_config() {
316
+ local config="$ROOT/harness.config.yml"
317
+ [ -r "$config" ] || return 0
318
+
319
+ local key value
320
+ while IFS=$'\t' read -r key value; do
321
+ case "$key" in
322
+ checks_timeout_secs) CONFIG_TIMEOUT="$value" ;;
323
+ allow_no_checks) CONFIG_ALLOW_NO_CHECKS="$value" ;;
324
+ esac
325
+ done < <(awk -v sq="'" '
326
+ function unquote(v, q) {
327
+ q = substr(v, 1, 1)
328
+ if (length(v) >= 2 && (q == "\"" || q == sq) && substr(v, length(v), 1) == q) {
329
+ return substr(v, 2, length(v) - 2)
330
+ }
331
+ return v
332
+ }
333
+ /^[^[:space:]]/ {
334
+ line = $0
335
+ sub(/#.*$/, "", line)
336
+ sub(/[[:space:]]+$/, "", line)
337
+ in_merge = (line == "merge:") ? 1 : 0
338
+ child_indent = ""
339
+ next
340
+ }
341
+ in_merge != 1 { next }
342
+ {
343
+ line = $0
344
+ sub(/#.*$/, "", line)
345
+ # Only the block DIRECT children carry policy: anchor to the indent of
346
+ # the first child, so a key nested deeper (a sub-map, a block-scalar
347
+ # line of prose) can never read as allow_no_checks and merge unchecked.
348
+ if (child_indent == "" && match(line, /^[[:space:]]+[^[:space:]]/)) {
349
+ child_indent = substr(line, 1, RLENGTH - 1)
350
+ }
351
+ indent = line
352
+ sub(/[^[:space:]].*$/, "", indent)
353
+ if (indent != child_indent) { next }
354
+ if (match(line, /^[[:space:]]+(checks_timeout_secs|allow_no_checks)[[:space:]]*:/)) {
355
+ key = line; sub(/^[[:space:]]+/, "", key); sub(/[[:space:]]*:.*$/, "", key)
356
+ value = line; sub(/^[^:]*:[[:space:]]*/, "", value); sub(/[[:space:]]+$/, "", value)
357
+ value = unquote(value)
358
+ if (value != "") printf "%s\t%s\n", key, value
359
+ }
360
+ }
361
+ ' "$config")
362
+ }
363
+ read_merge_config
364
+
365
+ if [ -z "$TIMEOUT_SECS" ]; then
366
+ # Same >9-digit bound as require_number: an int-overflowing config value
367
+ # would break every clock comparison and hang the loop — default instead.
368
+ case "$CONFIG_TIMEOUT" in
369
+ '' | *[!0-9]*) TIMEOUT_SECS="$DEFAULT_TIMEOUT_SECS" ;;
370
+ *)
371
+ if [ "${#CONFIG_TIMEOUT}" -le 9 ]; then
372
+ TIMEOUT_SECS="$CONFIG_TIMEOUT"
373
+ else
374
+ TIMEOUT_SECS="$DEFAULT_TIMEOUT_SECS"
375
+ fi
376
+ ;;
377
+ esac
378
+ fi
379
+
380
+ # The grace window is part of the total wait, never in excess of it.
381
+ if [ "$TIMEOUT_SECS" -lt "$GRACE_SECS" ]; then
382
+ TIMEOUT_SECS="$GRACE_SECS"
383
+ fi
384
+
385
+ # Case-normalized: YAML accepts `True`/`TRUE` as the boolean, and a silently
386
+ # ignored recorded answer would re-ask the human forever.
387
+ ALLOW_NO_CHECKS=0
388
+ if [ "$(printf '%s' "$CONFIG_ALLOW_NO_CHECKS" | tr '[:upper:]' '[:lower:]')" = "true" ]; then
389
+ ALLOW_NO_CHECKS=1
390
+ fi
391
+
392
+ # Stale-approve guard: "nothing reaches the human unreviewed" is only
393
+ # real if the content that merges is the content the APPROVE reviewed — the
394
+ # merge-gate wait is deliberately long, and any push in that window would
395
+ # otherwise merge on green without a fresh verdict. The Reviewer records the
396
+ # reviewed tree hash and a diff fingerprint in its APPROVE comment on the
397
+ # task issue; this precondition recomputes both for the PR's current head and
398
+ # applies the three-state algebra (current / base-advanced / changed). The
399
+ # fingerprint — the canonical command below, digesting changed paths + modes
400
+ # + full blob OIDs against the merge-base — exists because update-branch
401
+ # before merge is standing practice and must not invalidate verdicts: a clean
402
+ # base advance changes the tree but not the PR's own diff (verified
403
+ # empirically, including that a base edit to a file the PR also touched DOES
404
+ # change it — exactly the case that needs fresh eyes). `--no-abbrev` is
405
+ # load-bearing: --raw abbreviates blob OIDs to a repo-size-dependent width by
406
+ # default, so two machines could digest different bytes for the same diff.
407
+ # The parity of this command with the Reviewer's recipe (reviewer.md) is
408
+ # pinned by a guard spec — drift here silently flips every verdict to 40.
409
+ verify_approved_content() {
410
+ local record approved_tree approved_fp value pr_meta pr_num head_oid
411
+ local base_ref fetch_err fetched cur_tree base_oid merge_root cur_fp
412
+
413
+ # The latest comment carrying both markers wins: a re-review posts a fresh
414
+ # APPROVE with fresh hashes, and older records must not resurrect. Only
415
+ # comments from GitHub's write-ish trust rings (OWNER/MEMBER/COLLABORATOR
416
+ # author association) are considered — the hashes are computable from the
417
+ # public diff, so on a public repo an outside commenter could otherwise
418
+ # forge a record matching an unreviewed head. The export walks every page
419
+ # of the thread (under --json comments gh fetches the whole comment list,
420
+ # page after page, before it prints), so `last` is the newest qualifying
421
+ # record however long the thread grows. stderr is captured apart so gh
422
+ # chatter (update notices) can never pollute the record on a successful
423
+ # read.
424
+ record="$(gh issue view "$APPROVE_ISSUE" ${REPO_ARGS[@]+"${REPO_ARGS[@]}"} --json comments --jq '[.comments[] | select((.authorAssociation == "OWNER" or .authorAssociation == "MEMBER" or .authorAssociation == "COLLABORATOR") and (.body | contains("Reviewed-tree:")) and (.body | contains("Diff-fingerprint:")))] | last | .body' 2>"$CHECKS_ERR")"
425
+ if [ $? -ne 0 ]; then
426
+ cat "$CHECKS_ERR" >&2
427
+ warn "could not read the APPROVE record from issue #$APPROVE_ISSUE — refusing to merge."
428
+ fail 1
429
+ fi
430
+ if [ -z "$record" ] || [ "$record" = "null" ]; then
431
+ warn "no APPROVE record (Reviewed-tree/Diff-fingerprint) found on issue #$APPROVE_ISSUE — cannot verify what was reviewed; refusing to merge."
432
+ fail 1
433
+ fi
434
+
435
+ # The value is the field RIGHT AFTER the marker token (never $NF: a line
436
+ # mentioning a marker in prose, or both markers on one line, would shift a
437
+ # last-field read), the last occurrence wins, and a CR from a web-edited
438
+ # comment is stripped before validation. Values must be full hex object
439
+ # names (40 hex today, 64 on a sha256 repo) — anything else is a malformed
440
+ # record, not a verdict.
441
+ approved_tree="$(printf '%s\n' "$record" | awk '{ sub(/\r$/, ""); for (i = 1; i < NF; i++) if ($i == "Reviewed-tree:") v = $(i + 1) } END { if (v != "") print v }')"
442
+ approved_fp="$(printf '%s\n' "$record" | awk '{ sub(/\r$/, ""); for (i = 1; i < NF; i++) if ($i == "Diff-fingerprint:") v = $(i + 1) } END { if (v != "") print v }')"
443
+ for value in "$approved_tree" "$approved_fp"; do
444
+ case "$value" in
445
+ '' | *[!0-9a-f]*)
446
+ warn "malformed APPROVE record on issue #$APPROVE_ISSUE (Reviewed-tree/Diff-fingerprint are not hex object names) — refusing to merge."
447
+ fail 1
448
+ ;;
449
+ esac
450
+ if [ "${#value}" -ne 40 ] && [ "${#value}" -ne 64 ]; then
451
+ warn "malformed APPROVE record on issue #$APPROVE_ISSUE (hash length ${#value}) — refusing to merge."
452
+ fail 1
453
+ fi
454
+ done
455
+
456
+ pr_meta="$(gh pr view "$PR" ${REPO_ARGS[@]+"${REPO_ARGS[@]}"} --json number,headRefOid,baseRefName --jq '[.number, .headRefOid, .baseRefName] | @tsv' 2>"$CHECKS_ERR")"
457
+ if [ $? -ne 0 ]; then
458
+ cat "$CHECKS_ERR" >&2
459
+ warn "could not read the PR head/base from the platform — refusing to merge."
460
+ fail 1
461
+ fi
462
+ IFS=$'\t' read -r pr_num head_oid base_ref <<EOF
463
+ $pr_meta
464
+ EOF
465
+ case "$pr_num" in
466
+ '' | *[!0-9]*)
467
+ warn "unexpected PR metadata from gh ('$pr_meta') — refusing to merge."
468
+ fail 1
469
+ ;;
470
+ esac
471
+ case "$head_oid" in
472
+ '' | *[!0-9a-f]*)
473
+ warn "unexpected PR head OID from gh ('$head_oid') — refusing to merge."
474
+ fail 1
475
+ ;;
476
+ esac
477
+ if [ -z "$base_ref" ]; then
478
+ warn 'the platform reported no base ref for the PR — refusing to merge.'
479
+ fail 1
480
+ fi
481
+
482
+ # Two independent reads must agree: the platform names the head OID, and
483
+ # origin must serve that exact commit at refs/pull/<n>/head. A mismatch is
484
+ # a push racing this merge or an origin that is not the PR's repo — both
485
+ # are "stop", not "pick one".
486
+ if ! fetch_err="$(git -C "$ROOT" fetch -q origin "refs/pull/$pr_num/head" 2>&1)"; then
487
+ printf '%s\n' "$fetch_err" >&2
488
+ warn "could not fetch refs/pull/$pr_num/head from origin — the stale-approve guard needs a clone of the PR's repo; refusing to merge."
489
+ fail 1
490
+ fi
491
+ fetched="$(git -C "$ROOT" rev-parse -q --verify FETCH_HEAD 2>/dev/null)"
492
+ if [ -z "$fetched" ]; then
493
+ warn 'could not resolve FETCH_HEAD after fetching the PR head — refusing to merge.'
494
+ fail 1
495
+ fi
496
+ if [ "$fetched" != "$head_oid" ]; then
497
+ warn "the platform reports PR head $head_oid but origin serves $fetched — a push may be racing this merge, or origin is not the PR's repo; refusing to merge."
498
+ fail 1
499
+ fi
500
+
501
+ cur_tree="$(git -C "$ROOT" rev-parse -q --verify "$head_oid^{tree}" 2>/dev/null)"
502
+ if [ -z "$cur_tree" ]; then
503
+ warn "could not resolve the tree of $head_oid — refusing to merge."
504
+ fail 1
505
+ fi
506
+
507
+ if [ "$cur_tree" != "$approved_tree" ]; then
508
+ if ! fetch_err="$(git -C "$ROOT" fetch -q origin "$base_ref" 2>&1)"; then
509
+ printf '%s\n' "$fetch_err" >&2
510
+ warn "could not fetch the base ref '$base_ref' from origin — refusing to merge."
511
+ fail 1
512
+ fi
513
+ base_oid="$(git -C "$ROOT" rev-parse -q --verify FETCH_HEAD 2>/dev/null)"
514
+ merge_root="$(git -C "$ROOT" merge-base "$base_oid" "$head_oid" 2>/dev/null)"
515
+ if [ -z "$base_oid" ] || [ -z "$merge_root" ]; then
516
+ warn "could not compute the merge-base of '$base_ref' and the PR head — refusing to merge."
517
+ fail 1
518
+ fi
519
+ # Canonical fingerprint command — byte-for-byte the Reviewer's recipe
520
+ # (reviewer.md records the APPROVE side with the same pinned flags).
521
+ cur_fp="$(
522
+ set -o pipefail
523
+ git -C "$ROOT" -c core.quotePath=true diff --raw --no-abbrev --no-renames --no-color "$merge_root" "$head_oid" \
524
+ | git -C "$ROOT" hash-object --stdin
525
+ )"
526
+ if [ $? -ne 0 ] || [ -z "$cur_fp" ]; then
527
+ warn 'could not compute the diff fingerprint of the PR head — refusing to merge.'
528
+ fail 1
529
+ fi
530
+
531
+ if [ "$cur_fp" != "$approved_fp" ]; then
532
+ echo "stale-approve: PR $PR no longer matches what the APPROVE on issue #$APPROVE_ISSUE reviewed"
533
+ echo "(reviewed tree $approved_tree, fingerprint $approved_fp; current tree $cur_tree, fingerprint $cur_fp)."
534
+ echo 'Content changed after the review — never merge unreviewed content: re-present the'
535
+ echo 'merge gate and route the PR to re-review; a fresh APPROVE re-records the hashes.'
536
+ echo "A human's informed \"merge anyway\" re-runs with --force."
537
+ fail 40
538
+ fi
539
+ warn "stale-approve guard: the tree differs from the APPROVE on issue #$APPROVE_ISSUE but the diff fingerprint matches — the base advanced (e.g. update-branch); merging the approved content on the new base."
540
+ fi
541
+
542
+ # Pin the merge to the exact head this guard verified: a push landing
543
+ # between this read and the merge call is rejected by the platform itself.
544
+ GUARD_ARGS=(--match-head-commit "$head_oid")
545
+ }
546
+
547
+ # The merge itself, with the caller's pass-through flags. A gh rejection
548
+ # (branch protection wants approval, a conflict) keeps its stderr but is
549
+ # normalized to exit 1, so the 10/20/30 verdict codes stay unambiguous.
550
+ do_merge() {
551
+ # The stale-approve precondition runs before the signal mask (an operator
552
+ # can still abort it) and is skipped by --force alone — same doctrine as
553
+ # the checks precondition: only a human's informed decision bypasses.
554
+ if [ "$FORCE" != "1" ] && [ -n "$APPROVE_ISSUE" ]; then
555
+ verify_approved_content
556
+ fi
557
+ # A signal here must never turn into a false "not merged" on a PR that DID
558
+ # land. Masking keeps the script alive (and covers gh for INT/HUP, which
559
+ # inherit the ignore); Go re-arms SIGTERM, so a TERM-killed gh is resolved
560
+ # by asking the platform below rather than by assertion.
561
+ trap '' INT TERM HUP
562
+ # GUARD_ARGS rides between the repo selector and the pass-through flags so
563
+ # a caller's `--` sentinel can never demote the pin to a positional.
564
+ if [ ${#MERGE_ARGS[@]} -gt 0 ]; then
565
+ gh pr merge "$PR" ${REPO_ARGS[@]+"${REPO_ARGS[@]}"} ${GUARD_ARGS[@]+"${GUARD_ARGS[@]}"} "${MERGE_ARGS[@]}"
566
+ else
567
+ gh pr merge "$PR" ${REPO_ARGS[@]+"${REPO_ARGS[@]}"} ${GUARD_ARGS[@]+"${GUARD_ARGS[@]}"}
568
+ fi
569
+ local rc=$?
570
+ # Only 128+n (n in 1..64) encodes a signal-killed child; 255 etc. from a
571
+ # gh wrapper/shim falls through to the plain failure message below.
572
+ if [ "$rc" -gt 128 ] && [ "$rc" -le 192 ]; then
573
+ if gh pr view "$PR" ${REPO_ARGS[@]+"${REPO_ARGS[@]}"} --json state 2>/dev/null \
574
+ | grep -q '"MERGED"'; then
575
+ warn "gh pr merge was killed by signal $((rc - 128)), but the platform reports the PR as merged."
576
+ exit 0
577
+ fi
578
+ warn "gh pr merge was killed by signal $((rc - 128)) — merge state UNKNOWN; verify the PR before retrying."
579
+ fail 1
580
+ fi
581
+ if [ "$rc" -ne 0 ]; then
582
+ warn "gh pr merge failed (exit $rc) — not merged."
583
+ fail 1
584
+ fi
585
+ # gh exits 0 for outcomes that are NOT a merge (auto-merge armed, merge
586
+ # queue enqueue on a queue-protected base). The 0 verdict is a promise the
587
+ # calling contract acts on — so it comes from the platform, never from
588
+ # gh's exit code alone. A definitive non-MERGED read and an unreadable
589
+ # platform are different verdicts: only the first asserts a cause.
590
+ local state_out view_rc
591
+ state_out="$(gh pr view "$PR" ${REPO_ARGS[@]+"${REPO_ARGS[@]}"} --json state 2>&1)"
592
+ view_rc=$?
593
+ if [ "$view_rc" -eq 0 ]; then
594
+ case "$state_out" in
595
+ *'"MERGED"'*) exit 0 ;;
596
+ esac
597
+ warn 'gh pr merge exited 0 but the platform reports the PR as not merged (auto-merge or a merge queue may be pending) — not reporting merged.'
598
+ fail 1
599
+ fi
600
+ printf '%s\n' "$state_out" >&2
601
+ warn 'gh pr merge exited 0 but the platform state could not be read — merge state UNKNOWN; verify the PR before retrying.'
602
+ fail 1
603
+ }
604
+
605
+ if [ "$FORCE" = "1" ]; then
606
+ do_merge
607
+ fi
608
+
609
+ CHECKS_ERR="$(mktemp "${TMPDIR:-/tmp}/merge-pr-err.XXXXXX")"
610
+ trap 'rm -f "$CHECKS_ERR"' EXIT
611
+ # A trapped signal whose handler does not `exit` RESUMES the script after the
612
+ # handler — a cleanup-only INT/TERM trap would swallow Ctrl-C / a supervisor's
613
+ # SIGTERM and keep polling toward a merge the operator tried to cancel. The
614
+ # `exit` here fires the EXIT trap, so cleanup still happens exactly once.
615
+ trap 'exit 130' INT
616
+ trap 'exit 143' TERM
617
+ trap 'exit 129' HUP
618
+
619
+ # Poll until green (merge), red (10), absent (20), or timeout (30). `SECONDS`
620
+ # counts from script start, so grace and timeout share one clock.
621
+ while :; do
622
+ # Structured read: `--json bucket,name` + gh's embedded `--jq` emit
623
+ # `bucket<TAB>name` lines, with jq's @tsv escaping tabs/newlines inside
624
+ # values — so a check name containing a literal tab (gh's raw table printer
625
+ # writes fields unescaped) can never shift the bucket out of OUR column 1.
626
+ # Under --json gh exits 0 whatever the buckets say (its exporter returns
627
+ # before the table-mode 1/8 exit logic), so the verdict rests entirely on
628
+ # the bucket classification below; the exit-status cross-checks remain as
629
+ # belts for the states that still produce them (no-checks/transport errors
630
+ # return BEFORE the exporter and keep their stderr + nonzero exit; an
631
+ # output-plus-nonzero partial read is barely reachable but stays guarded).
632
+ # gh too old for `pr checks --json` (< 2.50) fails the invocation and lands
633
+ # in the could-not-read branch: fail closed, with gh's own message.
634
+ # GH_FORCE_TTY / NO_COLOR stay neutralized as a belt — --json output is
635
+ # TTY-independent on current gh, but the guarantee costs nothing.
636
+ CHECKS_OUT="$(GH_FORCE_TTY='' NO_COLOR=1 gh pr checks "$PR" ${REPO_ARGS[@]+"${REPO_ARGS[@]}"} --json bucket,name --jq '.[] | [.bucket, .name] | @tsv' 2>"$CHECKS_ERR")"
637
+ CHECKS_STATUS=$?
638
+
639
+ if [ -z "$CHECKS_OUT" ]; then
640
+ # Absent checks — gh reports "no checks reported …" on stderr (or, on a
641
+ # quiet success, nothing at all). Anything else is a real gh failure.
642
+ if ! grep -q 'no checks reported' "$CHECKS_ERR" 2>/dev/null \
643
+ && { [ "$CHECKS_STATUS" -ne 0 ] || [ -s "$CHECKS_ERR" ]; }; then
644
+ cat "$CHECKS_ERR" >&2
645
+ warn 'could not read the check status — refusing to merge.'
646
+ fail 1
647
+ fi
648
+ if [ "$SECONDS" -lt "$GRACE_SECS" ]; then
649
+ sleep "$POLL_SECS"
650
+ continue
651
+ fi
652
+ if [ "$ALLOW_NO_CHECKS" = "1" ]; then
653
+ warn 'no checks reported — merging per merge.allow_no_checks: true in harness.config.yml.'
654
+ do_merge
655
+ fi
656
+ echo "no-checks: the platform reports no checks for $PR (waited ${GRACE_SECS}s). Never"
657
+ echo 'merge alone on absent checks, whatever the cause — ask the human. A standing'
658
+ echo 'answer ("this repo has no CI — merge without checks") is recorded once as'
659
+ echo 'merge.allow_no_checks: true in harness.config.yml; a one-shot yes re-runs with --force.'
660
+ fail 20
661
+ fi
662
+
663
+ # Classify the bucket (column 1 — ours by construction, see the read
664
+ # above): fail/cancel → red, pass/skipping → ok, anything else (pending, …)
665
+ # → still running. An all-skipping set is deliberately green: gh reports
666
+ # the checks and none failed — path-filtered CI on a docs-only PR is the
667
+ # everyday case.
668
+ RED_LINES="$(printf '%s\n' "$CHECKS_OUT" | awk -F'\t' '$1 == "fail" || $1 == "cancel"')"
669
+ PENDING_COUNT="$(printf '%s\n' "$CHECKS_OUT" | awk -F'\t' '$1 != "pass" && $1 != "skipping" && $1 != "fail" && $1 != "cancel" { n++ } END { print n + 0 }')"
670
+
671
+ if [ -n "$RED_LINES" ]; then
672
+ echo "red: failing check(s) on $PR — not merging. Surface this to the human:"
673
+ printf '%s\n' "$RED_LINES"
674
+ fail 10
675
+ fi
676
+
677
+ if [ "$PENDING_COUNT" -eq 0 ]; then
678
+ # A green verdict is trusted only when gh agrees (exit 0 — a non-zero exit
679
+ # alongside a clean-looking table means a truncated/partial read: doubt,
680
+ # so no merge) and only once the grace window has elapsed (a fast check
681
+ # can report pass while a slow workflow is still registering).
682
+ if [ "$CHECKS_STATUS" -ne 0 ]; then
683
+ cat "$CHECKS_ERR" >&2
684
+ warn "gh pr checks exited $CHECKS_STATUS behind a green-looking read — refusing to merge on a partial read."
685
+ fail 1
686
+ fi
687
+ if [ "$SECONDS" -ge "$GRACE_SECS" ]; then
688
+ do_merge
689
+ fi
690
+ fi
691
+
692
+ if [ "$SECONDS" -ge "$TIMEOUT_SECS" ]; then
693
+ echo "timeout: checks still pending on $PR after ${TIMEOUT_SECS}s — not merging. Current state:"
694
+ printf '%s\n' "$CHECKS_OUT"
695
+ fail 30
696
+ fi
697
+
698
+ sleep "$POLL_SECS"
699
+ done