@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.
- package/dist/cli/check-learnings-budget-cmd.d.ts +3 -2
- package/dist/cli/check-learnings-budget-cmd.d.ts.map +1 -1
- package/dist/cli/check-learnings-budget-cmd.js +11 -4
- package/dist/cli/check-learnings-budget-cmd.js.map +1 -1
- package/dist/core/learnings-budget-check.d.ts +84 -0
- package/dist/core/learnings-budget-check.d.ts.map +1 -1
- package/dist/core/learnings-budget-check.js +88 -1
- package/dist/core/learnings-budget-check.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +1 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +3 -2
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- 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/scripts/check-learnings-budget.ts +94 -31
- 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.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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 (
|
|
90
|
-
|
|
91
|
-
return;
|
|
145
|
+
if (existsSync(ledger)) {
|
|
146
|
+
await check(checker, ledger);
|
|
147
|
+
return 2;
|
|
92
148
|
}
|
|
93
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3049
|
-
const
|
|
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
|
|
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
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
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 {
|