@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.
- package/README.md +19 -14
- package/catalog/VERSION +1 -1
- package/catalog/agents/architect.md +3 -1
- package/catalog/agents/implementer.md +43 -5
- package/catalog/agents/orchestrator.md +519 -60
- package/catalog/agents/partition.md +316 -0
- package/catalog/agents/reviewer.md +279 -67
- package/catalog/agents/spec-author.md +12 -3
- package/catalog/agents/triage.md +8 -5
- package/catalog/commands/add-capability.md +4 -4
- package/catalog/commands/define.md +7 -0
- package/catalog/commands/hotfix.md +15 -1
- package/catalog/commands/pause.md +5 -0
- package/catalog/commands/resume.md +37 -9
- package/catalog/commands/triage.md +2 -1
- package/catalog/harness.config.schema.json +40 -0
- package/catalog/hooks/lib/merge-pr.sh +699 -0
- package/catalog/skills/mutation-testing/SKILL.md +78 -21
- package/catalog/skills/prd-to-spec/SKILL.md +48 -2
- package/catalog/skills/raise-discovery/SKILL.md +6 -0
- package/catalog/skills/resolve-discovery/SKILL.md +6 -5
- package/catalog/skills/security-review/SKILL.md +119 -6
- package/catalog/skills/spec-to-issue/SKILL.md +7 -1
- package/catalog/skills/task-closeout/SKILL.md +82 -18
- package/catalog/skills/triage-issue/SKILL.md +65 -4
- package/catalog/templates/claude-code/agents.md.tpl +41 -12
- package/catalog/templates/claude-code/harness.config.yml.tpl +33 -0
- package/dist/cli.mjs +737 -36
- package/package.json +10 -6
|
@@ -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
|