@codyswann/lisa 3.0.0 → 3.1.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 (116) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +22 -11
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/expo/create-only/.github/workflows/nightly-e2e-report.yml +71 -0
  5. package/package.json +1 -1
  6. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  7. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  8. package/plugins/lisa/.codex-plugin/skills/lisa-atlassian-access/SKILL.md +75 -64
  9. package/plugins/lisa/.codex-plugin/skills/lisa-jam-access/SKILL.md +13 -5
  10. package/plugins/lisa/.codex-plugin/skills/lisa-linear-access/SKILL.md +30 -10
  11. package/plugins/lisa/.codex-plugin/skills/lisa-notion-access/SKILL.md +36 -23
  12. package/plugins/lisa/.codex-plugin/skills/lisa-posthog-access/SKILL.md +16 -6
  13. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/SKILL.md +8 -0
  14. package/plugins/lisa/.codex-plugin/skills/lisa-sentry-access/SKILL.md +16 -6
  15. package/plugins/lisa/.codex-plugin/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  16. package/plugins/lisa/rules/eager/integration-access-layer.md +7 -3
  17. package/plugins/lisa/rules/reference/credential-substrate-precedence.md +166 -0
  18. package/plugins/lisa/rules/reference/integration-access-layer.md +27 -15
  19. package/plugins/lisa/skills/lisa-atlassian-access/SKILL.md +76 -65
  20. package/plugins/lisa/skills/lisa-jam-access/SKILL.md +14 -6
  21. package/plugins/lisa/skills/lisa-linear-access/SKILL.md +31 -11
  22. package/plugins/lisa/skills/lisa-notion-access/SKILL.md +37 -24
  23. package/plugins/lisa/skills/lisa-posthog-access/SKILL.md +17 -7
  24. package/plugins/lisa/skills/lisa-secrets-access/SKILL.md +8 -0
  25. package/plugins/lisa/skills/lisa-sentry-access/SKILL.md +17 -7
  26. package/plugins/lisa/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  27. package/plugins/lisa-agy/plugin.json +1 -1
  28. package/plugins/lisa-agy/skills/lisa-atlassian-access/SKILL.md +76 -65
  29. package/plugins/lisa-agy/skills/lisa-jam-access/SKILL.md +14 -6
  30. package/plugins/lisa-agy/skills/lisa-linear-access/SKILL.md +31 -11
  31. package/plugins/lisa-agy/skills/lisa-notion-access/SKILL.md +37 -24
  32. package/plugins/lisa-agy/skills/lisa-posthog-access/SKILL.md +17 -7
  33. package/plugins/lisa-agy/skills/lisa-secrets-access/SKILL.md +8 -0
  34. package/plugins/lisa-agy/skills/lisa-sentry-access/SKILL.md +17 -7
  35. package/plugins/lisa-agy/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  36. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  39. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-copilot/rules/eager/integration-access-layer.md +7 -3
  43. package/plugins/lisa-copilot/rules/reference/credential-substrate-precedence.md +166 -0
  44. package/plugins/lisa-copilot/rules/reference/integration-access-layer.md +27 -15
  45. package/plugins/lisa-copilot/skills/lisa-atlassian-access/SKILL.md +76 -65
  46. package/plugins/lisa-copilot/skills/lisa-jam-access/SKILL.md +14 -6
  47. package/plugins/lisa-copilot/skills/lisa-linear-access/SKILL.md +31 -11
  48. package/plugins/lisa-copilot/skills/lisa-notion-access/SKILL.md +37 -24
  49. package/plugins/lisa-copilot/skills/lisa-posthog-access/SKILL.md +17 -7
  50. package/plugins/lisa-copilot/skills/lisa-secrets-access/SKILL.md +8 -0
  51. package/plugins/lisa-copilot/skills/lisa-sentry-access/SKILL.md +17 -7
  52. package/plugins/lisa-copilot/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  53. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-cursor/rules/credential-substrate-precedence-reference.mdc +171 -0
  55. package/plugins/lisa-cursor/rules/integration-access-layer-reference.mdc +27 -15
  56. package/plugins/lisa-cursor/rules/integration-access-layer.mdc +7 -3
  57. package/plugins/lisa-cursor/skills/lisa-atlassian-access/SKILL.md +76 -65
  58. package/plugins/lisa-cursor/skills/lisa-jam-access/SKILL.md +14 -6
  59. package/plugins/lisa-cursor/skills/lisa-linear-access/SKILL.md +31 -11
  60. package/plugins/lisa-cursor/skills/lisa-notion-access/SKILL.md +37 -24
  61. package/plugins/lisa-cursor/skills/lisa-posthog-access/SKILL.md +17 -7
  62. package/plugins/lisa-cursor/skills/lisa-secrets-access/SKILL.md +8 -0
  63. package/plugins/lisa-cursor/skills/lisa-sentry-access/SKILL.md +17 -7
  64. package/plugins/lisa-cursor/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  65. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-expo-agy/plugin.json +1 -1
  68. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  73. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  78. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  83. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  87. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  88. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  92. package/plugins/lisa-rails-agy/plugin.json +1 -1
  93. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  97. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  98. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  102. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  103. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  105. package/plugins/src/base/rules/eager/integration-access-layer.md +7 -3
  106. package/plugins/src/base/rules/reference/credential-substrate-precedence.md +166 -0
  107. package/plugins/src/base/rules/reference/integration-access-layer.md +27 -15
  108. package/plugins/src/base/skills/lisa-atlassian-access/SKILL.md +76 -65
  109. package/plugins/src/base/skills/lisa-jam-access/SKILL.md +14 -6
  110. package/plugins/src/base/skills/lisa-linear-access/SKILL.md +31 -11
  111. package/plugins/src/base/skills/lisa-notion-access/SKILL.md +37 -24
  112. package/plugins/src/base/skills/lisa-posthog-access/SKILL.md +17 -7
  113. package/plugins/src/base/skills/lisa-secrets-access/SKILL.md +8 -0
  114. package/plugins/src/base/skills/lisa-sentry-access/SKILL.md +17 -7
  115. package/plugins/src/base/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  116. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +631 -5
