@codyswann/lisa 3.39.0 → 3.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +9 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +6 -3
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/expo/create-only/.github/workflows/nightly-e2e-report.yml +40 -0
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +531 -18
- package/typescript/copy-overwrite/scripts/nightly-e2e-suites.schema.json +4 -0
|
@@ -89,7 +89,7 @@ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
|
|
|
89
89
|
* rather than running a contract neither half agrees on. See §8 of
|
|
90
90
|
* `docs/nightly-e2e-gate.md` for what counts as major / minor / patch.
|
|
91
91
|
*/
|
|
92
|
-
export const NIGHTLY_E2E_CONTRACT_VERSION = "1.
|
|
92
|
+
export const NIGHTLY_E2E_CONTRACT_VERSION = "1.5.0";
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
95
|
* The conclusions that constitute a verdict about the code.
|
|
@@ -231,6 +231,45 @@ export const ISSUE_ACTIONS = Object.freeze({
|
|
|
231
231
|
none: "none",
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Whether the gate actually blocks merges — MEASURED, never assumed (§10.7).
|
|
236
|
+
*
|
|
237
|
+
* The reporter used to state, in every issue it filed, that "pull requests into
|
|
238
|
+
* `<branch>` are blocked". That sentence was a hardcoded claim about somebody
|
|
239
|
+
* else's branch ruleset, and in TunnlAI/frontend it was measurably FALSE: not
|
|
240
|
+
* one required context on `dev` matched this gate, so the suite blocked nothing
|
|
241
|
+
* — while people burned audited bypass labels to clear a gate that was not
|
|
242
|
+
* gating. An issue that misstates its own consequences is worse than one that
|
|
243
|
+
* says nothing, because it is acted on.
|
|
244
|
+
*
|
|
245
|
+
* `unknown` is a first-class member of this set, not a tidy-up. If the branch
|
|
246
|
+
* rules cannot be read, the honest answer is that we do not know — and this
|
|
247
|
+
* file's whole doctrine is that "we could not check" must never render as an
|
|
248
|
+
* answer in either direction. Claiming `not_required` on an unreadable API
|
|
249
|
+
* would tell a reader to ignore a gate that may well be blocking every PR they
|
|
250
|
+
* have open.
|
|
251
|
+
*/
|
|
252
|
+
export const REQUIREDNESS = Object.freeze({
|
|
253
|
+
required: "required",
|
|
254
|
+
notRequired: "not_required",
|
|
255
|
+
unknown: "unknown",
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The status-check context the gate publishes, as Lisa's own caller names it.
|
|
260
|
+
*
|
|
261
|
+
* GitHub composes a reusable workflow's check name as
|
|
262
|
+
* `<caller job name> / <called job name>`, and Lisa's shipped
|
|
263
|
+
* `nightly-e2e-health.yml` template names those halves `🌙 Nightly E2E Health`
|
|
264
|
+
* and `🌙 Gate`. Byte for byte, emoji included — §5 is the section about how
|
|
265
|
+
* easily these two strings drift apart.
|
|
266
|
+
*
|
|
267
|
+
* Overridable via the `gate_context` input, because a repo that has not yet
|
|
268
|
+
* converged onto the template publishes a different string (PropSwapLLC's fork
|
|
269
|
+
* publishes the bare `🌙 Nightly E2E Health`, with no job suffix).
|
|
270
|
+
*/
|
|
271
|
+
export const DEFAULT_GATE_CONTEXT = "🌙 Nightly E2E Health / 🌙 Gate";
|
|
272
|
+
|
|
234
273
|
// ---------------------------------------------------------------------------
|
|
235
274
|
// SECURITY LIMITS — source constants, never env-readable (portfolio doctrine)
|
|
236
275
|
// ---------------------------------------------------------------------------
|
|
@@ -435,6 +474,7 @@ const SUITE_KEYS = Object.freeze(
|
|
|
435
474
|
"required_sha",
|
|
436
475
|
"first_seen",
|
|
437
476
|
"grace_days",
|
|
477
|
+
"gated",
|
|
438
478
|
"min_flows",
|
|
439
479
|
])
|
|
440
480
|
);
|
|
@@ -658,6 +698,16 @@ export function validateSuites(raw) {
|
|
|
658
698
|
);
|
|
659
699
|
}
|
|
660
700
|
}
|
|
701
|
+
// §10.7 — a REPORTING-only declaration. The gate ignores it completely; it
|
|
702
|
+
// exists so a suite that is tracked but not gating can say so in its issue
|
|
703
|
+
// instead of crying wolf. Boolean and nothing else: a string "false" is
|
|
704
|
+
// truthy, and a suite that believed it was ungated while claiming to block
|
|
705
|
+
// merges is the exact confusion this field removes.
|
|
706
|
+
if (entry.gated !== undefined && typeof entry.gated !== "boolean") {
|
|
707
|
+
throw new GateConfigError(
|
|
708
|
+
`${where}: \`gated\` must be a boolean (got ${JSON.stringify(entry.gated)}). It declares whether this suite's tracking issue may say that merges are blocked — it does not change the gate, which is decided by the branch ruleset.`
|
|
709
|
+
);
|
|
710
|
+
}
|
|
661
711
|
return Object.freeze({ ...entry, match: Object.freeze({ ...match }) });
|
|
662
712
|
});
|
|
663
713
|
|
|
@@ -1663,14 +1713,161 @@ function evidenceMarker(finding) {
|
|
|
1663
1713
|
)} -->`;
|
|
1664
1714
|
}
|
|
1665
1715
|
|
|
1716
|
+
/**
|
|
1717
|
+
* Whether one required-status-check context names THIS gate.
|
|
1718
|
+
*
|
|
1719
|
+
* Matching is a small family rather than string equality, because the same gate
|
|
1720
|
+
* publishes two different context strings depending on how it is wired. GitHub
|
|
1721
|
+
* composes a reusable workflow's check as `<caller job> / <called job>`, so a
|
|
1722
|
+
* repo calling Lisa's reusable is required under
|
|
1723
|
+
* `🌙 Nightly E2E Health / 🌙 Gate` while a repo still running a local
|
|
1724
|
+
* single-job reimplementation is required under the bare
|
|
1725
|
+
* `🌙 Nightly E2E Health`. Both ARE the gate. Demanding equality would report
|
|
1726
|
+
* `not_required` for the second — a false all-clear about a branch that really
|
|
1727
|
+
* is blocked, which is the same defect as the false "blocked" this whole
|
|
1728
|
+
* section exists to delete, pointed the other way.
|
|
1729
|
+
*
|
|
1730
|
+
* So a context matches when it is the configured one, or when one is the other
|
|
1731
|
+
* plus a ` / `-separated job suffix. Nothing looser: a bare substring test would
|
|
1732
|
+
* match `🌙 Nightly E2E Health (advisory)` and any other check somebody names
|
|
1733
|
+
* nearby.
|
|
1734
|
+
*
|
|
1735
|
+
* @param {string} context - A required status-check context
|
|
1736
|
+
* @param {string} gateContext - The configured gate context
|
|
1737
|
+
* @returns {boolean} True when the context denotes this gate
|
|
1738
|
+
*/
|
|
1739
|
+
export function contextMatchesGate(context, gateContext) {
|
|
1740
|
+
if (typeof context !== "string" || typeof gateContext !== "string")
|
|
1741
|
+
return false;
|
|
1742
|
+
if (context === gateContext) return true;
|
|
1743
|
+
return (
|
|
1744
|
+
context.startsWith(`${gateContext} / `) ||
|
|
1745
|
+
gateContext.startsWith(`${context} / `)
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/**
|
|
1750
|
+
* Resolves what THIS suite's issue may claim about blocking merges.
|
|
1751
|
+
*
|
|
1752
|
+
* Two independent facts combine, and the combination can only ever be WEAKER
|
|
1753
|
+
* than the branch-level measurement:
|
|
1754
|
+
*
|
|
1755
|
+
* 1. `requiredness` — measured from the branch ruleset, for the gate as a
|
|
1756
|
+
* whole. It is per-BRANCH: one context guards every suite in the table.
|
|
1757
|
+
* 2. `gated: false` — declared per-SUITE by the caller, for a suite that is
|
|
1758
|
+
* deliberately tracked without gating.
|
|
1759
|
+
*
|
|
1760
|
+
* A caller may therefore silence a blocking claim for one suite, and may never
|
|
1761
|
+
* manufacture one: `gated: true` on a branch where the gate is not required
|
|
1762
|
+
* still renders `not_required`. That asymmetry is the same one §6.2 applies to
|
|
1763
|
+
* the bypass pattern — an override narrows, it never loosens — and here it is
|
|
1764
|
+
* what stops a suite table from asserting a merge consequence that the branch's
|
|
1765
|
+
* rules do not actually impose.
|
|
1766
|
+
*
|
|
1767
|
+
* @param {object} finding - A finding, possibly carrying `gated`
|
|
1768
|
+
* @param {{state: string, detail?: string|null}} requiredness - Branch measurement
|
|
1769
|
+
* @returns {{state: string, detail: string|null, source: string}} The effective claim
|
|
1770
|
+
*/
|
|
1771
|
+
export function suiteRequiredness(finding, requiredness) {
|
|
1772
|
+
if (finding?.gated === false) {
|
|
1773
|
+
return Object.freeze({
|
|
1774
|
+
state: REQUIREDNESS.notRequired,
|
|
1775
|
+
detail:
|
|
1776
|
+
'this suite is declared `"gated": false` in the gate\'s suite table — it is tracked, not enforced',
|
|
1777
|
+
source: "suite_opt_out",
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
return Object.freeze({
|
|
1781
|
+
state: requiredness?.state ?? REQUIREDNESS.unknown,
|
|
1782
|
+
detail: requiredness?.detail ?? null,
|
|
1783
|
+
source: "branch_rules",
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
/** The title suffix each requiredness state earns. */
|
|
1788
|
+
const REQUIREDNESS_TITLE = Object.freeze({
|
|
1789
|
+
[REQUIREDNESS.required]: " (blocking merges)",
|
|
1790
|
+
[REQUIREDNESS.notRequired]: " (not blocking merges)",
|
|
1791
|
+
[REQUIREDNESS.unknown]: " (merge impact unknown)",
|
|
1792
|
+
});
|
|
1793
|
+
|
|
1666
1794
|
/**
|
|
1667
1795
|
* The issue title for one suite.
|
|
1668
1796
|
*
|
|
1797
|
+
* The requiredness state rides in the TITLE, not only in the body, because the
|
|
1798
|
+
* issue list is the surface an operator triages from and "is anything waiting on
|
|
1799
|
+
* this?" is the question they are triaging by. Retitling an existing issue is
|
|
1800
|
+
* safe: identity is the body marker (§10.1), never the title.
|
|
1801
|
+
*
|
|
1669
1802
|
* @param {object} finding - A finding
|
|
1803
|
+
* @param {{requiredness: {state: string}}} context - Reporting context
|
|
1670
1804
|
* @returns {string} A title
|
|
1671
1805
|
*/
|
|
1672
|
-
function issueTitle(finding) {
|
|
1673
|
-
|
|
1806
|
+
function issueTitle(finding, context) {
|
|
1807
|
+
const claim = suiteRequiredness(finding, context.requiredness);
|
|
1808
|
+
return `🌙 Nightly e2e is not green${REQUIREDNESS_TITLE[claim.state] ?? ""}: ${finding.label}`;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/**
|
|
1812
|
+
* The "what this means" paragraph, which used to be a hardcoded falsehood.
|
|
1813
|
+
*
|
|
1814
|
+
* @param {{state: string, detail: string|null, source: string}} claim - Effective claim
|
|
1815
|
+
* @param {string} branch - The branch under gate
|
|
1816
|
+
* @returns {string} One markdown paragraph
|
|
1817
|
+
*/
|
|
1818
|
+
function meaningParagraph(claim, branch) {
|
|
1819
|
+
if (claim.state === REQUIREDNESS.required) {
|
|
1820
|
+
return `**What this means.** Pull requests into \`${branch}\` are blocked until this suite is green again — measured just now from \`${branch}\`'s branch rules, not assumed. That is deliberate: merging on top of a red suite is how a nightly ends up measuring days of accumulated damage instead of the change that broke it.`;
|
|
1821
|
+
}
|
|
1822
|
+
if (claim.state === REQUIREDNESS.notRequired) {
|
|
1823
|
+
const why =
|
|
1824
|
+
claim.source === "suite_opt_out"
|
|
1825
|
+
? claim.detail
|
|
1826
|
+
: `no required status check on \`${branch}\` matches this gate`;
|
|
1827
|
+
return `**What this means.** This suite does **not** gate merges — ${why}. Pull requests into \`${branch}\` are going in on top of it. Fixing it is still the point: a nightly nobody fixes stops being evidence of anything, and it is the only signal you have that \`${branch}\` still works end to end. But nothing is waiting on you to merge, so do not treat this as an outage.`;
|
|
1828
|
+
}
|
|
1829
|
+
return `**What this means.** ⚠️ **Not known.** \`${branch}\`'s branch rules could not be read${claim.detail ? ` (${claim.detail})` : ""}, so this issue makes no claim either way about whether pull requests are blocked. Check the branch's ruleset before acting on either assumption — "we could not check" is not the same as "nothing is blocked".`;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/**
|
|
1833
|
+
* The bypass paragraph, which is conditional on the paragraph above it.
|
|
1834
|
+
*
|
|
1835
|
+
* A bypass recipe printed unconditionally is how people end up applying an
|
|
1836
|
+
* audited waiver label to a check that was never required — burning the audit
|
|
1837
|
+
* trail the label exists to create, to buy a merge that was never blocked. That
|
|
1838
|
+
* happened in TunnlAI/frontend.
|
|
1839
|
+
*
|
|
1840
|
+
* @param {{state: string}} claim - Effective claim
|
|
1841
|
+
* @param {{branch: string, bypassLabel: string}} context - Reporting context
|
|
1842
|
+
* @returns {ReadonlyArray<string>} Markdown lines
|
|
1843
|
+
*/
|
|
1844
|
+
function bypassParagraph(claim, context) {
|
|
1845
|
+
const label = context.bypassLabel;
|
|
1846
|
+
if (claim.state === REQUIREDNESS.notRequired) {
|
|
1847
|
+
return [
|
|
1848
|
+
`**Do you need a waiver? No.** This check is not required on \`${context.branch}\`, so applying the \`${label}\` label would waive nothing — it is an audited escape hatch for a gate that is actually holding a merge, and there is no merge being held. Do not spend one here.`,
|
|
1849
|
+
"",
|
|
1850
|
+
];
|
|
1851
|
+
}
|
|
1852
|
+
const recipe = [
|
|
1853
|
+
`1. Apply the \`${label}\` label to the pull request.`,
|
|
1854
|
+
"2. Put a line in the **pull request body** naming a ticket and a reason:",
|
|
1855
|
+
"",
|
|
1856
|
+
" ```",
|
|
1857
|
+
" Nightly-E2E-Bypass: ABC-123 hotfix for the outage; this suite is red for an unrelated harness bug",
|
|
1858
|
+
" ```",
|
|
1859
|
+
"",
|
|
1860
|
+
`3. The waiver is time-boxed and only an \`admin\` or \`maintain\` collaborator can grant one. It is recorded on the run, and the label is removed when the PR closes. This tracking issue stays open either way — a bypass waives ONE pull request, it does not make the nightly green.`,
|
|
1861
|
+
];
|
|
1862
|
+
if (claim.state === REQUIREDNESS.required) {
|
|
1863
|
+
return ["**If you must merge before this is fixed.**", "", ...recipe, ""];
|
|
1864
|
+
}
|
|
1865
|
+
return [
|
|
1866
|
+
`**If you must merge before this is fixed.** ⚠️ Confirm first whether the gate is actually required on \`${context.branch}\` — that could not be read tonight. If it is not, the \`${label}\` label waives nothing and should not be applied. If it is:`,
|
|
1867
|
+
"",
|
|
1868
|
+
...recipe,
|
|
1869
|
+
"",
|
|
1870
|
+
];
|
|
1674
1871
|
}
|
|
1675
1872
|
|
|
1676
1873
|
/**
|
|
@@ -1682,7 +1879,8 @@ function issueTitle(finding) {
|
|
|
1682
1879
|
* machine detail below a fold.
|
|
1683
1880
|
*
|
|
1684
1881
|
* @param {object} finding - A finding
|
|
1685
|
-
* @param {{branch: string, now: Date
|
|
1882
|
+
* @param {{branch: string, now: Date, requiredness: object, bypassLabel: string,
|
|
1883
|
+
* gateContext: string}} context - Reporting context
|
|
1686
1884
|
* @returns {string} Markdown
|
|
1687
1885
|
*/
|
|
1688
1886
|
function issueBody(finding, context) {
|
|
@@ -1690,13 +1888,22 @@ function issueBody(finding, context) {
|
|
|
1690
1888
|
const runLine = finding.url
|
|
1691
1889
|
? `[the run that reported it](${finding.url})`
|
|
1692
1890
|
: "the Actions tab";
|
|
1891
|
+
const claim = suiteRequiredness(finding, context.requiredness);
|
|
1892
|
+
const blockingCell = {
|
|
1893
|
+
[REQUIREDNESS.required]: `yes — \`${context.gateContext}\` is a required status check on \`${context.branch}\``,
|
|
1894
|
+
[REQUIREDNESS.notRequired]:
|
|
1895
|
+
claim.source === "suite_opt_out"
|
|
1896
|
+
? 'no — declared `"gated": false` for this suite'
|
|
1897
|
+
: `no — no required status check on \`${context.branch}\` matches \`${context.gateContext}\``,
|
|
1898
|
+
[REQUIREDNESS.unknown]: `unknown — the branch rules could not be read${claim.detail ? ` (${claim.detail})` : ""}`,
|
|
1899
|
+
}[claim.state];
|
|
1693
1900
|
return [
|
|
1694
1901
|
suiteMarker(finding.label),
|
|
1695
1902
|
evidenceMarker(finding),
|
|
1696
1903
|
"",
|
|
1697
1904
|
`## The \`${finding.label}\` end-to-end suite is not passing on \`${context.branch}\``,
|
|
1698
1905
|
"",
|
|
1699
|
-
|
|
1906
|
+
meaningParagraph(claim, context.branch),
|
|
1700
1907
|
"",
|
|
1701
1908
|
"**What to do, in order.**",
|
|
1702
1909
|
"",
|
|
@@ -1704,6 +1911,7 @@ function issueBody(finding, context) {
|
|
|
1704
1911
|
"2. Fix it, or — if the failure is in the test harness rather than the product — say so in a comment here so the next person does not re-diagnose it.",
|
|
1705
1912
|
`3. Re-run the **whole** suite against \`${context.branch}\`. Leave any platform / tag / shard picker on its \`all\` default: a run that skipped an arm says nothing about that arm, and will not clear the gate.`,
|
|
1706
1913
|
"",
|
|
1914
|
+
...bypassParagraph(claim, context),
|
|
1707
1915
|
"**You do not need to close this issue.** It closes itself the moment a full green run lands, and it is refreshed automatically every night it is still red. Closing it by hand while the suite is red just means tonight re-opens the question.",
|
|
1708
1916
|
"",
|
|
1709
1917
|
"<details><summary>Details</summary>",
|
|
@@ -1713,6 +1921,7 @@ function issueBody(finding, context) {
|
|
|
1713
1921
|
`| Suite | ${finding.label} |`,
|
|
1714
1922
|
`| Workflow | \`${finding.workflow ?? "—"}\` |`,
|
|
1715
1923
|
`| Branch | \`${context.branch}\` |`,
|
|
1924
|
+
`| Blocks merges | ${blockingCell} |`,
|
|
1716
1925
|
`| Newest run | ${finding.conclusion ?? "—"}${finding.createdAt ? ` at ${finding.createdAt}` : ""}${finding.event ? ` via \`${finding.event}\`` : ""} |`,
|
|
1717
1926
|
`| Why it is not green | ${detail || finding.reason} |`,
|
|
1718
1927
|
`| Last checked | ${context.now.toISOString()} |`,
|
|
@@ -1733,10 +1942,23 @@ function issueBody(finding, context) {
|
|
|
1733
1942
|
* (never run through `decide`, whose bootstrap rendering would hide a suite's
|
|
1734
1943
|
* real state from the reporter)
|
|
1735
1944
|
* @param {ReadonlyArray<object>} openIssues - Open issues carrying the label
|
|
1736
|
-
* @param {{branch: string, label: string, now: Date
|
|
1945
|
+
* @param {{branch: string, label: string, now: Date, requiredness?: object,
|
|
1946
|
+
* bypassLabel?: string, gateContext?: string, pinIssues?: boolean}} context -
|
|
1947
|
+
* Reporting context
|
|
1737
1948
|
* @returns {ReadonlyArray<object>} One plan entry per suite
|
|
1738
1949
|
*/
|
|
1739
1950
|
export function planIssueActions(findings, openIssues, context) {
|
|
1951
|
+
// Every renderer below reads these, and a caller that predates them must not
|
|
1952
|
+
// crash the reporter — the reporting half is the half that must never take
|
|
1953
|
+
// the gate down with it. An absent measurement is `unknown`, which is the
|
|
1954
|
+
// same answer a failed one gets: honest, and never a claim in either
|
|
1955
|
+
// direction.
|
|
1956
|
+
const resolved = {
|
|
1957
|
+
...context,
|
|
1958
|
+
requiredness: context.requiredness ?? { state: REQUIREDNESS.unknown },
|
|
1959
|
+
bypassLabel: context.bypassLabel ?? "nightly-e2e-bypass",
|
|
1960
|
+
gateContext: context.gateContext ?? DEFAULT_GATE_CONTEXT,
|
|
1961
|
+
};
|
|
1740
1962
|
return Object.freeze(
|
|
1741
1963
|
findings.map(finding => {
|
|
1742
1964
|
const marker = suiteMarker(finding.label);
|
|
@@ -1750,13 +1972,24 @@ export function planIssueActions(findings, openIssues, context) {
|
|
|
1750
1972
|
.slice()
|
|
1751
1973
|
.sort((left, right) => left.number - right.number);
|
|
1752
1974
|
const numbers = Object.freeze(matches.map(issue => issue.number));
|
|
1975
|
+
const nodeIds = Object.freeze(
|
|
1976
|
+
matches.map(issue => issue.node_id ?? null).filter(Boolean)
|
|
1977
|
+
);
|
|
1978
|
+
const claim = suiteRequiredness(finding, resolved.requiredness);
|
|
1753
1979
|
const base = {
|
|
1754
1980
|
label: finding.label,
|
|
1755
1981
|
state: finding.state,
|
|
1756
1982
|
issues: numbers,
|
|
1983
|
+
// Carried alongside the numbers because pinning is a GraphQL mutation
|
|
1984
|
+
// and GraphQL addresses an issue by node id, never by number.
|
|
1985
|
+
nodeIds,
|
|
1986
|
+
requiredness: claim.state,
|
|
1757
1987
|
title: null,
|
|
1758
1988
|
body: null,
|
|
1759
1989
|
comment: null,
|
|
1990
|
+
// `null` is "do not touch the pin", which is what every entry gets when
|
|
1991
|
+
// pinning is off. Distinct from `false`, which actively UNPINS.
|
|
1992
|
+
pin: null,
|
|
1760
1993
|
};
|
|
1761
1994
|
const quiet = reason => ({
|
|
1762
1995
|
...base,
|
|
@@ -1776,8 +2009,9 @@ export function planIssueActions(findings, openIssues, context) {
|
|
|
1776
2009
|
...base,
|
|
1777
2010
|
action: ISSUE_ACTIONS.create,
|
|
1778
2011
|
reason: "red_filed",
|
|
1779
|
-
title: issueTitle(finding),
|
|
1780
|
-
body: issueBody(finding,
|
|
2012
|
+
title: issueTitle(finding, resolved),
|
|
2013
|
+
body: issueBody(finding, resolved),
|
|
2014
|
+
pin: resolved.pinIssues === true ? true : null,
|
|
1781
2015
|
};
|
|
1782
2016
|
}
|
|
1783
2017
|
// The oldest open match is the canonical one. A second match means a
|
|
@@ -1790,10 +2024,14 @@ export function planIssueActions(findings, openIssues, context) {
|
|
|
1790
2024
|
return {
|
|
1791
2025
|
...base,
|
|
1792
2026
|
issues: Object.freeze([matches[0].number]),
|
|
2027
|
+
nodeIds: Object.freeze(
|
|
2028
|
+
matches[0].node_id ? [matches[0].node_id] : []
|
|
2029
|
+
),
|
|
1793
2030
|
action: ISSUE_ACTIONS.refresh,
|
|
1794
2031
|
reason: "red_refreshed",
|
|
1795
|
-
|
|
1796
|
-
|
|
2032
|
+
pin: resolved.pinIssues === true ? true : null,
|
|
2033
|
+
title: issueTitle(finding, resolved),
|
|
2034
|
+
body: issueBody(finding, resolved),
|
|
1797
2035
|
// A comment is a notification. One every night for the same failure
|
|
1798
2036
|
// trains people to mute the issue that is supposed to be alerting
|
|
1799
2037
|
// them, so only a CHANGE in the evidence earns one.
|
|
@@ -1805,11 +2043,28 @@ export function planIssueActions(findings, openIssues, context) {
|
|
|
1805
2043
|
|
|
1806
2044
|
if (finding.state === SUITE_STATES.pass) {
|
|
1807
2045
|
if (matches.length === 0) return quiet("green_untracked");
|
|
2046
|
+
// The close comment carries the requiredness claim too. It is the LAST
|
|
2047
|
+
// thing anyone reads on this issue and it is what gets quoted in a
|
|
2048
|
+
// standup — an all-clear that says "merges are unblocked" about a gate
|
|
2049
|
+
// that never blocked anything is the same falsehood as the one at the
|
|
2050
|
+
// top of the body, just harder to catch because everyone is relieved.
|
|
2051
|
+
const relief = {
|
|
2052
|
+
[REQUIREDNESS.required]: ` Merges into \`${resolved.branch}\` are no longer held by this suite.`,
|
|
2053
|
+
[REQUIREDNESS.notRequired]:
|
|
2054
|
+
claim.source === "suite_opt_out"
|
|
2055
|
+
? " (This suite is tracked but does not gate merges, so nothing was blocked.)"
|
|
2056
|
+
: ` (This suite is not a required check on \`${resolved.branch}\`, so nothing was blocked while it was red.)`,
|
|
2057
|
+
[REQUIREDNESS.unknown]: ` (Whether this suite gates merges on \`${resolved.branch}\` could not be read, so this all-clear says nothing about merges.)`,
|
|
2058
|
+
}[claim.state];
|
|
1808
2059
|
return {
|
|
1809
2060
|
...base,
|
|
1810
2061
|
action: ISSUE_ACTIONS.close,
|
|
1811
2062
|
reason: "green_complete",
|
|
1812
|
-
|
|
2063
|
+
// Unpinning on green is not cosmetic. A pinned issue is the repo's
|
|
2064
|
+
// "look at this" slot, and one that stays pinned after the suite
|
|
2065
|
+
// recovers is how a pin board stops meaning anything.
|
|
2066
|
+
pin: resolved.pinIssues === true ? false : null,
|
|
2067
|
+
comment: `✅ Closing automatically: a complete green run landed for **${finding.label}** on \`${resolved.branch}\`.${relief ?? ""}${finding.url ? `\n\n${finding.url}` : ""}`,
|
|
1813
2068
|
};
|
|
1814
2069
|
}
|
|
1815
2070
|
|
|
@@ -1822,7 +2077,8 @@ export function planIssueActions(findings, openIssues, context) {
|
|
|
1822
2077
|
* Renders the reporting outcome for the job log and summary.
|
|
1823
2078
|
*
|
|
1824
2079
|
* @param {ReadonlyArray<object>} results - Output of `applyIssuePlan`
|
|
1825
|
-
* @param {{branch: string}} context -
|
|
2080
|
+
* @param {{branch: string, requiredness?: object, gateContext?: string}} context -
|
|
2081
|
+
* Reporting context
|
|
1826
2082
|
* @returns {string} Markdown
|
|
1827
2083
|
*/
|
|
1828
2084
|
export function formatIssueReport(results, context) {
|
|
@@ -1832,11 +2088,22 @@ export function formatIssueReport(results, context) {
|
|
|
1832
2088
|
close: "closed the tracking issue — the suite is green again",
|
|
1833
2089
|
none: "left the tracking state alone",
|
|
1834
2090
|
};
|
|
2091
|
+
const state = context.requiredness?.state ?? REQUIREDNESS.unknown;
|
|
2092
|
+
const gateContext = context.gateContext ?? DEFAULT_GATE_CONTEXT;
|
|
2093
|
+
// Printed in the job log as well as the issues, so the measurement is
|
|
2094
|
+
// auditable from the run that made it rather than only from its output.
|
|
2095
|
+
const requirednessLine = {
|
|
2096
|
+
[REQUIREDNESS.required]: `🔒 The gate **is required** on \`${context.branch}\` (\`${gateContext}\`) — a red suite blocks merges.`,
|
|
2097
|
+
[REQUIREDNESS.notRequired]: `🔓 The gate is **not required** on \`${context.branch}\` — no required status check matches \`${gateContext}\`, so a red suite blocks nothing. The issues say so rather than claiming otherwise.`,
|
|
2098
|
+
[REQUIREDNESS.unknown]: `⚪ Whether the gate is required on \`${context.branch}\` could not be read${context.requiredness?.detail ? ` (${context.requiredness.detail})` : ""}. The issues claim neither.`,
|
|
2099
|
+
}[state];
|
|
1835
2100
|
const lines = [
|
|
1836
2101
|
"## 🌙 Nightly E2E tracking issues",
|
|
1837
2102
|
"",
|
|
1838
2103
|
`Branch: \`${context.branch}\``,
|
|
1839
2104
|
"",
|
|
2105
|
+
requirednessLine,
|
|
2106
|
+
"",
|
|
1840
2107
|
];
|
|
1841
2108
|
for (const result of results) {
|
|
1842
2109
|
const where = result.issues.length
|
|
@@ -1847,6 +2114,12 @@ export function formatIssueReport(results, context) {
|
|
|
1847
2114
|
? `- ✅ **${result.label}** — ${say[result.action] ?? result.action}${where} [${result.reason}]`
|
|
1848
2115
|
: `- ⚠️ **${result.label}** — could not ${result.action} its tracking issue${where}: ${result.error}`
|
|
1849
2116
|
);
|
|
2117
|
+
// Visible, but never folded into the line's ✅/⚠️ marker: a pin that did
|
|
2118
|
+
// not land is not a tracking issue that did not land, and the report must
|
|
2119
|
+
// not teach a reader to treat them as the same thing.
|
|
2120
|
+
for (const warning of result.warnings ?? []) {
|
|
2121
|
+
lines.push(` - 📌 ${warning}`);
|
|
2122
|
+
}
|
|
1850
2123
|
}
|
|
1851
2124
|
lines.push(
|
|
1852
2125
|
"",
|
|
@@ -2015,6 +2288,87 @@ export async function fetchAllJobs(api, runId, wait) {
|
|
|
2015
2288
|
);
|
|
2016
2289
|
}
|
|
2017
2290
|
|
|
2291
|
+
/**
|
|
2292
|
+
* Measures whether this gate actually blocks merges into one branch.
|
|
2293
|
+
*
|
|
2294
|
+
* REPORTING ONLY. Nothing on the gate path calls this, and it deliberately
|
|
2295
|
+
* cannot fail the caller: it catches everything and answers `unknown`.
|
|
2296
|
+
*
|
|
2297
|
+
* That is not defensive padding, it is the contract. This measurement decorates
|
|
2298
|
+
* an issue; the issue is the notification channel; §10.4 says an outage in the
|
|
2299
|
+
* notification channel must never become an outage anywhere else. A reporter
|
|
2300
|
+
* that aborted because it could not read a ruleset would stop filing the issues
|
|
2301
|
+
* that tell people the suite is down — trading a missing sentence for a missing
|
|
2302
|
+
* alert.
|
|
2303
|
+
*
|
|
2304
|
+
* `GET /repos/{owner}/{repo}/rules/branches/{branch}` is the right endpoint
|
|
2305
|
+
* rather than `/branches/{branch}/protection`: it returns the EFFECTIVE rules
|
|
2306
|
+
* for a branch from every source (repository rulesets, organization rulesets,
|
|
2307
|
+
* and classic branch protection projected into the same shape), which is the
|
|
2308
|
+
* question being asked. Reading a single ruleset by id would answer "is it in
|
|
2309
|
+
* THIS ruleset", and a context required by an org-level ruleset would render as
|
|
2310
|
+
* `not_required`.
|
|
2311
|
+
*
|
|
2312
|
+
* Note what a 404 means here and why it is `unknown` rather than
|
|
2313
|
+
* `not_required`: `apiGet` maps 404 to `null`, and this endpoint 404s for a
|
|
2314
|
+
* repository or branch it cannot see — including a token with too little scope.
|
|
2315
|
+
* A branch that genuinely has no rules answers `200 []`, which IS
|
|
2316
|
+
* `not_required`. Collapsing the two would report "nothing is blocking you"
|
|
2317
|
+
* because we were not allowed to look.
|
|
2318
|
+
*
|
|
2319
|
+
* @param {object} api - API coordinates
|
|
2320
|
+
* @param {string} branch - The branch whose rules are being read
|
|
2321
|
+
* @param {string} gateContext - The status-check context this gate publishes
|
|
2322
|
+
* @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
|
|
2323
|
+
* @returns {Promise<{state: string, detail: string|null, contexts: ReadonlyArray<string>}>}
|
|
2324
|
+
* The measurement, never a throw
|
|
2325
|
+
*/
|
|
2326
|
+
export async function fetchRequiredness(api, branch, gateContext, wait) {
|
|
2327
|
+
const unknown = detail =>
|
|
2328
|
+
Object.freeze({ state: REQUIREDNESS.unknown, detail, contexts: [] });
|
|
2329
|
+
let result;
|
|
2330
|
+
try {
|
|
2331
|
+
result = await apiGet(
|
|
2332
|
+
api,
|
|
2333
|
+
`/repos/${api.repo}/rules/branches/${encodeURIComponent(branch)}`,
|
|
2334
|
+
wait
|
|
2335
|
+
);
|
|
2336
|
+
} catch (error) {
|
|
2337
|
+
return unknown(
|
|
2338
|
+
`the branch-rules API was unreadable: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`
|
|
2339
|
+
);
|
|
2340
|
+
}
|
|
2341
|
+
if (result === null) {
|
|
2342
|
+
return unknown(
|
|
2343
|
+
`\`GET /repos/${api.repo}/rules/branches/${branch}\` returned 404 — the branch or the repository is not visible to this token`
|
|
2344
|
+
);
|
|
2345
|
+
}
|
|
2346
|
+
if (!Array.isArray(result.body)) {
|
|
2347
|
+
return unknown(
|
|
2348
|
+
"the branch-rules API returned something that is not a list of rules"
|
|
2349
|
+
);
|
|
2350
|
+
}
|
|
2351
|
+
const contexts = Object.freeze(
|
|
2352
|
+
result.body
|
|
2353
|
+
.filter(rule => rule?.type === "required_status_checks")
|
|
2354
|
+
.flatMap(rule => rule?.parameters?.required_status_checks ?? [])
|
|
2355
|
+
.map(check => check?.context)
|
|
2356
|
+
.filter(context => typeof context === "string")
|
|
2357
|
+
);
|
|
2358
|
+
const matched = contexts.filter(context =>
|
|
2359
|
+
contextMatchesGate(context, gateContext)
|
|
2360
|
+
);
|
|
2361
|
+
return Object.freeze({
|
|
2362
|
+
state:
|
|
2363
|
+
matched.length > 0 ? REQUIREDNESS.required : REQUIREDNESS.notRequired,
|
|
2364
|
+
detail:
|
|
2365
|
+
matched.length > 0
|
|
2366
|
+
? `required as ${matched.map(context => `\`${context}\``).join(", ")}`
|
|
2367
|
+
: null,
|
|
2368
|
+
contexts,
|
|
2369
|
+
});
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2018
2372
|
/**
|
|
2019
2373
|
* The artifact NAMES a run published, paginated to exhaustion.
|
|
2020
2374
|
*
|
|
@@ -2173,6 +2527,80 @@ export async function apiWrite(api, method, path, payload, wait = sleep) {
|
|
|
2173
2527
|
);
|
|
2174
2528
|
}
|
|
2175
2529
|
|
|
2530
|
+
/**
|
|
2531
|
+
* Pins or unpins one tracking issue. BEST EFFORT — never throws.
|
|
2532
|
+
*
|
|
2533
|
+
* Pinning is GraphQL-only; REST has no equivalent, which is why this is the one
|
|
2534
|
+
* place in the file that speaks a second protocol. GraphQL addresses an issue by
|
|
2535
|
+
* NODE ID, not by number, so the plan carries `nodeIds` beside `issues`.
|
|
2536
|
+
*
|
|
2537
|
+
* It returns a warning instead of throwing for one specific reason: **GitHub
|
|
2538
|
+
* allows at most three pinned issues per repository**, and the fourth
|
|
2539
|
+
* `pinIssue` fails. That failure is entirely ordinary — a repo with three
|
|
2540
|
+
* pinned issues and a fourth red suite is a Tuesday — and it says nothing about
|
|
2541
|
+
* whether the tracking issue itself was written correctly. Letting it fail the
|
|
2542
|
+
* reporting job would turn a decoration into a red check, and an operator who
|
|
2543
|
+
* sees the report job go red every night for a full pin board learns to ignore
|
|
2544
|
+
* the report job.
|
|
2545
|
+
*
|
|
2546
|
+
* Note also that a GraphQL error arrives as **HTTP 200 with an `errors` array**,
|
|
2547
|
+
* not as a failing status. Checking `response.ok` alone would read the pin limit
|
|
2548
|
+
* as a success and report a pin that never happened.
|
|
2549
|
+
*
|
|
2550
|
+
* @param {object} api - API coordinates, including `graphqlUrl`
|
|
2551
|
+
* @param {string} nodeId - The issue's GraphQL node id
|
|
2552
|
+
* @param {boolean} pinned - True to pin, false to unpin
|
|
2553
|
+
* @returns {Promise<{ok: boolean, warning: string|null}>} Outcome, never a throw
|
|
2554
|
+
*/
|
|
2555
|
+
export async function setIssuePin(api, nodeId, pinned) {
|
|
2556
|
+
const mutation = pinned ? "pinIssue" : "unpinIssue";
|
|
2557
|
+
if (typeof nodeId !== "string" || nodeId.length === 0) {
|
|
2558
|
+
return Object.freeze({
|
|
2559
|
+
ok: false,
|
|
2560
|
+
warning: `could not ${mutation}: the issue has no GraphQL node id`,
|
|
2561
|
+
});
|
|
2562
|
+
}
|
|
2563
|
+
try {
|
|
2564
|
+
const response = await fetch(api.graphqlUrl ?? `${api.apiUrl}/graphql`, {
|
|
2565
|
+
method: "POST",
|
|
2566
|
+
headers: {
|
|
2567
|
+
accept: "application/vnd.github+json",
|
|
2568
|
+
authorization: `Bearer ${api.token}`,
|
|
2569
|
+
"content-type": "application/json",
|
|
2570
|
+
"user-agent": "lisa-nightly-e2e-health",
|
|
2571
|
+
},
|
|
2572
|
+
body: JSON.stringify({
|
|
2573
|
+
query: `mutation($id: ID!) { ${mutation}(input: {issueId: $id}) { issue { number } } }`,
|
|
2574
|
+
variables: { id: nodeId },
|
|
2575
|
+
}),
|
|
2576
|
+
});
|
|
2577
|
+
if (!response.ok) {
|
|
2578
|
+
return Object.freeze({
|
|
2579
|
+
ok: false,
|
|
2580
|
+
warning: `${mutation} returned HTTP ${response.status}`,
|
|
2581
|
+
});
|
|
2582
|
+
}
|
|
2583
|
+
const body = await response.json();
|
|
2584
|
+
if (Array.isArray(body?.errors) && body.errors.length > 0) {
|
|
2585
|
+
const first = body.errors[0]?.message ?? "unknown GraphQL error";
|
|
2586
|
+
return Object.freeze({
|
|
2587
|
+
ok: false,
|
|
2588
|
+
warning: `${mutation} was refused: ${first}${
|
|
2589
|
+
/pinned|limit/i.test(String(first))
|
|
2590
|
+
? " (GitHub allows at most 3 pinned issues per repository — the tracking issue was still filed and refreshed correctly)"
|
|
2591
|
+
: ""
|
|
2592
|
+
}`,
|
|
2593
|
+
});
|
|
2594
|
+
}
|
|
2595
|
+
return Object.freeze({ ok: true, warning: null });
|
|
2596
|
+
} catch (error) {
|
|
2597
|
+
return Object.freeze({
|
|
2598
|
+
ok: false,
|
|
2599
|
+
warning: `${mutation} could not be sent: ${error instanceof Error ? error.message : String(error)}`,
|
|
2600
|
+
});
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2176
2604
|
/**
|
|
2177
2605
|
* Every OPEN issue carrying the tracking label, paginated to exhaustion.
|
|
2178
2606
|
*
|
|
@@ -2238,6 +2666,21 @@ export async function applyIssuePlan(api, plan, wait) {
|
|
|
2238
2666
|
action: entry.action,
|
|
2239
2667
|
reason: entry.reason,
|
|
2240
2668
|
issues: entry.issues,
|
|
2669
|
+
requiredness: entry.requiredness ?? null,
|
|
2670
|
+
warnings: Object.freeze([]),
|
|
2671
|
+
};
|
|
2672
|
+
// Pins are applied AFTER the write that matters has landed, and their
|
|
2673
|
+
// outcome is collected as warnings rather than folded into `ok`. `ok`
|
|
2674
|
+
// answers "was the tracking issue written", which is what the job's exit
|
|
2675
|
+
// code is derived from; a full pin board must not redden the report.
|
|
2676
|
+
const pin = async nodeIds => {
|
|
2677
|
+
if (entry.pin === null || entry.pin === undefined) return [];
|
|
2678
|
+
const outcomes = await Promise.all(
|
|
2679
|
+
nodeIds.map(nodeId => setIssuePin(api, nodeId, entry.pin))
|
|
2680
|
+
);
|
|
2681
|
+
return outcomes
|
|
2682
|
+
.filter(outcome => !outcome.ok)
|
|
2683
|
+
.map(outcome => outcome.warning);
|
|
2241
2684
|
};
|
|
2242
2685
|
try {
|
|
2243
2686
|
if (entry.action === ISSUE_ACTIONS.create) {
|
|
@@ -2255,6 +2698,7 @@ export async function applyIssuePlan(api, plan, wait) {
|
|
|
2255
2698
|
results.push({
|
|
2256
2699
|
...base,
|
|
2257
2700
|
issues: Object.freeze([created.number]),
|
|
2701
|
+
warnings: Object.freeze(await pin([created.node_id])),
|
|
2258
2702
|
ok: true,
|
|
2259
2703
|
error: null,
|
|
2260
2704
|
});
|
|
@@ -2278,10 +2722,20 @@ export async function applyIssuePlan(api, plan, wait) {
|
|
|
2278
2722
|
wait
|
|
2279
2723
|
);
|
|
2280
2724
|
}
|
|
2281
|
-
results.push({
|
|
2725
|
+
results.push({
|
|
2726
|
+
...base,
|
|
2727
|
+
warnings: Object.freeze(await pin(entry.nodeIds ?? [])),
|
|
2728
|
+
ok: true,
|
|
2729
|
+
error: null,
|
|
2730
|
+
});
|
|
2282
2731
|
continue;
|
|
2283
2732
|
}
|
|
2284
2733
|
if (entry.action === ISSUE_ACTIONS.close) {
|
|
2734
|
+
// Unpin BEFORE closing. A closed issue can still be unpinned, but
|
|
2735
|
+
// ordering it this way means the pin board is already correct at the
|
|
2736
|
+
// instant the issue disappears from the open list — there is no window
|
|
2737
|
+
// where the repo advertises a pinned issue that is closed.
|
|
2738
|
+
const warnings = await pin(entry.nodeIds ?? []);
|
|
2285
2739
|
for (const number of entry.issues) {
|
|
2286
2740
|
if (entry.comment) {
|
|
2287
2741
|
await apiWrite(
|
|
@@ -2300,7 +2754,12 @@ export async function applyIssuePlan(api, plan, wait) {
|
|
|
2300
2754
|
wait
|
|
2301
2755
|
);
|
|
2302
2756
|
}
|
|
2303
|
-
results.push({
|
|
2757
|
+
results.push({
|
|
2758
|
+
...base,
|
|
2759
|
+
warnings: Object.freeze(warnings),
|
|
2760
|
+
ok: true,
|
|
2761
|
+
error: null,
|
|
2762
|
+
});
|
|
2304
2763
|
continue;
|
|
2305
2764
|
}
|
|
2306
2765
|
results.push({ ...base, ok: true, error: null });
|
|
@@ -2444,8 +2903,22 @@ export function resolveSettings(env) {
|
|
|
2444
2903
|
// Carried on the API coordinates so the reporting writes cannot be
|
|
2445
2904
|
// labelled differently from the reads that look for them.
|
|
2446
2905
|
issueLabel: env.NIGHTLY_ISSUE_LABEL || TRACKING_ISSUE_LABEL,
|
|
2906
|
+
// GHES publishes GraphQL somewhere other than `${apiUrl}/graphql`, and
|
|
2907
|
+
// the runner already exports the right value.
|
|
2908
|
+
graphqlUrl:
|
|
2909
|
+
env.GITHUB_GRAPHQL_URL ||
|
|
2910
|
+
`${env.GITHUB_API_URL || "https://api.github.com"}/graphql`,
|
|
2447
2911
|
},
|
|
2448
2912
|
issueLabel: env.NIGHTLY_ISSUE_LABEL || TRACKING_ISSUE_LABEL,
|
|
2913
|
+
// Reporting-only settings. The gate reads none of them, which is why they
|
|
2914
|
+
// carry defaults rather than failing when absent — a gate that could be
|
|
2915
|
+
// configured into silence by omitting a variable is the shape this file
|
|
2916
|
+
// refuses, but the REPORTER's equivalent risk is the opposite one: a
|
|
2917
|
+
// missing decoration must not stop the notification going out.
|
|
2918
|
+
gateContext: (env.NIGHTLY_GATE_CONTEXT || DEFAULT_GATE_CONTEXT).trim(),
|
|
2919
|
+
// Opt-in. Pinning writes to a repository-wide, three-slot surface that
|
|
2920
|
+
// nothing else in this file touches, so it stays off until a caller asks.
|
|
2921
|
+
pinIssues: /^(1|true|yes)$/i.test(String(env.NIGHTLY_PIN_ISSUES ?? "")),
|
|
2449
2922
|
branch,
|
|
2450
2923
|
suites: validateSuites(env.NIGHTLY_SUITES),
|
|
2451
2924
|
freshnessHours: limits.freshnessHours,
|
|
@@ -2566,20 +3039,43 @@ export async function runReport(env, wait) {
|
|
|
2566
3039
|
settings.branch,
|
|
2567
3040
|
wait
|
|
2568
3041
|
);
|
|
2569
|
-
const findings = settings.suites.map((suite, index) =>
|
|
2570
|
-
assessSuite(suite, observations[index], {
|
|
3042
|
+
const findings = settings.suites.map((suite, index) => {
|
|
3043
|
+
const finding = assessSuite(suite, observations[index], {
|
|
2571
3044
|
branch: settings.branch,
|
|
2572
3045
|
freshnessHours: settings.freshnessHours,
|
|
2573
3046
|
now,
|
|
2574
|
-
})
|
|
3047
|
+
});
|
|
3048
|
+
// Attached only when the caller actually declared the suite ungated, so an
|
|
3049
|
+
// untouched suite table produces byte-identical findings — the same shape
|
|
3050
|
+
// `runGate` uses for `grace`.
|
|
3051
|
+
return suite.gated === false ? { ...finding, gated: false } : finding;
|
|
3052
|
+
});
|
|
3053
|
+
// Measured, not assumed, and measured ONCE per report rather than per suite:
|
|
3054
|
+
// requiredness is a property of the branch, and asking N times would be N
|
|
3055
|
+
// chances to get N different answers into one report.
|
|
3056
|
+
const requiredness = await fetchRequiredness(
|
|
3057
|
+
settings.api,
|
|
3058
|
+
settings.branch,
|
|
3059
|
+
settings.gateContext,
|
|
3060
|
+
wait
|
|
2575
3061
|
);
|
|
3062
|
+
const context = {
|
|
3063
|
+
branch: settings.branch,
|
|
3064
|
+
label: settings.issueLabel,
|
|
3065
|
+
now,
|
|
3066
|
+
requiredness,
|
|
3067
|
+
gateContext: settings.gateContext,
|
|
3068
|
+
bypassLabel: settings.bypassLabel,
|
|
3069
|
+
pinIssues: settings.pinIssues,
|
|
3070
|
+
};
|
|
2576
3071
|
const plan = planIssueActions(
|
|
2577
3072
|
findings,
|
|
2578
3073
|
await fetchTrackingIssues(settings.api, settings.issueLabel, wait),
|
|
2579
|
-
|
|
3074
|
+
context
|
|
2580
3075
|
);
|
|
2581
3076
|
return {
|
|
2582
3077
|
findings,
|
|
3078
|
+
requiredness,
|
|
2583
3079
|
plan,
|
|
2584
3080
|
results: await applyIssuePlan(settings.api, plan, wait),
|
|
2585
3081
|
settings,
|
|
@@ -2616,10 +3112,27 @@ async function reportIssues(asJson) {
|
|
|
2616
3112
|
} else {
|
|
2617
3113
|
const report = formatIssueReport(machine.results, {
|
|
2618
3114
|
branch: settings.branch,
|
|
3115
|
+
requiredness: machine.requiredness,
|
|
3116
|
+
gateContext: settings.gateContext,
|
|
2619
3117
|
});
|
|
2620
3118
|
process.stdout.write(report);
|
|
2621
3119
|
await appendSummary(report);
|
|
2622
3120
|
}
|
|
3121
|
+
// An `unknown` requiredness is annotated on the run, because it means every
|
|
3122
|
+
// issue this report touched is deliberately silent about merge consequences —
|
|
3123
|
+
// and the reason (a scope, a rename, an outage) is fixable.
|
|
3124
|
+
if (machine.requiredness?.state === REQUIREDNESS.unknown) {
|
|
3125
|
+
process.stderr.write(
|
|
3126
|
+
`::warning title=Nightly E2E requiredness unknown::Could not read \`${settings.branch}\`'s branch rules, so the tracking issues claim neither that merges are blocked nor that they are not. ${machine.requiredness.detail ?? ""}\n`
|
|
3127
|
+
);
|
|
3128
|
+
}
|
|
3129
|
+
for (const result of machine.results) {
|
|
3130
|
+
for (const warning of result.warnings ?? []) {
|
|
3131
|
+
process.stderr.write(
|
|
3132
|
+
`::warning title=Nightly E2E tracking issue not pinned::${result.label} — ${warning}\n`
|
|
3133
|
+
);
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
2623
3136
|
const failed = machine.results.filter(result => !result.ok);
|
|
2624
3137
|
for (const result of failed) {
|
|
2625
3138
|
process.stderr.write(
|