@codyswann/lisa 4.6.3 → 4.6.5

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 (67) hide show
  1. package/dist/cli/check-learnings-budget-cmd.d.ts +3 -2
  2. package/dist/cli/check-learnings-budget-cmd.d.ts.map +1 -1
  3. package/dist/cli/check-learnings-budget-cmd.js +11 -4
  4. package/dist/cli/check-learnings-budget-cmd.js.map +1 -1
  5. package/dist/core/learnings-budget-check.d.ts +84 -0
  6. package/dist/core/learnings-budget-check.d.ts.map +1 -1
  7. package/dist/core/learnings-budget-check.js +88 -1
  8. package/dist/core/learnings-budget-check.js.map +1 -1
  9. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  10. package/dist/core/lisa-owned-hash-ledger.js +1 -0
  11. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  12. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  13. package/dist/core/upstream-evidence-manifest.js +3 -2
  14. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  15. package/package.json +1 -1
  16. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  18. package/plugins/lisa-agy/plugin.json +1 -1
  19. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  22. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo-agy/plugin.json +1 -1
  29. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  39. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  44. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  49. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails-agy/plugin.json +1 -1
  54. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  59. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  64. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  66. package/scripts/check-learnings-budget.ts +94 -31
  67. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +167 -27
@@ -16,9 +16,16 @@
16
16
  *
17
17
  * The two surfaces are genuinely different and both matter: the template is
18
18
  * what every adopting project starts from, and the ledger is what this
