@codyswann/lisa 4.45.2 → 4.45.4
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-hooks/block-blind-automerge.sh +523 -70
- package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +160 -2
- package/all/copy-overwrite/scripts/lisa-hooks/parity-safety-net.sh +115 -12
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +10 -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 +19 -12
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/lisa-block-direct-issue-create.ts +40 -1
- package/dist/opencode/plugin-templates/parity-safety-net.sh +115 -12
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-github-write-issue/SKILL.md +16 -0
- package/plugins/lisa/hooks/block-blind-automerge.agy.sh +5 -4
- package/plugins/lisa/hooks/block-blind-automerge.sh +523 -70
- package/plugins/lisa/hooks/block-direct-issue-create.sh +160 -2
- package/plugins/lisa/hooks/parity-safety-net.sh +115 -12
- package/plugins/lisa/rules/eager/ready-role-filing.md +3 -2
- package/plugins/lisa/rules/reference/ready-role-filing.md +20 -2
- package/plugins/lisa/skills/lisa-github-write-issue/SKILL.md +16 -0
- package/plugins/lisa-agy/hooks/block-blind-automerge.agy.sh +5 -4
- package/plugins/lisa-agy/hooks/block-blind-automerge.sh +523 -70
- package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +160 -2
- package/plugins/lisa-agy/hooks/parity-safety-net.sh +115 -12
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-github-write-issue/SKILL.md +16 -0
- 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 +1 -1
- package/plugins/lisa-copilot/hooks/block-blind-automerge.sh +523 -70
- package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +160 -2
- package/plugins/lisa-copilot/hooks/parity-safety-net.sh +115 -12
- package/plugins/lisa-copilot/rules/eager/ready-role-filing.md +3 -2
- package/plugins/lisa-copilot/rules/reference/ready-role-filing.md +20 -2
- package/plugins/lisa-copilot/skills/lisa-github-write-issue/SKILL.md +16 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-blind-automerge.sh +523 -70
- package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +160 -2
- package/plugins/lisa-cursor/hooks/parity-safety-net.sh +115 -12
- package/plugins/lisa-cursor/rules/ready-role-filing-reference.mdc +20 -2
- package/plugins/lisa-cursor/rules/ready-role-filing.mdc +3 -2
- package/plugins/lisa-cursor/skills/lisa-github-write-issue/SKILL.md +16 -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/src/base/hooks/block-blind-automerge.agy.sh +5 -4
- package/plugins/src/base/hooks/block-blind-automerge.sh +523 -70
- package/plugins/src/base/hooks/block-direct-issue-create.sh +160 -2
- package/plugins/src/base/hooks/parity-safety-net.sh +115 -12
- package/plugins/src/base/rules/eager/ready-role-filing.md +3 -2
- package/plugins/src/base/rules/reference/ready-role-filing.md +20 -2
- package/plugins/src/base/skills/lisa-github-write-issue/SKILL.md +16 -0
- package/scripts/check-typecheck-tests.mjs +229 -72
- package/scripts/lib/dependency-tree.mjs +136 -0
- package/typescript/copy-contents/.husky/pre-push +45 -0
|
@@ -58,7 +58,71 @@
|
|
|
58
58
|
# on evidence CI already surfaces. The suite pins the boundary with a case, so a
|
|
59
59
|
# later reader can see it was decided rather than missed.
|
|
60
60
|
#
|
|
61
|
-
#
|
|
61
|
+
# THE SECOND VECTOR: A BASE REF NO RULESET COVERS
|
|
62
|
+
#
|
|
63
|
+
# The paragraphs above answer "is a review verdict blocking this merge?". They
|
|
64
|
+
# assume the merge has gates at all. On this repository that assumption is FALSE
|
|
65
|
+
# for every ref but four.
|
|
66
|
+
#
|
|
67
|
+
# Read live from the API, the `base` and `quality checks` rulesets both scope
|
|
68
|
+
# `conditions.ref_name.include` to exactly `~DEFAULT_BRANCH`, `refs/heads/dev`,
|
|
69
|
+
# `refs/heads/staging`, `refs/heads/main`, with no exclusions. A pull request
|
|
70
|
+
# based on ANY other ref — a `stack/**` batching branch, a feature branch —
|
|
71
|
+
# therefore has ZERO required checks. Every gate still runs; none of them can
|
|
72
|
+
# block. `gh api repos/<o>/<r>/rules/branches/main` returns fifteen required
|
|
73
|
+
# contexts; the same call for a stack ref returns `[]`.
|
|
74
|
+
#
|
|
75
|
+
# Two ordinary, permission-free acts turn that into a merged pull request that
|
|
76
|
+
# nobody approved and no gate passed. Both are intercepted here.
|
|
77
|
+
#
|
|
78
|
+
# 1. ARMING on an uncovered base. `gh pr merge <pr> --auto` on a stack base is
|
|
79
|
+
# a claim that CI will decide, made where CI decides nothing. The merge lands
|
|
80
|
+
# the moment the PR becomes mergeable, with no reader in the path.
|
|
81
|
+
# 2. RE-TARGETING a red pull request onto an uncovered base. `gh pr edit <pr>
|
|
82
|
+
# --base stack/x` is a metadata edit any contributor or agent can make. It
|
|
83
|
+
# requires no elevated permission, no `--force`, and no code change.
|
|
84
|
+
# CodySwannGT/lisa#3922 records it executed by accident, by an agent
|
|
85
|
+
# following an approved plan: a pull request BLOCKED by two genuinely failing
|
|
86
|
+
# required checks was re-targeted, both blockers ceased to apply because they
|
|
87
|
+
# were no longer required, the already-armed auto-merge fired, and the head
|
|
88
|
+
# branch was auto-deleted leaving the work reachable from one ref.
|
|
89
|
+
#
|
|
90
|
+
# WHY THE CONFIGURATION CANNOT ANSWER THIS AND THE API MUST
|
|
91
|
+
#
|
|
92
|
+
# The resolved Lisa config reads `policy.protect: force_push=true`, which any
|
|
93
|
+
# reader takes for "force-push is blocked here". It is not a global switch — it
|
|
94
|
+
# configures a ruleset whose ref scope is those four refs. **A policy flag names
|
|
95
|
+
# an intent; only the ruleset's ref scope says where it applies.** So coverage
|
|
96
|
+
# is read from `GET /repos/{owner}/{repo}/rules/branches/{ref}`, which answers
|
|
97
|
+
# for the ONE ref in question and folds in org rulesets, repository rulesets and
|
|
98
|
+
# their exclusions. Nothing here reads `.lisa.config.json`.
|
|
99
|
+
#
|
|
100
|
+
# WHY RE-TARGETING READS CHECKS WHEN ARMING DOES NOT
|
|
101
|
+
#
|
|
102
|
+
# The section above says this guard reads no check conclusions, and for the
|
|
103
|
+
# review question that is right — `CHANGES_REQUESTED` shows zero failing checks,
|
|
104
|
+
# so a check-derived signal is the defect rather than the detector. The
|
|
105
|
+
# re-target question is the opposite shape: "does this edit remove a live
|
|
106
|
+
# blocker?" and a blocker there IS a check by construction. Reading them is not
|
|
107
|
+
# re-deriving a gate; it is asking whether one is currently biting.
|
|
108
|
+
#
|
|
109
|
+
# The condition is deliberately narrow, because re-targeting onto a stack base
|
|
110
|
+
# is a SANCTIONED batching workflow and a guard that refused it outright would
|
|
111
|
+
# be switched off within a day. A red pull request is refused; a green one is
|
|
112
|
+
# announced and allowed. The property protected is "a pull request something is
|
|
113
|
+
# currently failing must not be moved onto a base where nothing can block it",
|
|
114
|
+
# which holds whatever base it came from — so the current base is not consulted.
|
|
115
|
+
#
|
|
116
|
+
# WHAT THE REFUSAL ASKS FOR INSTEAD
|
|
117
|
+
#
|
|
118
|
+
# There is no env-var escape hatch here either, and none is needed: the
|
|
119
|
+
# acknowledgement the arming refusal asks for is dropping `--auto`. That is not
|
|
120
|
+
# ceremony. `gh pr merge <pr> --merge` on a stack base performs the merge at a
|
|
121
|
+
# moment when the advisory results exist and something can read them, which is
|
|
122
|
+
# exactly the mitigation #3922 shows is unreachable while auto-merge is armed —
|
|
123
|
+
# "the merge lands before any reader exists".
|
|
124
|
+
#
|
|
125
|
+
# SCOPE: ARMING AND RE-TARGETING, NOT MERGING
|
|
62
126
|
#
|
|
63
127
|
# A direct `gh pr merge` against a blocked PR fails loudly and immediately, so
|
|
64
128
|
# it is not the invisible failure this exists to close — and refusing it would
|
|
@@ -96,7 +160,7 @@
|
|
|
96
160
|
# The line below is what lets `lisa apply` tell a downstream copy of this guard
|
|
97
161
|
# that is BEHIND from one that is AHEAD. Add a name here in the same commit that
|
|
98
162
|
# closes a vector.
|
|
99
|
-
# lisa-guard-capabilities: automerge-arm-review-decision, automerge-arm-graphql
|
|
163
|
+
# lisa-guard-capabilities: automerge-arm-review-decision, automerge-arm-graphql, automerge-arm-uncovered-base, retarget-uncovered-base, retarget-graphql
|
|
100
164
|
set -euo pipefail
|
|
101
165
|
|
|
102
166
|
input="$(cat)"
|
|
@@ -132,6 +196,7 @@ import re
|
|
|
132
196
|
import shlex
|
|
133
197
|
import subprocess
|
|
134
198
|
import sys
|
|
199
|
+
from urllib.parse import quote
|
|
135
200
|
|
|
136
201
|
command = os.environ.get("BLOCK_BLIND_AUTOMERGE_COMMAND", "")
|
|
137
202
|
|
|
@@ -184,7 +249,7 @@ NODE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_=-]+$")
|
|
|
184
249
|
# The probe that resolves a node id to the same fields `gh pr view` returns.
|
|
185
250
|
NODE_QUERY = (
|
|
186
251
|
"query($id:ID!){node(id:$id){... on PullRequest"
|
|
187
|
-
"{number url reviewDecision state}}}"
|
|
252
|
+
"{number url reviewDecision state baseRefName}}}"
|
|
188
253
|
)
|
|
189
254
|
|
|
190
255
|
# What a probe spec asks for. `view` and `node` differ only in how the PR is
|
|
@@ -193,6 +258,51 @@ PROBE_VIEW = "view"
|
|
|
193
258
|
PROBE_NODE = "node"
|
|
194
259
|
PROBE_UNREADABLE = "unreadable"
|
|
195
260
|
|
|
261
|
+
# What a probe needs to know, per act. The review question and the base-coverage
|
|
262
|
+
# question want different fields, and the arming path asks for the second set
|
|
263
|
+
# only when the first has already been answered "not blocked" — so a refusal
|
|
264
|
+
# still costs exactly one call.
|
|
265
|
+
ARM_FIELDS = "number,reviewDecision,state,url,baseRefName"
|
|
266
|
+
RETARGET_FIELDS = "number,state,url,baseRefName,statusCheckRollup"
|
|
267
|
+
|
|
268
|
+
# The two acts this guard intercepts.
|
|
269
|
+
ACT_ARM = "arm"
|
|
270
|
+
ACT_RETARGET = "retarget"
|
|
271
|
+
|
|
272
|
+
# The rule type that makes a check able to block a merge. A ref with no rule of
|
|
273
|
+
# this type has zero required checks, whatever runs against it.
|
|
274
|
+
REQUIRED_STATUS_CHECKS_RULE = "required_status_checks"
|
|
275
|
+
|
|
276
|
+
# The GraphQL mutation that re-targets a pull request. Same act as
|
|
277
|
+
# `gh pr edit --base`, reached through the API instead of the porcelain.
|
|
278
|
+
RETARGET_MUTATION = "updatePullRequest"
|
|
279
|
+
|
|
280
|
+
# How a re-target names its new base, whether inline or as a variable.
|
|
281
|
+
BASE_REF_NAME_PATTERN = re.compile(
|
|
282
|
+
r"baseRefName\s*:\s*(?:\"([^\"]+)\"|'([^']+)'|\$([A-Za-z_][A-Za-z0-9_]*))"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# `gh pr edit` options taking a SEPARATE value, so a value is never mistaken for
|
|
286
|
+
# the PR selector: `gh pr edit --title "fix 12" --base main` must not read
|
|
287
|
+
# `fix 12` as the PR.
|
|
288
|
+
EDIT_SEPARATE_VALUE = {
|
|
289
|
+
"--add-assignee", "--add-label", "--add-project", "--add-reviewer",
|
|
290
|
+
"-b", "--body", "-F", "--body-file", "-m", "--milestone",
|
|
291
|
+
"--remove-assignee", "--remove-label", "--remove-project",
|
|
292
|
+
"--remove-reviewer", "-t", "--title",
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
# Both spellings of the re-target flag, as `gh pr edit --help` lists them.
|
|
296
|
+
BASE_FLAGS = {"-B", "--base"}
|
|
297
|
+
|
|
298
|
+
# Check outcomes that mean something is currently failing. Read from either
|
|
299
|
+
# shape `statusCheckRollup` returns: a CheckRun carries `conclusion`, a
|
|
300
|
+
# commit StatusContext carries `state`.
|
|
301
|
+
FAILING_CHECK_OUTCOMES = {
|
|
302
|
+
"FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE",
|
|
303
|
+
"ERROR",
|
|
304
|
+
}
|
|
305
|
+
|
|
196
306
|
|
|
197
307
|
def strip_heredocs(text):
|
|
198
308
|
"""Drop heredoc bodies, which are data rather than commands.
|
|
@@ -274,15 +384,16 @@ def is_gh(token):
|
|
|
274
384
|
return token == "gh" or token.endswith("/gh")
|
|
275
385
|
|
|
276
386
|
|
|
277
|
-
def
|
|
278
|
-
"""Find a `pr
|
|
387
|
+
def subcommand_argv_after_gh(tokens, start, verb):
|
|
388
|
+
"""Find a `pr <verb>` invocation's argv within one gh command.
|
|
279
389
|
|
|
280
390
|
Args:
|
|
281
391
|
tokens: The operator-aware token list.
|
|
282
392
|
start: Index just past the `gh` token.
|
|
393
|
+
verb: The `pr` subcommand to look for, `merge` or `edit`.
|
|
283
394
|
|
|
284
395
|
Returns:
|
|
285
|
-
The argv following `
|
|
396
|
+
The argv following `verb`, or None when this gh call is not that verb.
|
|
286
397
|
"""
|
|
287
398
|
words = []
|
|
288
399
|
index = start
|
|
@@ -297,7 +408,7 @@ def merge_argv_after_gh(tokens, start):
|
|
|
297
408
|
words.append((token, index))
|
|
298
409
|
index += 1
|
|
299
410
|
for position, entry in enumerate(words):
|
|
300
|
-
if entry[0] ==
|
|
411
|
+
if entry[0] == verb and position > 0 and words[position - 1][0] == "pr":
|
|
301
412
|
return tokens[entry[1] + 1:]
|
|
302
413
|
return None
|
|
303
414
|
|
|
@@ -324,15 +435,17 @@ def arms_auto_merge(argv):
|
|
|
324
435
|
return False
|
|
325
436
|
|
|
326
437
|
|
|
327
|
-
def pr_selector(argv):
|
|
328
|
-
"""The PR the
|
|
438
|
+
def pr_selector(argv, separate_value=MERGE_SEPARATE_VALUE):
|
|
439
|
+
"""The PR the command names, as gh itself would read it.
|
|
329
440
|
|
|
330
441
|
Returned verbatim so it can be handed straight back to `gh pr view`, which
|
|
331
442
|
accepts the same number, URL, or branch. When the command names none, gh
|
|
332
443
|
resolves the current branch and so does the probe.
|
|
333
444
|
|
|
334
445
|
Args:
|
|
335
|
-
argv: Tokens following the `merge` subcommand.
|
|
446
|
+
argv: Tokens following the `merge` or `edit` subcommand.
|
|
447
|
+
separate_value: Options of that subcommand taking a separate value,
|
|
448
|
+
whose VALUE must never be read as the selector.
|
|
336
449
|
|
|
337
450
|
Returns:
|
|
338
451
|
The selector token, or None when the command relies on the branch.
|
|
@@ -343,7 +456,7 @@ def pr_selector(argv):
|
|
|
343
456
|
index += 1
|
|
344
457
|
if token in COMMAND_SEPARATORS:
|
|
345
458
|
return None
|
|
346
|
-
if token in
|
|
459
|
+
if token in separate_value or token in BASE_FLAGS:
|
|
347
460
|
index += 1
|
|
348
461
|
continue
|
|
349
462
|
if not token.startswith("-"):
|
|
@@ -351,6 +464,30 @@ def pr_selector(argv):
|
|
|
351
464
|
return None
|
|
352
465
|
|
|
353
466
|
|
|
467
|
+
def retarget_target(argv):
|
|
468
|
+
"""The base ref a `gh pr edit` invocation moves the pull request to.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
argv: Tokens following the `edit` subcommand.
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
The new base ref, or None when this edit changes no base.
|
|
475
|
+
"""
|
|
476
|
+
index = 0
|
|
477
|
+
while index < len(argv):
|
|
478
|
+
token = argv[index]
|
|
479
|
+
index += 1
|
|
480
|
+
if token in COMMAND_SEPARATORS:
|
|
481
|
+
return None
|
|
482
|
+
if token in BASE_FLAGS and index < len(argv):
|
|
483
|
+
return argv[index]
|
|
484
|
+
if token.startswith("--base="):
|
|
485
|
+
return token.split("=", 1)[1]
|
|
486
|
+
if token in EDIT_SEPARATE_VALUE:
|
|
487
|
+
index += 1
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
|
|
354
491
|
def repo_flag(tokens, start):
|
|
355
492
|
"""The `--repo` value a gh invocation carries, in either spelling.
|
|
356
493
|
|
|
@@ -416,6 +553,51 @@ def field_values(argv):
|
|
|
416
553
|
return values
|
|
417
554
|
|
|
418
555
|
|
|
556
|
+
def graphql_mutation(argv, mutation):
|
|
557
|
+
"""The text and fields of one `gh api graphql` call naming a mutation.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
argv: One gh invocation's tokens, `api` included.
|
|
561
|
+
mutation: The mutation name to look for.
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
A pair (query_text, fields). `query_text` is empty when this call is
|
|
565
|
+
not a graphql invocation running that mutation.
|
|
566
|
+
"""
|
|
567
|
+
words = [token for token in argv if not token.startswith("-")]
|
|
568
|
+
if len(words) < 2 or words[0] != "api" or words[1] != "graphql":
|
|
569
|
+
return ("", {})
|
|
570
|
+
fields = field_values(argv)
|
|
571
|
+
query = " ".join(
|
|
572
|
+
value for name, value in fields.items() if mutation in value
|
|
573
|
+
)
|
|
574
|
+
return (query, fields)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def resolved_literal(match, fields, pattern):
|
|
578
|
+
"""A mutation argument's value, whether written inline or as a variable.
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
match: The regex match over the mutation text, or None.
|
|
582
|
+
fields: The gh field payloads the same invocation sets.
|
|
583
|
+
pattern: A compiled pattern the value must satisfy, or None for any.
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
The value, or None when it cannot be resolved — which is a refusal
|
|
587
|
+
rather than a pass, because an act whose subject nobody can read is
|
|
588
|
+
this guard's own defect in a new place.
|
|
589
|
+
"""
|
|
590
|
+
if match is None:
|
|
591
|
+
return None
|
|
592
|
+
inline = match.group(1) or match.group(2)
|
|
593
|
+
candidate = inline if inline else fields.get(match.group(3))
|
|
594
|
+
if not candidate:
|
|
595
|
+
return None
|
|
596
|
+
if pattern is not None and not pattern.match(candidate):
|
|
597
|
+
return None
|
|
598
|
+
return candidate
|
|
599
|
+
|
|
600
|
+
|
|
419
601
|
def graphql_arming_id(argv):
|
|
420
602
|
"""The PR node id a `gh api graphql` auto-merge mutation names.
|
|
421
603
|
|
|
@@ -427,37 +609,57 @@ def graphql_arming_id(argv):
|
|
|
427
609
|
auto-merge but names its subject in a way this cannot resolve — which
|
|
428
610
|
is a refusal, not a pass.
|
|
429
611
|
"""
|
|
430
|
-
|
|
431
|
-
if
|
|
612
|
+
query, fields = graphql_mutation(argv, AUTO_MERGE_MUTATION)
|
|
613
|
+
if not query:
|
|
432
614
|
return (False, None)
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
615
|
+
return (
|
|
616
|
+
True,
|
|
617
|
+
resolved_literal(
|
|
618
|
+
PULL_REQUEST_ID_PATTERN.search(query), fields, NODE_ID_PATTERN
|
|
619
|
+
),
|
|
436
620
|
)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def graphql_retarget(argv):
|
|
624
|
+
"""The subject and new base a `gh api graphql` re-target mutation names.
|
|
625
|
+
|
|
626
|
+
`updatePullRequest` also edits titles, bodies and labels, none of which
|
|
627
|
+
move a pull request out from under its gates. Only a mutation that carries
|
|
628
|
+
`baseRefName` is a re-target, so the others are left alone entirely.
|
|
629
|
+
|
|
630
|
+
Args:
|
|
631
|
+
argv: One gh invocation's tokens, `api` included.
|
|
632
|
+
|
|
633
|
+
Returns:
|
|
634
|
+
A triple (is_retarget, node_id, base_ref). Either of the last two is
|
|
635
|
+
None when the mutation re-targets but names that part in a way this
|
|
636
|
+
cannot resolve.
|
|
637
|
+
"""
|
|
638
|
+
query, fields = graphql_mutation(argv, RETARGET_MUTATION)
|
|
437
639
|
if not query:
|
|
438
|
-
return (False, None)
|
|
439
|
-
|
|
440
|
-
if
|
|
441
|
-
return (
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
return (True, None)
|
|
640
|
+
return (False, None, None)
|
|
641
|
+
base_match = BASE_REF_NAME_PATTERN.search(query)
|
|
642
|
+
if base_match is None:
|
|
643
|
+
return (False, None, None)
|
|
644
|
+
return (
|
|
645
|
+
True,
|
|
646
|
+
resolved_literal(
|
|
647
|
+
PULL_REQUEST_ID_PATTERN.search(query), fields, NODE_ID_PATTERN
|
|
648
|
+
),
|
|
649
|
+
resolved_literal(base_match, fields, None),
|
|
650
|
+
)
|
|
450
651
|
|
|
451
652
|
|
|
452
|
-
def
|
|
453
|
-
"""Every
|
|
653
|
+
def guarded_invocations(text, depth=0):
|
|
654
|
+
"""Every arming and every re-target this command line performs.
|
|
454
655
|
|
|
455
656
|
Args:
|
|
456
657
|
text: The command line, heredoc payloads already stripped.
|
|
457
658
|
depth: Recursion depth, bounding the `bash -c` unwrap to one level.
|
|
458
659
|
|
|
459
660
|
Returns:
|
|
460
|
-
A list of (kind, subject, repo_args) probe specs, one
|
|
661
|
+
A list of (act, kind, subject, repo_args, target_ref) probe specs, one
|
|
662
|
+
per intercepted act. `target_ref` is meaningful only for a re-target.
|
|
461
663
|
"""
|
|
462
664
|
try:
|
|
463
665
|
tokens = shell_tokens(text)
|
|
@@ -468,31 +670,54 @@ def arming_invocations(text, depth=0):
|
|
|
468
670
|
if depth == 0 and token.split("/")[-1] in SHELL_WRAPPERS:
|
|
469
671
|
# `bash -c '<command>'` hides the whole invocation inside one token.
|
|
470
672
|
if index + 2 < len(tokens) and tokens[index + 1] == "-c":
|
|
471
|
-
found.extend(
|
|
673
|
+
found.extend(guarded_invocations(tokens[index + 2], depth + 1))
|
|
472
674
|
continue
|
|
473
675
|
if not is_gh(token):
|
|
474
676
|
continue
|
|
475
677
|
repo_args = repo_flag(tokens, index + 1)
|
|
476
|
-
|
|
477
|
-
if
|
|
478
|
-
found.append(
|
|
678
|
+
merge_argv = subcommand_argv_after_gh(tokens, index + 1, "merge")
|
|
679
|
+
if merge_argv is not None and arms_auto_merge(merge_argv):
|
|
680
|
+
found.append(
|
|
681
|
+
(ACT_ARM, PROBE_VIEW, pr_selector(merge_argv), repo_args, None)
|
|
682
|
+
)
|
|
479
683
|
continue
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
684
|
+
edit_argv = subcommand_argv_after_gh(tokens, index + 1, "edit")
|
|
685
|
+
if edit_argv is not None:
|
|
686
|
+
target = retarget_target(edit_argv)
|
|
687
|
+
if target is not None:
|
|
688
|
+
found.append(
|
|
689
|
+
(
|
|
690
|
+
ACT_RETARGET,
|
|
691
|
+
PROBE_VIEW,
|
|
692
|
+
pr_selector(edit_argv, EDIT_SEPARATE_VALUE),
|
|
693
|
+
repo_args,
|
|
694
|
+
target,
|
|
695
|
+
)
|
|
696
|
+
)
|
|
697
|
+
continue
|
|
698
|
+
invocation = gh_invocation_tokens(tokens, index + 1)
|
|
699
|
+
is_arming, node_id = graphql_arming_id(invocation)
|
|
483
700
|
if is_arming:
|
|
484
701
|
kind = PROBE_NODE if node_id else PROBE_UNREADABLE
|
|
485
|
-
found.append((kind, node_id, repo_args))
|
|
702
|
+
found.append((ACT_ARM, kind, node_id, repo_args, None))
|
|
703
|
+
continue
|
|
704
|
+
is_retarget, node_id, target = graphql_retarget(invocation)
|
|
705
|
+
if is_retarget:
|
|
706
|
+
kind = (
|
|
707
|
+
PROBE_NODE if node_id and target is not None else PROBE_UNREADABLE
|
|
708
|
+
)
|
|
709
|
+
found.append((ACT_RETARGET, kind, node_id, repo_args, target))
|
|
486
710
|
return found
|
|
487
711
|
|
|
488
712
|
|
|
489
|
-
def probe_args(kind, subject, repo_args):
|
|
490
|
-
"""The gh command that answers
|
|
713
|
+
def probe_args(kind, subject, repo_args, fields):
|
|
714
|
+
"""The gh command that answers this guard's questions about one PR.
|
|
491
715
|
|
|
492
716
|
Args:
|
|
493
717
|
kind: PROBE_VIEW or PROBE_NODE.
|
|
494
718
|
subject: A PR selector, a node id, or None for the current branch.
|
|
495
719
|
repo_args: gh arguments naming the repository, possibly empty.
|
|
720
|
+
fields: The `--json` field list the act needs.
|
|
496
721
|
|
|
497
722
|
Returns:
|
|
498
723
|
The full gh argv.
|
|
@@ -509,24 +734,22 @@ def probe_args(kind, subject, repo_args):
|
|
|
509
734
|
if subject is not None:
|
|
510
735
|
args.append(subject)
|
|
511
736
|
args.extend(repo_args)
|
|
512
|
-
args.extend(["--json",
|
|
737
|
+
args.extend(["--json", fields])
|
|
513
738
|
return args
|
|
514
739
|
|
|
515
740
|
|
|
516
|
-
def
|
|
517
|
-
"""
|
|
741
|
+
def gh_json(args):
|
|
742
|
+
"""Run a gh command and decode its JSON, without throwing.
|
|
518
743
|
|
|
519
744
|
Args:
|
|
520
|
-
|
|
521
|
-
subject: A PR selector, a node id, or None for the current branch.
|
|
522
|
-
repo_args: gh arguments naming the repository, possibly empty.
|
|
745
|
+
args: The full gh argv.
|
|
523
746
|
|
|
524
747
|
Returns:
|
|
525
|
-
A pair (payload, failure). Exactly one is non-None
|
|
526
|
-
|
|
527
|
-
|
|
748
|
+
A pair (payload, failure). Exactly one is non-None; `failure` names WHY
|
|
749
|
+
the question could not be answered, because "the box was busy" and "gh
|
|
750
|
+
said no" have different remedies and a killed child returns empty
|
|
751
|
+
streams that read like a clean refusal.
|
|
528
752
|
"""
|
|
529
|
-
args = probe_args(kind, subject, repo_args)
|
|
530
753
|
try:
|
|
531
754
|
result = subprocess.run(
|
|
532
755
|
args, capture_output=True, text=True, timeout=GH_TIMEOUT_SECONDS
|
|
@@ -540,14 +763,134 @@ def pull_request_state(kind, subject, repo_args):
|
|
|
540
763
|
if result.returncode != 0:
|
|
541
764
|
return (None, "gh exited %d" % result.returncode)
|
|
542
765
|
try:
|
|
543
|
-
|
|
766
|
+
return (json.loads(result.stdout or "{}"), None)
|
|
544
767
|
except ValueError:
|
|
545
768
|
return (None, "gh returned unreadable JSON")
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def pull_request_state(kind, subject, repo_args, fields=ARM_FIELDS):
|
|
772
|
+
"""Ask GitHub for the fields that decide whether a merge can happen.
|
|
773
|
+
|
|
774
|
+
Args:
|
|
775
|
+
kind: PROBE_VIEW or PROBE_NODE.
|
|
776
|
+
subject: A PR selector, a node id, or None for the current branch.
|
|
777
|
+
repo_args: gh arguments naming the repository, possibly empty.
|
|
778
|
+
|
|
779
|
+
Returns:
|
|
780
|
+
A pair (payload, failure). Exactly one is non-None: `payload` is the
|
|
781
|
+
decoded pull request object, `failure` is a human-readable reason the
|
|
782
|
+
question could not be answered.
|
|
783
|
+
"""
|
|
784
|
+
payload, failure = gh_json(probe_args(kind, subject, repo_args, fields))
|
|
785
|
+
if failure is not None:
|
|
786
|
+
return (None, failure)
|
|
546
787
|
if not isinstance(payload, dict):
|
|
547
788
|
return (None, "gh returned unreadable JSON")
|
|
548
789
|
return (payload, None)
|
|
549
790
|
|
|
550
791
|
|
|
792
|
+
def repo_slug(payload, repo_args):
|
|
793
|
+
"""The `owner/name` the pull request belongs to.
|
|
794
|
+
|
|
795
|
+
Read from the PR's own URL first, because that is the repository GitHub
|
|
796
|
+
says the PR is in — a `--repo` flag is what the caller CLAIMED, and the two
|
|
797
|
+
can differ. The flag is the fallback for a payload with no URL.
|
|
798
|
+
|
|
799
|
+
Args:
|
|
800
|
+
payload: The decoded pull request object.
|
|
801
|
+
repo_args: gh arguments naming the repository, possibly empty.
|
|
802
|
+
|
|
803
|
+
Returns:
|
|
804
|
+
The slug, or None when neither source names one.
|
|
805
|
+
"""
|
|
806
|
+
url = str(payload.get("url") or "")
|
|
807
|
+
parts = url.split("://", 1)[-1].split("/")
|
|
808
|
+
# host / owner / name / "pull" / number
|
|
809
|
+
if len(parts) >= 4 and parts[3] == "pull" and parts[1] and parts[2]:
|
|
810
|
+
return "%s/%s" % (parts[1], parts[2])
|
|
811
|
+
if len(repo_args) == 2 and repo_args[1].count("/") == 1:
|
|
812
|
+
return repo_args[1]
|
|
813
|
+
return None
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def branch_rules(repo, ref):
|
|
817
|
+
"""Every branch rule GitHub says applies to one ref, read live.
|
|
818
|
+
|
|
819
|
+
This is the whole reason the guard asks the API rather than the checked-in
|
|
820
|
+
policy: a `policy.protect` flag names an INTENT, and only the ruleset's ref
|
|
821
|
+
scope says where it applies. This endpoint answers for the one ref in
|
|
822
|
+
question and folds in organisation rulesets, repository rulesets, and every
|
|
823
|
+
exclusion — none of which a config file states.
|
|
824
|
+
|
|
825
|
+
Args:
|
|
826
|
+
repo: `owner/name`.
|
|
827
|
+
ref: The branch name, unencoded.
|
|
828
|
+
|
|
829
|
+
Returns:
|
|
830
|
+
A pair (rules, failure). Exactly one is non-None.
|
|
831
|
+
"""
|
|
832
|
+
payload, failure = gh_json(
|
|
833
|
+
["gh", "api", "repos/%s/rules/branches/%s" % (repo, quote(ref, safe=""))]
|
|
834
|
+
)
|
|
835
|
+
if failure is not None:
|
|
836
|
+
return (None, failure)
|
|
837
|
+
if not isinstance(payload, list):
|
|
838
|
+
return (None, "gh returned unreadable JSON")
|
|
839
|
+
return (payload, None)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def required_context_count(rules):
|
|
843
|
+
"""How many status checks can actually block a merge on that ref.
|
|
844
|
+
|
|
845
|
+
A `required_status_checks` rule carrying an empty context list enforces
|
|
846
|
+
nothing, so rules are counted by their CONTEXTS rather than by their
|
|
847
|
+
presence. Counting rules would report a vacuous rule as protection.
|
|
848
|
+
|
|
849
|
+
Args:
|
|
850
|
+
rules: The rule objects the API returned for one ref.
|
|
851
|
+
|
|
852
|
+
Returns:
|
|
853
|
+
The number of distinct required contexts.
|
|
854
|
+
"""
|
|
855
|
+
contexts = set()
|
|
856
|
+
for rule in rules:
|
|
857
|
+
if not isinstance(rule, dict):
|
|
858
|
+
continue
|
|
859
|
+
if rule.get("type") != REQUIRED_STATUS_CHECKS_RULE:
|
|
860
|
+
continue
|
|
861
|
+
parameters = rule.get("parameters")
|
|
862
|
+
listed = (parameters or {}).get(REQUIRED_STATUS_CHECKS_RULE) or []
|
|
863
|
+
for entry in listed:
|
|
864
|
+
if isinstance(entry, dict) and entry.get("context"):
|
|
865
|
+
contexts.add(entry["context"])
|
|
866
|
+
return len(contexts)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def failing_check_names(payload):
|
|
870
|
+
"""Every check on the pull request that is currently reporting failure.
|
|
871
|
+
|
|
872
|
+
Both shapes `statusCheckRollup` returns are read: a CheckRun carries
|
|
873
|
+
`conclusion` and `name`, a commit status carries `state` and `context`. A
|
|
874
|
+
guard that read one of them would report a clean pull request whenever the
|
|
875
|
+
blocker arrived on the other substrate.
|
|
876
|
+
|
|
877
|
+
Args:
|
|
878
|
+
payload: The decoded pull request object.
|
|
879
|
+
|
|
880
|
+
Returns:
|
|
881
|
+
The names of the failing checks, in the order GitHub listed them.
|
|
882
|
+
"""
|
|
883
|
+
rollup = payload.get("statusCheckRollup")
|
|
884
|
+
names = []
|
|
885
|
+
for entry in rollup if isinstance(rollup, list) else []:
|
|
886
|
+
if not isinstance(entry, dict):
|
|
887
|
+
continue
|
|
888
|
+
outcome = str(entry.get("conclusion") or entry.get("state") or "").upper()
|
|
889
|
+
if outcome in FAILING_CHECK_OUTCOMES:
|
|
890
|
+
names.append(str(entry.get("name") or entry.get("context") or "?"))
|
|
891
|
+
return names
|
|
892
|
+
|
|
893
|
+
|
|
551
894
|
def pr_name(payload):
|
|
552
895
|
"""The most identifying name available for the PR in a refusal message.
|
|
553
896
|
|
|
@@ -582,39 +925,149 @@ Do not report the PR as armed-and-merging until this reads anything other than
|
|
|
582
925
|
CHANGES_REQUESTED.
|
|
583
926
|
"""
|
|
584
927
|
|
|
585
|
-
UNREADABLE_REFUSAL = """Blocked: this command
|
|
586
|
-
pullRequestId cannot be read, so
|
|
587
|
-
|
|
928
|
+
UNREADABLE_REFUSAL = """Blocked: this command acts on a pull request through a GraphQL mutation whose
|
|
929
|
+
pullRequestId cannot be read, so what it does is unknowable at the moment the
|
|
930
|
+
claim "this will merge" is made.
|
|
588
931
|
|
|
589
|
-
|
|
590
|
-
or as a field this command also sets. An
|
|
591
|
-
failure this guard exists to close, one substrate over.
|
|
932
|
+
Use `gh pr merge <pr> --auto` or `gh pr edit <pr> --base <ref>` instead, or pass
|
|
933
|
+
the node id as a literal or as a field this command also sets. An act nobody can
|
|
934
|
+
inspect is the failure this guard exists to close, one substrate over.
|
|
592
935
|
"""
|
|
593
936
|
|
|
594
|
-
|
|
595
|
-
|
|
937
|
+
UNCOVERED_ARM_REFUSAL = """Blocked: arming auto-merge on %s would merge it with NO required checks.
|
|
938
|
+
|
|
939
|
+
Its base branch is "%s", and GitHub reports ZERO required status checks on that
|
|
940
|
+
ref. Every gate still RUNS against this PR; none of them can block the merge.
|
|
941
|
+
Auto-merge therefore lands the moment the PR is mergeable, before any advisory
|
|
942
|
+
result has a reader — which is exactly how a PR blocked by two failing required
|
|
943
|
+
checks reached a merged state without either being fixed or waived
|
|
944
|
+
(CodySwannGT/lisa#3922).
|
|
945
|
+
|
|
946
|
+
This is not the checks being red. It is the checks being unable to say no.
|
|
947
|
+
|
|
948
|
+
Merge it deliberately instead, at a moment when the results exist:
|
|
949
|
+
- read them — `gh pr checks %s` — and merge only if you accept every one;
|
|
950
|
+
- `gh pr merge %s --merge` performs the merge now, with you as the reader
|
|
951
|
+
auto-merge would have removed from the path;
|
|
952
|
+
- or re-target onto a covered base — the default branch, or any ref the
|
|
953
|
+
rulesets name — and arm there, where the ruleset enforces the gates for you.
|
|
954
|
+
"""
|
|
955
|
+
|
|
956
|
+
UNCOVERED_RETARGET_REFUSAL = """Blocked: re-targeting %s onto "%s" would evaporate its failing checks.
|
|
957
|
+
|
|
958
|
+
GitHub reports ZERO required status checks on that ref, and this PR is
|
|
959
|
+
currently failing:
|
|
960
|
+
%s
|
|
961
|
+
|
|
962
|
+
Those failures would not be fixed and would not be waived — they would simply
|
|
963
|
+
stop being required, and an armed auto-merge would then fire. That is a
|
|
964
|
+
permission-free bypass of every gate: no --force, no --admin, no code change,
|
|
965
|
+
one metadata edit (CodySwannGT/lisa#3922).
|
|
966
|
+
|
|
967
|
+
Do one of these instead:
|
|
968
|
+
- fix the failing checks, then re-target; a green PR may be batched freely;
|
|
969
|
+
- leave the PR on its covered base and merge it there.
|
|
970
|
+
"""
|
|
971
|
+
|
|
972
|
+
UNCOVERED_RETARGET_NOTICE = (
|
|
973
|
+
"block-blind-automerge: %s is being re-targeted onto \"%s\", a ref with ZERO "
|
|
974
|
+
"required status checks; its gates will run but cannot block a merge\n"
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def degraded(question, failure):
|
|
979
|
+
"""Announce that the guard could not measure, rather than passing silently.
|
|
980
|
+
|
|
981
|
+
Args:
|
|
982
|
+
question: What could not be read.
|
|
983
|
+
failure: Why not.
|
|
984
|
+
"""
|
|
985
|
+
sys.stderr.write(
|
|
986
|
+
"block-blind-automerge: could not read %s (%s); auto-merge protection "
|
|
987
|
+
"is NOT active for this command\n" % (question, failure)
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def base_is_uncovered(payload, repo_args, base_ref):
|
|
992
|
+
"""Whether a ref has zero required status checks, read live from GitHub.
|
|
993
|
+
|
|
994
|
+
Args:
|
|
995
|
+
payload: The decoded pull request object.
|
|
996
|
+
repo_args: gh arguments naming the repository, possibly empty.
|
|
997
|
+
base_ref: The ref to ask about.
|
|
998
|
+
|
|
999
|
+
Returns:
|
|
1000
|
+
True only when GitHub answered and the answer was zero. Every
|
|
1001
|
+
unanswerable case returns False and announces itself, because a guard
|
|
1002
|
+
that is silently absent reads exactly like a guard that is passing.
|
|
1003
|
+
"""
|
|
1004
|
+
if not base_ref:
|
|
1005
|
+
degraded("the base branch", "the PR payload named none")
|
|
1006
|
+
return False
|
|
1007
|
+
repo = repo_slug(payload, repo_args)
|
|
1008
|
+
if repo is None:
|
|
1009
|
+
degraded("the repository", "neither the PR URL nor --repo named one")
|
|
1010
|
+
return False
|
|
1011
|
+
rules, failure = branch_rules(repo, base_ref)
|
|
1012
|
+
if failure is not None:
|
|
1013
|
+
degraded("the branch rules for %s" % base_ref, failure)
|
|
1014
|
+
return False
|
|
1015
|
+
return required_context_count(rules) == 0
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
acts = guarded_invocations(strip_heredocs(command))
|
|
1019
|
+
if not acts:
|
|
596
1020
|
sys.exit(0)
|
|
597
1021
|
|
|
598
|
-
for kind, subject, repo_args in
|
|
1022
|
+
for act, kind, subject, repo_args, target_ref in acts:
|
|
599
1023
|
if kind == PROBE_UNREADABLE:
|
|
600
1024
|
# Fails CLOSED, unlike every other unanswerable case below. Those are
|
|
601
1025
|
# the guard being unable to run; this is the command refusing to say
|
|
602
1026
|
# what it acts on, which is a property of the command.
|
|
603
1027
|
sys.stderr.write(UNREADABLE_REFUSAL)
|
|
604
1028
|
sys.exit(1)
|
|
605
|
-
|
|
1029
|
+
fields = ARM_FIELDS if act == ACT_ARM else RETARGET_FIELDS
|
|
1030
|
+
payload, failure = pull_request_state(kind, subject, repo_args, fields)
|
|
606
1031
|
if failure is not None:
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
sys.stderr.write(
|
|
610
|
-
"block-blind-automerge: could not read reviewDecision (%s); "
|
|
611
|
-
"auto-merge review-gate protection is NOT active for this command\n"
|
|
612
|
-
% failure
|
|
1032
|
+
degraded(
|
|
1033
|
+
"reviewDecision" if act == ACT_ARM else "the pull request", failure
|
|
613
1034
|
)
|
|
614
1035
|
continue
|
|
615
|
-
if
|
|
1036
|
+
if act == ACT_RETARGET:
|
|
1037
|
+
# The current base is deliberately NOT consulted. The property is "a PR
|
|
1038
|
+
# something is currently failing must not be moved onto a ref where
|
|
1039
|
+
# nothing can block it", which holds whatever base it came from.
|
|
1040
|
+
if not base_is_uncovered(payload, repo_args, target_ref):
|
|
1041
|
+
continue
|
|
1042
|
+
failing = failing_check_names(payload)
|
|
1043
|
+
if not failing:
|
|
1044
|
+
# A green PR moving onto a stack base is the sanctioned batching
|
|
1045
|
+
# workflow. Announced, not refused — a guard that refused it would
|
|
1046
|
+
# be switched off within a day.
|
|
1047
|
+
sys.stderr.write(
|
|
1048
|
+
UNCOVERED_RETARGET_NOTICE % (pr_name(payload), target_ref)
|
|
1049
|
+
)
|
|
1050
|
+
continue
|
|
1051
|
+
sys.stderr.write(
|
|
1052
|
+
UNCOVERED_RETARGET_REFUSAL
|
|
1053
|
+
% (
|
|
1054
|
+
pr_name(payload),
|
|
1055
|
+
target_ref,
|
|
1056
|
+
"\n".join(" - %s" % name for name in failing),
|
|
1057
|
+
)
|
|
1058
|
+
)
|
|
1059
|
+
sys.exit(1)
|
|
1060
|
+
if (payload.get("reviewDecision") or "") == BLOCKING_REVIEW_DECISION:
|
|
1061
|
+
sys.stderr.write(REFUSAL % pr_name(payload))
|
|
1062
|
+
sys.exit(1)
|
|
1063
|
+
base_ref = payload.get("baseRefName")
|
|
1064
|
+
if not base_is_uncovered(payload, repo_args, base_ref):
|
|
616
1065
|
continue
|
|
617
|
-
|
|
1066
|
+
selector = subject if subject is not None else str(payload.get("number") or "")
|
|
1067
|
+
sys.stderr.write(
|
|
1068
|
+
UNCOVERED_ARM_REFUSAL
|
|
1069
|
+
% (pr_name(payload), base_ref, selector, selector)
|
|
1070
|
+
)
|
|
618
1071
|
sys.exit(1)
|
|
619
1072
|
|
|
620
1073
|
sys.exit(0)
|