@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.
Files changed (60) hide show
  1. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  2. package/dist/core/lisa-owned-hash-ledger.js +9 -0
  3. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  4. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  5. package/dist/core/upstream-evidence-manifest.js +6 -3
  6. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  7. package/expo/create-only/.github/workflows/nightly-e2e-report.yml +40 -0
  8. package/package.json +1 -1
  9. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  10. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  11. package/plugins/lisa-agy/plugin.json +1 -1
  12. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  15. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  21. package/plugins/lisa-expo-agy/plugin.json +1 -1
  22. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  31. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  32. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  37. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  42. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-rails-agy/plugin.json +1 -1
  47. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  52. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  57. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  59. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +531 -18
  60. 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.4.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
- return `🌙 Nightly e2e is not green: ${finding.label}`;
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}} context - Reporting context
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
- `**What this means.** Pull requests into \`${context.branch}\` are blocked until this suite is green again. 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.`,
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}} context - Reporting context
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, context),
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
- title: issueTitle(finding),
1796
- body: issueBody(finding, context),
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
- comment: `✅ Closing automatically: a complete green run landed for **${finding.label}** on \`${context.branch}\`.${finding.url ? `\n\n${finding.url}` : ""}`,
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 - Reporting 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({ ...base, ok: true, error: null });
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({ ...base, ok: true, error: null });
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
- { branch: settings.branch, label: settings.issueLabel, now }
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(