@@ -6,12 +6,27 @@
6
6
  * `CodySwannGT/lisa/.github/workflows/nightly-e2e-health.yml`; the contract both
7
7
  * halves implement is `docs/nightly-e2e-gate.md` in Lisa, whose §2 truth table
8
8
  * is proven row-by-row by `tests/unit/scripts/nightly-e2e-health*.test.ts`
9
- * (rows 1-16, `-api` rows 17-20, `-bypass` rows 21-25, `-completeness` row 26).
9
+ * (rows 1-16, `-api` rows 17-20, `-bypass` rows 21-25, `-completeness` row 26,
10
+ * `-issues` rows 27-31).
10
11
  *
11
12
  * Usage:
12
13
  * node scripts/check-nightly-e2e-health.mjs # human report, exit 1 when blocked
13
14
  * node scripts/check-nightly-e2e-health.mjs --json # machine report, always exit 0
14
15
  * node scripts/check-nightly-e2e-health.mjs --contract-version
16
+ * node scripts/check-nightly-e2e-health.mjs --report-issues # the REPORTING half
17
+ *
18
+ * ## Two halves, and only one of them writes
19
+ *
20
+ * The default invocation is the merge GATE: a required status check that reads
21
+ * run history and writes nothing. `--report-issues` is the REPORTING half (§10),
22
+ * driven by `nightly-e2e-report.yml` on a schedule, which maintains exactly one
23
+ * open tracking issue per suite — filed on the first red night, refreshed while
24
+ * it stays red, closed when a complete green run lands.
25
+ *
26
+ * They are separate because filing is reporting, not verdict. Issue writes live
27
+ * behind `apiWrite`, reachable only from `runReport`; the gate path cannot reach
28
+ * them, and the gate's reusable workflow requests no `issues:` scope. An Issues
29
+ * API that is down must never be able to redden a required check.
15
30
  *
16
31
  * Zero dependencies and no install step, on purpose: a gate that sits on every
17
32
  * pull request has to be cheap enough to stay uncontroversial, and a gate that
@@ -71,7 +86,7 @@ import { pathToFileURL } from "node:url";
71
86
  * rather than running a contract neither half agrees on. See §8 of
72
87
  * `docs/nightly-e2e-gate.md` for what counts as major / minor / patch.
73
88
  */
74
- export const NIGHTLY_E2E_CONTRACT_VERSION = "1.1.0";
89
+ export const NIGHTLY_E2E_CONTRACT_VERSION = "1.2.0";
75
90
 
76
91
  /**
77
92
  * The conclusions that constitute a verdict about the code.
@@ -112,6 +127,27 @@ export const SUITE_STATES = Object.freeze({
112
127
  unknown: "unknown",
113
128
  });
114
129
 
130
+ /**
131
+ * The reason token row 26 stamps on a run that did not run everything.
132
+ *
133
+ * Named as a constant rather than repeated as a literal because the REPORTING
134
+ * half (§10) asks the completeness question in its own right: closing a tracking
135
+ * issue is the reporter declaring a suite healthy, and it must never do that on
136
+ * evidence the suite never gathered.
137
+ */
138
+ export const INCOMPLETE_EVIDENCE_REASON = "incomplete_run";
139
+
140
+ /** The label that identifies a tracking issue this reporter owns. */
141
+ export const TRACKING_ISSUE_LABEL = "nightly-e2e";
142
+
143
+ /** What the reporter may do about one suite (§10). */
144
+ export const ISSUE_ACTIONS = Object.freeze({
145
+ create: "create",
146
+ refresh: "refresh",
147
+ close: "close",
148
+ none: "none",
149
+ });
150
+
115
151
  // ---------------------------------------------------------------------------
116
152
  // SECURITY LIMITS — source constants, never env-readable (portfolio doctrine)
117
153
  // ---------------------------------------------------------------------------
