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