@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.
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +22 -11
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/expo/create-only/.github/workflows/nightly-e2e-report.yml +71 -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/.codex-plugin/skills/lisa-atlassian-access/SKILL.md +75 -64
- package/plugins/lisa/.codex-plugin/skills/lisa-jam-access/SKILL.md +13 -5
- package/plugins/lisa/.codex-plugin/skills/lisa-linear-access/SKILL.md +30 -10
- package/plugins/lisa/.codex-plugin/skills/lisa-notion-access/SKILL.md +36 -23
- package/plugins/lisa/.codex-plugin/skills/lisa-posthog-access/SKILL.md +16 -6
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/SKILL.md +8 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-sentry-access/SKILL.md +16 -6
- package/plugins/lisa/.codex-plugin/skills/lisa-sonarcloud-access/SKILL.md +7 -1
- package/plugins/lisa/rules/eager/integration-access-layer.md +7 -3
- package/plugins/lisa/rules/reference/credential-substrate-precedence.md +166 -0
- package/plugins/lisa/rules/reference/integration-access-layer.md +27 -15
- package/plugins/lisa/skills/lisa-atlassian-access/SKILL.md +76 -65
- package/plugins/lisa/skills/lisa-jam-access/SKILL.md +14 -6
- package/plugins/lisa/skills/lisa-linear-access/SKILL.md +31 -11
- package/plugins/lisa/skills/lisa-notion-access/SKILL.md +37 -24
- package/plugins/lisa/skills/lisa-posthog-access/SKILL.md +17 -7
- package/plugins/lisa/skills/lisa-secrets-access/SKILL.md +8 -0
- package/plugins/lisa/skills/lisa-sentry-access/SKILL.md +17 -7
- package/plugins/lisa/skills/lisa-sonarcloud-access/SKILL.md +7 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-atlassian-access/SKILL.md +76 -65
- package/plugins/lisa-agy/skills/lisa-jam-access/SKILL.md +14 -6
- package/plugins/lisa-agy/skills/lisa-linear-access/SKILL.md +31 -11
- package/plugins/lisa-agy/skills/lisa-notion-access/SKILL.md +37 -24
- package/plugins/lisa-agy/skills/lisa-posthog-access/SKILL.md +17 -7
- package/plugins/lisa-agy/skills/lisa-secrets-access/SKILL.md +8 -0
- package/plugins/lisa-agy/skills/lisa-sentry-access/SKILL.md +17 -7
- package/plugins/lisa-agy/skills/lisa-sonarcloud-access/SKILL.md +7 -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-copilot/rules/eager/integration-access-layer.md +7 -3
- package/plugins/lisa-copilot/rules/reference/credential-substrate-precedence.md +166 -0
- package/plugins/lisa-copilot/rules/reference/integration-access-layer.md +27 -15
- package/plugins/lisa-copilot/skills/lisa-atlassian-access/SKILL.md +76 -65
- package/plugins/lisa-copilot/skills/lisa-jam-access/SKILL.md +14 -6
- package/plugins/lisa-copilot/skills/lisa-linear-access/SKILL.md +31 -11
- package/plugins/lisa-copilot/skills/lisa-notion-access/SKILL.md +37 -24
- package/plugins/lisa-copilot/skills/lisa-posthog-access/SKILL.md +17 -7
- package/plugins/lisa-copilot/skills/lisa-secrets-access/SKILL.md +8 -0
- package/plugins/lisa-copilot/skills/lisa-sentry-access/SKILL.md +17 -7
- package/plugins/lisa-copilot/skills/lisa-sonarcloud-access/SKILL.md +7 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/rules/credential-substrate-precedence-reference.mdc +171 -0
- package/plugins/lisa-cursor/rules/integration-access-layer-reference.mdc +27 -15
- package/plugins/lisa-cursor/rules/integration-access-layer.mdc +7 -3
- package/plugins/lisa-cursor/skills/lisa-atlassian-access/SKILL.md +76 -65
- package/plugins/lisa-cursor/skills/lisa-jam-access/SKILL.md +14 -6
- package/plugins/lisa-cursor/skills/lisa-linear-access/SKILL.md +31 -11
- package/plugins/lisa-cursor/skills/lisa-notion-access/SKILL.md +37 -24
- package/plugins/lisa-cursor/skills/lisa-posthog-access/SKILL.md +17 -7
- package/plugins/lisa-cursor/skills/lisa-secrets-access/SKILL.md +8 -0
- package/plugins/lisa-cursor/skills/lisa-sentry-access/SKILL.md +17 -7
- package/plugins/lisa-cursor/skills/lisa-sonarcloud-access/SKILL.md +7 -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/plugins/src/base/rules/eager/integration-access-layer.md +7 -3
- package/plugins/src/base/rules/reference/credential-substrate-precedence.md +166 -0
- package/plugins/src/base/rules/reference/integration-access-layer.md +27 -15
- package/plugins/src/base/skills/lisa-atlassian-access/SKILL.md +76 -65
- package/plugins/src/base/skills/lisa-jam-access/SKILL.md +14 -6
- package/plugins/src/base/skills/lisa-linear-access/SKILL.md +31 -11
- package/plugins/src/base/skills/lisa-notion-access/SKILL.md +37 -24
- package/plugins/src/base/skills/lisa-posthog-access/SKILL.md +17 -7
- package/plugins/src/base/skills/lisa-secrets-access/SKILL.md +8 -0
- package/plugins/src/base/skills/lisa-sentry-access/SKILL.md +17 -7
- package/plugins/src/base/skills/lisa-sonarcloud-access/SKILL.md +7 -1
- 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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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;
|