@codyswann/lisa 4.29.10 → 4.29.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/all/copy-overwrite/scripts/check-third-party-review-evidence.mjs +706 -0
- package/all/copy-overwrite/scripts/lisa-gates.mjs +55 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +10 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +10 -4
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-drive-pr-to-merge/SKILL.md +138 -37
- package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +138 -37
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +138 -37
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/skills/lisa-drive-pr-to-merge/SKILL.md +138 -37
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +138 -37
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +138 -37
- package/scripts/two-channel-couplings.json +1 -1
- package/typescript/copy-overwrite/.prettierignore +1 -0
- package/typescript/create-only/.github/workflows/third-party-review-evidence.yml +114 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
3
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
4
|
+
/**
|
|
5
|
+
* Prove that a third-party review either RAN at the head commit, or that a
|
|
6
|
+
* local adversarial review was posted in its place (issue #3591).
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* ## The measurement this exists for
|
|
10
|
+
*
|
|
11
|
+
* A third-party review app posts a **commit status** on the pull request head.
|
|
12
|
+
* When it is throttled, or declines to review at all, it still reports
|
|
13
|
+
* `state: success` — it never blocks. Only the status `description`
|
|
14
|
+
* distinguishes a review that ran from one that never started.
|
|
15
|
+
*
|
|
16
|
+
* Re-measured over the last 30 merged pull requests in this repository, reading
|
|
17
|
+
* the reviewer's status on each pull request's head SHA:
|
|
18
|
+
*
|
|
19
|
+
* ```
|
|
20
|
+
* success :: Review rate limited 19
|
|
21
|
+
* success :: Review completed 6
|
|
22
|
+
* success :: Review skipped: manual review required for this OSS repo 5
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* **24 of 30 merges passed a required review gate that reviewed nothing.** A
|
|
26
|
+
* second repository sampled `success` on 50 of 50 and contributed a fourth
|
|
27
|
+
* string, `Review skipped: NNN files exceed the limit of NNN`.
|
|
28
|
+
*
|
|
29
|
+
* ## Allowlist of ONE, fail closed
|
|
30
|
+
*
|
|
31
|
+
* Only a description the project declared as its reviewed-when phrase counts as
|
|
32
|
+
* evidence a review happened. Anything else — throttled, skipped, absent,
|
|
33
|
+
* empty, or a string nobody has seen before — means NOT REVIEWED.
|
|
34
|
+
*
|
|
35
|
+
* A denylist of known-bad phrases fails OPEN, and the vocabulary is open-ended:
|
|
36
|
+
* four distinct strings are known already, from two repositories, and the vendor
|
|
37
|
+
* decorates them at will. An unrecognised string must cost an extra local
|
|
38
|
+
* review, never a free pass. `EVIDENCE_DEFAULTS.no_work` is therefore consulted
|
|
39
|
+
* ONLY to word the explanation, and never to reach the verdict.
|
|
40
|
+
*
|
|
41
|
+
* ## The rollup cannot see this
|
|
42
|
+
*
|
|
43
|
+
* `gh pr view --json statusCheckRollup` returns a `StatusContext` entry with NO
|
|
44
|
+
* `description` key at all — verified:
|
|
45
|
+
*
|
|
46
|
+
* ```json
|
|
47
|
+
* {"__typename":"StatusContext","context":"…","state":"SUCCESS","targetUrl":""}
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* Hollow green is invisible there. The evidence read MUST come from
|
|
51
|
+
* `repos/{owner}/{repo}/commits/{sha}/status` and use `.statuses[].description`.
|
|
52
|
+
*
|
|
53
|
+
* ## A review OBJECT is not a status CONTEXT
|
|
54
|
+
*
|
|
55
|
+
* An empty-bodied `APPROVED` review is an ordinary human approval, not hollow.
|
|
56
|
+
* Empty-body reasoning belongs to status descriptions and must never leak onto
|
|
57
|
+
* review objects — `classifyReviewObject` exists to keep the two apart. A
|
|
58
|
+
* `CHANGES_REQUESTED` is a blocking objection whatever its body, because its
|
|
59
|
+
* content commonly lives entirely in inline threads.
|
|
60
|
+
*
|
|
61
|
+
* ## Never re-request a review to refresh it
|
|
62
|
+
*
|
|
63
|
+
* Re-requesting OVERWRITES the existing status rather than adding to it. Under
|
|
64
|
+
* a throttle that destroys a real review, one-way. Nothing here posts a review
|
|
65
|
+
* request, and nothing downstream should add one.
|
|
66
|
+
*
|
|
67
|
+
* ## No reviewer configured is a FIRST-CLASS state
|
|
68
|
+
*
|
|
69
|
+
* Not every project uses a third-party reviewer. An absent or empty reviewer
|
|
70
|
+
* list is a no-op that SAYS it is a no-op. A silent pass is the exact defect
|
|
71
|
+
* this module exists to end, so it is not reproduced here.
|
|
72
|
+
*
|
|
73
|
+
* CLI:
|
|
74
|
+
* node scripts/check-third-party-review-evidence.mjs --sha <sha> [--json]
|
|
75
|
+
* [--context <context>] [--root <dir>] [--repo <owner/repo>]
|
|
76
|
+
*
|
|
77
|
+
* `--context` names the status context that woke the run. When it is given and
|
|
78
|
+
* belongs to no declared reviewer, the run is an explicit no-op: a status from
|
|
79
|
+
* some other app cannot change this answer, and evaluating on one would report
|
|
80
|
+
* a reviewer as absent merely because it had not posted yet.
|
|
81
|
+
*
|
|
82
|
+
* @module scripts/check-third-party-review-evidence
|
|
83
|
+
*/
|
|
84
|
+
import { boundedExecFileSync } from "./lib/bounded-spawn.mjs";
|
|
85
|
+
import { invokedAsScript } from "./lib/invoked-as-script.mjs";
|
|
86
|
+
import { EVIDENCE_DEFAULTS, PULL_REQUEST, readGates } from "./lisa-gates.mjs";
|
|
87
|
+
|
|
88
|
+
/** How a reviewer's status at one commit reads. */
|
|
89
|
+
export const EVIDENCE_READINGS = Object.freeze({
|
|
90
|
+
/** The description matched a declared reviewed-when phrase. */
|
|
91
|
+
reviewed: "reviewed",
|
|
92
|
+
/** No status for this context on the commit at all. */
|
|
93
|
+
absent: "absent",
|
|
94
|
+
/** A status with no description, or only whitespace. */
|
|
95
|
+
empty: "empty",
|
|
96
|
+
/** A description the vendor uses to say it did no work. */
|
|
97
|
+
noWork: "no-work",
|
|
98
|
+
/** A description in no declared list. Fails closed, deliberately. */
|
|
99
|
+
unrecognised: "unrecognised",
|
|
100
|
+
/** The reviewer has started and has not answered yet. Not an answer either way. */
|
|
101
|
+
pending: "pending",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/** What the whole evaluation concluded. */
|
|
105
|
+
export const EVIDENCE_OUTCOMES = Object.freeze({
|
|
106
|
+
/** No gate declares a third-party reviewer. An explicit no-op. */
|
|
107
|
+
noReviewerConfigured: "no-reviewer-configured",
|
|
108
|
+
/** The commit is nobody's pull request head. Nothing to review here. */
|
|
109
|
+
notAPullRequestHead: "not-a-pull-request-head",
|
|
110
|
+
/** The triggering status belongs to no declared reviewer. An explicit no-op. */
|
|
111
|
+
notAConfiguredReviewer: "not-a-configured-reviewer",
|
|
112
|
+
/** A declared reviewer is still working. Not an answer, so not a verdict. */
|
|
113
|
+
pending: "pending",
|
|
114
|
+
/** Every declared reviewer showed real evidence. */
|
|
115
|
+
satisfied: "satisfied",
|
|
116
|
+
/** A reviewer showed none, and a posted substitute stands in its place. */
|
|
117
|
+
substituted: "substituted",
|
|
118
|
+
/** A reviewer showed none, and nothing stands in its place. */
|
|
119
|
+
unsatisfied: "unsatisfied",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/** How a review OBJECT reads. Never described in status-description terms. */
|
|
123
|
+
export const REVIEW_OBJECT_VERDICTS = Object.freeze({
|
|
124
|
+
approval: "approval",
|
|
125
|
+
objection: "objection",
|
|
126
|
+
comment: "comment",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* What each reading means, in a sentence an operator can act on.
|
|
131
|
+
*
|
|
132
|
+
* REPORT THE OBSERVATION, NEVER A GUESS AT THE CAUSE. Several different causes
|
|
133
|
+
* produce the same reading and are indistinguishable from outside the vendor: a
|
|
134
|
+
* quota throttle, a per-repository decline, and auto-review being disabled for
|
|
135
|
+
* pull requests whose BASE IS NOT THE DEFAULT BRANCH all end with no evidence at
|
|
136
|
+
* the head. The last of those is permanent rather than transient for the pull
|
|
137
|
+
* requests it affects — a consumer targeting an integration branch would never
|
|
138
|
+
* be reviewed — and it looks identical to the healthy case from here.
|
|
139
|
+
*
|
|
140
|
+
* The allowlist already handles all of them correctly, because none of them
|
|
141
|
+
* produces a declared proof phrase, so NONE gets a special case. What the
|
|
142
|
+
* operator needs is the observation stated precisely enough that they are not
|
|
143
|
+
* left guessing between throttle, decline, and never-configured.
|
|
144
|
+
*/
|
|
145
|
+
export const READING_SENTENCES = Object.freeze({
|
|
146
|
+
[EVIDENCE_READINGS.reviewed]:
|
|
147
|
+
"the reviewer reported a description this project declares as proof it read the code",
|
|
148
|
+
[EVIDENCE_READINGS.absent]:
|
|
149
|
+
"no third-party status was present at this head at all — nothing from that reviewer, of any state",
|
|
150
|
+
[EVIDENCE_READINGS.empty]:
|
|
151
|
+
"the reviewer posted a status carrying no description, so nothing says whether it read anything",
|
|
152
|
+
[EVIDENCE_READINGS.noWork]:
|
|
153
|
+
"the reviewer said in its own words that it did no work",
|
|
154
|
+
[EVIDENCE_READINGS.unrecognised]:
|
|
155
|
+
"the reviewer reported a description this project does not declare as proof, and which nothing here recognises",
|
|
156
|
+
[EVIDENCE_READINGS.pending]:
|
|
157
|
+
"the reviewer has started and has not answered yet",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The marker a posted substitute review carries.
|
|
162
|
+
*
|
|
163
|
+
* An HTML comment so it renders as nothing, and machine-readable so CI can find
|
|
164
|
+
* it without parsing prose. One marker per reviewer per head commit: evidence
|
|
165
|
+
* for one reviewer never satisfies another, and a substitute written for an
|
|
166
|
+
* earlier head says nothing about the code at this one.
|
|
167
|
+
*/
|
|
168
|
+
export const SUBSTITUTE_MARKER = "lisa:review-substitute";
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Normalise a description for whole-string comparison.
|
|
172
|
+
*
|
|
173
|
+
* Whole-string, case-insensitive, whitespace-collapsed. Never a substring: a
|
|
174
|
+
* substring match GRANTS credit here, and `Review skipped` contains `review`.
|
|
175
|
+
* @param {string|undefined|null} description - A status description.
|
|
176
|
+
* @returns {string} The normalised form, or the empty string.
|
|
177
|
+
*/
|
|
178
|
+
export function normalizeDescription(description) {
|
|
179
|
+
return String(description ?? "")
|
|
180
|
+
.trim()
|
|
181
|
+
.replace(/\s+/gu, " ")
|
|
182
|
+
.toLowerCase();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The third-party reviewers a project declares, in gate-id order.
|
|
187
|
+
*
|
|
188
|
+
* A reviewer is a gate that (a) awaits a status context and (b) marks that
|
|
189
|
+
* awaited signal `evidence.reviewer: true`. Several gates may do so, and each
|
|
190
|
+
* one must then show its own evidence — one reviewer's proof never covers
|
|
191
|
+
* another's silence.
|
|
192
|
+
*
|
|
193
|
+
* The reviewed-when allowlist is the gate's merged `evidence.proof`: Lisa's
|
|
194
|
+
* shipped phrases plus whatever the project adds. Extension, never replacement,
|
|
195
|
+
* for the reason `mergeEvidence` records.
|
|
196
|
+
* @param {object} options - Resolution inputs.
|
|
197
|
+
* @param {object} options.gates - The `gates` block of `.lisa.config.json`.
|
|
198
|
+
* @param {string} [options.moment] - Which moment to read. Defaults to the pull-request moment.
|
|
199
|
+
* @returns {Array<{gateId: string, context: string, proof: string[]}>} Declared reviewers.
|
|
200
|
+
*/
|
|
201
|
+
export function configuredReviewers({ gates, moment = PULL_REQUEST }) {
|
|
202
|
+
const reviewers = [];
|
|
203
|
+
for (const [gateId, gate] of Object.entries(gates ?? {})) {
|
|
204
|
+
if (!gate || typeof gate !== "object" || Array.isArray(gate)) continue;
|
|
205
|
+
const entry = gate[moment];
|
|
206
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
207
|
+
if (entry.evidence?.reviewer !== true) continue;
|
|
208
|
+
if (typeof entry.await !== "string" || entry.await.trim() === "") continue;
|
|
209
|
+
reviewers.push({
|
|
210
|
+
gateId,
|
|
211
|
+
context: entry.await,
|
|
212
|
+
proof: [...EVIDENCE_DEFAULTS.proof, ...(entry.evidence.proof ?? [])].map(
|
|
213
|
+
normalizeDescription
|
|
214
|
+
),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return reviewers.sort((left, right) =>
|
|
218
|
+
left.gateId.localeCompare(right.gateId)
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Whether a description reads as the vendor saying it did no work.
|
|
224
|
+
*
|
|
225
|
+
* REPORTING ONLY. This never grants nor denies credit — the verdict is settled
|
|
226
|
+
* by the allowlist above, and this only chooses between two ways of saying
|
|
227
|
+
* "not reviewed" so an operator can tell a known vendor state from a string
|
|
228
|
+
* nobody has seen. Wiring it into the verdict would turn the allowlist back
|
|
229
|
+
* into a denylist, which is the fail-open shape this module refuses.
|
|
230
|
+
* @param {string} normalised - An already-normalised description.
|
|
231
|
+
* @returns {boolean} Whether a shipped no-work phrase appears in it.
|
|
232
|
+
*/
|
|
233
|
+
function readsAsNoWork(normalised) {
|
|
234
|
+
return EVIDENCE_DEFAULTS.no_work.some(phrase =>
|
|
235
|
+
normalised.includes(normalizeDescription(phrase))
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Read one reviewer's evidence off the statuses on a commit.
|
|
241
|
+
*
|
|
242
|
+
* The verdict is `reviewed` only when the description matches a declared
|
|
243
|
+
* reviewed-when phrase exactly. Every other shape — absent, empty, a known
|
|
244
|
+
* no-work phrase, or an unrecognised string — is NOT REVIEWED, and the observed
|
|
245
|
+
* string is carried back verbatim so the report can say what was actually seen.
|
|
246
|
+
* @param {object} options - Reading inputs.
|
|
247
|
+
* @param {{context: string, proof: string[]}} options.reviewer - One declared reviewer.
|
|
248
|
+
* @param {ReadonlyArray<{context?: string, state?: string, description?: string}>} options.statuses -
|
|
249
|
+
* `.statuses` from `repos/{owner}/{repo}/commits/{sha}/status`, newest first.
|
|
250
|
+
* @returns {{context: string, reviewed: boolean, reading: string, observed: string|null, state: string|null}} The reading.
|
|
251
|
+
*/
|
|
252
|
+
export function readReviewerEvidence({ reviewer, statuses }) {
|
|
253
|
+
const found = (statuses ?? []).find(
|
|
254
|
+
status => status?.context === reviewer.context
|
|
255
|
+
);
|
|
256
|
+
if (found === undefined) {
|
|
257
|
+
return {
|
|
258
|
+
context: reviewer.context,
|
|
259
|
+
reviewed: false,
|
|
260
|
+
reading: EVIDENCE_READINGS.absent,
|
|
261
|
+
observed: null,
|
|
262
|
+
state: null,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const observed = found.description ?? null;
|
|
266
|
+
const normalised = normalizeDescription(observed);
|
|
267
|
+
const state = found.state ?? null;
|
|
268
|
+
// A reviewer posts several statuses on one head before the settled one —
|
|
269
|
+
// measured `Review queued` then `Review in progress` then the verdict. Judging
|
|
270
|
+
// an unsettled one would report a finding on every pull request, and a guard
|
|
271
|
+
// that fires every time gets deleted rather than read.
|
|
272
|
+
if (String(state).toLowerCase() === "pending") {
|
|
273
|
+
return {
|
|
274
|
+
context: reviewer.context,
|
|
275
|
+
reviewed: false,
|
|
276
|
+
reading: EVIDENCE_READINGS.pending,
|
|
277
|
+
observed,
|
|
278
|
+
state,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (normalised === "") {
|
|
282
|
+
return {
|
|
283
|
+
context: reviewer.context,
|
|
284
|
+
reviewed: false,
|
|
285
|
+
reading: EVIDENCE_READINGS.empty,
|
|
286
|
+
observed,
|
|
287
|
+
state,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (reviewer.proof.includes(normalised)) {
|
|
291
|
+
return {
|
|
292
|
+
context: reviewer.context,
|
|
293
|
+
reviewed: true,
|
|
294
|
+
reading: EVIDENCE_READINGS.reviewed,
|
|
295
|
+
observed,
|
|
296
|
+
state,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
context: reviewer.context,
|
|
301
|
+
reviewed: false,
|
|
302
|
+
reading: readsAsNoWork(normalised)
|
|
303
|
+
? EVIDENCE_READINGS.noWork
|
|
304
|
+
: EVIDENCE_READINGS.unrecognised,
|
|
305
|
+
observed,
|
|
306
|
+
state,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The marker line a substitute review carries for one reviewer at one commit.
|
|
312
|
+
* @param {object} options - Marker inputs.
|
|
313
|
+
* @param {string} options.context - The reviewer's status context.
|
|
314
|
+
* @param {string} options.sha - The head commit the substitute reviewed.
|
|
315
|
+
* @returns {string} The HTML-comment marker.
|
|
316
|
+
*/
|
|
317
|
+
export function substituteMarker({ context, sha }) {
|
|
318
|
+
return `<!-- ${SUBSTITUTE_MARKER} context="${context}" head="${sha}" -->`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Whether a substitute review was posted for this reviewer at this commit.
|
|
323
|
+
*
|
|
324
|
+
* Both halves must match. A substitute posted for a different reviewer says
|
|
325
|
+
* nothing about this one, and one posted against an earlier head reviewed code
|
|
326
|
+
* that is no longer what would merge — review evidence decays on every push.
|
|
327
|
+
* @param {object} options - Search inputs.
|
|
328
|
+
* @param {ReadonlyArray<{body?: string}>} options.comments - Issue comments on the pull request.
|
|
329
|
+
* @param {string} options.context - The reviewer's status context.
|
|
330
|
+
* @param {string} options.sha - The head commit.
|
|
331
|
+
* @returns {boolean} Whether a matching substitute exists.
|
|
332
|
+
*/
|
|
333
|
+
export function substitutePosted({ comments, context, sha }) {
|
|
334
|
+
const marker = substituteMarker({ context, sha });
|
|
335
|
+
return (comments ?? []).some(comment =>
|
|
336
|
+
String(comment?.body ?? "").includes(marker)
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Classify a review OBJECT — never a status description.
|
|
342
|
+
*
|
|
343
|
+
* Deliberately separate from everything above, and deliberately blind to body
|
|
344
|
+
* emptiness. An empty-bodied `APPROVED` is a normal human approval; treating it
|
|
345
|
+
* as hollow because the status-description logic treats an empty description as
|
|
346
|
+
* hollow would reject real approvals. A `CHANGES_REQUESTED` is a blocking
|
|
347
|
+
* objection whatever its body, because its content commonly lives entirely in
|
|
348
|
+
* inline threads rather than the review body.
|
|
349
|
+
* @param {{state?: string}} review - One review object.
|
|
350
|
+
* @returns {string} One of {@link REVIEW_OBJECT_VERDICTS}.
|
|
351
|
+
*/
|
|
352
|
+
export function classifyReviewObject(review) {
|
|
353
|
+
const state = String(review?.state ?? "").toUpperCase();
|
|
354
|
+
if (state === "CHANGES_REQUESTED") return REVIEW_OBJECT_VERDICTS.objection;
|
|
355
|
+
if (state === "APPROVED") return REVIEW_OBJECT_VERDICTS.approval;
|
|
356
|
+
return REVIEW_OBJECT_VERDICTS.comment;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The review objects that actually looked at this commit.
|
|
361
|
+
*
|
|
362
|
+
* Filtered on `commit_id`, and the filter has to run in BOTH directions —
|
|
363
|
+
* writing it for one is the easy bug:
|
|
364
|
+
*
|
|
365
|
+
* - a stale `APPROVED` from an older commit reads as fresh approval of code it
|
|
366
|
+
* never saw;
|
|
367
|
+
* - a stale `CHANGES_REQUESTED` reads as a current objection and blocks work a
|
|
368
|
+
* newer head already fixed.
|
|
369
|
+
*
|
|
370
|
+
* Neither direction is the safe one to leave out. A review whose `commit_id` is
|
|
371
|
+
* missing is dropped too: unknown is not "at this head", and admitting it would
|
|
372
|
+
* reinstate whichever of the two staleness bugs its state happens to cause.
|
|
373
|
+
* @param {ReadonlyArray<{commit_id?: string}>} reviews - Review objects from the reviews API.
|
|
374
|
+
* @param {string} sha - The head commit.
|
|
375
|
+
* @returns {ReadonlyArray<object>} Only the reviews submitted against `sha`.
|
|
376
|
+
*/
|
|
377
|
+
export function reviewsAtHead(reviews, sha) {
|
|
378
|
+
return (reviews ?? []).filter(review => review?.commit_id === sha);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* What the review objects at this head say, approvals and objections apart.
|
|
383
|
+
*
|
|
384
|
+
* ## "No review object" and "unreviewed" are different propositions
|
|
385
|
+
*
|
|
386
|
+
* Conflating them is a measured incident, not a hypothetical: a lane counted the
|
|
387
|
+
* absence of a THIRD-PARTY review object across 15 pull requests, read it as
|
|
388
|
+
* "12 of 15 have no approving review", and froze merging fleet-wide. A pull
|
|
389
|
+
* request carrying a genuine human approval and no third-party object is
|
|
390
|
+
* reviewed. So this function answers only "what did the reviews at this head
|
|
391
|
+
* say", and says nothing at all about third-party status evidence — which is
|
|
392
|
+
* `reviewEvidenceVerdict`'s separate question, over a separate data source.
|
|
393
|
+
* @param {object} options - Standing inputs.
|
|
394
|
+
* @param {ReadonlyArray<object>} options.reviews - Review objects from the reviews API.
|
|
395
|
+
* @param {string} options.sha - The head commit.
|
|
396
|
+
* @returns {{approvals: number, objections: number, staleDropped: number}} The standing at head.
|
|
397
|
+
*/
|
|
398
|
+
export function reviewStanding({ reviews, sha }) {
|
|
399
|
+
const atHead = reviewsAtHead(reviews, sha);
|
|
400
|
+
const verdicts = atHead.map(classifyReviewObject);
|
|
401
|
+
return {
|
|
402
|
+
approvals: verdicts.filter(
|
|
403
|
+
verdict => verdict === REVIEW_OBJECT_VERDICTS.approval
|
|
404
|
+
).length,
|
|
405
|
+
objections: verdicts.filter(
|
|
406
|
+
verdict => verdict === REVIEW_OBJECT_VERDICTS.objection
|
|
407
|
+
).length,
|
|
408
|
+
staleDropped: (reviews ?? []).length - atHead.length,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Decide the whole question for one commit.
|
|
414
|
+
*
|
|
415
|
+
* Four outcomes, and the first is not a pass. `no-reviewer-configured` means
|
|
416
|
+
* this project declared no third-party reviewer, which is a legitimate state
|
|
417
|
+
* that must be SAID rather than passed through in silence.
|
|
418
|
+
* @param {object} options - Decision inputs.
|
|
419
|
+
* @param {Array<{gateId: string, context: string, proof: string[]}>} options.reviewers - Declared reviewers.
|
|
420
|
+
* @param {ReadonlyArray<object>} options.statuses - Statuses on the head commit.
|
|
421
|
+
* @param {ReadonlyArray<{body?: string}>} options.comments - Issue comments on the pull request.
|
|
422
|
+
* @param {string} options.sha - The head commit.
|
|
423
|
+
* @param {boolean} [options.isPullRequestHead] - Whether an open pull request has this commit as its head.
|
|
424
|
+
* @returns {{outcome: string, sha: string, readings: Array<object>}} The verdict.
|
|
425
|
+
*/
|
|
426
|
+
export function reviewEvidenceVerdict({
|
|
427
|
+
reviewers,
|
|
428
|
+
statuses,
|
|
429
|
+
comments,
|
|
430
|
+
sha,
|
|
431
|
+
isPullRequestHead = true,
|
|
432
|
+
}) {
|
|
433
|
+
if ((reviewers ?? []).length === 0) {
|
|
434
|
+
return {
|
|
435
|
+
outcome: EVIDENCE_OUTCOMES.noReviewerConfigured,
|
|
436
|
+
sha,
|
|
437
|
+
readings: [],
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
if (!isPullRequestHead) {
|
|
441
|
+
return {
|
|
442
|
+
outcome: EVIDENCE_OUTCOMES.notAPullRequestHead,
|
|
443
|
+
sha,
|
|
444
|
+
readings: [],
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const readings = reviewers.map(reviewer => {
|
|
448
|
+
const reading = readReviewerEvidence({ reviewer, statuses });
|
|
449
|
+
return {
|
|
450
|
+
...reading,
|
|
451
|
+
gateId: reviewer.gateId,
|
|
452
|
+
substituted:
|
|
453
|
+
!reading.reviewed &&
|
|
454
|
+
substitutePosted({ comments, context: reviewer.context, sha }),
|
|
455
|
+
};
|
|
456
|
+
});
|
|
457
|
+
if (readings.every(reading => reading.reviewed)) {
|
|
458
|
+
return { outcome: EVIDENCE_OUTCOMES.satisfied, sha, readings };
|
|
459
|
+
}
|
|
460
|
+
// Deferred, not passed. The settled status fires its own event, and the
|
|
461
|
+
// pull-request-event arm of this pair is what catches a reviewer that never
|
|
462
|
+
// settles at all — one covers hollowness, the other covers silence.
|
|
463
|
+
if (
|
|
464
|
+
readings.some(
|
|
465
|
+
reading =>
|
|
466
|
+
reading.reading === EVIDENCE_READINGS.pending && !reading.substituted
|
|
467
|
+
)
|
|
468
|
+
) {
|
|
469
|
+
return { outcome: EVIDENCE_OUTCOMES.pending, sha, readings };
|
|
470
|
+
}
|
|
471
|
+
if (readings.every(reading => reading.reviewed || reading.substituted)) {
|
|
472
|
+
return { outcome: EVIDENCE_OUTCOMES.substituted, sha, readings };
|
|
473
|
+
}
|
|
474
|
+
return { outcome: EVIDENCE_OUTCOMES.unsatisfied, sha, readings };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* One operator-readable line per reviewer, plus the headline.
|
|
479
|
+
* @param {{outcome: string, sha: string, readings: Array<object>}} verdict - A verdict.
|
|
480
|
+
* @returns {string} The report.
|
|
481
|
+
*/
|
|
482
|
+
export function humanReport(verdict) {
|
|
483
|
+
if (verdict.outcome === EVIDENCE_OUTCOMES.noReviewerConfigured) {
|
|
484
|
+
return (
|
|
485
|
+
`NO-OP — no third-party reviewer is configured.\n` +
|
|
486
|
+
`No gate in .lisa.config.json declares evidence.reviewer at the ` +
|
|
487
|
+
`pull-request moment, so there is no third-party review for this commit ` +
|
|
488
|
+
`to be missing. This is a no-op, NOT a pass: nothing was read, and ` +
|
|
489
|
+
`nothing here asserts the code was reviewed.`
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
if (verdict.outcome === EVIDENCE_OUTCOMES.notAConfiguredReviewer) {
|
|
493
|
+
return (
|
|
494
|
+
`NO-OP — the triggering status belongs to no declared reviewer.\n` +
|
|
495
|
+
`Reviewers ARE configured; this status is not one of theirs, so it ` +
|
|
496
|
+
`cannot change the answer. This is a no-op, NOT a pass.`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
if (verdict.outcome === EVIDENCE_OUTCOMES.notAPullRequestHead) {
|
|
500
|
+
return (
|
|
501
|
+
`NO-OP — ${verdict.sha} is not the head of any open pull request.\n` +
|
|
502
|
+
`A reviewer IS configured; this commit is simply not the thing that ` +
|
|
503
|
+
`would merge. This is a no-op, NOT a pass.`
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
const lines = verdict.readings.map(reading => {
|
|
507
|
+
const seen =
|
|
508
|
+
reading.observed === null
|
|
509
|
+
? "no status posted"
|
|
510
|
+
: `${reading.state ?? "?"} — ${JSON.stringify(reading.observed)}`;
|
|
511
|
+
if (reading.reviewed) return ` reviewed ${reading.context}: ${seen}`;
|
|
512
|
+
const stood = reading.substituted
|
|
513
|
+
? "a local adversarial review was posted in its place"
|
|
514
|
+
: "NOTHING stands in its place";
|
|
515
|
+
return (
|
|
516
|
+
` NOT REVIEWED ${reading.context} (${reading.reading}): ${seen}\n` +
|
|
517
|
+
` observed: ${READING_SENTENCES[reading.reading]}\n` +
|
|
518
|
+
` ${stood}`
|
|
519
|
+
);
|
|
520
|
+
});
|
|
521
|
+
return `${verdict.outcome.toUpperCase()} at ${verdict.sha}\n${lines.join("\n")}`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Parse the CLI arguments.
|
|
526
|
+
* @param {string[]} argv - Arguments after the script path.
|
|
527
|
+
* @returns {{sha: string|null, context: string|null, root: string, repo: string|null, json: boolean}} Options.
|
|
528
|
+
*/
|
|
529
|
+
export function parseArgs(argv) {
|
|
530
|
+
const opts = {
|
|
531
|
+
sha: null,
|
|
532
|
+
context: null,
|
|
533
|
+
root: process.cwd(),
|
|
534
|
+
repo: null,
|
|
535
|
+
json: false,
|
|
536
|
+
};
|
|
537
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
538
|
+
const arg = argv[index];
|
|
539
|
+
if (arg === "--json") {
|
|
540
|
+
opts.json = true;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const [flag, inline] = arg.startsWith("--") ? splitFlag(arg) : [null, null];
|
|
544
|
+
if (flag === null) continue;
|
|
545
|
+
let value = inline;
|
|
546
|
+
if (value === null) {
|
|
547
|
+
index += 1;
|
|
548
|
+
value = argv[index] ?? null;
|
|
549
|
+
}
|
|
550
|
+
if (flag in opts) opts[flag] = value;
|
|
551
|
+
}
|
|
552
|
+
return opts;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Split `--name=value` or `--name` into its two halves.
|
|
557
|
+
* @param {string} arg - One argument.
|
|
558
|
+
* @returns {[string, string|null]} The flag name and any inline value.
|
|
559
|
+
*/
|
|
560
|
+
function splitFlag(arg) {
|
|
561
|
+
const body = arg.slice(2);
|
|
562
|
+
const equals = body.indexOf("=");
|
|
563
|
+
return equals === -1
|
|
564
|
+
? [body, null]
|
|
565
|
+
: [body.slice(0, equals), body.slice(equals + 1)];
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* One `gh api` read, parsed as JSON.
|
|
570
|
+
*
|
|
571
|
+
* `gh` is asked for a path rather than handed a query, and the child is bounded
|
|
572
|
+
* — a guard whose child never returned would otherwise report a verdict it
|
|
573
|
+
* never reached. A failed read returns `null`, which the callers turn into
|
|
574
|
+
* "absent", so a broken read can only ever make this stricter.
|
|
575
|
+
* @param {string} path - The API path.
|
|
576
|
+
* @returns {unknown} The parsed body, or null.
|
|
577
|
+
*/
|
|
578
|
+
function ghJson(path) {
|
|
579
|
+
try {
|
|
580
|
+
return JSON.parse(
|
|
581
|
+
boundedExecFileSync("gh", ["api", path], { encoding: "utf8" })
|
|
582
|
+
);
|
|
583
|
+
} catch {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* The combined status on one commit.
|
|
590
|
+
*
|
|
591
|
+
* `repos/{repo}/commits/{sha}/status` and NOT `gh pr view --json
|
|
592
|
+
* statusCheckRollup` — verified, the rollup returns a `StatusContext` with no
|
|
593
|
+
* `description` key at all, so hollow green is invisible through it.
|
|
594
|
+
* @param {string} repo - `owner/repo`.
|
|
595
|
+
* @param {string} sha - The commit.
|
|
596
|
+
* @returns {ReadonlyArray<object>} The statuses, newest first.
|
|
597
|
+
*/
|
|
598
|
+
function readStatuses(repo, sha) {
|
|
599
|
+
const body = ghJson(`repos/${repo}/commits/${sha}/status`);
|
|
600
|
+
return Array.isArray(body?.statuses) ? body.statuses : [];
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* The open pull requests whose HEAD is this commit.
|
|
605
|
+
*
|
|
606
|
+
* Head, not "associated with". A commit that merely appears in a pull request's
|
|
607
|
+
* history is not what would merge, and judging review evidence on it would
|
|
608
|
+
* redden pull requests for their ancestors' statuses.
|
|
609
|
+
* @param {string} repo - `owner/repo`.
|
|
610
|
+
* @param {string} sha - The commit.
|
|
611
|
+
* @returns {number[]} The pull request numbers.
|
|
612
|
+
*/
|
|
613
|
+
function readHeadPulls(repo, sha) {
|
|
614
|
+
const pulls = ghJson(`repos/${repo}/commits/${sha}/pulls`);
|
|
615
|
+
if (!Array.isArray(pulls)) return [];
|
|
616
|
+
return pulls
|
|
617
|
+
.filter(
|
|
618
|
+
pull =>
|
|
619
|
+
pull?.head?.sha === sha &&
|
|
620
|
+
pull?.state === "open" &&
|
|
621
|
+
Number.isInteger(pull?.number)
|
|
622
|
+
)
|
|
623
|
+
.map(pull => pull.number);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Issue comments on the given pull requests.
|
|
628
|
+
* @param {string} repo - `owner/repo`.
|
|
629
|
+
* @param {number[]} numbers - Pull request numbers.
|
|
630
|
+
* @returns {ReadonlyArray<object>} The comments.
|
|
631
|
+
*/
|
|
632
|
+
function readComments(repo, numbers) {
|
|
633
|
+
return numbers.flatMap(number => {
|
|
634
|
+
const comments = ghJson(
|
|
635
|
+
`repos/${repo}/issues/${number}/comments?per_page=100`
|
|
636
|
+
);
|
|
637
|
+
return Array.isArray(comments) ? comments : [];
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* The CLI body.
|
|
643
|
+
* @param {string[]} argv - Arguments after the script path.
|
|
644
|
+
* @param {object} [io] - Injected side effects, for tests.
|
|
645
|
+
* @param {object} [io.gates] - The gates block, read from disk when absent.
|
|
646
|
+
* @param {(sha: string) => number[]} [io.pulls] - Head-pull-request reader.
|
|
647
|
+
* @param {(sha: string) => ReadonlyArray<object>} [io.statuses] - Status reader.
|
|
648
|
+
* @param {(numbers: number[]) => ReadonlyArray<object>} [io.comments] - Comment reader.
|
|
649
|
+
* @param {{write: (text: string) => void}} [io.out] - Stdout.
|
|
650
|
+
* @param {{write: (text: string) => void}} [io.err] - Stderr.
|
|
651
|
+
* @returns {number} The exit code.
|
|
652
|
+
*/
|
|
653
|
+
export function main(argv, io = {}) {
|
|
654
|
+
const out = io.out ?? process.stdout;
|
|
655
|
+
const err = io.err ?? process.stderr;
|
|
656
|
+
const opts = parseArgs(argv);
|
|
657
|
+
if (!opts.sha) {
|
|
658
|
+
err.write("error: --sha <commit> is required\n");
|
|
659
|
+
return 2;
|
|
660
|
+
}
|
|
661
|
+
const gates = io.gates ?? readGates(opts.root).gates;
|
|
662
|
+
const reviewers = configuredReviewers({ gates });
|
|
663
|
+
const repo = opts.repo ?? process.env.GITHUB_REPOSITORY ?? "";
|
|
664
|
+
// A status from some other app cannot change this answer, and evaluating on
|
|
665
|
+
// one would read a reviewer as absent merely because it had not posted yet —
|
|
666
|
+
// a finding on every pull request, which is how a guard gets deleted.
|
|
667
|
+
const woken =
|
|
668
|
+
reviewers.length === 0 ||
|
|
669
|
+
opts.context === null ||
|
|
670
|
+
reviewers.some(reviewer => reviewer.context === opts.context);
|
|
671
|
+
// Nothing is read at all when nothing is configured. A no-op that still
|
|
672
|
+
// spends three API calls invites someone to "optimise" it by dropping the
|
|
673
|
+
// report rather than the calls.
|
|
674
|
+
const pulls =
|
|
675
|
+
reviewers.length === 0 || !woken
|
|
676
|
+
? []
|
|
677
|
+
: (io.pulls ?? (sha => readHeadPulls(repo, sha)))(opts.sha);
|
|
678
|
+
const evaluated = reviewers.length > 0 && woken && pulls.length > 0;
|
|
679
|
+
const verdict = !woken
|
|
680
|
+
? {
|
|
681
|
+
outcome: EVIDENCE_OUTCOMES.notAConfiguredReviewer,
|
|
682
|
+
sha: opts.sha,
|
|
683
|
+
readings: [],
|
|
684
|
+
}
|
|
685
|
+
: reviewEvidenceVerdict({
|
|
686
|
+
reviewers,
|
|
687
|
+
statuses: evaluated
|
|
688
|
+
? (io.statuses ?? (sha => readStatuses(repo, sha)))(opts.sha)
|
|
689
|
+
: [],
|
|
690
|
+
comments: evaluated
|
|
691
|
+
? (io.comments ?? (numbers => readComments(repo, numbers)))(pulls)
|
|
692
|
+
: [],
|
|
693
|
+
sha: opts.sha,
|
|
694
|
+
isPullRequestHead: pulls.length > 0,
|
|
695
|
+
});
|
|
696
|
+
out.write(
|
|
697
|
+
`${opts.json ? JSON.stringify(verdict, null, 2) : humanReport(verdict)}\n`
|
|
698
|
+
);
|
|
699
|
+
return verdict.outcome === EVIDENCE_OUTCOMES.unsatisfied ? 1 : 0;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (invokedAsScript(import.meta.url)) {
|
|
703
|
+
// exitCode (not process.exit): when stdout is a pipe, writes are async and
|
|
704
|
+
// process.exit() truncates the report mid-flush.
|
|
705
|
+
process.exitCode = main(process.argv.slice(2));
|
|
706
|
+
}
|