@codyswann/lisa 4.87.5 → 4.87.6
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 +568 -80
- package/dist/cli/doctor-seeded-artifacts.d.ts.map +1 -1
- package/dist/cli/doctor-seeded-artifacts.js +10 -3
- package/dist/cli/doctor-seeded-artifacts.js.map +1 -1
- package/dist/cli/doctor-third-party-evidence-seed.d.ts +32 -0
- package/dist/cli/doctor-third-party-evidence-seed.d.ts.map +1 -0
- package/dist/cli/doctor-third-party-evidence-seed.js +239 -0
- package/dist/cli/doctor-third-party-evidence-seed.js.map +1 -0
- package/dist/cli/doctor.js +2 -2
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +6 -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/third-party-evidence-seed.d.ts +42 -0
- package/dist/core/third-party-evidence-seed.d.ts.map +1 -0
- package/dist/core/third-party-evidence-seed.js +233 -0
- package/dist/core/third-party-evidence-seed.js.map +1 -0
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +21 -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 +59 -31
- package/plugins/lisa/.codex-plugin/skills/lisa-pull-request-review/SKILL.md +11 -5
- package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +59 -31
- package/plugins/lisa/skills/lisa-pull-request-review/SKILL.md +11 -5
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +59 -31
- package/plugins/lisa-agy/skills/lisa-pull-request-review/SKILL.md +11 -5
- 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-cdk-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-devin/.devin-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 +59 -31
- package/plugins/lisa-copilot/skills/lisa-pull-request-review/SKILL.md +11 -5
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +59 -31
- package/plugins/lisa-cursor/skills/lisa-pull-request-review/SKILL.md +11 -5
- package/plugins/lisa-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-devin/.devin-plugin/plugin.json +1 -1
- package/plugins/lisa-devin/skills/lisa-drive-pr-to-merge/SKILL.md +59 -31
- package/plugins/lisa-devin/skills/lisa-pull-request-review/SKILL.md +11 -5
- 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-expo-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-devin/.devin-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-harper-fabric-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-devin/.devin-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-nestjs-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-devin/.devin-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-openclaw-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-devin/.devin-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-phaser-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-devin/.devin-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-rails-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-devin/.devin-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-typescript-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-devin/.devin-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/lisa-wiki-devin/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-devin/.devin-plugin/plugin.json +1 -1
- package/plugins/src/base/skills/lisa-drive-pr-to-merge/SKILL.md +59 -31
- package/plugins/src/base/skills/lisa-pull-request-review/SKILL.md +11 -5
- package/typescript/create-only/.github/workflows/third-party-review-evidence.yml +62 -30
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
* @remarks
|
|
9
9
|
* ## The measurement this exists for
|
|
10
10
|
*
|
|
11
|
-
* A third-party review app
|
|
12
|
-
* When it is throttled, or declines to review at
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* A third-party review app reports on the pull request head through a **commit
|
|
12
|
+
* status** or a **check run**. When it is throttled, or declines to review at
|
|
13
|
+
* all, it never blocks — a status still reports `state: success`, and a check
|
|
14
|
+
* run concludes `neutral`, which GitHub counts as passing a required check. Only
|
|
15
|
+
* the reviewer's own words distinguish a review that ran from one that never
|
|
16
|
+
* started.
|
|
15
17
|
*
|
|
16
18
|
* Re-measured over the last 30 merged pull requests in this repository, reading
|
|
17
19
|
* the reviewer's status on each pull request's head SHA:
|
|
@@ -50,6 +52,52 @@
|
|
|
50
52
|
* Hollow green is invisible there. The evidence read MUST come from
|
|
51
53
|
* `repos/{owner}/{repo}/commits/{sha}/status` and use `.statuses[].description`.
|
|
52
54
|
*
|
|
55
|
+
* ## Two representations, one question (#4553)
|
|
56
|
+
*
|
|
57
|
+
* The same reviewer can move from statuses to check runs without notice.
|
|
58
|
+
* Measured on this repository's last 30 merged pull requests: the 18 up to
|
|
59
|
+
* 2026-10-26 carry a `CodeRabbit` commit status and no check run, and the 12
|
|
60
|
+
* from 2026-10-27 carry a `CodeRabbit` check run and no status. A reader of
|
|
61
|
+
* statuses alone reported `absent` at every one of those 12 heads — including
|
|
62
|
+
* one the reviewer had approved — so every pull request needed a hand-written
|
|
63
|
+
* substitute review.
|
|
64
|
+
*
|
|
65
|
+
* Both are read now, and statuses exactly as before. A check run is the
|
|
66
|
+
* reviewer's evidence only when it carries the declared context as its name,
|
|
67
|
+
* was reported for this exact head, and — when the gate pins `posted_by` — was
|
|
68
|
+
* posted by that app; an unpinned gate accepts any app, which is GitHub's own
|
|
69
|
+
* meaning of an unpinned required check. When such a run exists it is
|
|
70
|
+
* authoritative over a status of the same name, and the newest run wins.
|
|
71
|
+
*
|
|
72
|
+
* Those two rules and the decoding below are the ones
|
|
73
|
+
* `check-skipped-required-checks.mjs` applies (#4547). Four differences are
|
|
74
|
+
* deliberate. The first three only keep a reviewer's real evidence from being
|
|
75
|
+
* hidden; the fourth follows from the two provers reading different
|
|
76
|
+
* declarations:
|
|
77
|
+
*
|
|
78
|
+
* - a run from an app other than the pinned `posted_by` is set aside BEFORE
|
|
79
|
+
* the newest run is chosen, so another app's later run under the
|
|
80
|
+
* reviewer's name cannot shadow the reviewer's own (that prover reads no
|
|
81
|
+
* app at all);
|
|
82
|
+
* - when the check-run read fails, the reviewer's own status still counts,
|
|
83
|
+
* so a host whose token cannot read check runs keeps reading statuses
|
|
84
|
+
* exactly as before (that prover refuses the whole read);
|
|
85
|
+
* - a phrase is compared under this prover's existing normalisation — whole
|
|
86
|
+
* string, case-insensitive, whitespace-collapsed — as statuses always were;
|
|
87
|
+
* - the words that are denied first are Lisa's shipped no-work phrases, while
|
|
88
|
+
* that prover also denies phrases its own declaration configures
|
|
89
|
+
* (`no_work`, `waive`). A phrase only configured there is never one
|
|
90
|
+
* this prover's allowlist declares as proof unless a project declares the
|
|
91
|
+
* same words as both, which no shipped configuration does.
|
|
92
|
+
*
|
|
93
|
+
* A check run's title is often generic (`Finished`), so its result is read from
|
|
94
|
+
* the FIRST line of its summary, exactly as #4547 reads it. Only
|
|
95
|
+
* `Result: Success - Review completed`, on a run that completed with `success`,
|
|
96
|
+
* stands for the proof phrase `Review completed`; every other result-shaped
|
|
97
|
+
* first line stays authoritative, so a negative result can never fall back to a
|
|
98
|
+
* positive title. Later lines, a generic title, and `success` alone grant
|
|
99
|
+
* nothing — and a run that did not succeed grants nothing whatever it says.
|
|
100
|
+
*
|
|
53
101
|
* ## A review OBJECT is not a status CONTEXT
|
|
54
102
|
*
|
|
55
103
|
* An empty-bodied `APPROVED` review is an ordinary human approval, not hollow.
|
|
@@ -74,10 +122,11 @@
|
|
|
74
122
|
* node scripts/check-third-party-review-evidence.mjs --sha <sha> [--json]
|
|
75
123
|
* [--context <context>] [--root <dir>] [--repo <owner/repo>]
|
|
76
124
|
*
|
|
77
|
-
* `--context` names the status context that woke the run.
|
|
78
|
-
* belongs to no declared reviewer, the run is an explicit
|
|
79
|
-
* some other app cannot change this answer,
|
|
80
|
-
* a reviewer as absent merely because it had
|
|
125
|
+
* `--context` names the status context or check-run name that woke the run.
|
|
126
|
+
* When it is given and belongs to no declared reviewer, the run is an explicit
|
|
127
|
+
* no-op: a status or check run from some other app cannot change this answer,
|
|
128
|
+
* and evaluating on one would report a reviewer as absent merely because it had
|
|
129
|
+
* not posted yet.
|
|
81
130
|
*
|
|
82
131
|
* @module scripts/check-third-party-review-evidence
|
|
83
132
|
*/
|
|
@@ -85,11 +134,11 @@ import { boundedExecFileSync } from "./lib/bounded-spawn.mjs";
|
|
|
85
134
|
import { invokedAsScript } from "./lib/invoked-as-script.mjs";
|
|
86
135
|
import { EVIDENCE_DEFAULTS, PULL_REQUEST, readGates } from "./lisa-gates.mjs";
|
|
87
136
|
|
|
88
|
-
/** How a reviewer's
|
|
137
|
+
/** How a reviewer's evidence at one commit reads. */
|
|
89
138
|
export const EVIDENCE_READINGS = Object.freeze({
|
|
90
139
|
/** The description matched a declared reviewed-when phrase. */
|
|
91
140
|
reviewed: "reviewed",
|
|
92
|
-
/** No status for this context on the commit at all. */
|
|
141
|
+
/** No status and no check run for this context on the commit at all. */
|
|
93
142
|
absent: "absent",
|
|
94
143
|
/** A status with no description, or only whitespace. */
|
|
95
144
|
empty: "empty",
|
|
@@ -99,6 +148,18 @@ export const EVIDENCE_READINGS = Object.freeze({
|
|
|
99
148
|
unrecognised: "unrecognised",
|
|
100
149
|
/** The reviewer has started and has not answered yet. Not an answer either way. */
|
|
101
150
|
pending: "pending",
|
|
151
|
+
/** The reviewer's check run finished with a conclusion other than success. */
|
|
152
|
+
unsuccessful: "unsuccessful",
|
|
153
|
+
/** A read this answer depends on failed, so nothing could be seen. */
|
|
154
|
+
unreadable: "unreadable",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/** Which representation a reading's evidence came from. */
|
|
158
|
+
export const EVIDENCE_SOURCES = Object.freeze({
|
|
159
|
+
/** `repos/{repo}/commits/{sha}/status`. */
|
|
160
|
+
status: "status",
|
|
161
|
+
/** `repos/{repo}/commits/{sha}/check-runs`. */
|
|
162
|
+
checkRun: "check-run",
|
|
102
163
|
});
|
|
103
164
|
|
|
104
165
|
/** What the whole evaluation concluded. */
|
|
@@ -146,7 +207,7 @@ export const READING_SENTENCES = Object.freeze({
|
|
|
146
207
|
[EVIDENCE_READINGS.reviewed]:
|
|
147
208
|
"the reviewer reported a description this project declares as proof it read the code",
|
|
148
209
|
[EVIDENCE_READINGS.absent]:
|
|
149
|
-
"no third-party status was present at this head at all — nothing from that reviewer, of any state",
|
|
210
|
+
"no third-party status was present at this head at all — nothing from that reviewer, of any state, as a commit status or as a check run",
|
|
150
211
|
[EVIDENCE_READINGS.empty]:
|
|
151
212
|
"the reviewer posted a status carrying no description, so nothing says whether it read anything",
|
|
152
213
|
[EVIDENCE_READINGS.noWork]:
|
|
@@ -155,6 +216,10 @@ export const READING_SENTENCES = Object.freeze({
|
|
|
155
216
|
"the reviewer reported a description this project does not declare as proof, and which nothing here recognises",
|
|
156
217
|
[EVIDENCE_READINGS.pending]:
|
|
157
218
|
"the reviewer has started and has not answered yet",
|
|
219
|
+
[EVIDENCE_READINGS.unsuccessful]:
|
|
220
|
+
"the reviewer's check run finished without success, so nothing it reported counts as proof it read the code",
|
|
221
|
+
[EVIDENCE_READINGS.unreadable]:
|
|
222
|
+
"the reviewer's statuses or check runs at this head could not be read, so nothing here can say whether it reviewed",
|
|
158
223
|
});
|
|
159
224
|
|
|
160
225
|
/**
|
|
@@ -193,10 +258,16 @@ export function normalizeDescription(description) {
|
|
|
193
258
|
* The reviewed-when allowlist is the gate's merged `evidence.proof`: Lisa's
|
|
194
259
|
* shipped phrases plus whatever the project adds. Extension, never replacement,
|
|
195
260
|
* for the reason `mergeEvidence` records.
|
|
261
|
+
*
|
|
262
|
+
* `postedBy` is the gate's `posted_by` — the app a check run must come from to
|
|
263
|
+
* speak for this reviewer — exactly as declared, and absent when the gate omits
|
|
264
|
+
* it, which is unpinned. Any declared value is carried as written, so a
|
|
265
|
+
* malformed pin such as `null` or a string id matches no app rather than
|
|
266
|
+
* widening to every app; `validateGates` refuses it separately.
|
|
196
267
|
* @param {object} options - Resolution inputs.
|
|
197
268
|
* @param {object} options.gates - The `gates` block of `.lisa.config.json`.
|
|
198
269
|
* @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.
|
|
270
|
+
* @returns {Array<{gateId: string, context: string, proof: string[], postedBy?: unknown}>} Declared reviewers.
|
|
200
271
|
*/
|
|
201
272
|
export function configuredReviewers({ gates, moment = PULL_REQUEST }) {
|
|
202
273
|
const reviewers = [];
|
|
@@ -212,6 +283,7 @@ export function configuredReviewers({ gates, moment = PULL_REQUEST }) {
|
|
|
212
283
|
proof: [...EVIDENCE_DEFAULTS.proof, ...(entry.evidence.proof ?? [])].map(
|
|
213
284
|
normalizeDescription
|
|
214
285
|
),
|
|
286
|
+
...(entry.posted_by === undefined ? {} : { postedBy: entry.posted_by }),
|
|
215
287
|
});
|
|
216
288
|
}
|
|
217
289
|
return reviewers.sort((left, right) =>
|
|
@@ -222,11 +294,14 @@ export function configuredReviewers({ gates, moment = PULL_REQUEST }) {
|
|
|
222
294
|
/**
|
|
223
295
|
* Whether a description reads as the vendor saying it did no work.
|
|
224
296
|
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
297
|
+
* It NEVER GRANTS credit — the verdict is settled by the allowlist above. For
|
|
298
|
+
* a status it only chooses between two ways of saying "not reviewed", so an
|
|
299
|
+
* operator can tell a known vendor state from a string nobody has seen. For a
|
|
300
|
+
* check run it also decides which of the run's words are read, denial first,
|
|
301
|
+
* exactly as #4547 decodes a run: a no-work title or first line is read in
|
|
302
|
+
* place of the structured result. That can only withhold credit. Wiring it in
|
|
303
|
+
* anywhere it could grant credit would turn the allowlist back into a
|
|
304
|
+
* denylist, which is the fail-open shape this module refuses.
|
|
230
305
|
* @param {string} normalised - An already-normalised description.
|
|
231
306
|
* @returns {boolean} Whether a shipped no-work phrase appears in it.
|
|
232
307
|
*/
|
|
@@ -236,6 +311,66 @@ function readsAsNoWork(normalised) {
|
|
|
236
311
|
);
|
|
237
312
|
}
|
|
238
313
|
|
|
314
|
+
/**
|
|
315
|
+
* @typedef {object} ReviewerShape
|
|
316
|
+
* @property {string} context - The status context or check-run name it reports under.
|
|
317
|
+
* @property {string[]} proof - The normalised reviewed-when allowlist.
|
|
318
|
+
* @property {unknown} [postedBy] - The app id a check run must come from, exactly as declared; absent for any app.
|
|
319
|
+
*/
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* One row of `.check_runs` from `repos/{repo}/commits/{sha}/check-runs`, as the
|
|
323
|
+
* API returns it. Every field is untrusted until read here.
|
|
324
|
+
* @typedef {object} RawCheckRun
|
|
325
|
+
* @property {unknown} [id] - The run id, which GitHub assigns in creation order.
|
|
326
|
+
* @property {unknown} [name] - The check-run name; a reviewer's is its declared context.
|
|
327
|
+
* @property {unknown} [head_sha] - The commit the run reports on.
|
|
328
|
+
* @property {unknown} [status] - `queued`, `in_progress`, `completed`, ...
|
|
329
|
+
* @property {unknown} [conclusion] - Set once completed: `success`, `neutral`, ...
|
|
330
|
+
* @property {unknown} [created_at] - Absent from the API object; honoured if a source supplies it.
|
|
331
|
+
* @property {{id?: unknown}|null} [app] - The app that posted the run.
|
|
332
|
+
* @property {{title?: unknown, summary?: unknown}|null} [output] - What the run reported.
|
|
333
|
+
*/
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* @typedef {object} EvidenceReading
|
|
337
|
+
* @property {string} context - The reviewer's context.
|
|
338
|
+
* @property {boolean} reviewed - Whether this is evidence the reviewer read the code.
|
|
339
|
+
* @property {string} reading - One of {@link EVIDENCE_READINGS}.
|
|
340
|
+
* @property {string|null} observed - The words that decided, verbatim.
|
|
341
|
+
* @property {string|null} state - A status's state, or a check run's conclusion or lifecycle.
|
|
342
|
+
* @property {string|null} source - One of {@link EVIDENCE_SOURCES}, or null when nothing was found.
|
|
343
|
+
* @property {unknown} [checkRunId] - The run that decided, for a check-run reading.
|
|
344
|
+
* @property {Array<{id: unknown, reason: string}>} ignoredCheckRuns - Runs under the reviewer's name that could not speak for it, and why.
|
|
345
|
+
*/
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The one structured first line that says a review completed.
|
|
349
|
+
*
|
|
350
|
+
* Byte-identical to the line `check-skipped-required-checks.mjs` recognises
|
|
351
|
+
* (#4547), and matched the same way: exactly, as the first physical line, on a
|
|
352
|
+
* run that completed with `success`. Duplicated rather than imported because
|
|
353
|
+
* the two scripts ship from different template trees and a host that has this
|
|
354
|
+
* one need not have that one; `tests/integration/third-party-review-check-run-cli`
|
|
355
|
+
* holds both provers to the same answers.
|
|
356
|
+
*/
|
|
357
|
+
export const COMPLETED_REVIEW_RESULT = "Result: Success - Review completed";
|
|
358
|
+
|
|
359
|
+
/** The proof phrase that line stands for. */
|
|
360
|
+
const COMPLETED_REVIEW_PROOF = "Review completed";
|
|
361
|
+
|
|
362
|
+
/** What a summary that is present but not text is reported as. */
|
|
363
|
+
const MALFORMED_SUMMARY = "Malformed check-run summary";
|
|
364
|
+
|
|
365
|
+
/** Check-run lifecycle values that mean the reviewer has not answered yet. */
|
|
366
|
+
const UNSETTLED_RUN_STATUSES = new Set([
|
|
367
|
+
"queued",
|
|
368
|
+
"in_progress",
|
|
369
|
+
"requested",
|
|
370
|
+
"waiting",
|
|
371
|
+
"pending",
|
|
372
|
+
]);
|
|
373
|
+
|
|
239
374
|
/**
|
|
240
375
|
* Read one reviewer's evidence off the statuses on a commit.
|
|
241
376
|
*
|
|
@@ -244,15 +379,13 @@ function readsAsNoWork(normalised) {
|
|
|
244
379
|
* no-work phrase, or an unrecognised string — is NOT REVIEWED, and the observed
|
|
245
380
|
* string is carried back verbatim so the report can say what was actually seen.
|
|
246
381
|
* @param {object} options - Reading inputs.
|
|
247
|
-
* @param {
|
|
382
|
+
* @param {ReviewerShape} options.reviewer - One declared reviewer.
|
|
248
383
|
* @param {ReadonlyArray<{context?: string, state?: string, description?: string}>} options.statuses -
|
|
249
384
|
* `.statuses` from `repos/{owner}/{repo}/commits/{sha}/status`, newest first.
|
|
250
|
-
* @returns {
|
|
385
|
+
* @returns {Omit<EvidenceReading, "ignoredCheckRuns">} The reading.
|
|
251
386
|
*/
|
|
252
|
-
|
|
253
|
-
const found = (
|
|
254
|
-
status => status?.context === reviewer.context
|
|
255
|
-
);
|
|
387
|
+
function readStatusEvidence({ reviewer, statuses }) {
|
|
388
|
+
const found = statuses.find(status => status?.context === reviewer.context);
|
|
256
389
|
if (found === undefined) {
|
|
257
390
|
return {
|
|
258
391
|
context: reviewer.context,
|
|
@@ -260,50 +393,267 @@ export function readReviewerEvidence({ reviewer, statuses }) {
|
|
|
260
393
|
reading: EVIDENCE_READINGS.absent,
|
|
261
394
|
observed: null,
|
|
262
395
|
state: null,
|
|
396
|
+
source: null,
|
|
263
397
|
};
|
|
264
398
|
}
|
|
265
399
|
const observed = found.description ?? null;
|
|
266
400
|
const normalised = normalizeDescription(observed);
|
|
267
401
|
const state = found.state ?? null;
|
|
402
|
+
const answer = (
|
|
403
|
+
/** @type {string} */ kind,
|
|
404
|
+
/** @type {boolean} */ reviewed = false
|
|
405
|
+
) => ({
|
|
406
|
+
context: reviewer.context,
|
|
407
|
+
reviewed,
|
|
408
|
+
reading: kind,
|
|
409
|
+
observed,
|
|
410
|
+
state,
|
|
411
|
+
source: EVIDENCE_SOURCES.status,
|
|
412
|
+
});
|
|
268
413
|
// A reviewer posts several statuses on one head before the settled one —
|
|
269
414
|
// measured `Review queued` then `Review in progress` then the verdict. Judging
|
|
270
415
|
// an unsettled one would report a finding on every pull request, and a guard
|
|
271
416
|
// that fires every time gets deleted rather than read.
|
|
272
417
|
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
|
-
};
|
|
418
|
+
return answer(EVIDENCE_READINGS.pending);
|
|
289
419
|
}
|
|
420
|
+
if (normalised === "") return answer(EVIDENCE_READINGS.empty);
|
|
290
421
|
if (reviewer.proof.includes(normalised)) {
|
|
422
|
+
return answer(EVIDENCE_READINGS.reviewed, true);
|
|
423
|
+
}
|
|
424
|
+
return answer(
|
|
425
|
+
readsAsNoWork(normalised)
|
|
426
|
+
? EVIDENCE_READINGS.noWork
|
|
427
|
+
: EVIDENCE_READINGS.unrecognised
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Whether a line is shaped like a structured result, however it is decorated.
|
|
433
|
+
*
|
|
434
|
+
* The same two tests `check-skipped-required-checks.mjs` applies. Such a line is
|
|
435
|
+
* authoritative even when it is not the one positive form, which is what stops
|
|
436
|
+
* a negative or malformed result from falling back to a positive title.
|
|
437
|
+
* @param {string} line - A first summary line.
|
|
438
|
+
* @returns {boolean} True for anything that reads as a result line.
|
|
439
|
+
*/
|
|
440
|
+
function looksLikeResult(line) {
|
|
441
|
+
return /^[\s>#|]*Result\b/iu.test(line) || /\bResult\s*[:=-]/iu.test(line);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* What a check run says, in the place a status carries its description.
|
|
446
|
+
*
|
|
447
|
+
* The decoding #4547 shipped for the registration prover, applied unchanged:
|
|
448
|
+
*
|
|
449
|
+
* 1. A title that reads as no-work decides first — a denial is never
|
|
450
|
+
* overridden by later text.
|
|
451
|
+
* 2. No summary keeps the title, the representation's own description slot.
|
|
452
|
+
* 3. A summary that is not text is never read: it stands for no phrase, and
|
|
453
|
+
* the report carries the value it actually was.
|
|
454
|
+
* 4. A first summary line that reads as no-work decides next.
|
|
455
|
+
* 5. `Result: Success - Review completed`, as the whole first line of a run
|
|
456
|
+
* that completed with `success`, stands for the proof phrase
|
|
457
|
+
* `Review completed`. This is the ONLY route from a summary to credit.
|
|
458
|
+
* 6. Any other result-shaped first line is authoritative as it stands, so a
|
|
459
|
+
* failed or unknown result cannot inherit a positive title.
|
|
460
|
+
* 7. A plain first line keeps the title, and later lines are never read.
|
|
461
|
+
*
|
|
462
|
+
* `text` is what decided, verbatim, for the report; `canonical` is what the
|
|
463
|
+
* allowlist compares. They differ only in rules 3 and 5.
|
|
464
|
+
* @param {RawCheckRun} run - One raw check run.
|
|
465
|
+
* @returns {{text: string, canonical: string}} The run's words.
|
|
466
|
+
*/
|
|
467
|
+
export function checkRunResult(run) {
|
|
468
|
+
const output = run?.output ?? {};
|
|
469
|
+
const title = typeof output.title === "string" ? output.title : "";
|
|
470
|
+
const said = (/** @type {string} */ text) => ({ text, canonical: text });
|
|
471
|
+
if (readsAsNoWork(normalizeDescription(title))) return said(title);
|
|
472
|
+
const summary = output.summary;
|
|
473
|
+
if (summary === null || summary === undefined) return said(title);
|
|
474
|
+
if (typeof summary !== "string") {
|
|
475
|
+
return { text: JSON.stringify(summary), canonical: MALFORMED_SUMMARY };
|
|
476
|
+
}
|
|
477
|
+
const firstLine = summary.split(/\r?\n/u, 1)[0] ?? "";
|
|
478
|
+
if (readsAsNoWork(normalizeDescription(firstLine))) return said(firstLine);
|
|
479
|
+
if (
|
|
480
|
+
run?.status === "completed" &&
|
|
481
|
+
run?.conclusion === "success" &&
|
|
482
|
+
firstLine === COMPLETED_REVIEW_RESULT
|
|
483
|
+
) {
|
|
484
|
+
return { text: firstLine, canonical: COMPLETED_REVIEW_PROOF };
|
|
485
|
+
}
|
|
486
|
+
if (looksLikeResult(firstLine)) return said(firstLine);
|
|
487
|
+
return said(title);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Order two runs by creation, newest last.
|
|
492
|
+
*
|
|
493
|
+
* Creation time first and id second, as `newestCheckRuns` in
|
|
494
|
+
* `check-skipped-required-checks.mjs` orders them. The API object carries no
|
|
495
|
+
* `created_at` — measured — so in practice the id decides, and GitHub assigns
|
|
496
|
+
* ids in creation order.
|
|
497
|
+
* @param {RawCheckRun} run - One run.
|
|
498
|
+
* @returns {[number, number]} Its creation time and id, zero when unknown.
|
|
499
|
+
*/
|
|
500
|
+
function runRank(run) {
|
|
501
|
+
const created =
|
|
502
|
+
typeof run.created_at === "string" ? Date.parse(run.created_at) : NaN;
|
|
503
|
+
return [Number.isFinite(created) ? created : 0, Number(run.id) || 0];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Why a run under the reviewer's name cannot speak for it, or null if it can.
|
|
508
|
+
* @param {object} options - The run and what it must match.
|
|
509
|
+
* @param {ReviewerShape} options.reviewer - One declared reviewer.
|
|
510
|
+
* @param {RawCheckRun} options.run - A run carrying the reviewer's name.
|
|
511
|
+
* @param {string|undefined} options.sha - The head commit.
|
|
512
|
+
* @returns {string|null} The reason, or null when the run is the reviewer's.
|
|
513
|
+
*/
|
|
514
|
+
function whyNotTheReviewers({ reviewer, run, sha }) {
|
|
515
|
+
if (typeof sha !== "string" || run.head_sha !== sha) {
|
|
516
|
+
return `reported for head ${JSON.stringify(run.head_sha ?? null)}, not ${JSON.stringify(sha ?? null)}`;
|
|
517
|
+
}
|
|
518
|
+
// Absent means unpinned. Anything declared — a malformed `null` included —
|
|
519
|
+
// must match exactly, so a broken pin narrows to no app instead of widening
|
|
520
|
+
// to every app.
|
|
521
|
+
const pin = reviewer.postedBy;
|
|
522
|
+
if (pin !== undefined && run.app?.id !== pin) {
|
|
523
|
+
return `posted by app ${JSON.stringify(run.app?.id ?? null)}, not the declared ${JSON.stringify(pin)}`;
|
|
524
|
+
}
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* The check run that speaks for one reviewer at one head, if any.
|
|
530
|
+
*
|
|
531
|
+
* A run counts only when it carries the reviewer's context as its name, was
|
|
532
|
+
* reported for this exact head, and — when the gate pins `posted_by` — came
|
|
533
|
+
* from that app. Anything else under the reviewer's name is set aside WITH its
|
|
534
|
+
* reason rather than dropped in silence: a run another app posted under the
|
|
535
|
+
* reviewer's name is the case an operator most needs explained. Of the runs
|
|
536
|
+
* that count, the newest decides.
|
|
537
|
+
* @param {object} options - Selection inputs.
|
|
538
|
+
* @param {ReviewerShape} options.reviewer - One declared reviewer.
|
|
539
|
+
* @param {ReadonlyArray<RawCheckRun>|null|undefined} options.runs - Runs read for its name; null when the read failed.
|
|
540
|
+
* @param {string|undefined} options.sha - The head commit.
|
|
541
|
+
* @returns {{run: RawCheckRun|null, ignored: Array<{id: unknown, reason: string}>}} The run, and those set aside.
|
|
542
|
+
*/
|
|
543
|
+
export function selectReviewerCheckRun({ reviewer, runs, sha }) {
|
|
544
|
+
/** @type {Array<{id: unknown, reason: string}>} */
|
|
545
|
+
const ignored = [];
|
|
546
|
+
/** @type {RawCheckRun|null} */
|
|
547
|
+
let selected = null;
|
|
548
|
+
for (const run of Array.isArray(runs) ? runs : []) {
|
|
549
|
+
if (run?.name !== reviewer.context) continue;
|
|
550
|
+
const reason = whyNotTheReviewers({ reviewer, run, sha });
|
|
551
|
+
if (reason !== null) {
|
|
552
|
+
ignored.push({ id: run.id ?? null, reason });
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (selected === null) {
|
|
556
|
+
selected = run;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const [time, id] = runRank(run);
|
|
560
|
+
const [selectedTime, selectedId] = runRank(selected);
|
|
561
|
+
if (time > selectedTime || (time === selectedTime && id > selectedId)) {
|
|
562
|
+
selected = run;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return { run: selected, ignored };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Read one reviewer's evidence off the check run that speaks for it.
|
|
570
|
+
*
|
|
571
|
+
* Credit needs all three: the run completed, it concluded `success`, and what
|
|
572
|
+
* it said is a declared reviewed-when phrase. A run that has not finished is
|
|
573
|
+
* pending — not an answer either way — and one that finished any other way
|
|
574
|
+
* grants nothing, whatever its words.
|
|
575
|
+
*
|
|
576
|
+
* The lifecycle and conclusion are compared exactly as GitHub sends them,
|
|
577
|
+
* lowercase, the way the registration prover compares them. A value in any
|
|
578
|
+
* other spelling is not one GitHub defines, so it is read as unknown rather
|
|
579
|
+
* than folded into one that grants credit.
|
|
580
|
+
* @param {object} options - Reading inputs.
|
|
581
|
+
* @param {ReviewerShape} options.reviewer - One declared reviewer.
|
|
582
|
+
* @param {RawCheckRun} options.run - The run selected for it.
|
|
583
|
+
* @returns {Omit<EvidenceReading, "ignoredCheckRuns">} The reading.
|
|
584
|
+
*/
|
|
585
|
+
function readCheckRunEvidence({ reviewer, run }) {
|
|
586
|
+
const status = typeof run.status === "string" ? run.status : "";
|
|
587
|
+
const conclusion = typeof run.conclusion === "string" ? run.conclusion : null;
|
|
588
|
+
const { text, canonical } = checkRunResult(run);
|
|
589
|
+
const settled = status === "completed";
|
|
590
|
+
const normalised = normalizeDescription(text);
|
|
591
|
+
let reading = EVIDENCE_READINGS.unrecognised;
|
|
592
|
+
if (UNSETTLED_RUN_STATUSES.has(status)) {
|
|
593
|
+
reading = EVIDENCE_READINGS.pending;
|
|
594
|
+
} else if (!settled) {
|
|
595
|
+
reading = EVIDENCE_READINGS.unrecognised;
|
|
596
|
+
} else if (
|
|
597
|
+
conclusion === "success" &&
|
|
598
|
+
reviewer.proof.includes(normalizeDescription(canonical))
|
|
599
|
+
) {
|
|
600
|
+
reading = EVIDENCE_READINGS.reviewed;
|
|
601
|
+
} else if (readsAsNoWork(normalised)) {
|
|
602
|
+
reading = EVIDENCE_READINGS.noWork;
|
|
603
|
+
} else if (conclusion !== "success") {
|
|
604
|
+
reading = EVIDENCE_READINGS.unsuccessful;
|
|
605
|
+
} else if (normalised === "") {
|
|
606
|
+
reading = EVIDENCE_READINGS.empty;
|
|
607
|
+
}
|
|
608
|
+
return {
|
|
609
|
+
context: reviewer.context,
|
|
610
|
+
reviewed: reading === EVIDENCE_READINGS.reviewed,
|
|
611
|
+
reading,
|
|
612
|
+
observed: text,
|
|
613
|
+
state: settled ? (conclusion ?? status) : status || null,
|
|
614
|
+
source: EVIDENCE_SOURCES.checkRun,
|
|
615
|
+
checkRunId: run.id ?? null,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Read one reviewer's evidence at one commit, from both representations.
|
|
621
|
+
*
|
|
622
|
+
* A check run that speaks for the reviewer is authoritative; otherwise its
|
|
623
|
+
* status is read exactly as before. A read that failed is never reported as
|
|
624
|
+
* an absence: when nothing stands and a read this answer depended on failed,
|
|
625
|
+
* the reading is `unreadable`, because "nothing was there" and "nothing could
|
|
626
|
+
* be seen" call for different operator actions. A failed check-run read never
|
|
627
|
+
* takes away a status's credit — reviewers that still post statuses keep
|
|
628
|
+
* reading exactly as they did.
|
|
629
|
+
* @param {object} options - Reading inputs.
|
|
630
|
+
* @param {ReviewerShape} options.reviewer - One declared reviewer.
|
|
631
|
+
* @param {ReadonlyArray<{context?: string, state?: string, description?: string}>|null} [options.statuses] -
|
|
632
|
+
* `.statuses` from `repos/{owner}/{repo}/commits/{sha}/status`, newest first; null when the read failed.
|
|
633
|
+
* @param {ReadonlyArray<RawCheckRun>|null} [options.checkRuns] - Runs read for the reviewer's name; null when the read failed, absent when none were read.
|
|
634
|
+
* @param {string} [options.sha] - The head commit, which every check run must report on.
|
|
635
|
+
* @returns {EvidenceReading} The reading.
|
|
636
|
+
*/
|
|
637
|
+
export function readReviewerEvidence({ reviewer, statuses, checkRuns, sha }) {
|
|
638
|
+
const { run, ignored } = selectReviewerCheckRun({
|
|
639
|
+
reviewer,
|
|
640
|
+
runs: checkRuns,
|
|
641
|
+
sha,
|
|
642
|
+
});
|
|
643
|
+
if (run !== null) {
|
|
291
644
|
return {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
reading: EVIDENCE_READINGS.reviewed,
|
|
295
|
-
observed,
|
|
296
|
-
state,
|
|
645
|
+
...readCheckRunEvidence({ reviewer, run }),
|
|
646
|
+
ignoredCheckRuns: ignored,
|
|
297
647
|
};
|
|
298
648
|
}
|
|
649
|
+
const fromStatus = readStatusEvidence({ reviewer, statuses: statuses ?? [] });
|
|
650
|
+
const unreadable =
|
|
651
|
+
fromStatus.reading === EVIDENCE_READINGS.absent &&
|
|
652
|
+
(checkRuns === null || statuses === null);
|
|
299
653
|
return {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
? EVIDENCE_READINGS.noWork
|
|
304
|
-
: EVIDENCE_READINGS.unrecognised,
|
|
305
|
-
observed,
|
|
306
|
-
state,
|
|
654
|
+
...fromStatus,
|
|
655
|
+
...(unreadable ? { reading: EVIDENCE_READINGS.unreadable } : {}),
|
|
656
|
+
ignoredCheckRuns: ignored,
|
|
307
657
|
};
|
|
308
658
|
}
|
|
309
659
|
|
|
@@ -416,16 +766,21 @@ export function reviewStanding({ reviews, sha }) {
|
|
|
416
766
|
* this project declared no third-party reviewer, which is a legitimate state
|
|
417
767
|
* that must be SAID rather than passed through in silence.
|
|
418
768
|
* @param {object} options - Decision inputs.
|
|
419
|
-
* @param {Array<{gateId: string, context: string, proof: string[]}>} options.reviewers - Declared reviewers.
|
|
420
|
-
* @param {ReadonlyArray<object
|
|
769
|
+
* @param {Array<{gateId: string, context: string, proof: string[], postedBy?: unknown}>} options.reviewers - Declared reviewers.
|
|
770
|
+
* @param {ReadonlyArray<object>|null} options.statuses - Statuses on the head commit; null when the read failed.
|
|
771
|
+
* @param {Record<string, ReadonlyArray<RawCheckRun>|null>} [options.checkRuns] -
|
|
772
|
+
* Check runs read for each reviewer's context, keyed by that context; a null
|
|
773
|
+
* entry is a read that failed, and a missing key is a reviewer whose runs
|
|
774
|
+
* were not read.
|
|
421
775
|
* @param {ReadonlyArray<{body?: string}>} options.comments - Issue comments on the pull request.
|
|
422
776
|
* @param {string} options.sha - The head commit.
|
|
423
777
|
* @param {boolean} [options.isPullRequestHead] - Whether an open pull request has this commit as its head.
|
|
424
|
-
* @returns {{outcome: string, sha: string, readings: Array<
|
|
778
|
+
* @returns {{outcome: string, sha: string, readings: Array<EvidenceReading & {gateId: string, substituted: boolean}>}} The verdict.
|
|
425
779
|
*/
|
|
426
780
|
export function reviewEvidenceVerdict({
|
|
427
781
|
reviewers,
|
|
428
782
|
statuses,
|
|
783
|
+
checkRuns,
|
|
429
784
|
comments,
|
|
430
785
|
sha,
|
|
431
786
|
isPullRequestHead = true,
|
|
@@ -445,7 +800,15 @@ export function reviewEvidenceVerdict({
|
|
|
445
800
|
};
|
|
446
801
|
}
|
|
447
802
|
const readings = reviewers.map(reviewer => {
|
|
448
|
-
const reading = readReviewerEvidence({
|
|
803
|
+
const reading = readReviewerEvidence({
|
|
804
|
+
reviewer,
|
|
805
|
+
statuses,
|
|
806
|
+
checkRuns:
|
|
807
|
+
checkRuns && Object.hasOwn(checkRuns, reviewer.context)
|
|
808
|
+
? checkRuns[reviewer.context]
|
|
809
|
+
: undefined,
|
|
810
|
+
sha,
|
|
811
|
+
});
|
|
449
812
|
return {
|
|
450
813
|
...reading,
|
|
451
814
|
gateId: reviewer.gateId,
|
|
@@ -474,9 +837,22 @@ export function reviewEvidenceVerdict({
|
|
|
474
837
|
return { outcome: EVIDENCE_OUTCOMES.unsatisfied, sha, readings };
|
|
475
838
|
}
|
|
476
839
|
|
|
840
|
+
/**
|
|
841
|
+
* A reading as the report prints it.
|
|
842
|
+
* @typedef {object} ReportedReading
|
|
843
|
+
* @property {string} context - The reviewer's context.
|
|
844
|
+
* @property {boolean} reviewed - Whether it is evidence of a review.
|
|
845
|
+
* @property {string} reading - One of {@link EVIDENCE_READINGS}.
|
|
846
|
+
* @property {string|null} observed - The words that decided, verbatim.
|
|
847
|
+
* @property {string|null} state - The state, conclusion or lifecycle seen.
|
|
848
|
+
* @property {string|null} [source] - One of {@link EVIDENCE_SOURCES}.
|
|
849
|
+
* @property {boolean} [substituted] - Whether a substitute stands in.
|
|
850
|
+
* @property {Array<{id: unknown, reason: string}>} [ignoredCheckRuns] - Runs set aside, and why.
|
|
851
|
+
*/
|
|
852
|
+
|
|
477
853
|
/**
|
|
478
854
|
* One operator-readable line per reviewer, plus the headline.
|
|
479
|
-
* @param {{outcome: string, sha: string, readings:
|
|
855
|
+
* @param {{outcome: string, sha: string, readings: ReadonlyArray<ReportedReading>}} verdict - A verdict.
|
|
480
856
|
* @returns {string} The report.
|
|
481
857
|
*/
|
|
482
858
|
export function humanReport(verdict) {
|
|
@@ -491,8 +867,8 @@ export function humanReport(verdict) {
|
|
|
491
867
|
}
|
|
492
868
|
if (verdict.outcome === EVIDENCE_OUTCOMES.notAConfiguredReviewer) {
|
|
493
869
|
return (
|
|
494
|
-
`NO-OP — the triggering status belongs to no declared reviewer.\n` +
|
|
495
|
-
`Reviewers ARE configured; this
|
|
870
|
+
`NO-OP — the triggering status or check run belongs to no declared reviewer.\n` +
|
|
871
|
+
`Reviewers ARE configured; this one is not one of theirs, so it ` +
|
|
496
872
|
`cannot change the answer. This is a no-op, NOT a pass.`
|
|
497
873
|
);
|
|
498
874
|
}
|
|
@@ -504,23 +880,49 @@ export function humanReport(verdict) {
|
|
|
504
880
|
);
|
|
505
881
|
}
|
|
506
882
|
const lines = verdict.readings.map(reading => {
|
|
507
|
-
const seen =
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if (reading.reviewed)
|
|
883
|
+
const seen = describeSeen(reading);
|
|
884
|
+
const ignored = (reading.ignoredCheckRuns ?? []).map(
|
|
885
|
+
entry => ` ignored check run ${String(entry.id)}: ${entry.reason}`
|
|
886
|
+
);
|
|
887
|
+
if (reading.reviewed) {
|
|
888
|
+
return [` reviewed ${reading.context}: ${seen}`, ...ignored].join(
|
|
889
|
+
"\n"
|
|
890
|
+
);
|
|
891
|
+
}
|
|
512
892
|
const stood = reading.substituted
|
|
513
893
|
? "a local adversarial review was posted in its place"
|
|
514
894
|
: "NOTHING stands in its place";
|
|
515
|
-
return
|
|
516
|
-
` NOT REVIEWED ${reading.context} (${reading.reading}): ${seen}
|
|
517
|
-
` observed: ${READING_SENTENCES[reading.reading]}
|
|
518
|
-
` ${stood}
|
|
519
|
-
|
|
895
|
+
return [
|
|
896
|
+
` NOT REVIEWED ${reading.context} (${reading.reading}): ${seen}`,
|
|
897
|
+
` observed: ${READING_SENTENCES[reading.reading]}`,
|
|
898
|
+
` ${stood}`,
|
|
899
|
+
...ignored,
|
|
900
|
+
].join("\n");
|
|
520
901
|
});
|
|
521
902
|
return `${verdict.outcome.toUpperCase()} at ${verdict.sha}\n${lines.join("\n")}`;
|
|
522
903
|
}
|
|
523
904
|
|
|
905
|
+
/**
|
|
906
|
+
* What was seen for one reviewer, in the words the report prints.
|
|
907
|
+
*
|
|
908
|
+
* A reading without a `source` — one built before check runs were read — is
|
|
909
|
+
* placed by whether it observed anything, which is how it was always printed.
|
|
910
|
+
* @param {{reading: string, observed: string|null, state: string|null, source?: string|null}} reading - One reading.
|
|
911
|
+
* @returns {string} The observation.
|
|
912
|
+
*/
|
|
913
|
+
function describeSeen(reading) {
|
|
914
|
+
if (reading.reading === EVIDENCE_READINGS.unreadable) {
|
|
915
|
+
return "its statuses or check runs could not be read";
|
|
916
|
+
}
|
|
917
|
+
const source =
|
|
918
|
+
reading.source ??
|
|
919
|
+
(reading.observed === null ? null : EVIDENCE_SOURCES.status);
|
|
920
|
+
const words = `${reading.state ?? "?"} — ${JSON.stringify(reading.observed)}`;
|
|
921
|
+
if (source === EVIDENCE_SOURCES.checkRun) return `check run ${words}`;
|
|
922
|
+
if (source === EVIDENCE_SOURCES.status) return words;
|
|
923
|
+
return "no status or check run posted";
|
|
924
|
+
}
|
|
925
|
+
|
|
524
926
|
/**
|
|
525
927
|
* Parse the CLI arguments.
|
|
526
928
|
* @param {string[]} argv - Arguments after the script path.
|
|
@@ -570,8 +972,11 @@ function splitFlag(arg) {
|
|
|
570
972
|
*
|
|
571
973
|
* `gh` is asked for a path rather than handed a query, and the child is bounded
|
|
572
974
|
* — a guard whose child never returned would otherwise report a verdict it
|
|
573
|
-
* never reached. A failed read returns `null
|
|
574
|
-
* "
|
|
975
|
+
* never reached. A failed read returns `null`. A failed pull-request read
|
|
976
|
+
* reads as "not a pull request head", and a failed status or check-run read is
|
|
977
|
+
* reported as unreadable rather than as evidence of anything — except that a
|
|
978
|
+
* failed check-run read leaves the reviewer's own status standing, so a host
|
|
979
|
+
* whose token cannot read check runs keeps reading statuses as before.
|
|
575
980
|
* @param {string} path - The API path.
|
|
576
981
|
* @returns {unknown} The parsed body, or null.
|
|
577
982
|
*/
|
|
@@ -591,13 +996,43 @@ function ghJson(path) {
|
|
|
591
996
|
* `repos/{repo}/commits/{sha}/status` and NOT `gh pr view --json
|
|
592
997
|
* statusCheckRollup` — verified, the rollup returns a `StatusContext` with no
|
|
593
998
|
* `description` key at all, so hollow green is invisible through it.
|
|
999
|
+
* A read that failed returns null rather than an empty list, so the reading
|
|
1000
|
+
* can say the statuses could not be read instead of claiming there were none.
|
|
594
1001
|
* @param {string} repo - `owner/repo`.
|
|
595
1002
|
* @param {string} sha - The commit.
|
|
596
|
-
* @returns {ReadonlyArray<object
|
|
1003
|
+
* @returns {ReadonlyArray<object>|null} The statuses, newest first, or null.
|
|
597
1004
|
*/
|
|
598
1005
|
function readStatuses(repo, sha) {
|
|
599
|
-
const body =
|
|
600
|
-
|
|
1006
|
+
const body = /** @type {{statuses?: unknown}|null} */ (
|
|
1007
|
+
ghJson(`repos/${repo}/commits/${sha}/status`)
|
|
1008
|
+
);
|
|
1009
|
+
return Array.isArray(body?.statuses) ? body.statuses : null;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* The check runs on one commit that carry one reviewer's name.
|
|
1014
|
+
*
|
|
1015
|
+
* Filtered by name at the source: a head here carries around sixty check runs,
|
|
1016
|
+
* past the endpoint's default page of thirty, so an unfiltered first page can
|
|
1017
|
+
* simply not contain the reviewer's run. A response that admits to more rows
|
|
1018
|
+
* than it returned is incomplete, and an incomplete read is reported as a
|
|
1019
|
+
* failed one — the newest run could be on the page that was not read.
|
|
1020
|
+
* @param {string} repo - `owner/repo`.
|
|
1021
|
+
* @param {string} sha - The commit.
|
|
1022
|
+
* @param {string} context - The reviewer's declared context, which is its run name.
|
|
1023
|
+
* @returns {ReadonlyArray<RawCheckRun>|null} The runs, or null when they could not all be read.
|
|
1024
|
+
*/
|
|
1025
|
+
function readCheckRuns(repo, sha, context) {
|
|
1026
|
+
const body =
|
|
1027
|
+
/** @type {{total_count?: unknown, check_runs?: unknown}|null} */ (
|
|
1028
|
+
ghJson(
|
|
1029
|
+
`repos/${repo}/commits/${sha}/check-runs?check_name=${encodeURIComponent(context)}&per_page=100`
|
|
1030
|
+
)
|
|
1031
|
+
);
|
|
1032
|
+
if (!Array.isArray(body?.check_runs)) return null;
|
|
1033
|
+
const total = body?.total_count;
|
|
1034
|
+
if (typeof total === "number" && total > body.check_runs.length) return null;
|
|
1035
|
+
return body.check_runs;
|
|
601
1036
|
}
|
|
602
1037
|
|
|
603
1038
|
/**
|
|
@@ -638,13 +1073,64 @@ function readComments(repo, numbers) {
|
|
|
638
1073
|
});
|
|
639
1074
|
}
|
|
640
1075
|
|
|
1076
|
+
/**
|
|
1077
|
+
* Which check-run reader a run uses.
|
|
1078
|
+
*
|
|
1079
|
+
* A caller that injected the status reader supplies the evidence itself, so it
|
|
1080
|
+
* gets no check runs rather than a live read mixed into its fixture.
|
|
1081
|
+
* @param {{statuses?: unknown, checkRuns?: (sha: string, context: string) => ReadonlyArray<RawCheckRun>|null}} io - Injected side effects.
|
|
1082
|
+
* @param {string} repo - `owner/repo`.
|
|
1083
|
+
* @returns {(sha: string, context: string) => ReadonlyArray<RawCheckRun>|null} The reader.
|
|
1084
|
+
*/
|
|
1085
|
+
function checkRunReader(io, repo) {
|
|
1086
|
+
if (io.checkRuns) return io.checkRuns;
|
|
1087
|
+
if (io.statuses) return () => [];
|
|
1088
|
+
return (sha, context) => readCheckRuns(repo, sha, context);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Which status reader a run uses — the same rule, the other way round.
|
|
1093
|
+
* @param {{statuses?: (sha: string) => ReadonlyArray<object>|null, checkRuns?: unknown}} io - Injected side effects.
|
|
1094
|
+
* @param {string} repo - `owner/repo`.
|
|
1095
|
+
* @returns {(sha: string) => ReadonlyArray<object>|null} The reader.
|
|
1096
|
+
*/
|
|
1097
|
+
function statusReader(io, repo) {
|
|
1098
|
+
if (io.statuses) return io.statuses;
|
|
1099
|
+
if (io.checkRuns) return () => [];
|
|
1100
|
+
return sha => readStatuses(repo, sha);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Read each declared reviewer's check runs once, keyed by its context.
|
|
1105
|
+
*
|
|
1106
|
+
* Two gates may await one context; that is one read, not two.
|
|
1107
|
+
* @param {ReadonlyArray<{context: string}>} reviewers - Declared reviewers.
|
|
1108
|
+
* @param {string} sha - The head commit.
|
|
1109
|
+
* @param {(sha: string, context: string) => ReadonlyArray<RawCheckRun>|null} read - The reader.
|
|
1110
|
+
* @returns {Record<string, ReadonlyArray<RawCheckRun>|null>} Runs per context; null for a failed read.
|
|
1111
|
+
*/
|
|
1112
|
+
function checkRunsByContext(reviewers, sha, read) {
|
|
1113
|
+
/** @type {Record<string, ReadonlyArray<RawCheckRun>|null>} */
|
|
1114
|
+
const runs = Object.create(null);
|
|
1115
|
+
for (const { context } of reviewers) {
|
|
1116
|
+
if (Object.hasOwn(runs, context)) continue;
|
|
1117
|
+
runs[context] = read(sha, context);
|
|
1118
|
+
}
|
|
1119
|
+
return runs;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
641
1122
|
/**
|
|
642
1123
|
* The CLI body.
|
|
643
1124
|
* @param {string[]} argv - Arguments after the script path.
|
|
644
1125
|
* @param {object} [io] - Injected side effects, for tests.
|
|
645
1126
|
* @param {object} [io.gates] - The gates block, read from disk when absent.
|
|
646
1127
|
* @param {(sha: string) => number[]} [io.pulls] - Head-pull-request reader.
|
|
647
|
-
* @param {(sha: string) => ReadonlyArray<object
|
|
1128
|
+
* @param {(sha: string) => ReadonlyArray<object>|null} [io.statuses] - Status reader.
|
|
1129
|
+
* @param {(sha: string, context: string) => ReadonlyArray<RawCheckRun>|null} [io.checkRuns] -
|
|
1130
|
+
* Check-run reader for one reviewer's name. When a caller injects either
|
|
1131
|
+
* reader and not the other, the other reads nothing: an injected reader
|
|
1132
|
+
* means the caller supplies the evidence, and mixing a live read into it
|
|
1133
|
+
* would judge a fixture against the network.
|
|
648
1134
|
* @param {(numbers: number[]) => ReadonlyArray<object>} [io.comments] - Comment reader.
|
|
649
1135
|
* @param {{write: (text: string) => void}} [io.out] - Stdout.
|
|
650
1136
|
* @param {{write: (text: string) => void}} [io.err] - Stderr.
|
|
@@ -661,9 +1147,10 @@ export function main(argv, io = {}) {
|
|
|
661
1147
|
const gates = io.gates ?? readGates(opts.root).gates;
|
|
662
1148
|
const reviewers = configuredReviewers({ gates });
|
|
663
1149
|
const repo = opts.repo ?? process.env.GITHUB_REPOSITORY ?? "";
|
|
664
|
-
// A status from some other app cannot change this answer, and
|
|
665
|
-
// one would read a reviewer as absent merely because it had
|
|
666
|
-
// a finding on every pull request, which is how a guard
|
|
1150
|
+
// A status or check run from some other app cannot change this answer, and
|
|
1151
|
+
// evaluating on one would read a reviewer as absent merely because it had
|
|
1152
|
+
// not posted yet — a finding on every pull request, which is how a guard
|
|
1153
|
+
// gets deleted.
|
|
667
1154
|
const woken =
|
|
668
1155
|
reviewers.length === 0 ||
|
|
669
1156
|
opts.context === null ||
|
|
@@ -684,9 +1171,10 @@ export function main(argv, io = {}) {
|
|
|
684
1171
|
}
|
|
685
1172
|
: reviewEvidenceVerdict({
|
|
686
1173
|
reviewers,
|
|
687
|
-
statuses: evaluated
|
|
688
|
-
|
|
689
|
-
|
|
1174
|
+
statuses: evaluated ? statusReader(io, repo)(opts.sha) : [],
|
|
1175
|
+
checkRuns: evaluated
|
|
1176
|
+
? checkRunsByContext(reviewers, opts.sha, checkRunReader(io, repo))
|
|
1177
|
+
: {},
|
|
690
1178
|
comments: evaluated
|
|
691
1179
|
? (io.comments ?? (numbers => readComments(repo, numbers)))(pulls)
|
|
692
1180
|
: [],
|