19
- * repository's agents actually read. A missing template is a failure — it is
20
- * committed here and always exists. A missing ledger is a pass, because a
21
- * project that has never recorded a learning has no file.
19
+ * repository's agents actually read. Both are committed in a source checkout,
20
+ * so a missing one there is a failure see `checkDefaultSurfaces` for why the
21
+ * ledger's absence used to be a pass and no longer is. Running from the
22
+ * published package, where no tarball carries a ledger, its absence stays the
23
+ * ordinary quiet pass.
24
+ *
25
+ * A within-budget ledger reports one of TWO verdicts, not one: `learnings
26
+ * budget passed` while room remains, and `learnings budget saturated` once the
27
+ * next capture would not fit (#3089). Both exit 0. The reasoning for warning
28
+ * rather than failing lives on `describeLearningsSaturation` in the core.
22
29
  * @module scripts/check-learnings-budget
23
30
  */
24
31
  import { existsSync, readFileSync } from "node:fs";
@@ -28,7 +35,7 @@ import type * as BudgetCheckModule from "../src/core/learnings-budget-check.js";
28
35
 
29
36
  type BudgetChecker = Pick<
30
37
  typeof BudgetCheckModule,
31
- "checkLearningsBudget" | "formatDiagnosticPath"
38
+ "checkLearningsBudget" | "formatBudgetVerdict" | "formatDiagnosticPath"
32
39
  >;
33
40
 
34
41
  const REPO_ROOT = path.resolve(import.meta.dir, "..");
@@ -69,6 +76,22 @@ function resolveLedger(): string {
69
76
  return path.resolve(REPO_ROOT, relative);
70
77
  }
71
78
 
79
+ /**
80
+ * Whether this script is running from a source checkout rather than from the
81
+ * published package, decided by the presence of the TypeScript core the
82
+ * published tarball deliberately excludes.
83
+ *
84
+ * The same discriminator the quality workflow uses to choose between running
85
+ * this script and running the shipped CLI subcommand, so the two agree on what
86
+ * "Lisa's own repository" means by construction rather than by convention.
87
+ * @returns True when the in-tree TypeScript core is present
88
+ */
89
+ function isSourceCheckout(): boolean {
90
+ return existsSync(
91
+ path.join(REPO_ROOT, "src", "core", "learnings-budget-check.ts")
92
+ );
93
+ }
94
+
72
95
  /** Run the package-facing checker with zero or one explicit file path. */
73
96
  async function main(): Promise<void> {
74
97
  const arguments_ = process.argv.slice(2);
@@ -77,48 +100,87 @@ async function main(): Promise<void> {
77
100
  }
78
101
 
79
102
  const checker = await loadBudgetChecker();
80
- if (arguments_.length === 1) {
81
- await check(checker, path.resolve(process.cwd(), arguments_[0] as string), {
82
- absenceIsFailure: true,
83
- });
84
- return;
103
+ const inspected =
104
+ arguments_.length === 1
105
+ ? await checkExplicitSurface(checker, arguments_[0] as string)
106
+ : await checkDefaultSurfaces(checker);
107
+
108
+ // VACUITY GUARD. Every path above either judges a document or exits, so this
109
+ // should be unreachable — which is the point. A run that inspected nothing
110
+ // and a run that inspected a healthy ledger produce identical output and an
111
+ // identical exit code, and that ambiguity is how this gate spent a release
112
+ // checking only a 0-entry template while looking like a gate on the ledger
113
+ // (#2932). Counting what was actually judged makes "nothing" say so instead
114
+ // of reporting all-clear.
115
+ if (inspected === 0) {
116
+ fail(
117
+ "inspected no learnings surface — an empty inspection is indistinguishable from a healthy ledger, so it fails rather than reporting all-clear"
118
+ );
85
119
  }
120
+ }
86
121
 
87
- await check(checker, TEMPLATE_LEARNINGS_FILE, { absenceIsFailure: true });
122
+ /**
123
+ * Check the one document named on the command line.
124
+ * @param checker - The loaded budget checker
125
+ * @param argument - Caller-supplied learnings path
126
+ * @returns How many documents were judged
127
+ */
128
+ async function checkExplicitSurface(
129
+ checker: BudgetChecker,
130
+ argument: string
131
+ ): Promise<number> {
132
+ await check(checker, path.resolve(process.cwd(), argument));
133
+ return 1;
134
+ }
135
+
136
+ /**
137
+ * Check the two default surfaces: the shipped template and this repository's
138
+ * own ledger.
139
+ * @param checker - The loaded budget checker
140
+ * @returns How many documents were judged
141
+ */
142
+ async function checkDefaultSurfaces(checker: BudgetChecker): Promise<number> {
143
+ await check(checker, TEMPLATE_LEARNINGS_FILE);
88
144
  const ledger = resolveLedger();
89
- if (!existsSync(ledger)) {
90
- console.log(`${checker.formatDiagnosticPath(ledger)}: no learnings file`);
91
- return;
145
+ if (existsSync(ledger)) {
146
+ await check(checker, ledger);
147
+ return 2;
92
148
  }
93
- await check(checker, ledger, { absenceIsFailure: true });
149
+ // In a source checkout the ledger is a committed file, so its absence means
150
+ // the resolver drifted off it — and "no learnings file" is a verdict the CI
151
+ // marker grep accepts as green, which turns that drift into a gate on the
152
+ // template alone. Fail instead. From the published package the same absence
153
+ // is the ordinary case (no tarball carries a ledger) and stays a quiet pass,
154
+ // exactly as it does for host projects on the CLI subcommand.
155
+ if (isSourceCheckout()) {
156
+ fail(
157
+ `${checker.formatDiagnosticPath(ledger)}: resolved ledger does not exist, and this is a source checkout where the ledger is committed — the configured \`learnings.file\` no longer resolves to a document, so this run would have gated the shipped template alone`
158
+ );
159
+ }
160
+ console.log(`${checker.formatDiagnosticPath(ledger)}: no learnings file`);
161
+ return 1;
94
162
  }
95
163
 
96
164
  /**
97
165
  * Check one file and report, failing the process on any violation.
166
+ *
167
+ * Every document this script reaches must exist — an explicit argument names
168
+ * one, and both defaults are committed here — so a missing file is as much a
169
+ * failure as any other violation. Host projects use the
170
+ * `lisa check-learnings-budget` CLI subcommand instead, which treats a missing
171
+ * file as an expected pass.
98
172
  * @param checker - The loaded budget checker
99
173
  * @param file - Absolute path to the document to check
100
- * @param options - Whether a missing file is a violation
101
- * @param options.absenceIsFailure - True when the file must exist
102
174
  */
103
- async function check(
104
- checker: BudgetChecker,
105
- file: string,
106
- options: { absenceIsFailure: boolean }
107
- ): Promise<void> {
175
+ async function check(checker: BudgetChecker, file: string): Promise<void> {
108
176
  const result = await checker.checkLearningsBudget(file);
109
177
  if (result.kind === "ok") {
110
- console.log(
111
- `${checker.formatDiagnosticPath(file)}: learnings budget passed (${result.entryCount}/${result.maxEntries} entries, ${result.measuredTokens}/${result.maxTokens} maxTokens)`
112
- );
178
+ // Prints `learnings budget saturated` for a full-but-valid ledger and exits
179
+ // 0 all the same; the reasoning lives on describeLearningsSaturation.
180
+ console.log(checker.formatBudgetVerdict(file, result));
113
181
  return;
114
182
  }
115
- // Both files this script checks are committed here, so a missing one is as
116
- // much a failure as any other violation. Host projects use the
117
- // `lisa check-learnings-budget` CLI subcommand instead, which treats a
118
- // missing file as an expected pass.
119
- if (options.absenceIsFailure) {
120
- fail(`${checker.formatDiagnosticPath(file)}: ${result.detail}`);
121
- }
183
+ fail(`${checker.formatDiagnosticPath(file)}: ${result.detail}`);
122
184
  }
123
185
 
124
186
  /**
@@ -146,6 +208,7 @@ async function loadBudgetChecker(): Promise<BudgetChecker> {
146
208
  );
147
209
  return {
148
210
  checkLearningsBudget: module_.checkLearningsBudget,
211
+ formatBudgetVerdict: module_.formatBudgetVerdict,
149
212
  formatDiagnosticPath: module_.formatDiagnosticPath,
150
213
  } as BudgetChecker;
151
214
  }
@@ -67,6 +67,18 @@
67
67
  * expiry timestamp on screen. Bootstrap forgives absence of evidence, never
68
68
  * evidence of failure — a red run is red inside the window too.
69
69
  *
70
+ * ## The bypass reads the pull request LIVE (contract 1.6.0, row 40)
71
+ *
72
+ * `github.event.pull_request` is a SNAPSHOT taken when the run was triggered,
73
+ * and a re-run replays it verbatim. Reading the bypass label and the
74
+ * `Nightly-E2E-Bypass:` trailer out of it made the gate's own printed remedy
75
+ * impossible to follow: applying the label fired no run, and re-running — the
76
+ * obvious next move — replayed a payload from before the label existed. The
77
+ * waiver was real, correctly recorded, and invisible to the gate that asked for
78
+ * it. `fetchPullRequestState` reads both halves from the API at gate time
79
+ * instead, which also means a label somebody REMOVED stops waiving. An
80
+ * unreadable pull request is a REJECTED bypass, never a granted one.
81
+ *
70
82
  * ## Inherited from three implementations, with one path closed
71
83
  *
72
84
  * `DECISIVE_CONCLUSIONS` comes from acmeorgb's `check-nightly-e2e.mjs` and is
@@ -89,7 +101,7 @@ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
89
101
  * rather than running a contract neither half agrees on. See §8 of
90
102
  * `docs/nightly-e2e-gate.md` for what counts as major / minor / patch.
91
103
  */
92
- export const NIGHTLY_E2E_CONTRACT_VERSION = "1.5.0";
104
+ export const NIGHTLY_E2E_CONTRACT_VERSION = "1.6.0";
93
105
 
94
106
  /**
95
107
  * The conclusions that constitute a verdict about the code.
@@ -1381,6 +1393,8 @@ const BYPASS_REJECTIONS = Object.freeze({
1381
1393
  "it was applied longer ago than `bypass_max_hours` allows. Bypasses auto-expire so a label nobody removes cannot become a permanent hole.",
1382
1394
  no_reason_or_ticket:
1383
1395
  "the PR body carries no `Nightly-E2E-Bypass: <TICKET> <reason>` line. A bypass without a reason and a ticket is not auditable.",
1396
+ pr_state_unreadable:
1397
+ "this pull request's live labels and body could not be read, so whether a waiver was requested is UNKNOWN. The gate stays closed: a bypass that fires when it could not read the request is worse than no bypass. On a private repository the caller job needs `pull-requests: read`; otherwise this is a transient API failure and a re-run resolves it.",
1384
1398
  });
1385
1399
 
1386
1400
  // ---------------------------------------------------------------------------
@@ -1591,8 +1605,17 @@ export function formatReport(verdict, context) {
1591
1605
  );
1592
1606
  } else if (verdict.blocked) {
1593
1607
  if (verdict.bypass && !verdict.bypass.valid) {
1608
+ // `pr_state_unreadable` gets its own lead because the usual one asserts
1609
+ // something this branch cannot know. When the live read failed, whether a
1610
+ // label is present is precisely the unanswered question — saying "a label
1611
+ // is present but was REJECTED" would send the reader to remove a label
1612
+ // that may not exist.
1613
+ const lead =
1614
+ verdict.bypass.reason === "pr_state_unreadable"
1615
+ ? "⛔ **The bypass could not be evaluated**"
1616
+ : `⛔ **A \`${context.bypassLabel}\` label is present but was REJECTED**`;
1594
1617
  lines.push(
1595
- `⛔ **A \`${context.bypassLabel}\` label is present but was REJECTED** — ${BYPASS_REJECTIONS[verdict.bypass.reason] ?? verdict.bypass.reason}`,
1618
+ `${lead} — ${BYPASS_REJECTIONS[verdict.bypass.reason] ?? verdict.bypass.reason}`,
1596
1619
  ""
1597
1620
  );
1598
1621
  }
@@ -2829,6 +2852,94 @@ export async function applyIssuePlan(api, plan, wait) {
2829
2852
  return Object.freeze(results);
2830
2853
  }
2831
2854
 
2855
+ /**
2856
+ * Reads the pull request's CURRENT labels, body and author, LIVE from the API.
2857
+ *
2858
+ * THIS IS THE BYPASS'S ONLY SOURCE OF TRUTH, and the reason it exists is a
2859
+ * measured defect. The gate used to read both halves of the bypass request from
2860
+ * `github.event.pull_request` — `toJSON(...labels.*.name)` and `...body`. That
2861
+ * object is the event payload CAPTURED WHEN THE RUN WAS TRIGGERED. It is a
2862
+ * snapshot, not a live read, and a re-run REPLAYS the original payload. So the
2863
+ * gate's own printed remedy — "add the trailer, then apply the label" — could
2864
+ * not work: applying a label fires no run under the default activity types, and
2865
+ * re-running (the obvious next move, and the one the failure message invites)
2866
+ * replays a payload from before the label existed. Measured on two consumer
2867
+ * repositories: the label sat on the pull request the whole time while the job
2868
+ * logged `NIGHTLY_PR_LABELS: []`. The only thing that worked was an empty
2869
+ * commit, to manufacture a `synchronize` whose payload happened to carry the
2870
+ * label — a workaround nobody should have to discover.
2871
+ *
2872
+ * A live read is immune to both halves of that: it is correct on a re-run, and
2873
+ * correct whether or not the caller subscribed to the `labeled` activity type.
2874
+ *
2875
+ * It also fixes the mirror-image hole, which matters more: a payload that still
2876
+ * carries a label somebody has since REMOVED would waive a gate whose waiver was
2877
+ * withdrawn. Reading live is the only way "the label is gone" can reach the
2878
+ * gate at all.
2879
+ *
2880
+ * FAILS CLOSED, and never falls back to the payload. `null` means the pull
2881
+ * request could not be read — 404, an unreadable API, a body with no `labels`
2882
+ * array — and the caller turns that into a REJECTED bypass, never into a
2883
+ * bypass. Falling back to the payload here would reintroduce exactly the
2884
+ * stale-label waiver above, on the one path where nobody is watching. Note the
2885
+ * distinction the caller depends on: `null` is "unreadable", while a readable
2886
+ * pull request carrying no labels is `labels: []` — both stay gated, for
2887
+ * reasons the report states differently.
2888
+ *
2889
+ * @param {object} api - API coordinates
2890
+ * @param {number} prNumber - Pull request number
2891
+ * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
2892
+ * @returns {Promise<{labels: ReadonlyArray<string>, body: string, author: string|null}|null>} Live PR state, or `null` when unreadable
2893
+ */
2894
+ export async function fetchPullRequestState(api, prNumber, wait) {
2895
+ const result = await apiGet(
2896
+ api,
2897
+ `/repos/${api.repo}/pulls/${prNumber}`,
2898
+ wait
2899
+ ).catch(() => null);
2900
+ const pr = result?.body;
2901
+ // A missing `labels` array is UNREADABLE, not "no labels". The two must not
2902
+ // collapse: one is a broken read and the other is a fact about the PR.
2903
+ if (!pr || !Array.isArray(pr.labels)) return null;
2904
+ const labels = pr.labels
2905
+ .map(entry => (typeof entry === "string" ? entry : entry?.name))
2906
+ .filter(name => typeof name === "string" && name.length > 0);
2907
+ return Object.freeze({
2908
+ labels: Object.freeze(labels),
2909
+ body: typeof pr.body === "string" ? pr.body : "",
2910
+ author: typeof pr.user?.login === "string" ? pr.user.login : null,
2911
+ });
2912
+ }
2913
+
2914
+ /**
2915
+ * The emitted decision for "the pull request could not be read".
2916
+ *
2917
+ * Same keys as every `evaluateBypass` outcome, so `audit_json` has one shape
2918
+ * whatever happened — §6's emitted record is asserted key for key.
2919
+ *
2920
+ * @param {{prNumber: number|null, label: string|null, prAuthor: string|null}} subject - What was being evaluated
2921
+ * @returns {{valid: boolean, reason: string}} A rejected bypass
2922
+ */
2923
+ export function unreadablePullRequestBypass({
2924
+ prNumber = null,
2925
+ label = null,
2926
+ prAuthor = null,
2927
+ }) {
2928
+ return Object.freeze({
2929
+ label,
2930
+ prAuthor,
2931
+ prNumber,
2932
+ actorPermission: null,
2933
+ valid: false,
2934
+ reason: "pr_state_unreadable",
2935
+ actor: null,
2936
+ appliedAt: null,
2937
+ expiresAt: null,
2938
+ ticket: null,
2939
+ detail: null,
2940
+ });
2941
+ }
2942
+
2832
2943
  /**
2833
2944
  * Reads who most recently applied the bypass label, from the PR's issue events.
2834
2945
  *
@@ -2992,18 +3103,26 @@ export function resolveSettings(env) {
2992
3103
  // An ADDITIONAL project rule, never a replacement — see `evaluateBypass`.
2993
3104
  extraBypassReasonPattern: env.NIGHTLY_BYPASS_REASON_PATTERN || "",
2994
3105
  clamped,
3106
+ // The ONLY thing taken from the event payload is the pull request NUMBER,
3107
+ // and only because a PR's number is immutable — it is an address, not a
3108
+ // fact about the PR. Everything the bypass decides on (labels, body, and
3109
+ // the author carried onto the audit) is read LIVE by
3110
+ // `fetchPullRequestState`, because `github.event` is a snapshot from
3111
+ // trigger time that a re-run replays verbatim.
3112
+ //
3113
+ // `NIGHTLY_PR_LABELS` and `NIGHTLY_PR_BODY` are DELIBERATELY NOT READ from
3114
+ // here as of contract 1.6.0. The reusable workflow still sets them, on
3115
+ // purpose: a consumer that has taken the newer workflow ref but not yet run
3116
+ // `lisa apply` is still running a pre-1.6.0 guard, and that guard needs
3117
+ // them. Reading them here would defeat the fix — the payload is exactly
3118
+ // what was wrong.
3119
+ //
3120
+ // `payloadAuthor` is a fallback for the audit record only. It never gates
3121
+ // anything (see `evaluateBypass`: `prAuthor` is recorded, never used to
3122
+ // reject), so a stale value cannot change a verdict.
2995
3123
  pr: {
2996
3124
  number: Number(env.NIGHTLY_PR_NUMBER) || null,
2997
- author: env.NIGHTLY_PR_AUTHOR || null,
2998
- body: env.NIGHTLY_PR_BODY || "",
2999
- labels: (() => {
3000
- try {
3001
- const parsed = JSON.parse(env.NIGHTLY_PR_LABELS || "[]");
3002
- return Array.isArray(parsed) ? parsed.map(String) : [];
3003
- } catch {
3004
- return [];
3005
- }
3006
- })(),
3125
+ payloadAuthor: env.NIGHTLY_PR_AUTHOR || null,
3007
3126
  },
3008
3127
  };
3009
3128
  }
@@ -3044,28 +3163,49 @@ export async function runGate(env, wait) {
3044
3163
  return grace.firstSeen === null ? finding : { ...finding, grace };
3045
3164
  });
3046
3165
 
3166
+ // The bypass reads the pull request LIVE and never consults `github.event`.
3167
+ // Three outcomes, and the middle one is the vacuity guard: unreadable is a
3168
+ // REJECTED bypass, never an absent one and never a granted one.
3047
3169
  let bypass = null;
3048
- if (settings.pr.number && settings.pr.labels.includes(settings.bypassLabel)) {
3049
- const labelEvent = await fetchLabelEvent(
3170
+ if (settings.pr.number) {
3171
+ const live = await fetchPullRequestState(
3050
3172
  settings.api,
3051
3173
  settings.pr.number,
3052
- settings.bypassLabel,
3053
3174
  wait
3054
3175
  );
3055
- const actorPermission = labelEvent
3056
- ? await fetchActorPermission(settings.api, labelEvent.actor, wait)
3057
- : null;
3058
- bypass = evaluateBypass({
3059
- labelEvent,
3060
- prAuthor: settings.pr.author,
3061
- prBody: settings.pr.body,
3062
- actorPermission,
3176
+ const subject = {
3063
3177
  prNumber: settings.pr.number,
3064
3178
  label: settings.bypassLabel,
3065
- maxHours: settings.bypassMaxHours,
3066
- extraReasonPattern: settings.extraBypassReasonPattern,
3067
- now,
3068
- });
3179
+ prAuthor: settings.pr.payloadAuthor,
3180
+ };
3181
+ if (live === null) {
3182
+ bypass = unreadablePullRequestBypass(subject);
3183
+ } else if (live.labels.includes(settings.bypassLabel)) {
3184
+ const labelEvent = await fetchLabelEvent(
3185
+ settings.api,
3186
+ settings.pr.number,
3187
+ settings.bypassLabel,
3188
+ wait
3189
+ );
3190
+ const actorPermission = labelEvent
3191
+ ? await fetchActorPermission(settings.api, labelEvent.actor, wait)
3192
+ : null;
3193
+ bypass = evaluateBypass({
3194
+ labelEvent,
3195
+ prAuthor: live.author ?? settings.pr.payloadAuthor,
3196
+ prBody: live.body,
3197
+ actorPermission,
3198
+ prNumber: settings.pr.number,
3199
+ label: settings.bypassLabel,
3200
+ maxHours: settings.bypassMaxHours,
3201
+ extraReasonPattern: settings.extraBypassReasonPattern,
3202
+ now,
3203
+ });
3204
+ }
3205
+ // The remaining case — read successfully, label not present — leaves
3206
+ // `bypass` null. Nobody asked for a waiver, so there is nothing to report,
3207
+ // and a label REMOVED since the run was triggered lands here rather than
3208
+ // waiving anything.
3069
3209
  }
3070
3210
 
3071
3211
  return {