@@ -628,7 +664,7 @@ export function assessSuite(suite, observation, context) {
628
664
  stateForConclusion(conclusion) === SUITE_STATES.fail
629
665
  ? SUITE_STATES.fail
630
666
  : SUITE_STATES.unknown,
631
- reason: "incomplete_run",
667
+ reason: INCOMPLETE_EVIDENCE_REASON,
632
668
  };
633
669
  }
634
670
 
@@ -964,7 +1000,7 @@ const REASON_TEXT = Object.freeze({
964
1000
  "the run completed without ever producing the job this gate reads. The job was renamed, which silently disarms the gate.",
965
1001
  pattern_matched_nothing:
966
1002
  "the job pattern matched zero jobs in the newest run. Zero matches is the signature of a renamed job.",
967
- incomplete_run:
1003
+ [INCOMPLETE_EVIDENCE_REASON]:
968
1004
  "the run reported `success`, but it did not run everything: at least one job was skipped, failed under `continue-on-error`, or could not be read. A run that skipped part of itself did not gather the evidence its green claims — a suite re-run for one platform only is not a verdict about the other one. Re-run the suite WITHOUT narrowing it.",
969
1005
  run_conclusion: "",
970
1006
  job_conclusion: "",
@@ -1030,7 +1066,7 @@ export function formatReport(verdict, context) {
1030
1066
  ` - ${finding.label} — ${finding.conclusion ?? finding.reason}${finding.url ? `: ${finding.url}` : ""}`
1031
1067
  ),
1032
1068
  "",
1033
- "The nightly is still red. This waives the gate for THIS pull request only; the tracking issue stays open until a green run lands."
1069
+ `The nightly is still red. This waives the gate for THIS pull request only; the tracking issue — filed and maintained by the \`nightly-e2e-report\` workflow, one per suite — stays open until a green run lands.`
1034
1070
  );
1035
1071
  } else if (verdict.blocked) {
1036
1072
  if (verdict.bypass && !verdict.bypass.valid) {
@@ -1055,6 +1091,290 @@ export function formatReport(verdict, context) {
1055
1091
  return `${lines.join("\n")}\n`;
1056
1092
  }
1057
1093
 
1094
+ // ---------------------------------------------------------------------------
1095
+ // 6.1 The tracking issue — §10 of the contract
1096
+ // ---------------------------------------------------------------------------
1097
+ //
1098
+ // The gate blocks merges, which tells whoever opened a pull request. It tells
1099
+ // NOBODY ELSE. A red nightly with no open pull requests is invisible, there is
1100
+ // nothing to assign, and there is no record that the suite came back. This half
1101
+ // closes that: exactly ONE open tracking issue per suite, refreshed on each red
1102
+ // night and closed when a full green run lands.
1103
+ //
1104
+ // One per SUITE, never one per red night. A reporter that files every morning
1105
+ // produces a backlog nobody triages and makes the suite's actual state illegible
1106
+ // from the issue list. The issue is a STATE MIRROR of one suite.
1107
+ //
1108
+ // Filing is REPORTING, not verdict, and the two are kept apart structurally:
1109
+ // this code runs only from `runReport` (the scheduled reporting workflow), never
1110
+ // from `runGate` (the required status check). The gate's reusable workflow does
1111
+ // not even request `issues:` scope, so an issues API that is down, throttled or
1112
+ // forbidden cannot turn a green nightly into a red pull request.
1113
+
1114
+ /**
1115
+ * Whether a finding rests on evidence the suite actually gathered.
1116
+ *
1117
+ * Row 26 already refuses to call a partial run a pass, so for the GATE this is
1118
+ * implied. The reporter asks it separately and on purpose: closing a tracking
1119
+ * issue is a stronger claim than letting a pull request through — it announces
1120
+ * that a suite is healthy — and it is the one action that must never fire on a
1121
+ * run that skipped part of itself. propswap's trap, in their words: *one spec
1122
+ * reporting success would close the tracking issue while the failures that
1123
+ * opened it went unrun.* Asking the question here means a future loosening of
1124
+ * row 26 cannot silently re-open that hole.
1125
+ *
1126
+ * @param {{reason: string}} finding - A finding from `assessSuite`
1127
+ * @returns {boolean} True when the run was complete
1128
+ */
1129
+ export function isCompleteEvidence(finding) {
1130
+ return finding.reason !== INCOMPLETE_EVIDENCE_REASON;
1131
+ }
1132
+
1133
+ /**
1134
+ * Percent-encodes a string down to `[A-Za-z0-9_.~%]`, comment-safe.
1135
+ *
1136
+ * `encodeURIComponent` alone leaves `-`, `!`, `'`, `(`, `)` and `*` untouched.
1137
+ * Only `>` can actually terminate an HTML comment and that one it does escape,
1138
+ * so this is belt and braces — but a marker is an identity, and an identity that
1139
+ * needs a paragraph explaining why it *happens* to be safe is one refactor away
1140
+ * from not being.
1141
+ *
1142
+ * @param {string} value - Arbitrary text
1143
+ * @returns {string} An inert encoding of it
1144
+ */
1145
+ function inertEncode(value) {
1146
+ return encodeURIComponent(value).replace(
1147
+ /[-!'()*]/g,
1148
+ character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`
1149
+ );
1150
+ }
1151
+
1152
+ /**
1153
+ * The HTML-comment marker that ties one issue to one suite.
1154
+ *
1155
+ * Identity has to survive an edited title, a renamed label and a human's
1156
+ * rewording, so it lives in the body as a comment rather than in anything a
1157
+ * person is likely to touch. The suite label is encoded, which is not
1158
+ * decoration: a suite called `evil --> <!--` would otherwise terminate the
1159
+ * comment early and make its marker match every other suite's issue — which on
1160
+ * a green night would close them all.
1161
+ *
1162
+ * @param {string} label - The suite label
1163
+ * @returns {string} A marker unique to that suite
1164
+ */
1165
+ export function suiteMarker(label) {
1166
+ return `<!-- lisa_nightly_e2e_suite:${inertEncode(label)} -->`;
1167
+ }
1168
+
1169
+ /**
1170
+ * A compact fingerprint of *why* a suite is red, embedded in the issue body.
1171
+ *
1172
+ * It is what lets a refresh tell "still the same failure" from "a different
1173
+ * failure now", so the reporter can rewrite the body every night (free) while
1174
+ * commenting (a notification) only when something actually changed.
1175
+ *
1176
+ * @param {object} finding - A finding
1177
+ * @returns {string} An opaque, comment-safe stamp
1178
+ */
1179
+ function evidenceMarker(finding) {
1180
+ return `<!-- lisa_nightly_e2e_evidence:${inertEncode(
1181
+ [finding.reason, finding.conclusion ?? "", finding.url ?? ""].join("|")
1182
+ )} -->`;
1183
+ }
1184
+
1185
+ /**
1186
+ * The issue title for one suite.
1187
+ *
1188
+ * @param {object} finding - A finding
1189
+ * @returns {string} A title
1190
+ */
1191
+ function issueTitle(finding) {
1192
+ return `🌙 Nightly e2e is not green: ${finding.label}`;
1193
+ }
1194
+
1195
+ /**
1196
+ * The issue body — written for whoever is standing at the gate.
1197
+ *
1198
+ * Lisa's factories are meant to be operable by people who do not read code
1199
+ * (AGENTS.md), and a tracking issue is one of the artefacts that crosses the
1200
+ * gate outward. So it opens with what broke and what to do, and keeps the
1201
+ * machine detail below a fold.
1202
+ *
1203
+ * @param {object} finding - A finding
1204
+ * @param {{branch: string, now: Date}} context - Reporting context
1205
+ * @returns {string} Markdown
1206
+ */
1207
+ function issueBody(finding, context) {
1208
+ const detail = REASON_TEXT[finding.reason] || "";
1209
+ const runLine = finding.url
1210
+ ? `[the run that reported it](${finding.url})`
1211
+ : "the Actions tab";
1212
+ return [
1213
+ suiteMarker(finding.label),
1214
+ evidenceMarker(finding),
1215
+ "",
1216
+ `## The \`${finding.label}\` end-to-end suite is not passing on \`${context.branch}\``,
1217
+ "",
1218
+ `**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.`,
1219
+ "",
1220
+ "**What to do, in order.**",
1221
+ "",
1222
+ `1. Open ${runLine} and read what failed.`,
1223
+ "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.",
1224
+ `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.`,
1225
+ "",
1226
+ "**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.",
1227
+ "",
1228
+ "<details><summary>Details</summary>",
1229
+ "",
1230
+ "| | |",
1231
+ "|---|---|",
1232
+ `| Suite | ${finding.label} |`,
1233
+ `| Workflow | \`${finding.workflow ?? "—"}\` |`,
1234
+ `| Branch | \`${context.branch}\` |`,
1235
+ `| Newest run | ${finding.conclusion ?? "—"}${finding.createdAt ? ` at ${finding.createdAt}` : ""}${finding.event ? ` via \`${finding.event}\`` : ""} |`,
1236
+ `| Why it is not green | ${detail || finding.reason} |`,
1237
+ `| Last checked | ${context.now.toISOString()} |`,
1238
+ "",
1239
+ "</details>",
1240
+ "",
1241
+ "<sub>Filed and maintained by Lisa's nightly e2e reporter. The contract is `docs/nightly-e2e-gate.md` §10.</sub>",
1242
+ ].join("\n");
1243
+ }
1244
+
1245
+ /**
1246
+ * Decides what to do about every suite's tracking issue. PURE.
1247
+ *
1248
+ * Every HTTP call lives in `applyIssuePlan`, so the whole decision — including
1249
+ * the one that must never misfire, closing — is testable without a network.
1250
+ *
1251
+ * @param {ReadonlyArray<object>} findings - Findings from `assessSuite`, RAW
1252
+ * (never run through `decide`, whose bootstrap rendering would hide a suite's
1253
+ * real state from the reporter)
1254
+ * @param {ReadonlyArray<object>} openIssues - Open issues carrying the label
1255
+ * @param {{branch: string, label: string, now: Date}} context - Reporting context
1256
+ * @returns {ReadonlyArray<object>} One plan entry per suite
1257
+ */
1258
+ export function planIssueActions(findings, openIssues, context) {
1259
+ return Object.freeze(
1260
+ findings.map(finding => {
1261
+ const marker = suiteMarker(finding.label);
1262
+ const matches = openIssues
1263
+ // `GET /repos/{owner}/{repo}/issues` returns PULL REQUESTS as well as
1264
+ // issues. Mistaking one for the tracking issue would comment on
1265
+ // somebody's pull request and then CLOSE it the night the suite went
1266
+ // green.
1267
+ .filter(issue => !issue.pull_request)
1268
+ .filter(issue => (issue.body ?? "").includes(marker))
1269
+ .slice()
1270
+ .sort((left, right) => left.number - right.number);
1271
+ const numbers = Object.freeze(matches.map(issue => issue.number));
1272
+ const base = {
1273
+ label: finding.label,
1274
+ state: finding.state,
1275
+ issues: numbers,
1276
+ title: null,
1277
+ body: null,
1278
+ comment: null,
1279
+ };
1280
+ const quiet = reason => ({
1281
+ ...base,
1282
+ action: ISSUE_ACTIONS.none,
1283
+ reason,
1284
+ });
1285
+
1286
+ // Evidence the suite never gathered decides NOTHING. It does not close an
1287
+ // issue (that would be an all-clear the run cannot support) and it does
1288
+ // not open one (absence of evidence is not evidence of failure). The
1289
+ // existing issue simply stays as it was.
1290
+ if (!isCompleteEvidence(finding)) return quiet("evidence_incomplete");
1291
+
1292
+ if (finding.state === SUITE_STATES.fail) {
1293
+ if (matches.length === 0) {
1294
+ return {
1295
+ ...base,
1296
+ action: ISSUE_ACTIONS.create,
1297
+ reason: "red_filed",
1298
+ title: issueTitle(finding),
1299
+ body: issueBody(finding, context),
1300
+ };
1301
+ }
1302
+ // The oldest open match is the canonical one. A second match means a
1303
+ // duplicate got filed anyway (a hand-filed issue, or a race that beat
1304
+ // the concurrency group); refreshing the oldest keeps the history in one
1305
+ // place, and the green night closes every duplicate at once.
1306
+ const unchanged = (matches[0].body ?? "").includes(
1307
+ evidenceMarker(finding)
1308
+ );
1309
+ return {
1310
+ ...base,
1311
+ issues: Object.freeze([matches[0].number]),
1312
+ action: ISSUE_ACTIONS.refresh,
1313
+ reason: "red_refreshed",
1314
+ title: issueTitle(finding),
1315
+ body: issueBody(finding, context),
1316
+ // A comment is a notification. One every night for the same failure
1317
+ // trains people to mute the issue that is supposed to be alerting
1318
+ // them, so only a CHANGE in the evidence earns one.
1319
+ comment: unchanged
1320
+ ? null
1321
+ : `🔴 Still not green, and the evidence changed.\n\n${formatFinding(finding)}`,
1322
+ };
1323
+ }
1324
+
1325
+ if (finding.state === SUITE_STATES.pass) {
1326
+ if (matches.length === 0) return quiet("green_untracked");
1327
+ return {
1328
+ ...base,
1329
+ action: ISSUE_ACTIONS.close,
1330
+ reason: "green_complete",
1331
+ comment: `✅ Closing automatically: a complete green run landed for **${finding.label}** on \`${context.branch}\`.${finding.url ? `\n\n${finding.url}` : ""}`,
1332
+ };
1333
+ }
1334
+
1335
+ return quiet("evidence_missing");
1336
+ })
1337
+ );
1338
+ }
1339
+
1340
+ /**
1341
+ * Renders the reporting outcome for the job log and summary.
1342
+ *
1343
+ * @param {ReadonlyArray<object>} results - Output of `applyIssuePlan`
1344
+ * @param {{branch: string}} context - Reporting context
1345
+ * @returns {string} Markdown
1346
+ */
1347
+ export function formatIssueReport(results, context) {
1348
+ const say = {
1349
+ create: "filed a tracking issue",
1350
+ refresh: "refreshed the open tracking issue",
1351
+ close: "closed the tracking issue — the suite is green again",
1352
+ none: "left the tracking state alone",
1353
+ };
1354
+ const lines = [
1355
+ "## 🌙 Nightly E2E tracking issues",
1356
+ "",
1357
+ `Branch: \`${context.branch}\``,
1358
+ "",
1359
+ ];
1360
+ for (const result of results) {
1361
+ const where = result.issues.length
1362
+ ? ` (#${result.issues.join(", #")})`
1363
+ : "";
1364
+ lines.push(
1365
+ result.ok
1366
+ ? `- ✅ **${result.label}** — ${say[result.action] ?? result.action}${where} [${result.reason}]`
1367
+ : `- ⚠️ **${result.label}** — could not ${result.action} its tracking issue${where}: ${result.error}`
1368
+ );
1369
+ }
1370
+ lines.push(
1371
+ "",
1372
+ "This job REPORTS; it does not gate. Nothing here can block a pull request — the merge gate is a separate workflow that never writes.",
1373
+ ""
1374
+ );
1375
+ return `${lines.join("\n")}\n`;
1376
+ }
1377
+
1058
1378
  // ---------------------------------------------------------------------------
