@codyswann/lisa 4.42.2 → 4.42.3
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-orphaned-branches.mjs +458 -29
- package/dist/cli/doctor-reusable-workflow-refs.d.ts +3 -3
- package/dist/cli/doctor-reusable-workflow-refs.d.ts.map +1 -1
- package/dist/cli/doctor-reusable-workflow-refs.js +46 -74
- package/dist/cli/doctor-reusable-workflow-refs.js.map +1 -1
- package/dist/cli/version.d.ts +17 -0
- package/dist/cli/version.d.ts.map +1 -1
- package/dist/cli/version.js +34 -0
- package/dist/cli/version.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +18 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/lisa-release-pin.d.ts +63 -0
- package/dist/core/lisa-release-pin.d.ts.map +1 -0
- package/dist/core/lisa-release-pin.js +147 -0
- package/dist/core/lisa-release-pin.js.map +1 -0
- package/dist/core/lisa.d.ts.map +1 -1
- package/dist/core/lisa.js +4 -1
- package/dist/core/lisa.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/reusable-workflow-pin.d.ts +113 -0
- package/dist/core/reusable-workflow-pin.d.ts.map +1 -0
- package/dist/core/reusable-workflow-pin.js +165 -0
- package/dist/core/reusable-workflow-pin.js.map +1 -0
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +14 -7
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/migrations/ensure-nightly-e2e-workflow-pins.d.ts +16 -36
- package/dist/migrations/ensure-nightly-e2e-workflow-pins.d.ts.map +1 -1
- package/dist/migrations/ensure-nightly-e2e-workflow-pins.js +33 -76
- package/dist/migrations/ensure-nightly-e2e-workflow-pins.js.map +1 -1
- package/dist/migrations/ensure-pinned-reusable-workflow-refs.d.ts +143 -0
- package/dist/migrations/ensure-pinned-reusable-workflow-refs.d.ts.map +1 -0
- package/dist/migrations/ensure-pinned-reusable-workflow-refs.js +293 -0
- package/dist/migrations/ensure-pinned-reusable-workflow-refs.js.map +1 -0
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/migrations/index.js +5 -0
- package/dist/migrations/index.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 +27 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-git-submit-pr/SKILL.md +16 -0
- package/plugins/lisa/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
- package/plugins/lisa/skills/lisa-git-submit-pr/SKILL.md +16 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
- package/plugins/lisa-agy/skills/lisa-git-submit-pr/SKILL.md +16 -0
- 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 +27 -0
- package/plugins/lisa-copilot/skills/lisa-git-submit-pr/SKILL.md +16 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-drive-pr-to-merge/SKILL.md +27 -0
- package/plugins/lisa-cursor/skills/lisa-git-submit-pr/SKILL.md +16 -0
- 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 +27 -0
- package/plugins/src/base/skills/lisa-git-submit-pr/SKILL.md +16 -0
- package/scripts/lisa-update-local.sh +1 -0
- package/typescript/copy-overwrite/.github/GITHUB_ACTIONS.md +6 -1
- package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +370 -4
- package/typescript/create-only/.github/workflows/review-evidence.yml +20 -0
|
@@ -344,6 +344,7 @@ export const VIOLATIONS = Object.freeze({
|
|
|
344
344
|
absent: "absent_required_check",
|
|
345
345
|
reviewWaived: "review_evidence_waived",
|
|
346
346
|
reviewUnsatisfied: "review_evidence_unsatisfied",
|
|
347
|
+
reviewCarried: "review_evidence_carried_unreviewed",
|
|
347
348
|
});
|
|
348
349
|
|
|
349
350
|
/**
|
|
@@ -475,6 +476,12 @@ export const NEVER_BLOCKING = Object.freeze([
|
|
|
475
476
|
// operator must see that — and it never fails the build, which is the whole
|
|
476
477
|
// content of the owner's ruling on CodySwannGT/lisa#3221.
|
|
477
478
|
VIOLATIONS.reviewWaived,
|
|
479
|
+
// A carried finding is about ANOTHER pull request. Reddening this one for a
|
|
480
|
+
// condition its own author cannot reach would punish the batch for the
|
|
481
|
+
// constituent, and #3658 is a VISIBILITY defect, not an enforcement one: the
|
|
482
|
+
// constituents' own gates already went red, on branches no ruleset watches.
|
|
483
|
+
// What was missing was that the batch rendered green anyway.
|
|
484
|
+
VIOLATIONS.reviewCarried,
|
|
478
485
|
]);
|
|
479
486
|
|
|
480
487
|
/**
|
|
@@ -1863,6 +1870,103 @@ export function evaluateReviewGate(declaration, checks, options = {}) {
|
|
|
1863
1870
|
return { violations, states, conditions, descriptions, checked };
|
|
1864
1871
|
}
|
|
1865
1872
|
|
|
1873
|
+
/**
|
|
1874
|
+
* How many carried pull requests this will account for before it gives up.
|
|
1875
|
+
*
|
|
1876
|
+
* A cap that SILENTLY truncates is the fail-open this file exists to refuse, so
|
|
1877
|
+
* exceeding it is reported as "not read" rather than as the part that was read.
|
|
1878
|
+
* Fifty is well above any batch observed here — the drain that produced #3658
|
|
1879
|
+
* carried seven — and it exists so a pathological history cannot turn one job
|
|
1880
|
+
* into hundreds of API reads.
|
|
1881
|
+
*/
|
|
1882
|
+
export const CARRIED_PULL_REQUEST_LIMIT = 50;
|
|
1883
|
+
|
|
1884
|
+
/** Why a carried finding matters, appended to every one of them. */
|
|
1885
|
+
const CARRIED_WHY =
|
|
1886
|
+
"A batch integration pull request is the only place this gap is enforceable. The constituent's own review gate ran on its own pull request, whose base branch no ruleset watches, so its red changed nothing; merging this one carries that unreviewed diff into the default branch. Read the constituent before recording this batch as reviewed.";
|
|
1887
|
+
|
|
1888
|
+
/**
|
|
1889
|
+
* Runs the review gate over the pull requests THIS pull request carries.
|
|
1890
|
+
*
|
|
1891
|
+
* ## Why a batch needs its own arm (#3658)
|
|
1892
|
+
*
|
|
1893
|
+
* {@link evaluateReviewGate} reads one commit: this pull request's head. That is
|
|
1894
|
+
* the right commit for the pull request's own diff and the wrong one for a
|
|
1895
|
+
* batch, because a stack integration pull request's head carries the merges of
|
|
1896
|
+
* every constituent and none of their review statuses. MEASURED 2026-09-03 on
|
|
1897
|
+
* one queue drain: four constituents based on a batching branch each reported
|
|
1898
|
+
* `Review skipped: reviews are disabled for this base branch`, each one's own
|
|
1899
|
+
* review-evidence run went red, and every one of those reds landed on a branch
|
|
1900
|
+
* outside the rulesets' ref conditions — so nothing was watching. The
|
|
1901
|
+
* integration pull request that carried all of them into the default branch was
|
|
1902
|
+
* the one place a ruleset applied, and it read only itself.
|
|
1903
|
+
*
|
|
1904
|
+
* Pure, and injected with already-read rows, so the property that matters — a
|
|
1905
|
+
* batch carrying an unreviewed constituent never renders as a reviewed one — is
|
|
1906
|
+
* testable without a GitHub API.
|
|
1907
|
+
*
|
|
1908
|
+
* @param {object} declaration - The per-repo declaration
|
|
1909
|
+
* @param {ReadonlyArray<{number: number|string, headSha?: string, checks?: ReadonlyArray<object>, unreadable?: string}>} carried -
|
|
1910
|
+
* One entry per carried pull request; `unreadable` marks one whose evidence
|
|
1911
|
+
* could not be fetched, which counts AGAINST the batch rather than for it
|
|
1912
|
+
* @returns {{violations: object[], unreviewed: string[], reviewed: number}} Findings and the tally
|
|
1913
|
+
*/
|
|
1914
|
+
export function evaluateCarriedReview(declaration, carried) {
|
|
1915
|
+
const declared = Object.entries(declaration.evidence_bearing_checks ?? {});
|
|
1916
|
+
const violations = [];
|
|
1917
|
+
const unreviewed = [];
|
|
1918
|
+
let reviewed = 0;
|
|
1919
|
+
|
|
1920
|
+
for (const constituent of carried) {
|
|
1921
|
+
const label = `#${constituent.number}`;
|
|
1922
|
+
if (constituent.unreadable) {
|
|
1923
|
+
unreviewed.push(label);
|
|
1924
|
+
violations.push({
|
|
1925
|
+
kind: VIOLATIONS.reviewCarried,
|
|
1926
|
+
token: label,
|
|
1927
|
+
contexts: [],
|
|
1928
|
+
message: `This pull request CARRIES ${label}, whose review evidence could NOT be read (${constituent.unreadable}). An unread constituent is not a reviewed one. ${CARRIED_WHY}`,
|
|
1929
|
+
});
|
|
1930
|
+
continue;
|
|
1931
|
+
}
|
|
1932
|
+
const failing = [];
|
|
1933
|
+
for (const [name, entry] of declared) {
|
|
1934
|
+
const vocabulary =
|
|
1935
|
+
typeof entry === "object" && entry !== null ? entry : {};
|
|
1936
|
+
const found = (constituent.checks ?? []).find(
|
|
1937
|
+
check => check.name === name
|
|
1938
|
+
);
|
|
1939
|
+
const verdict = reviewGateState(
|
|
1940
|
+
found === undefined
|
|
1941
|
+
? { present: false }
|
|
1942
|
+
: {
|
|
1943
|
+
present: true,
|
|
1944
|
+
state: found.state,
|
|
1945
|
+
description: found.description,
|
|
1946
|
+
},
|
|
1947
|
+
vocabulary
|
|
1948
|
+
);
|
|
1949
|
+
if (verdict.state !== REVIEW_GATE_STATES.satisfied)
|
|
1950
|
+
failing.push({ name, verdict });
|
|
1951
|
+
}
|
|
1952
|
+
if (failing.length === 0) {
|
|
1953
|
+
reviewed += 1;
|
|
1954
|
+
continue;
|
|
1955
|
+
}
|
|
1956
|
+
unreviewed.push(label);
|
|
1957
|
+
for (const { name, verdict } of failing) {
|
|
1958
|
+
violations.push({
|
|
1959
|
+
kind: VIOLATIONS.reviewCarried,
|
|
1960
|
+
token: label,
|
|
1961
|
+
contexts: [name],
|
|
1962
|
+
message: `This pull request CARRIES ${label}, whose \`${name}\` ${verdict.why}${citeHeadSha(constituent.headSha)} ${CARRIED_WHY}`,
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
return { violations, unreviewed, reviewed };
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1866
1970
|
/**
|
|
1867
1971
|
* The check-run conclusion each review-gate verdict is published under.
|
|
1868
1972
|
*
|
|
@@ -1928,6 +2032,27 @@ function waiveRateSuffix(rate) {
|
|
|
1928
2032
|
: ` · waived on ${rate.waived} of the last ${rate.sampled} merged pull requests`;
|
|
1929
2033
|
}
|
|
1930
2034
|
|
|
2035
|
+
/**
|
|
2036
|
+
* The carried-batch sentence appended to a verdict title, or nothing.
|
|
2037
|
+
*
|
|
2038
|
+
* NUMBERS ONLY, no vendor descriptions. The title is capped at
|
|
2039
|
+
* {@link REVIEW_VERDICT_TITLE_LIMIT}, and one quoted skip string is long enough
|
|
2040
|
+
* to push the constituent list out of it — which would lose exactly the fact
|
|
2041
|
+
* this exists to render. The quotes live in the findings, where nothing
|
|
2042
|
+
* truncates them.
|
|
2043
|
+
*
|
|
2044
|
+
* @param {{unreviewed?: readonly string[], unread?: string}|undefined} carried - The carried tally
|
|
2045
|
+
* @returns {string} The sentence, or an empty string when the batch is accounted for
|
|
2046
|
+
*/
|
|
2047
|
+
function carriedSuffix(carried) {
|
|
2048
|
+
if (carried === undefined) return "";
|
|
2049
|
+
if (carried.unread !== undefined && carried.unread !== "")
|
|
2050
|
+
return " · the pull requests this one CARRIES could NOT be enumerated, so nothing accounts for their review";
|
|
2051
|
+
const unreviewed = carried.unreviewed ?? [];
|
|
2052
|
+
if (unreviewed.length === 0) return "";
|
|
2053
|
+
return ` · carries ${unreviewed.length} UNREVIEWED pull request${unreviewed.length === 1 ? "" : "s"}: ${unreviewed.join(", ")}`;
|
|
2054
|
+
}
|
|
2055
|
+
|
|
1931
2056
|
/**
|
|
1932
2057
|
* Renders one merge-time verdict for every declared evidence-bearing check.
|
|
1933
2058
|
*
|
|
@@ -1948,9 +2073,14 @@ function waiveRateSuffix(rate) {
|
|
|
1948
2073
|
* the one path where it mattered, a red check blocking a merge, was the one
|
|
1949
2074
|
* path that never said it. The conditions are carried here for that reason.
|
|
1950
2075
|
*
|
|
1951
|
-
*
|
|
2076
|
+
* AND SO IS WHAT THE PULL REQUEST CARRIES (#3658), for the same reason one step
|
|
2077
|
+
* out: every state above is read from THIS pull request's head, which is the
|
|
2078
|
+
* one commit a batch integration pull request does not speak for.
|
|
2079
|
+
*
|
|
2080
|
+
* @param {{states?: Record<string, string>, conditions?: Record<string, string>, descriptions?: Record<string, string>, refusal?: {kind: string}|null, waiveRate?: {waived: number, sampled: number}, carried?: {unreviewed?: readonly string[], reviewed?: number, unread?: string}}} reading -
|
|
1952
2081
|
* The gate's per-check states and conditions, the descriptions they were read
|
|
1953
|
-
* from, any refusal,
|
|
2082
|
+
* from, any refusal, an optional sampled waive rate, and the tally for the
|
|
2083
|
+
* pull requests this one CARRIES
|
|
1954
2084
|
* @returns {{verdict: string, conclusion: string, title: string}} What to publish
|
|
1955
2085
|
*/
|
|
1956
2086
|
export function reviewGateVerdict(reading = {}) {
|
|
@@ -1958,7 +2088,8 @@ export function reviewGateVerdict(reading = {}) {
|
|
|
1958
2088
|
const conditions = reading.conditions ?? {};
|
|
1959
2089
|
const descriptions = reading.descriptions ?? {};
|
|
1960
2090
|
const names = Object.keys(states);
|
|
1961
|
-
const
|
|
2091
|
+
const carried = carriedSuffix(reading.carried);
|
|
2092
|
+
const suffix = `${carried}${waiveRateSuffix(reading.waiveRate)}`;
|
|
1962
2093
|
|
|
1963
2094
|
if (reading.refusal || names.length === 0) {
|
|
1964
2095
|
return {
|
|
@@ -2033,6 +2164,28 @@ export function reviewGateVerdict(reading = {}) {
|
|
|
2033
2164
|
};
|
|
2034
2165
|
}
|
|
2035
2166
|
|
|
2167
|
+
// #3658. Everything above judged THIS pull request's own head commit, which
|
|
2168
|
+
// is the only commit a batch integration pull request does not speak for. A
|
|
2169
|
+
// batch whose own review completed while it carries constituents nothing read
|
|
2170
|
+
// is the exact indistinguishability #3639 removed one level down: green here
|
|
2171
|
+
// and green for a fully reviewed pull request, character for character.
|
|
2172
|
+
//
|
|
2173
|
+
// It renders `waived`, never `unsatisfied`, and the cap is deliberate. The
|
|
2174
|
+
// constituents' own gates already reached the failing verdict on their own
|
|
2175
|
+
// pull requests; re-failing the batch would redden every integration pull
|
|
2176
|
+
// request for a vendor condition its author cannot reach, which is the
|
|
2177
|
+
// "gate that gets deleted" failure this file names twice already. The batch
|
|
2178
|
+
// stops looking reviewed without acquiring the power to block.
|
|
2179
|
+
if (carried !== "") {
|
|
2180
|
+
return {
|
|
2181
|
+
verdict: REVIEW_GATE_STATES.waived,
|
|
2182
|
+
conclusion: REVIEW_VERDICT_CONCLUSIONS.waived,
|
|
2183
|
+
title: fitTitle(
|
|
2184
|
+
`CARRIED UNREVIEWED — this pull request's own review ran, but the work it carries was not reviewed with it${suffix}`
|
|
2185
|
+
),
|
|
2186
|
+
};
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2036
2189
|
return {
|
|
2037
2190
|
verdict: REVIEW_GATE_STATES.satisfied,
|
|
2038
2191
|
conclusion: REVIEW_VERDICT_CONCLUSIONS.satisfied,
|
|
@@ -2153,6 +2306,193 @@ export function sampleWaiveRate(declaration, limit, repo, fetch) {
|
|
|
2153
2306
|
}
|
|
2154
2307
|
}
|
|
2155
2308
|
|
|
2309
|
+
/**
|
|
2310
|
+
* How many merge commits this will look behind before giving up on the batch.
|
|
2311
|
+
*
|
|
2312
|
+
* Each one costs a REST read, and an ordinary pull request has none, so the
|
|
2313
|
+
* ceiling only ever binds on a history nobody could review by hand either.
|
|
2314
|
+
*/
|
|
2315
|
+
export const CARRIED_MERGE_COMMIT_LIMIT = 200;
|
|
2316
|
+
|
|
2317
|
+
/**
|
|
2318
|
+
* Refuses a commit list that the pull-request endpoint silently truncated.
|
|
2319
|
+
*
|
|
2320
|
+
* `GET /repos/{owner}/{repo}/pulls/{pr}/commits` stops at **250 commits** and
|
|
2321
|
+
* does not paginate past it — `--paginate` gets the same 250. A caller that
|
|
2322
|
+
* reads the short list and carries on has enumerated a PARTIAL batch and cannot
|
|
2323
|
+
* tell that it did, which is precisely the fail-open this whole file exists to
|
|
2324
|
+
* refuse: the missing merge commits are constituents nobody accounted for, and
|
|
2325
|
+
* the batch would render as though they had been.
|
|
2326
|
+
*
|
|
2327
|
+
* So the count is checked against the pull request's own `commits` figure,
|
|
2328
|
+
* which comes from a different endpoint and is not subject to the ceiling. A
|
|
2329
|
+
* mismatch is reported as UNREAD, never as the part that was read. An
|
|
2330
|
+
* unreadable figure is also unread — without it there is no way to tell a
|
|
2331
|
+
* complete list from a truncated one, and guessing in the permissive direction
|
|
2332
|
+
* is the whole defect.
|
|
2333
|
+
*
|
|
2334
|
+
* Pure and exported so the refusal can be exercised without a 251-commit pull
|
|
2335
|
+
* request, which is not a fixture anybody can reasonably build.
|
|
2336
|
+
*
|
|
2337
|
+
* @param {string|number} pr - The pull request, named in the message
|
|
2338
|
+
* @param {number} enumerated - How many commits the list endpoint returned
|
|
2339
|
+
* @param {unknown} declared - The pull request's own commit count
|
|
2340
|
+
* @returns {void}
|
|
2341
|
+
* @throws {Error} When the counts disagree or the declared count is unreadable
|
|
2342
|
+
*/
|
|
2343
|
+
export function assertCompleteCommitList(pr, enumerated, declared) {
|
|
2344
|
+
// `Number("")` is 0, not NaN — an empty answer from `gh` would otherwise read
|
|
2345
|
+
// as "this pull request has zero commits" and produce the TRUNCATED message
|
|
2346
|
+
// for what is really an unreadable count. Two different facts, two messages.
|
|
2347
|
+
const raw = String(declared ?? "").trim();
|
|
2348
|
+
const total = raw === "" ? Number.NaN : Number(raw);
|
|
2349
|
+
if (!Number.isInteger(total) || total < 0) {
|
|
2350
|
+
throw new Error(
|
|
2351
|
+
`could not read how many commits PR ${pr} has (${JSON.stringify(declared)}), so nothing can tell a complete commit list from a truncated one`
|
|
2352
|
+
);
|
|
2353
|
+
}
|
|
2354
|
+
if (enumerated !== total) {
|
|
2355
|
+
throw new Error(
|
|
2356
|
+
`enumerated ${enumerated} of the ${total} commits on PR ${pr} — \`/pulls/{pr}/commits\` stops at 250 even under --paginate, so this batch is TRUNCATED and cannot be accounted for`
|
|
2357
|
+
);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
/**
|
|
2362
|
+
* Enumerates the pull requests this pull request CARRIES.
|
|
2363
|
+
*
|
|
2364
|
+
* The merge commits in a pull request's own commit list are what a batch is
|
|
2365
|
+
* made of, and `/commits/{sha}/pulls` resolves each one to the pull request it
|
|
2366
|
+
* brought in, head SHA included. MEASURED 2026-09-03 against the integration
|
|
2367
|
+
* pull request that surfaced #3658: every constituent resolved, including one
|
|
2368
|
+
* reached through a back-merge of the batching branch into a constituent
|
|
2369
|
+
* branch, which deduplicates against the constituent it already found.
|
|
2370
|
+
*
|
|
2371
|
+
* NO BRANCH-NAME MATCHING ANYWHERE, deliberately. Matching a batching-branch
|
|
2372
|
+
* pattern would be an allowlist of bases, which is the shape the convention can
|
|
2373
|
+
* rename its way out of without anybody noticing. Merge commits are what
|
|
2374
|
+
* carrying actually IS, so this generalises to any batched pull request and
|
|
2375
|
+
* cannot be dodged by naming the branch something else.
|
|
2376
|
+
*
|
|
2377
|
+
* A pull request that merged its own base back in yields merge commits that
|
|
2378
|
+
* resolve to ITSELF, which is filtered out — so an ordinary pull request
|
|
2379
|
+
* reports an empty batch rather than a false one.
|
|
2380
|
+
*
|
|
2381
|
+
* @param {string|number} pr - The pull request whose carried work to enumerate
|
|
2382
|
+
* @param {string} [repo] - `OWNER/NAME`; defaults to the current repository
|
|
2383
|
+
* @returns {Array<{number: number, headSha: string}>} One entry per carried pull request
|
|
2384
|
+
* @throws {Error} When the slug cannot be resolved, `gh` cannot answer, the commit list was truncated (see {@link assertCompleteCommitList}), or the batch exceeds {@link CARRIED_MERGE_COMMIT_LIMIT}
|
|
2385
|
+
*/
|
|
2386
|
+
export function fetchCarriedPullRequests(pr, repo) {
|
|
2387
|
+
const slug = resolveRepoSlug(repo);
|
|
2388
|
+
if (slug === undefined) {
|
|
2389
|
+
throw new Error(
|
|
2390
|
+
`check-skipped-required-checks: cannot enumerate what PR ${pr} carries without an OWNER/NAME. Pass \`--repo=OWNER/NAME\`, or set GITHUB_REPOSITORY.`
|
|
2391
|
+
);
|
|
2392
|
+
}
|
|
2393
|
+
// Read BEFORE the list, so a pull request that grows mid-read is caught by
|
|
2394
|
+
// the comparison rather than hidden by it.
|
|
2395
|
+
const declaredCommits = boundedExecFileSync(
|
|
2396
|
+
"gh",
|
|
2397
|
+
["api", `repos/${slug}/pulls/${pr}`, "--jq", ".commits"],
|
|
2398
|
+
{ encoding: "utf8" }
|
|
2399
|
+
).trim();
|
|
2400
|
+
const commits = ghApiPaginatedArray(
|
|
2401
|
+
`repos/${slug}/pulls/${pr}/commits?per_page=100`,
|
|
2402
|
+
"[.[] | {sha: .sha, parents: (.parents | length)}]"
|
|
2403
|
+
);
|
|
2404
|
+
assertCompleteCommitList(pr, commits.length, declaredCommits);
|
|
2405
|
+
const merges = commits
|
|
2406
|
+
.filter(row => Number(row?.parents) > 1)
|
|
2407
|
+
.map(row => String(row?.sha));
|
|
2408
|
+
// Applied only to a list already proven COMPLETE. A ceiling checked against a
|
|
2409
|
+
// truncated list is a ceiling that never fires.
|
|
2410
|
+
if (merges.length > CARRIED_MERGE_COMMIT_LIMIT) {
|
|
2411
|
+
throw new Error(
|
|
2412
|
+
`PR ${pr} contains ${merges.length} merge commits, past the ${CARRIED_MERGE_COMMIT_LIMIT} this will look behind`
|
|
2413
|
+
);
|
|
2414
|
+
}
|
|
2415
|
+
const carried = new Map();
|
|
2416
|
+
for (const sha of merges) {
|
|
2417
|
+
const rows = ghApiPaginatedArray(
|
|
2418
|
+
`repos/${slug}/commits/${sha}/pulls?per_page=100`,
|
|
2419
|
+
"[.[] | {number: .number, headSha: .head.sha}]"
|
|
2420
|
+
);
|
|
2421
|
+
for (const row of rows) {
|
|
2422
|
+
const number = row?.number;
|
|
2423
|
+
const headSha = row?.headSha;
|
|
2424
|
+
if (number === undefined || headSha === undefined) continue;
|
|
2425
|
+
if (String(number) === String(pr)) continue;
|
|
2426
|
+
if (!carried.has(number)) carried.set(number, { number, headSha });
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
return [...carried.values()];
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
/**
|
|
2433
|
+
* Reads and judges the review evidence for everything this pull request carries.
|
|
2434
|
+
*
|
|
2435
|
+
* FAILING TO READ IS NOT PASSING. Every error path here — an unresolvable
|
|
2436
|
+
* repository, an unreadable commit, a batch past the cap — reports the batch as
|
|
2437
|
+
* UNREAD, which renders the same way an unreviewed one does. That is the
|
|
2438
|
+
* "refusing to report a clean scan that never ran" shape already in this tree,
|
|
2439
|
+
* applied to the one arm whose silence would otherwise read as approval.
|
|
2440
|
+
*
|
|
2441
|
+
* @param {object} declaration - The per-repo declaration
|
|
2442
|
+
* @param {string|number} pr - The pull request under inspection
|
|
2443
|
+
* @param {string} [repo] - `OWNER/NAME`; defaults to the current repository
|
|
2444
|
+
* @param {{fetchCarried?: Function, fetchCarriedChecks?: Function}} [options] - Injection seams
|
|
2445
|
+
* @returns {{violations: object[], unreviewed: string[], reviewed: number, unread?: string}} Findings and the tally
|
|
2446
|
+
*/
|
|
2447
|
+
export function readCarriedReview(declaration, pr, repo, options = {}) {
|
|
2448
|
+
const enumerate = options.fetchCarried ?? fetchCarriedPullRequests;
|
|
2449
|
+
const readChecks = options.fetchCarriedChecks ?? fetchChecksForCommit;
|
|
2450
|
+
/**
|
|
2451
|
+
* Renders "the batch was not read" as a finding, never as an empty one.
|
|
2452
|
+
*
|
|
2453
|
+
* @param {string} why - What stopped the read
|
|
2454
|
+
* @returns {{violations: object[], unreviewed: string[], reviewed: number, unread: string}} The unread result
|
|
2455
|
+
*/
|
|
2456
|
+
const unread = why => ({
|
|
2457
|
+
violations: [
|
|
2458
|
+
{
|
|
2459
|
+
kind: VIOLATIONS.reviewCarried,
|
|
2460
|
+
token: "carried",
|
|
2461
|
+
contexts: [],
|
|
2462
|
+
message: `The pull requests this one CARRIES could not be enumerated (${why}), so this run cannot say whether the work it brings was reviewed. Refusing to report a clean batch nothing read. ${CARRIED_WHY}`,
|
|
2463
|
+
},
|
|
2464
|
+
],
|
|
2465
|
+
unreviewed: [],
|
|
2466
|
+
reviewed: 0,
|
|
2467
|
+
unread: why,
|
|
2468
|
+
});
|
|
2469
|
+
|
|
2470
|
+
let listed;
|
|
2471
|
+
try {
|
|
2472
|
+
listed = enumerate(pr, repo);
|
|
2473
|
+
} catch (error) {
|
|
2474
|
+
return unread(error instanceof Error ? error.message : String(error));
|
|
2475
|
+
}
|
|
2476
|
+
if (listed.length > CARRIED_PULL_REQUEST_LIMIT) {
|
|
2477
|
+
return unread(
|
|
2478
|
+
`PR ${pr} carries ${listed.length} pull requests, past the ${CARRIED_PULL_REQUEST_LIMIT} this will account for`
|
|
2479
|
+
);
|
|
2480
|
+
}
|
|
2481
|
+
return evaluateCarriedReview(
|
|
2482
|
+
declaration,
|
|
2483
|
+
listed.map(entry => {
|
|
2484
|
+
try {
|
|
2485
|
+
return { ...entry, checks: readChecks(entry.headSha, repo) };
|
|
2486
|
+
} catch (error) {
|
|
2487
|
+
return {
|
|
2488
|
+
...entry,
|
|
2489
|
+
unreadable: error instanceof Error ? error.message : String(error),
|
|
2490
|
+
};
|
|
2491
|
+
}
|
|
2492
|
+
})
|
|
2493
|
+
);
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2156
2496
|
/**
|
|
2157
2497
|
* Publishes the verdict where a workflow step can read it.
|
|
2158
2498
|
*
|
|
@@ -2917,6 +3257,15 @@ export function inspectVacuity(argv, declaration, options = {}) {
|
|
|
2917
3257
|
// downstream sees a check row that looks identical either way (#3716).
|
|
2918
3258
|
waitExpired: read.settled === false,
|
|
2919
3259
|
});
|
|
3260
|
+
// #3658. Everything above judged ONE commit — this pull request's head — and
|
|
3261
|
+
// that is the one commit a batch integration pull request does not speak for.
|
|
3262
|
+
// Armed by the same flag as the gate itself, because this IS the gate,
|
|
3263
|
+
// applied to what the pull request CARRIES rather than to what it changes. A
|
|
3264
|
+
// pull request with no merge commits enumerates an empty batch and pays one
|
|
3265
|
+
// read for the privilege, so the ordinary case is untouched.
|
|
3266
|
+
const carried = requireReviewEvidence
|
|
3267
|
+
? readCarriedReview(declaration, pr, repo, options)
|
|
3268
|
+
: undefined;
|
|
2920
3269
|
// Sampled ONLY when this pull request is itself waived. The rate answers
|
|
2921
3270
|
// "is this the exception or the rule?", a question that only arises once a
|
|
2922
3271
|
// waiver is on the table, and every other run keeps the sampler's network
|
|
@@ -2940,17 +3289,20 @@ export function inspectVacuity(argv, declaration, options = {}) {
|
|
|
2940
3289
|
...evaluated.violations,
|
|
2941
3290
|
...absent.violations,
|
|
2942
3291
|
...gate.violations,
|
|
3292
|
+
...(carried?.violations ?? []),
|
|
2943
3293
|
],
|
|
2944
3294
|
absent: absent.absent,
|
|
2945
3295
|
gateStates: gate.states,
|
|
2946
3296
|
gateConditions: gate.conditions,
|
|
2947
3297
|
gateDescriptions: gate.descriptions,
|
|
2948
3298
|
waiveRate,
|
|
3299
|
+
carried,
|
|
2949
3300
|
verdict: reviewGateVerdict({
|
|
2950
3301
|
states: gate.states,
|
|
2951
3302
|
conditions: gate.conditions,
|
|
2952
3303
|
descriptions: gate.descriptions,
|
|
2953
3304
|
waiveRate,
|
|
3305
|
+
carried,
|
|
2954
3306
|
}),
|
|
2955
3307
|
settled: read.settled,
|
|
2956
3308
|
refusal: null,
|
|
@@ -3110,7 +3462,21 @@ function violationBlocks(violation, policy) {
|
|
|
3110
3462
|
if (REVIEW_GATE_BLOCKING.includes(violation.kind)) {
|
|
3111
3463
|
return policy.requireReviewEvidence;
|
|
3112
3464
|
}
|
|
3113
|
-
|
|
3465
|
+
// A named waiver is report-only under every flag. `--fail-on-vacuous`
|
|
3466
|
+
// governs checks that claimed success without proving work; it must not
|
|
3467
|
+
// turn a vendor entitlement waiver into a failure through a shared array.
|
|
3468
|
+
//
|
|
3469
|
+
// A carried finding (#3658) is report-only for a second reason on top of
|
|
3470
|
+
// that one: it is about ANOTHER pull request, whose own gate already
|
|
3471
|
+
// reached its own verdict on its own diff. `--fail-on-vacuous` asks "did
|
|
3472
|
+
// the checks on THIS pull request prove their work?", and answering it with
|
|
3473
|
+
// a constituent's condition would fail an author for a diff they did not
|
|
3474
|
+
// write and cannot change.
|
|
3475
|
+
if (
|
|
3476
|
+
violation.kind === VIOLATIONS.reviewWaived ||
|
|
3477
|
+
violation.kind === VIOLATIONS.reviewCarried
|
|
3478
|
+
)
|
|
3479
|
+
return false;
|
|
3114
3480
|
if (NEVER_BLOCKING.includes(violation.kind)) return policy.failOnVacuous;
|
|
3115
3481
|
return !policy.warnOnly || ALWAYS_BLOCKING.includes(violation.kind);
|
|
3116
3482
|
}
|
|
@@ -43,6 +43,26 @@
|
|
|
43
43
|
# a gate that reddens every pull request on a vendor's billing state gets
|
|
44
44
|
# deleted, and then nothing is watching at all.
|
|
45
45
|
#
|
|
46
|
+
# AND A PULL REQUEST SPEAKS FOR ONE COMMIT ONLY (#3658). Everything above judges
|
|
47
|
+
# this pull request's HEAD, which is the one commit a batch integration pull
|
|
48
|
+
# request does not speak for: its head carries the MERGES of every constituent
|
|
49
|
+
# and none of their review statuses. MEASURED 2026-09-03 on one queue drain —
|
|
50
|
+
# four constituents based on a batching branch each reported `Review skipped:
|
|
51
|
+
# reviews are disabled for this base branch`, each one's own run of this workflow
|
|
52
|
+
# went red, and every one of those reds landed on a branch outside the rulesets'
|
|
53
|
+
# ref conditions, so nothing was watching. The integration pull request that
|
|
54
|
+
# carried all four into the default branch was the one place a ruleset applied,
|
|
55
|
+
# and it read only itself. `--require-review-evidence` now also enumerates what
|
|
56
|
+
# this pull request CARRIES — through its merge commits, never through a branch
|
|
57
|
+
# NAME, because a name is an allowlist the convention can rename its way out of
|
|
58
|
+
# — reads each constituent at its OWN head, and renders a batch carrying an
|
|
59
|
+
# unreviewed constituent `neutral` instead of `success`. Capped at `neutral`
|
|
60
|
+
# deliberately: the constituents' own runs already reached the failing verdict
|
|
61
|
+
# where their diffs live, and reddening the batch too would fail an author for a
|
|
62
|
+
# diff they did not write. Failing to READ the batch renders `neutral` as well —
|
|
63
|
+
# an unread batch is not a reviewed one. An ordinary pull request has no merge
|
|
64
|
+
# commits, enumerates an empty batch, and is untouched.
|
|
65
|
+
#
|
|
46
66
|
# `--waive-rate-sample=<N>` samples recently merged pull requests and reports
|
|
47
67
|
# how many of them waived, in the job summary and in the verdict title. It runs
|
|
48
68
|
# ONLY when this pull request is itself waived, is one GraphQL call, and is
|