@codyswann/lisa 4.36.2 → 4.37.1
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/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +2 -2
- package/all/copy-overwrite/scripts/lisa-hooks/block-blind-automerge.sh +626 -0
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +12 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +25 -7
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/strategies/package-lisa.js +10 -0
- package/dist/strategies/package-lisa.js.map +1 -1
- package/package.json +5 -4
- package/plugins/lisa/.claude-plugin/plugin.json +5 -1
- package/plugins/lisa/.codex-plugin/hooks.json +4 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/block-blind-automerge.agy.sh +54 -0
- package/plugins/lisa/hooks/block-blind-automerge.sh +623 -0
- package/plugins/lisa-agy/hooks/block-blind-automerge.agy.sh +54 -0
- package/plugins/lisa-agy/hooks/block-blind-automerge.sh +623 -0
- package/plugins/lisa-agy/hooks.json +13 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +5 -1
- package/plugins/lisa-copilot/hooks/block-blind-automerge.sh +623 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-blind-automerge.sh +623 -0
- package/plugins/lisa-cursor/hooks/hooks.json +4 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/materialized-artifacts.json +1 -0
- package/plugins/src/base/.claude-plugin/plugin.json +4 -0
- package/plugins/src/base/hooks/block-blind-automerge.agy.sh +54 -0
- package/plugins/src/base/hooks/block-blind-automerge.sh +623 -0
- package/scripts/build-plugins.sh +1 -1
- package/scripts/check-self-dependency-pin.mjs +401 -0
- package/scripts/generate-agy-plugin-artifacts.mjs +11 -0
- package/scripts/lib/per-agent-hook-filter.mjs +10 -0
- package/scripts/lisa-enforcement-fallback.sh +2 -2
- package/scripts/two-channel-couplings.json +1 -1
- package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +750 -365
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lisa",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.37.1",
|
|
4
4
|
"description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Cody Swann"
|
|
@@ -70,6 +70,10 @@
|
|
|
70
70
|
"type": "command",
|
|
71
71
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/block-direct-issue-create.sh"
|
|
72
72
|
},
|
|
73
|
+
{
|
|
74
|
+
"type": "command",
|
|
75
|
+
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/block-blind-automerge.sh"
|
|
76
|
+
},
|
|
73
77
|
{
|
|
74
78
|
"type": "command",
|
|
75
79
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/block-host-name-leak.sh"
|
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PreToolUse hook for Bash: refuse to ARM auto-merge on a pull request that
|
|
3
|
+
# GitHub already says is blocked on review.
|
|
4
|
+
#
|
|
5
|
+
# WHY THIS IS A HOOK AND NOT A RULE
|
|
6
|
+
#
|
|
7
|
+
# Arming auto-merge is the moment an agent claims "this will merge", and the
|
|
8
|
+
# claim can be false at exactly that moment. Measured on this repository: three
|
|
9
|
+
# of five open PRs sat armed, fully green, with `reviewDecision =
|
|
10
|
+
# CHANGES_REQUESTED`. Nothing surfaced it. `reviewDecision` is not part of the
|
|
11
|
+
# check rollup, so the failing-check count read 0, the checks tab was entirely
|
|
12
|
+
# green, and three separate agents armed and moved on. One PR had been parked
|
|
13
|
+
# for hours before someone queried the field by hand.
|
|
14
|
+
#
|
|
15
|
+
# The stale case is the one actually observed, and it is the one prose cannot
|
|
16
|
+
# help with: the `base` ruleset sets `dismiss_stale_reviews_on_push: false`, so
|
|
17
|
+
# a CHANGES_REQUESTED review stays attached to the commit it was made on and
|
|
18
|
+
# pushing the fix never clears it, while `required_approving_review_count: 0`
|
|
19
|
+
# means no scheduled re-review will ever clear it either. The PR is blocked
|
|
20
|
+
# indefinitely and every check-based signal reads ready.
|
|
21
|
+
#
|
|
22
|
+
# WHAT IT CHECKS, AND WHY THAT AND NOT THE CHECK ROLLUP
|
|
23
|
+
#
|
|
24
|
+
# The defect class here is a probe that measures what it can see rather than
|
|
25
|
+
# what it means. A signal assembled from check-runs reports "ready to merge" on
|
|
26
|
+
# a PR that can never merge — that is precisely how this stayed invisible — and
|
|
27
|
+
# a code-review gate can arrive as a commit STATUS rather than a check-run, so
|
|
28
|
+
# a check-run-only query misses that too. So this guard does not look at checks
|
|
29
|
+
# at all. It asks GitHub the one question whose answer is the blocker:
|
|
30
|
+
# `reviewDecision`.
|
|
31
|
+
#
|
|
32
|
+
# `mergeStateStatus` is deliberately NOT read. It is computed rather than
|
|
33
|
+
# stored, reports UNKNOWN transiently while GitHub recomputes it, and was
|
|
34
|
+
# observed reporting CONFLICTING on two PRs minutes apart where one had a real
|
|
35
|
+
# conflict and the other merged cleanly with zero conflicted paths. Same field,
|
|
36
|
+
# opposite truths. A guard built on it would refuse honest commands at random,
|
|
37
|
+
# which is how a guard gets switched off.
|
|
38
|
+
#
|
|
39
|
+
# WHAT THIS GUARD DOES *NOT* CLAIM
|
|
40
|
+
#
|
|
41
|
+
# It answers "is there a standing review verdict blocking this merge?", not
|
|
42
|
+
# "can this PR merge?". The difference matters for one measured case that looks
|
|
43
|
+
# like this ticket and is not it: a PR opened against a base where reviews are
|
|
44
|
+
# disabled gets a review bot reporting `success` with the description "Review
|
|
45
|
+
# skipped: reviews are disabled for this base branch", and the repository's two
|
|
46
|
+
# review-evidence gates then FAIL. That PR cannot merge either — but it is not
|
|
47
|
+
# invisible: the failing-check count is 2, and any check-based signal reports it
|
|
48
|
+
# correctly. Only the individual bot's *conclusion* misleads, and nothing here
|
|
49
|
+
# reads conclusions.
|
|
50
|
+
#
|
|
51
|
+
# So that case is deliberately out of scope rather than overlooked, and the
|
|
52
|
+
# distinction is the whole reason this guard exists: `CHANGES_REQUESTED` shows
|
|
53
|
+
# ZERO failing checks. A guard that also tried to adjudicate check descriptions
|
|
54
|
+
# would be re-deriving gates that already run, and would start refusing commands
|
|
55
|
+
# on evidence CI already surfaces. The suite pins the boundary with a case, so a
|
|
56
|
+
# later reader can see it was decided rather than missed.
|
|
57
|
+
#
|
|
58
|
+
# SCOPE: `--auto` ONLY
|
|
59
|
+
#
|
|
60
|
+
# A direct `gh pr merge` against a blocked PR fails loudly and immediately, so
|
|
61
|
+
# it is not the invisible failure this exists to close — and refusing it would
|
|
62
|
+
# also refuse the deliberate `--admin` override a human reaches for. Only the
|
|
63
|
+
# ARMING form is intercepted, because only arming produces a false report of
|
|
64
|
+
# success that outlives the command.
|
|
65
|
+
#
|
|
66
|
+
# The refusal is not a dead end: dismissing a genuinely stale review is a
|
|
67
|
+
# supported step of `lisa-drive-pr-to-merge`, and that is what the message
|
|
68
|
+
# names. There is deliberately no env-var escape hatch — an override the
|
|
69
|
+
# governed agent can set is theatre, and the real remedy is one command away.
|
|
70
|
+
#
|
|
71
|
+
# SUBSTRATES
|
|
72
|
+
#
|
|
73
|
+
# Arming has TWO shell spellings, not one. `gh pr merge --auto` is the common
|
|
74
|
+
# one; `gh api graphql` running the `enablePullRequestAutoMerge` mutation is the
|
|
75
|
+
# same act through the API, and a guard that recognises only the first reports
|
|
76
|
+
# safety about both. That is the failure mode CodySwannGT/lisa#3753 records one
|
|
77
|
+
# guard over — a control that sees a single substrate and speaks for all of
|
|
78
|
+
# them — so both are matched here. The mutation names a node id rather than a
|
|
79
|
+
# number, so the probe resolves it through `node(id:)`; a mutation whose id
|
|
80
|
+
# cannot be read at all FAILS CLOSED, because silence about an arming this
|
|
81
|
+
# guard could not inspect is this ticket's own defect in a new place.
|
|
82
|
+
#
|
|
83
|
+
# Recorded residual: the guard is registered `matcher: "Bash"`, so a structured
|
|
84
|
+
# tool call that arms auto-merge would not reach it. No MCP server provisioned
|
|
85
|
+
# here exposes auto-merge arming — the GitHub MCP merges directly, which is out
|
|
86
|
+
# of scope by the paragraph above — so the gap is currently unreachable rather
|
|
87
|
+
# than open. If one appears, the fix is #3753's: move the registration AND the
|
|
88
|
+
# internal `tool_name` gate together, since either alone ships a change that
|
|
89
|
+
# nothing fails on. Whether it earns its own `matcher: ""` group is a cost
|
|
90
|
+
# question that PR measured and bounded (~11.5ms per non-Bash call, with the
|
|
91
|
+
# whole broad set to stay under ~100ms); re-measure rather than assume.
|
|
92
|
+
#
|
|
93
|
+
# The line below is what lets `lisa apply` tell a downstream copy of this guard
|
|
94
|
+
# that is BEHIND from one that is AHEAD. Add a name here in the same commit that
|
|
95
|
+
# closes a vector.
|
|
96
|
+
# lisa-guard-capabilities: automerge-arm-review-decision, automerge-arm-graphql
|
|
97
|
+
set -euo pipefail
|
|
98
|
+
|
|
99
|
+
input="$(cat)"
|
|
100
|
+
|
|
101
|
+
# Both interpreters are probed BEFORE use, and a missing one is ANNOUNCED
|
|
102
|
+
# rather than swallowed. Under `set -e` an absent jq would abort with 127, and
|
|
103
|
+
# Claude Code treats any non-2 exit as a non-blocking hook error — so the guard
|
|
104
|
+
# would silently permit the very thing it exists to stop. Degrading to "allow"
|
|
105
|
+
# is right (a hook that cannot parse its input cannot tell an arming command
|
|
106
|
+
# from an ordinary one); doing it quietly is not.
|
|
107
|
+
for required in jq python3; do
|
|
108
|
+
if ! command -v "$required" >/dev/null 2>&1; then
|
|
109
|
+
printf 'block-blind-automerge: %s not found; auto-merge review-gate protection is NOT active\n' \
|
|
110
|
+
"$required" >&2
|
|
111
|
+
exit 0
|
|
112
|
+
fi
|
|
113
|
+
done
|
|
114
|
+
|
|
115
|
+
tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null || true)"
|
|
116
|
+
if [ "$tool_name" != "Bash" ]; then
|
|
117
|
+
exit 0
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
|
|
121
|
+
if [ -z "$command_str" ]; then
|
|
122
|
+
exit 0
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
if ! BLOCK_BLIND_AUTOMERGE_COMMAND="$command_str" python3 - <<'PY'
|
|
126
|
+
import json
|
|
127
|
+
import os
|
|
128
|
+
import re
|
|
129
|
+
import shlex
|
|
130
|
+
import subprocess
|
|
131
|
+
import sys
|
|
132
|
+
|
|
133
|
+
command = os.environ.get("BLOCK_BLIND_AUTOMERGE_COMMAND", "")
|
|
134
|
+
|
|
135
|
+
# The one verdict that means "GitHub is refusing this merge on review grounds".
|
|
136
|
+
BLOCKING_REVIEW_DECISION = "CHANGES_REQUESTED"
|
|
137
|
+
|
|
138
|
+
# A hook must not become the reason a command hangs. `timeout(1)` is not
|
|
139
|
+
# present on every machine this runs on, so the bound is expressed where it is
|
|
140
|
+
# always available — and a TimeoutExpired is a NAMED exception, so a slow
|
|
141
|
+
# network can never be mistaken for a PR that reads as clean.
|
|
142
|
+
GH_TIMEOUT_SECONDS = 20
|
|
143
|
+
|
|
144
|
+
# gh's persistent flags that take a SEPARATE value token, which must be skipped
|
|
145
|
+
# when looking for the subcommand: `gh --repo o/r pr merge` reaches `merge`.
|
|
146
|
+
GH_SEPARATE_VALUE = {"-R", "--repo", "--hostname"}
|
|
147
|
+
|
|
148
|
+
# `gh pr merge` options taking a separate value. Listed so a VALUE is never
|
|
149
|
+
# mistaken for the PR selector — `gh pr merge --subject "fix 12" --auto` must
|
|
150
|
+
# not read `fix 12` as the PR.
|
|
151
|
+
MERGE_SEPARATE_VALUE = {
|
|
152
|
+
"-b", "--body", "-F", "--body-file", "-t", "--subject",
|
|
153
|
+
"--match-head-commit", "--author-email",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
COMMAND_SEPARATORS = {
|
|
157
|
+
";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
# Shell wrappers whose `-c` argument is a command in a string rather than argv.
|
|
161
|
+
# The outer argv names bash, so the arming is invisible without recursing once.
|
|
162
|
+
SHELL_WRAPPERS = {"bash", "sh", "zsh", "dash"}
|
|
163
|
+
|
|
164
|
+
# The GraphQL mutation that arms auto-merge. Same act as `gh pr merge --auto`,
|
|
165
|
+
# reached through the API instead of the porcelain.
|
|
166
|
+
AUTO_MERGE_MUTATION = "enablePullRequestAutoMerge"
|
|
167
|
+
|
|
168
|
+
# gh's field flags. `-f`/`--raw-field` and friends carry `name=value` pairs, so
|
|
169
|
+
# both the mutation text and its variables arrive through them.
|
|
170
|
+
GH_FIELD_FLAGS = {"-f", "--field", "-F", "--raw-field"}
|
|
171
|
+
|
|
172
|
+
# How the mutation names its subject, whether inline or as a variable.
|
|
173
|
+
PULL_REQUEST_ID_PATTERN = re.compile(
|
|
174
|
+
r"pullRequestId\s*:\s*(?:\"([^\"]+)\"|'([^']+)'|\$([A-Za-z_][A-Za-z0-9_]*))"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# A PR node id as GitHub issues them. Matched rather than accepted verbatim so
|
|
178
|
+
# a variable reference is never sent to the API as if it were an id.
|
|
179
|
+
NODE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_=-]+$")
|
|
180
|
+
|
|
181
|
+
# The probe that resolves a node id to the same fields `gh pr view` returns.
|
|
182
|
+
NODE_QUERY = (
|
|
183
|
+
"query($id:ID!){node(id:$id){... on PullRequest"
|
|
184
|
+
"{number url reviewDecision state}}}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# What a probe spec asks for. `view` and `node` differ only in how the PR is
|
|
188
|
+
# named; `unreadable` is an arming whose subject could not be determined.
|
|
189
|
+
PROBE_VIEW = "view"
|
|
190
|
+
PROBE_NODE = "node"
|
|
191
|
+
PROBE_UNREADABLE = "unreadable"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def strip_heredocs(text):
|
|
195
|
+
"""Drop heredoc bodies, which are data rather than commands.
|
|
196
|
+
|
|
197
|
+
A PR body or commit message pasted into a heredoc can contain the literal
|
|
198
|
+
text of an arming command; running the guard over it would refuse a command
|
|
199
|
+
that arms nothing.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
text: The raw command line.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
The command with heredoc payloads removed.
|
|
206
|
+
"""
|
|
207
|
+
lines = text.splitlines()
|
|
208
|
+
output = []
|
|
209
|
+
pending = []
|
|
210
|
+
marker_pattern = re.compile(
|
|
211
|
+
r"<<-?\s*(?:'([^']+)'|\"([^\"]+)\"|([A-Za-z_][A-Za-z0-9_]*))"
|
|
212
|
+
)
|
|
213
|
+
index = 0
|
|
214
|
+
while index < len(lines):
|
|
215
|
+
line = lines[index]
|
|
216
|
+
output.append(line)
|
|
217
|
+
pending.extend(
|
|
218
|
+
next(group for group in match.groups() if group)
|
|
219
|
+
for match in marker_pattern.finditer(line)
|
|
220
|
+
)
|
|
221
|
+
index += 1
|
|
222
|
+
while pending and index < len(lines):
|
|
223
|
+
if lines[index].strip() == pending[0]:
|
|
224
|
+
output.append(lines[index])
|
|
225
|
+
pending.pop(0)
|
|
226
|
+
index += 1
|
|
227
|
+
break
|
|
228
|
+
index += 1
|
|
229
|
+
return "\n".join(output)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def line_boundaries_as_separators(text):
|
|
233
|
+
"""Turn newlines into command separators, keeping continuations intact.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
text: The command line, heredoc payloads already stripped.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
The same command with line breaks spelled as separators.
|
|
240
|
+
"""
|
|
241
|
+
joined = text.replace("\r\n", "\n").replace("\\\n", " ")
|
|
242
|
+
return joined.replace("\n", " ; ")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def shell_tokens(text):
|
|
246
|
+
"""Tokenize a command with shell operators kept as their own tokens.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
text: The command line, heredoc payloads already stripped.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
|
|
253
|
+
"""
|
|
254
|
+
lexer = shlex.shlex(
|
|
255
|
+
line_boundaries_as_separators(text), posix=True, punctuation_chars=True
|
|
256
|
+
)
|
|
257
|
+
lexer.whitespace_split = True
|
|
258
|
+
lexer.commenters = ""
|
|
259
|
+
return list(lexer)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def is_gh(token):
|
|
263
|
+
"""Whether a token invokes the gh CLI.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
token: A single shell token.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
True for a bare `gh` or any absolute/relative path ending in `/gh`.
|
|
270
|
+
"""
|
|
271
|
+
return token == "gh" or token.endswith("/gh")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def merge_argv_after_gh(tokens, start):
|
|
275
|
+
"""Find a `pr merge` invocation's argv within one gh command.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
tokens: The operator-aware token list.
|
|
279
|
+
start: Index just past the `gh` token.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
The argv following `merge`, or None when this gh call is not a merge.
|
|
283
|
+
"""
|
|
284
|
+
words = []
|
|
285
|
+
index = start
|
|
286
|
+
while index < len(tokens):
|
|
287
|
+
token = tokens[index]
|
|
288
|
+
if token in COMMAND_SEPARATORS:
|
|
289
|
+
break
|
|
290
|
+
if token.startswith("-"):
|
|
291
|
+
# `--repo=o/r` carries its value; `--repo o/r` eats the next token.
|
|
292
|
+
index += 2 if token in GH_SEPARATE_VALUE else 1
|
|
293
|
+
continue
|
|
294
|
+
words.append((token, index))
|
|
295
|
+
index += 1
|
|
296
|
+
for position, entry in enumerate(words):
|
|
297
|
+
if entry[0] == "merge" and position > 0 and words[position - 1][0] == "pr":
|
|
298
|
+
return tokens[entry[1] + 1:]
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def arms_auto_merge(argv):
|
|
303
|
+
"""Whether a `gh pr merge` argv carries `--auto`.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
argv: Tokens following the `merge` subcommand.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
True if this invocation arms auto-merge rather than merging now.
|
|
310
|
+
"""
|
|
311
|
+
index = 0
|
|
312
|
+
while index < len(argv):
|
|
313
|
+
token = argv[index]
|
|
314
|
+
index += 1
|
|
315
|
+
if token in COMMAND_SEPARATORS:
|
|
316
|
+
return False
|
|
317
|
+
if token == "--auto" or token.startswith("--auto="):
|
|
318
|
+
return True
|
|
319
|
+
if token in MERGE_SEPARATE_VALUE:
|
|
320
|
+
index += 1
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def pr_selector(argv):
|
|
325
|
+
"""The PR the arming command names, as gh itself would read it.
|
|
326
|
+
|
|
327
|
+
Returned verbatim so it can be handed straight back to `gh pr view`, which
|
|
328
|
+
accepts the same number, URL, or branch. When the command names none, gh
|
|
329
|
+
resolves the current branch and so does the probe.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
argv: Tokens following the `merge` subcommand.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
The selector token, or None when the command relies on the branch.
|
|
336
|
+
"""
|
|
337
|
+
index = 0
|
|
338
|
+
while index < len(argv):
|
|
339
|
+
token = argv[index]
|
|
340
|
+
index += 1
|
|
341
|
+
if token in COMMAND_SEPARATORS:
|
|
342
|
+
return None
|
|
343
|
+
if token in MERGE_SEPARATE_VALUE:
|
|
344
|
+
index += 1
|
|
345
|
+
continue
|
|
346
|
+
if not token.startswith("-"):
|
|
347
|
+
return token
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def repo_flag(tokens, start):
|
|
352
|
+
"""The `--repo` value a gh invocation carries, in either spelling.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
tokens: The operator-aware token list.
|
|
356
|
+
start: Index just past the `gh` token.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
A list of gh arguments re-expressing the repo, empty when none.
|
|
360
|
+
"""
|
|
361
|
+
index = start
|
|
362
|
+
while index < len(tokens):
|
|
363
|
+
token = tokens[index]
|
|
364
|
+
if token in COMMAND_SEPARATORS:
|
|
365
|
+
break
|
|
366
|
+
if token in {"-R", "--repo"} and index + 1 < len(tokens):
|
|
367
|
+
return ["--repo", tokens[index + 1]]
|
|
368
|
+
if token.startswith("--repo="):
|
|
369
|
+
return ["--repo", token.split("=", 1)[1]]
|
|
370
|
+
index += 1
|
|
371
|
+
return []
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def gh_invocation_tokens(tokens, start):
|
|
375
|
+
"""The tokens belonging to one gh invocation, up to the next separator.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
tokens: The operator-aware token list.
|
|
379
|
+
start: Index just past the `gh` token.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
The invocation's own tokens.
|
|
383
|
+
"""
|
|
384
|
+
end = start
|
|
385
|
+
while end < len(tokens) and tokens[end] not in COMMAND_SEPARATORS:
|
|
386
|
+
end += 1
|
|
387
|
+
return tokens[start:end]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def field_values(argv):
|
|
391
|
+
"""The `name=value` payloads a gh invocation passes as fields.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
argv: One gh invocation's tokens.
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
A dict of field name to value. Later fields win, as gh does.
|
|
398
|
+
"""
|
|
399
|
+
values = {}
|
|
400
|
+
index = 0
|
|
401
|
+
while index < len(argv):
|
|
402
|
+
token = argv[index]
|
|
403
|
+
index += 1
|
|
404
|
+
raw = None
|
|
405
|
+
if token in GH_FIELD_FLAGS and index < len(argv):
|
|
406
|
+
raw = argv[index]
|
|
407
|
+
index += 1
|
|
408
|
+
elif token.startswith("--field=") or token.startswith("--raw-field="):
|
|
409
|
+
raw = token.split("=", 1)[1]
|
|
410
|
+
if raw is not None and "=" in raw:
|
|
411
|
+
name, value = raw.split("=", 1)
|
|
412
|
+
values[name] = value
|
|
413
|
+
return values
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def graphql_arming_id(argv):
|
|
417
|
+
"""The PR node id a `gh api graphql` auto-merge mutation names.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
argv: One gh invocation's tokens, `api` included.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
A pair (is_arming, node_id). `node_id` is None when the mutation arms
|
|
424
|
+
auto-merge but names its subject in a way this cannot resolve — which
|
|
425
|
+
is a refusal, not a pass.
|
|
426
|
+
"""
|
|
427
|
+
words = [token for token in argv if not token.startswith("-")]
|
|
428
|
+
if len(words) < 2 or words[0] != "api" or words[1] != "graphql":
|
|
429
|
+
return (False, None)
|
|
430
|
+
fields = field_values(argv)
|
|
431
|
+
query = " ".join(
|
|
432
|
+
value for name, value in fields.items() if AUTO_MERGE_MUTATION in value
|
|
433
|
+
)
|
|
434
|
+
if not query:
|
|
435
|
+
return (False, None)
|
|
436
|
+
match = PULL_REQUEST_ID_PATTERN.search(query)
|
|
437
|
+
if match is None:
|
|
438
|
+
return (True, None)
|
|
439
|
+
inline = match.group(1) or match.group(2)
|
|
440
|
+
if inline:
|
|
441
|
+
return (True, inline if NODE_ID_PATTERN.match(inline) else None)
|
|
442
|
+
# `pullRequestId: $prId` — the id arrives as a separate variable field.
|
|
443
|
+
variable = fields.get(match.group(3))
|
|
444
|
+
if variable and NODE_ID_PATTERN.match(variable):
|
|
445
|
+
return (True, variable)
|
|
446
|
+
return (True, None)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def arming_invocations(text, depth=0):
|
|
450
|
+
"""Every auto-merge arming this command line performs.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
text: The command line, heredoc payloads already stripped.
|
|
454
|
+
depth: Recursion depth, bounding the `bash -c` unwrap to one level.
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
A list of (kind, subject, repo_args) probe specs, one per arming.
|
|
458
|
+
"""
|
|
459
|
+
try:
|
|
460
|
+
tokens = shell_tokens(text)
|
|
461
|
+
except ValueError:
|
|
462
|
+
return []
|
|
463
|
+
found = []
|
|
464
|
+
for index, token in enumerate(tokens):
|
|
465
|
+
if depth == 0 and token.split("/")[-1] in SHELL_WRAPPERS:
|
|
466
|
+
# `bash -c '<command>'` hides the whole invocation inside one token.
|
|
467
|
+
if index + 2 < len(tokens) and tokens[index + 1] == "-c":
|
|
468
|
+
found.extend(arming_invocations(tokens[index + 2], depth + 1))
|
|
469
|
+
continue
|
|
470
|
+
if not is_gh(token):
|
|
471
|
+
continue
|
|
472
|
+
repo_args = repo_flag(tokens, index + 1)
|
|
473
|
+
argv = merge_argv_after_gh(tokens, index + 1)
|
|
474
|
+
if argv is not None and arms_auto_merge(argv):
|
|
475
|
+
found.append((PROBE_VIEW, pr_selector(argv), repo_args))
|
|
476
|
+
continue
|
|
477
|
+
is_arming, node_id = graphql_arming_id(
|
|
478
|
+
gh_invocation_tokens(tokens, index + 1)
|
|
479
|
+
)
|
|
480
|
+
if is_arming:
|
|
481
|
+
kind = PROBE_NODE if node_id else PROBE_UNREADABLE
|
|
482
|
+
found.append((kind, node_id, repo_args))
|
|
483
|
+
return found
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def probe_args(kind, subject, repo_args):
|
|
487
|
+
"""The gh command that answers the review question for one arming.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
kind: PROBE_VIEW or PROBE_NODE.
|
|
491
|
+
subject: A PR selector, a node id, or None for the current branch.
|
|
492
|
+
repo_args: gh arguments naming the repository, possibly empty.
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
The full gh argv.
|
|
496
|
+
"""
|
|
497
|
+
if kind == PROBE_NODE:
|
|
498
|
+
# `--jq` unwraps the envelope so both probes return the same shape.
|
|
499
|
+
return [
|
|
500
|
+
"gh", "api", "graphql",
|
|
501
|
+
"-f", "query=" + NODE_QUERY,
|
|
502
|
+
"-f", "id=" + subject,
|
|
503
|
+
"--jq", ".data.node",
|
|
504
|
+
]
|
|
505
|
+
args = ["gh", "pr", "view"]
|
|
506
|
+
if subject is not None:
|
|
507
|
+
args.append(subject)
|
|
508
|
+
args.extend(repo_args)
|
|
509
|
+
args.extend(["--json", "number,reviewDecision,state,url"])
|
|
510
|
+
return args
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def pull_request_state(kind, subject, repo_args):
|
|
514
|
+
"""Ask GitHub for the fields that decide whether a merge can happen.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
kind: PROBE_VIEW or PROBE_NODE.
|
|
518
|
+
subject: A PR selector, a node id, or None for the current branch.
|
|
519
|
+
repo_args: gh arguments naming the repository, possibly empty.
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
A pair (payload, failure). Exactly one is non-None: `payload` is the
|
|
523
|
+
decoded pull request object, `failure` is a human-readable reason the
|
|
524
|
+
question could not be answered.
|
|
525
|
+
"""
|
|
526
|
+
args = probe_args(kind, subject, repo_args)
|
|
527
|
+
try:
|
|
528
|
+
result = subprocess.run(
|
|
529
|
+
args, capture_output=True, text=True, timeout=GH_TIMEOUT_SECONDS
|
|
530
|
+
)
|
|
531
|
+
except FileNotFoundError:
|
|
532
|
+
return (None, "gh not found")
|
|
533
|
+
except subprocess.TimeoutExpired:
|
|
534
|
+
return (None, "gh timed out after %ds" % GH_TIMEOUT_SECONDS)
|
|
535
|
+
except OSError as error:
|
|
536
|
+
return (None, "gh could not be run (%s)" % error)
|
|
537
|
+
if result.returncode != 0:
|
|
538
|
+
return (None, "gh exited %d" % result.returncode)
|
|
539
|
+
try:
|
|
540
|
+
payload = json.loads(result.stdout or "{}")
|
|
541
|
+
except ValueError:
|
|
542
|
+
return (None, "gh returned unreadable JSON")
|
|
543
|
+
if not isinstance(payload, dict):
|
|
544
|
+
return (None, "gh returned unreadable JSON")
|
|
545
|
+
return (payload, None)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def pr_name(payload):
|
|
549
|
+
"""The most identifying name available for the PR in a refusal message.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
payload: The decoded `gh pr view --json` object.
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
A URL, a `#<number>`, or a generic phrase.
|
|
556
|
+
"""
|
|
557
|
+
if payload.get("url"):
|
|
558
|
+
return payload["url"]
|
|
559
|
+
if payload.get("number"):
|
|
560
|
+
return "#%s" % payload["number"]
|
|
561
|
+
return "this PR"
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
REFUSAL = """Blocked: arming auto-merge on %s would report a merge that can never happen.
|
|
565
|
+
|
|
566
|
+
GitHub reports reviewDecision = CHANGES_REQUESTED. That is a merge blocker with
|
|
567
|
+
NO check-run representation: the failing-check count reads 0 and the checks tab
|
|
568
|
+
is entirely green while the PR is permanently unmergeable. Where the ruleset
|
|
569
|
+
sets dismiss_stale_reviews_on_push: false, pushing the fix does not clear it and
|
|
570
|
+
no re-review is scheduled to.
|
|
571
|
+
|
|
572
|
+
Clear the blocker, then arm:
|
|
573
|
+
- resolve every review thread and address or reply to the feedback
|
|
574
|
+
(`lisa-pull-request-review`);
|
|
575
|
+
- if the review is stale — made on an older head, with the fix already pushed
|
|
576
|
+
— dismiss it (`lisa-drive-pr-to-merge`, review-gate stall), then re-arm.
|
|
577
|
+
|
|
578
|
+
Do not report the PR as armed-and-merging until this reads anything other than
|
|
579
|
+
CHANGES_REQUESTED.
|
|
580
|
+
"""
|
|
581
|
+
|
|
582
|
+
UNREADABLE_REFUSAL = """Blocked: this command arms auto-merge through a GraphQL mutation whose
|
|
583
|
+
pullRequestId cannot be read, so whether the PR is blocked on review is
|
|
584
|
+
unknowable at the moment the claim "this will merge" is made.
|
|
585
|
+
|
|
586
|
+
Arm through `gh pr merge <pr> --auto` instead, or pass the node id as a literal
|
|
587
|
+
or as a field this command also sets. An arming nobody can inspect is the
|
|
588
|
+
failure this guard exists to close, one substrate over.
|
|
589
|
+
"""
|
|
590
|
+
|
|
591
|
+
armings = arming_invocations(strip_heredocs(command))
|
|
592
|
+
if not armings:
|
|
593
|
+
sys.exit(0)
|
|
594
|
+
|
|
595
|
+
for kind, subject, repo_args in armings:
|
|
596
|
+
if kind == PROBE_UNREADABLE:
|
|
597
|
+
# Fails CLOSED, unlike every other unanswerable case below. Those are
|
|
598
|
+
# the guard being unable to run; this is the command refusing to say
|
|
599
|
+
# what it acts on, which is a property of the command.
|
|
600
|
+
sys.stderr.write(UNREADABLE_REFUSAL)
|
|
601
|
+
sys.exit(1)
|
|
602
|
+
payload, failure = pull_request_state(kind, subject, repo_args)
|
|
603
|
+
if failure is not None:
|
|
604
|
+
# A guard that is silently absent reads exactly like a guard that is
|
|
605
|
+
# passing, so say so and let the command through.
|
|
606
|
+
sys.stderr.write(
|
|
607
|
+
"block-blind-automerge: could not read reviewDecision (%s); "
|
|
608
|
+
"auto-merge review-gate protection is NOT active for this command\n"
|
|
609
|
+
% failure
|
|
610
|
+
)
|
|
611
|
+
continue
|
|
612
|
+
if (payload.get("reviewDecision") or "") != BLOCKING_REVIEW_DECISION:
|
|
613
|
+
continue
|
|
614
|
+
sys.stderr.write(REFUSAL % pr_name(payload))
|
|
615
|
+
sys.exit(1)
|
|
616
|
+
|
|
617
|
+
sys.exit(0)
|
|
618
|
+
PY
|
|
619
|
+
then
|
|
620
|
+
exit 2
|
|
621
|
+
fi
|
|
622
|
+
|
|
623
|
+
exit 0
|