1059
1379
  // 7. The Actions API
1060
1380
  // ---------------------------------------------------------------------------
@@ -1260,6 +1580,215 @@ export async function observe(api, suites, branch, wait) {
1260
1580
  );
1261
1581
  }
1262
1582
 
1583
+ /**
1584
+ * One write against the Issues API, with the same bounded retry as `apiGet`.
1585
+ *
1586
+ * Deliberately a SEPARATE function rather than a `method` parameter on
1587
+ * `apiGet`: everything the merge gate calls must be provably read-only, and a
1588
+ * shared function with a write mode makes that a matter of reading argument
1589
+ * lists. Nothing on the gate path can reach this.
1590
+ *
1591
+ * @param {object} api - `{ apiUrl, repo, token, maxAttempts, retryMaxSeconds }`
1592
+ * @param {string} method - `POST` or `PATCH`
1593
+ * @param {string} path - API path beginning with `/`
1594
+ * @param {object} payload - JSON body
1595
+ * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep, for tests
1596
+ * @returns {Promise<object>} The created or updated resource
1597
+ * @throws {GateApiError} When the write did not land
1598
+ */
1599
+ export async function apiWrite(api, method, path, payload, wait = sleep) {
1600
+ let lastProblem = "unknown";
1601
+ for (let attempt = 1; attempt <= api.maxAttempts; attempt += 1) {
1602
+ let response;
1603
+ try {
1604
+ response = await fetch(`${api.apiUrl}${path}`, {
1605
+ method,
1606
+ headers: {
1607
+ accept: "application/vnd.github+json",
1608
+ authorization: `Bearer ${api.token}`,
1609
+ "content-type": "application/json",
1610
+ "x-github-api-version": "2022-11-28",
1611
+ "user-agent": "lisa-nightly-e2e-health",
1612
+ },
1613
+ body: JSON.stringify(payload),
1614
+ });
1615
+ } catch (error) {
1616
+ lastProblem = `network error: ${error instanceof Error ? error.message : String(error)}`;
1617
+ if (attempt < api.maxAttempts)
1618
+ await wait(
1619
+ retryDelayMs({ headers: null }, attempt, api.retryMaxSeconds)
1620
+ );
1621
+ continue;
1622
+ }
1623
+ if (response.ok) return await response.json();
1624
+ const remaining = response.headers?.get?.("x-ratelimit-remaining");
1625
+ const throttled =
1626
+ response.status === 429 || (response.status === 403 && remaining === "0");
1627
+ if ((response.status === 401 || response.status === 403) && !throttled) {
1628
+ throw new GateApiError(
1629
+ `The Issues API returned ${response.status} for ${method} ${path}. The reporting job needs \`permissions: issues: write\`. (The merge gate does NOT — it is a separate workflow that never writes, so this cannot block anyone's pull request.)`
1630
+ );
1631
+ }
1632
+ if (response.status === 404) {
1633
+ throw new GateApiError(
1634
+ `The Issues API returned 404 for ${method} ${path}. Either Issues are disabled on this repository or the target issue no longer exists.`
1635
+ );
1636
+ }
1637
+ lastProblem = `HTTP ${response.status}`;
1638
+ if (attempt < api.maxAttempts) {
1639
+ await wait(retryDelayMs(response, attempt, api.retryMaxSeconds));
1640
+ }
1641
+ }
1642
+ throw new GateApiError(
1643
+ `The Issues API stayed unwritable for ${method} ${path} after ${api.maxAttempts} attempts (${lastProblem}).`
1644
+ );
1645
+ }
1646
+
1647
+ /**
1648
+ * Every OPEN issue carrying the tracking label, paginated to exhaustion.
1649
+ *
1650
+ * Read through the issues LIST rather than the search API on purpose. Search is
1651
+ * an index with its own latency, and an index that has not caught up yet reports
1652
+ * "no issue for this suite" — which is exactly how a reporter files a duplicate
1653
+ * every night. The list endpoint is immediately consistent.
1654
+ *
1655
+ * An unknown label simply matches nothing (an empty list, not an error), which
1656
+ * is what makes the very first run — before the label exists — file rather than
1657
+ * fail.
1658
+ *
1659
+ * @param {object} api - API coordinates
1660
+ * @param {string} label - The tracking label
1661
+ * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
1662
+ * @returns {Promise<ReadonlyArray<object>>} Open issues
1663
+ */
1664
+ export async function fetchTrackingIssues(api, label, wait) {
1665
+ const issues = [];
1666
+ for (let page = 1; page <= api.maxPages; page += 1) {
1667
+ const query = new URLSearchParams({
1668
+ state: "open",
1669
+ labels: label,
1670
+ per_page: "100",
1671
+ page: String(page),
1672
+ });
1673
+ const result = await apiGet(
1674
+ api,
1675
+ `/repos/${api.repo}/issues?${query}`,
1676
+ wait
1677
+ );
1678
+ if (result === null) {
1679
+ throw new GateApiError(
1680
+ `Could not list issues for ${api.repo} (404). Issues are probably disabled on this repository, so the nightly reporter has nowhere to file. Enable Issues or stop scheduling the reporting workflow.`
1681
+ );
1682
+ }
1683
+ const batch = Array.isArray(result.body) ? result.body : [];
1684
+ issues.push(...batch);
1685
+ if (batch.length < 100) return Object.freeze(issues);
1686
+ }
1687
+ throw new GateApiError(
1688
+ `More than \`api_max_pages\` (${api.maxPages}) pages of open \`${label}\` issues. Refusing to file against a truncated list, which would duplicate an issue this reporter already owns.`
1689
+ );
1690
+ }
1691
+
1692
+ /**
1693
+ * Executes a plan. One suite's failure never abandons the others.
1694
+ *
1695
+ * Sequential rather than parallel: these are writes to one issue set, and a
1696
+ * burst of them is exactly what GitHub's secondary rate limits exist to slow
1697
+ * down. Ordered execution also makes the job log readable top to bottom.
1698
+ *
1699
+ * @param {object} api - API coordinates
1700
+ * @param {ReadonlyArray<object>} plan - Output of `planIssueActions`
1701
+ * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
1702
+ * @returns {Promise<ReadonlyArray<object>>} One result per plan entry
1703
+ */
1704
+ export async function applyIssuePlan(api, plan, wait) {
1705
+ const results = [];
1706
+ for (const entry of plan) {
1707
+ const base = {
1708
+ label: entry.label,
1709
+ action: entry.action,
1710
+ reason: entry.reason,
1711
+ issues: entry.issues,
1712
+ };
1713
+ try {
1714
+ if (entry.action === ISSUE_ACTIONS.create) {
1715
+ const created = await apiWrite(
1716
+ api,
1717
+ "POST",
1718
+ `/repos/${api.repo}/issues`,
1719
+ {
1720
+ title: entry.title,
1721
+ body: entry.body,
1722
+ labels: [api.issueLabel ?? TRACKING_ISSUE_LABEL],
1723
+ },
1724
+ wait
1725
+ );
1726
+ results.push({
1727
+ ...base,
1728
+ issues: Object.freeze([created.number]),
1729
+ ok: true,
1730
+ error: null,
1731
+ });
1732
+ continue;
1733
+ }
1734
+ if (entry.action === ISSUE_ACTIONS.refresh) {
1735
+ const [number] = entry.issues;
1736
+ await apiWrite(
1737
+ api,
1738
+ "PATCH",
1739
+ `/repos/${api.repo}/issues/${number}`,
1740
+ { title: entry.title, body: entry.body },
1741
+ wait
1742
+ );
1743
+ if (entry.comment) {
1744
+ await apiWrite(
1745
+ api,
1746
+ "POST",
1747
+ `/repos/${api.repo}/issues/${number}/comments`,
1748
+ { body: entry.comment },
1749
+ wait
1750
+ );
1751
+ }
1752
+ results.push({ ...base, ok: true, error: null });
1753
+ continue;
1754
+ }
1755
+ if (entry.action === ISSUE_ACTIONS.close) {
1756
+ for (const number of entry.issues) {
1757
+ if (entry.comment) {
1758
+ await apiWrite(
1759
+ api,
1760
+ "POST",
1761
+ `/repos/${api.repo}/issues/${number}/comments`,
1762
+ { body: entry.comment },
1763
+ wait
1764
+ );
1765
+ }
1766
+ await apiWrite(
1767
+ api,
1768
+ "PATCH",
1769
+ `/repos/${api.repo}/issues/${number}`,
1770
+ { state: "closed", state_reason: "completed" },
1771
+ wait
1772
+ );
1773
+ }
1774
+ results.push({ ...base, ok: true, error: null });
1775
+ continue;
1776
+ }
1777
+ results.push({ ...base, ok: true, error: null });
1778
+ } catch (error) {
1779
+ // One suite's reporting failure must not silence the rest. Recorded, not
1780
+ // thrown: the caller decides the job's exit code once, having seen every
1781
+ // suite.
1782
+ results.push({
1783
+ ...base,
1784
+ ok: false,
1785
+ error: error instanceof Error ? error.message : String(error),
1786
+ });
1787
+ }
1788
+ }
1789
+ return Object.freeze(results);
1790
+ }
1791
+
1263
1792
  /**
1264
1793
  * Reads who most recently applied the bypass label, from the PR's issue events.
1265
1794
  *
@@ -1383,7 +1912,11 @@ export function resolveSettings(env) {
1383
1912
  maxAttempts: limits.apiMaxAttempts,
1384
1913
  maxPages: limits.apiMaxPages,
1385
1914
  retryMaxSeconds: limits.apiRetryMaxSeconds,
1915
+ // Carried on the API coordinates so the reporting writes cannot be
1916
+ // labelled differently from the reads that look for them.
1917
+ issueLabel: env.NIGHTLY_ISSUE_LABEL || TRACKING_ISSUE_LABEL,
1386
1918
  },
1919
+ issueLabel: env.NIGHTLY_ISSUE_LABEL || TRACKING_ISSUE_LABEL,
1387
1920
  branch,
1388
1921
  suites: validateSuites(env.NIGHTLY_SUITES),
1389
1922
  freshnessHours: limits.freshnessHours,
@@ -1470,6 +2003,96 @@ export async function runGate(env, wait) {
1470
2003
  };
1471
2004
  }
1472
2005
 
2006
+ /**
2007
+ * Runs the REPORTING half: files, refreshes and closes the tracking issues.
2008
+ *
2009
+ * Reads exactly what the gate reads and assesses it with exactly the same
2010
+ * classifier, so the issue and the merge gate can never disagree about whether a
2011
+ * suite is red. What it does NOT do is call `decide`: that renders `unknown` as
2012
+ * `bootstrap` while a window is open, and the reporter needs each suite's real
2013
+ * state. (Bootstrap changes nothing here anyway — an `unknown` suite is left
2014
+ * alone either way, and a genuinely red one is red inside the window too.)
2015
+ *
2016
+ * The bypass is likewise absent by construction: a bypass waives the gate for
2017
+ * ONE pull request, it does not make the nightly green, and the tracking issue
2018
+ * stays open until a green run lands.
2019
+ *
2020
+ * @param {NodeJS.ProcessEnv} env - The environment
2021
+ * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
2022
+ * @returns {Promise<object>} Findings, plan and per-suite results
2023
+ */
2024
+ export async function runReport(env, wait) {
2025
+ const settings = resolveSettings(env);
2026
+ const now = new Date();
2027
+ const observations = await observe(
2028
+ settings.api,
2029
+ settings.suites,
2030
+ settings.branch,
2031
+ wait
2032
+ );
2033
+ const findings = settings.suites.map((suite, index) =>
2034
+ assessSuite(suite, observations[index], {
2035
+ branch: settings.branch,
2036
+ freshnessHours: settings.freshnessHours,
2037
+ now,
2038
+ })
2039
+ );
2040
+ const plan = planIssueActions(
2041
+ findings,
2042
+ await fetchTrackingIssues(settings.api, settings.issueLabel, wait),
2043
+ { branch: settings.branch, label: settings.issueLabel, now }
2044
+ );
2045
+ return {
2046
+ findings,
2047
+ plan,
2048
+ results: await applyIssuePlan(settings.api, plan, wait),
2049
+ settings,
2050
+ };
2051
+ }
2052
+
2053
+ /**
2054
+ * The reporting entry point, as the scheduled workflow invokes it.
2055
+ *
2056
+ * Its exit code answers "did REPORTING work", never "is the suite green". A red
2057
+ * nightly reported correctly is a SUCCESSFUL report — conflating the two would
2058
+ * hand operators a second red check that means something different from the
2059
+ * first one.
2060
+ *
2061
+ * @param {boolean} asJson - Emit the machine record instead of prose
2062
+ * @returns {Promise<void>} Resolves once the report is written
2063
+ */
2064
+ async function reportIssues(asJson) {
2065
+ let outcome;
2066
+ try {
2067
+ outcome = await runReport(process.env);
2068
+ } catch (error) {
2069
+ const message = error instanceof Error ? error.message : String(error);
2070
+ process.stderr.write(
2071
+ `::error title=Nightly E2E reporting failed::${message.split("\n")[0]}\n`
2072
+ );
2073
+ process.stdout.write(`${message}\n`);
2074
+ process.exitCode = 1;
2075
+ return;
2076
+ }
2077
+ const { settings, ...machine } = outcome;
2078
+ if (asJson) {
2079
+ process.stdout.write(`${JSON.stringify(machine, null, 2)}\n`);
2080
+ } else {
2081
+ const report = formatIssueReport(machine.results, {
2082
+ branch: settings.branch,
2083
+ });
2084
+ process.stdout.write(report);
2085
+ await appendSummary(report);
2086
+ }
2087
+ const failed = machine.results.filter(result => !result.ok);
2088
+ for (const result of failed) {
2089
+ process.stderr.write(
2090
+ `::error title=Nightly E2E tracking issue not updated::${result.label} — ${result.error}\n`
2091
+ );
2092
+ }
2093
+ if (failed.length > 0) process.exitCode = 1;
2094
+ }
2095
+
1473
2096
  /**
1474
2097
  * CLI.
1475
2098
  *
@@ -1482,6 +2105,9 @@ async function main(argv) {
1482
2105
  return;
1483
2106
  }
1484
2107
  const asJson = argv.includes("--json");
2108
+ // The reporting half is opt-in at the call site, which is what keeps the
2109
+ // default invocation — the required status check — provably read-only.
2110
+ if (argv.includes("--report-issues")) return await reportIssues(asJson);
1485
2111
 
1486
2112
  /** @type {object} */
1487
2113
  let verdict;