forgedock 1.29.0 → 1.29.1
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/bin/engine-cli.mjs +12 -6
- package/bin/lean/context.mjs +27 -4
- package/bin/lean/context.test.mjs +40 -12
- package/bin/lean/lean.mjs +23 -18
- package/bin/lean/lean.test.mjs +31 -3
- package/bin/lean/orchestrate.mjs +41 -11
- package/bin/lean/prefilter.mjs +79 -14
- package/bin/lean/prefilter.test.mjs +88 -2
- package/bin/lean/recovery.test.mjs +196 -7
- package/bin/lean/render.mjs +13 -0
- package/bin/lean/testmap.mjs +17 -0
- package/bin/lean/testmap.test.mjs +16 -1
- package/commands/deploy-pr.md +5 -4
- package/commands/orchestrate.md +4 -3
- package/commands/review-pr-staging.md +6 -1
- package/docs/OUTCOME-CODES.md +1 -1
- package/package.json +1 -1
- package/scripts/code-index.sh +33 -33
- package/scripts/code-index.test.sh +21 -2
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +2 -0
- package/scripts/repo-facts.test.sh +5 -5
- package/scripts/review-prepare.sh +28 -6
- package/scripts/review-prepare.test.sh +22 -2
- package/scripts/test-map.sh +139 -33
- package/scripts/test-map.test.sh +195 -7
- package/scripts/verify-phase-trail.test.sh +44 -44
- package/scripts/wait-ci-green.sh +1 -1
- package/scripts/wait-ci-green.test.sh +4 -0
package/bin/lean/prefilter.mjs
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* Already-fixed pre-filter (#4123): cheap checks before any lane starts, so an issue that a merged PR or a later green
|
|
5
5
|
* CI run already resolved does not cost a worktree, a lease and a builder session.
|
|
6
6
|
* 1. merged PRs (and lane commits) that claim to fix #N -> candidate
|
|
7
|
-
* 2. auto-filed CI-failure issue whose workflow is green again on the failing branch -> closed with the run link
|
|
7
|
+
* 2. auto-filed CI-failure issue whose workflow is green again on the failing branch -> closed with the run link,
|
|
8
|
+
* unless it is flaky (#4305): flaky wording or label, a reopen after an earlier pre-filter close, or a failed run
|
|
9
|
+
* of the workflow at the green run's SHA. Then the shortcut is skipped and the issue goes on as normal.
|
|
8
10
|
* 3. every repo path the issue names is gone from origin/<lane> -> candidate
|
|
9
11
|
* A candidate gets ONE read-only, single-turn validation on a small model in the main checkout (no worktree). Only an
|
|
10
12
|
* ALREADY-FIXED/INVALID verdict with evidence closes the issue; everything else, and every error, goes to the normal lane.
|
|
@@ -12,6 +14,8 @@
|
|
|
12
14
|
*/
|
|
13
15
|
import { capComment, sanitizeMd, noComments } from "./render.mjs";
|
|
14
16
|
import { PREFILTER_SYSTEM, prefilterPrompt, parsePrefilterVerdict } from "./prompts.mjs";
|
|
17
|
+
import { isTrustedComment } from "../engine/phases.mjs";
|
|
18
|
+
import { APP_SLUG } from "../init-repo.mjs";
|
|
15
19
|
|
|
16
20
|
export const DEFAULT_PREFILTER_MODEL = "claude-haiku-5-5";
|
|
17
21
|
export const PREFILTER_MARKER = "FORGE:PREFILTER";
|
|
@@ -35,6 +39,40 @@ export function ciFailureRun(iss) {
|
|
|
35
39
|
return m && CI_FAIL_RE.test(text) ? { repo: m[1], id: Number(m[2]) } : null;
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
// #4316: a bare "sometimes" is ordinary prose; only "fails sometimes" / "sometimes fails" says the failure is intermittent
|
|
43
|
+
const FLAKY_RE = /\b(?:intermittent\w*|flaky|flak(?:e|es|iness)|fail(?:s|ed|ing)?\s+sometimes|sometimes\s+fail(?:s|ed|ing)?|non-?deterministic\w*)\b/i;
|
|
44
|
+
const loginOf = (c) => String(c?.author?.login ?? c?.user?.login ?? "");
|
|
45
|
+
const trusted = (c) => {
|
|
46
|
+
const login = loginOf(c);
|
|
47
|
+
return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
|
|
48
|
+
};
|
|
49
|
+
/** #4315: a login compared as the engine posts it: lowercased, without `app/` or a `[bot]` suffix (`gh issue view` drops both). */
|
|
50
|
+
const bareLogin = (l) => String(l ?? "").trim().toLowerCase().replace(/^app\//, "").replace(/\[bot\]$/, "");
|
|
51
|
+
const isMarked = (c) => String(c?.body ?? "").trimStart().startsWith(`<!-- ${PREFILTER_MARKER} -->`);
|
|
52
|
+
/**
|
|
53
|
+
* #4315: the logins the engine posts as: the ForgeDock App (`gh issue view` reports it as `rapiercraft-forgedock` with
|
|
54
|
+
* CONTRIBUTOR, never `[bot]`) plus the token's own user (`gh api user`; an App installation token cannot read it).
|
|
55
|
+
*/
|
|
56
|
+
export async function ownLogins(gh) {
|
|
57
|
+
const out = new Set([bareLogin(APP_SLUG)]);
|
|
58
|
+
try { const me = bareLogin(await gh(["api", "user", "--jq", ".login"])); if (me) out.add(me); } catch { /* installation token: the App slug only */ }
|
|
59
|
+
return [...out];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* #4305: why one green run is no proof for this issue, without an API call: flaky wording or a `flaky` label, or an
|
|
63
|
+
* earlier pre-filter close by the pipeline (the issue is open again, so an operator reopened it). {reason, why} or null.
|
|
64
|
+
* #4315: a marker comment is the pipeline's own when it is trusted or written by one of `own` (bare logins).
|
|
65
|
+
*/
|
|
66
|
+
export function flakyReason(iss, own = [bareLogin(APP_SLUG)]) {
|
|
67
|
+
if (FLAKY_RE.test(`${iss.title ?? ""}\n${noComments(String(iss.body ?? ""), " ")}`)) return { reason: "flaky", why: "issue says the failure is intermittent" };
|
|
68
|
+
if ((iss.labels ?? []).some((l) => String(l?.name ?? l).toLowerCase() === "flaky")) return { reason: "flaky", why: "flaky label" };
|
|
69
|
+
const mine = new Set((own ?? []).map(bareLogin));
|
|
70
|
+
if ((iss.comments ?? []).some((c) => isMarked(c) && (trusted(c) || mine.has(bareLogin(loginOf(c)))))) return { reason: "reopened", why: "reopened after a pre-filter close" };
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
/** The own logins, looked up only when an otherwise-untrusted marker comment needs them (no call for most issues). */
|
|
74
|
+
const ownFor = async (iss, resolve) => ((iss.comments ?? []).some((c) => isMarked(c) && !trusted(c)) ? resolve() : undefined);
|
|
75
|
+
|
|
38
76
|
const PATH_RE = /(?:^|[\s`'"(\[])((?:\.{0,2}\/)?(?:[\w@.-]+\/)+[\w@.-]+\.[A-Za-z0-9]{1,8})(?::\d+(?:-\d+)?)?(?=$|[\s`'"),.:;\]])/gm;
|
|
39
77
|
/** Repo-relative file paths the issue names (`dir/file.ext`, optionally `:line`); URLs and HTML comments are ignored. */
|
|
40
78
|
export function namedPaths(text) {
|
|
@@ -43,9 +81,9 @@ export function namedPaths(text) {
|
|
|
43
81
|
}
|
|
44
82
|
|
|
45
83
|
/**
|
|
46
|
-
* @returns {Promise<{prs:object[], commits:object[], ci:?object, missingPaths:string[], namedPaths:string[]}>}
|
|
84
|
+
* @returns {Promise<{prs:object[], commits:object[], ci:?object, ciSkip?:{reason:string, why:string}, missingPaths:string[], namedPaths:string[]}>}
|
|
47
85
|
*/
|
|
48
|
-
export async function collectSignals({ iss, repo, lane, io, cwd }) {
|
|
86
|
+
export async function collectSignals({ iss, repo, lane, io, cwd, selfLogins = null }) {
|
|
49
87
|
const n = iss.number;
|
|
50
88
|
const sig = { prs: [], commits: [], ci: null, missingPaths: [], namedPaths: [] };
|
|
51
89
|
try {
|
|
@@ -63,7 +101,19 @@ export async function collectSignals({ iss, repo, lane, io, cwd }) {
|
|
|
63
101
|
const branch = failed.head_branch || lane;
|
|
64
102
|
const latest = JSON.parse(String(await io.gh(["api", `repos/${repo}/actions/workflows/${failed.workflow_id}/runs?branch=${encodeURIComponent(branch)}&status=completed&per_page=1`])))?.workflow_runs?.[0];
|
|
65
103
|
if (latest && latest.id !== failed.id && latest.conclusion === "success" && Date.parse(latest.created_at) > Date.parse(failed.created_at)) {
|
|
66
|
-
|
|
104
|
+
const sha = latest.head_sha ?? "";
|
|
105
|
+
// #4305: a workflow that failed and passed at the same SHA is flaky; the lookup is any branch (PR run vs push)
|
|
106
|
+
// and only made when the issue itself gave no reason. An unreadable lookup throws: no CI signal.
|
|
107
|
+
const own = await ownFor(iss, typeof selfLogins === "function" ? selfLogins : selfLogins ? async () => selfLogins : () => ownLogins(io.gh));
|
|
108
|
+
let skip = flakyReason(iss, own);
|
|
109
|
+
const same = { reason: "same-sha", why: `failed and passed at ${sha.slice(0, 12)}` };
|
|
110
|
+
if (!skip && sha && failed.head_sha === sha) skip = same;
|
|
111
|
+
if (!skip && sha) {
|
|
112
|
+
const atSha = JSON.parse(String(await io.gh(["api", `repos/${repo}/actions/workflows/${failed.workflow_id}/runs?head_sha=${sha}&status=completed&per_page=100`])))?.workflow_runs ?? [];
|
|
113
|
+
if (atSha.some((r) => r?.conclusion === "failure")) skip = same;
|
|
114
|
+
}
|
|
115
|
+
if (skip) sig.ciSkip = skip;
|
|
116
|
+
else sig.ci = { workflow: failed.name ?? latest.name ?? String(failed.workflow_id), branch, failedUrl: failed.html_url, greenUrl: latest.html_url, greenSha: sha };
|
|
67
117
|
}
|
|
68
118
|
} catch { /* run unreadable: no CI signal */ }
|
|
69
119
|
}
|
|
@@ -82,10 +132,10 @@ export async function collectSignals({ iss, repo, lane, io, cwd }) {
|
|
|
82
132
|
* Pre-filter one issue. Never throws.
|
|
83
133
|
* @returns {Promise<{issue:number, closed:boolean, kind:?string, candidate:boolean, verdict:?string, cost_usd:number, detail:string}>}
|
|
84
134
|
*/
|
|
85
|
-
export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5 }) {
|
|
135
|
+
export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null }) {
|
|
86
136
|
const out = { issue, closed: false, kind: null, candidate: false, verdict: null, cost_usd: 0, detail: "" };
|
|
87
137
|
try {
|
|
88
|
-
const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels"])));
|
|
138
|
+
const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels,comments"])));
|
|
89
139
|
if (String(iss.state).toUpperCase() !== "OPEN") return { ...out, detail: "not open" };
|
|
90
140
|
if ((iss.labels ?? []).some((l) => ["needs-human", "workflow:merged"].includes(l?.name ?? l))) return { ...out, detail: "labelled, left to the lane" };
|
|
91
141
|
if (io.projector) {
|
|
@@ -93,35 +143,48 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
|
|
|
93
143
|
try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
|
|
94
144
|
if (st?.lease && st.lease.until > Date.now() && st.lease.by !== agentId) return { ...out, detail: `leased by ${st.lease.by}` };
|
|
95
145
|
}
|
|
96
|
-
const sig = await collectSignals({ iss, repo, lane, io, cwd });
|
|
146
|
+
const sig = await collectSignals({ iss, repo, lane, io, cwd, selfLogins });
|
|
97
147
|
const close = async (kind, body, detail) => {
|
|
148
|
+
let note = "";
|
|
98
149
|
if (!dryRun) {
|
|
99
|
-
|
|
150
|
+
// #4315: close first: the marker is posted only on an issue that was closed, so it always means "reopened" later
|
|
100
151
|
await io.gh(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
|
|
152
|
+
// #4314: the issue IS closed from here on: the result is closed:true whatever the comment does, so no lane starts on it.
|
|
153
|
+
// The comment is retried once, then a marker-only comment (a second, tiny payload) keeps the #4305 reopen guard working.
|
|
154
|
+
const comment = (text) => io.gh(["issue", "comment", String(issue), "-R", repo, "--body", text]);
|
|
155
|
+
const full = capComment(`<!-- ${PREFILTER_MARKER} -->\n<!-- FORGE:VALIDATE -->\n${body}`);
|
|
156
|
+
const minimal = `<!-- ${PREFILTER_MARKER} -->\nClosed by the pre-filter (${kind}); the explanation comment could not be posted. Reopen if this is wrong.`;
|
|
157
|
+
let posted = false;
|
|
158
|
+
for (const text of [full, full, minimal]) {
|
|
159
|
+
try { await comment(text); posted = true; break; } catch { /* next attempt */ }
|
|
160
|
+
}
|
|
161
|
+
if (!posted) note = " (closed, but no marker comment could be posted: a reopen is not recognised)";
|
|
101
162
|
try { await io.transitionLabel?.(issue, "invalid"); } catch { /* labels are best effort */ }
|
|
102
163
|
}
|
|
103
|
-
return { ...out, closed: !dryRun, kind, detail };
|
|
164
|
+
return { ...out, closed: !dryRun, kind, detail: `${detail}${note}` };
|
|
104
165
|
};
|
|
105
166
|
|
|
167
|
+
// #4305: a skipped CI-green shortcut is named in every non-closing detail line that follows (#4316: with its reason).
|
|
168
|
+
const keep = (detail) => ({ ...out, detail: sig.ciSkip ? `skipped CI-green shortcut: ${sig.ciSkip.reason} (${sig.ciSkip.why}); ${detail}` : detail });
|
|
106
169
|
if (sig.ci) {
|
|
107
170
|
out.candidate = true;
|
|
108
171
|
const c = sig.ci;
|
|
109
172
|
return await close("ci-green", `## Pre-filter: CI failure no longer reproduces\n**Workflow:** ${c.workflow} on \`${c.branch}\`\n**Failing run:** ${c.failedUrl}\n**Latest run (green):** ${c.greenUrl}${c.greenSha ? ` at \`${c.greenSha.slice(0, 12)}\`` : ""}\n\nClosed without a lane. Reopen if the failure comes back.`,
|
|
110
173
|
`CI green again: ${c.greenUrl}`);
|
|
111
174
|
}
|
|
112
|
-
if (!sig.prs.length && !sig.commits.length && !sig.missingPaths.length) return
|
|
175
|
+
if (!sig.prs.length && !sig.commits.length && !sig.missingPaths.length) return keep("no already-fixed signal");
|
|
113
176
|
out.candidate = true;
|
|
114
177
|
const why = [...sig.prs.map((p) => `PR #${p.number}`), ...sig.commits.map((c) => c.sha.slice(0, 12)), ...(sig.missingPaths.length ? ["named paths missing"] : [])].join(", ");
|
|
115
|
-
if (dryRun) return
|
|
178
|
+
if (dryRun) return keep(`candidate: ${why}`);
|
|
116
179
|
let r;
|
|
117
180
|
try {
|
|
118
181
|
r = await io.runner({ role: "prefilter", issue, cwd, model, resume: null, system: PREFILTER_SYSTEM, readOnly: true,
|
|
119
182
|
prompt: prefilterPrompt({ issue: iss, lane, signals: sig }), maxMs: Math.round(maxMinutes * 60000) });
|
|
120
|
-
} catch (e) { return
|
|
183
|
+
} catch (e) { return keep(`validation failed (${String(e?.message ?? e).slice(0, 120)}): lane`); }
|
|
121
184
|
out.cost_usd = Number.isFinite(r?.costUsd) ? r.costUsd : 0;
|
|
122
185
|
const v = parsePrefilterVerdict(r?.text);
|
|
123
186
|
out.verdict = v?.verdict ?? null;
|
|
124
|
-
if (!v || v.verdict === "CONFIRMED" || !v.evidence.length) return
|
|
187
|
+
if (!v || v.verdict === "CONFIRMED" || !v.evidence.length) return keep(`candidate (${why}) ${v ? (v.verdict === "CONFIRMED" ? "confirmed" : "without evidence") : "unparseable verdict"}: lane`);
|
|
125
188
|
const ev = v.evidence.map((x) => `- ${sanitizeMd(x).replace(/\s+/g, " ").slice(0, 400)}`).join("\n");
|
|
126
189
|
return await close("validated", `## Pre-filter: ${v.verdict}\n**Signal:** ${why}\n**Summary:** ${sanitizeMd(v.summary).replace(/\s+/g, " ").slice(0, 600) || "not reported"}\n\n**Evidence:**\n${ev}\n\nValidated by a single read-only check (${model}), without a lane. Reopen if this is wrong.`,
|
|
127
190
|
`${v.verdict.toLowerCase()} (${why})`);
|
|
@@ -132,7 +195,9 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
|
|
|
132
195
|
|
|
133
196
|
/** Per-issue hook for `orchestrate({ prefilter })`; `lane` is the issue's own resolved lane (#4125). */
|
|
134
197
|
export function makePrefilter(opts) {
|
|
135
|
-
|
|
198
|
+
let own = null; // #4315: one `gh api user` per batch, and only when a marker comment needs it
|
|
199
|
+
const selfLogins = opts.selfLogins ?? (() => (own ??= ownLogins(opts.io.gh)));
|
|
200
|
+
return (issue, lane) => prefilterIssue({ ...opts, selfLogins, issue, ...(lane ? { lane } : {}) });
|
|
136
201
|
}
|
|
137
202
|
|
|
138
203
|
/** One report line: how many the pre-filter checked, flagged and closed, what validation cost, and the spend avoided. */
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
|
-
import { prefilterIssue, makePrefilter, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, DEFAULT_PREFILTER_MODEL } from "./prefilter.mjs";
|
|
5
|
+
import { prefilterIssue, flakyReason, makePrefilter, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, DEFAULT_PREFILTER_MODEL } from "./prefilter.mjs";
|
|
6
6
|
import { parsePrefilterVerdict, prefilterPrompt } from "./prompts.mjs";
|
|
7
7
|
import { orchestrate, formatTable } from "./orchestrate.mjs";
|
|
8
8
|
import { triageFromCli } from "../engine-cli.mjs";
|
|
@@ -12,7 +12,7 @@ const fence = (o) => "```json\n" + JSON.stringify(o) + "\n```";
|
|
|
12
12
|
/** Fake GitHub + git for one repo `o/r`, lane `staging`. */
|
|
13
13
|
function world(o = {}) {
|
|
14
14
|
const w = { comments: [], closed: [], labels: [], runner: [], lanes: [], gitCalls: [],
|
|
15
|
-
issues: o.issues ?? {}, prs: o.prs ?? [], commits: o.commits ?? "", paths: new Set(o.paths ?? []), runs: o.runs ?? {}, workflowRuns: o.workflowRuns ?? {},
|
|
15
|
+
issues: o.issues ?? {}, prs: o.prs ?? [], commits: o.commits ?? "", paths: new Set(o.paths ?? []), runs: o.runs ?? {}, workflowRuns: o.workflowRuns ?? {}, shaRuns: o.shaRuns ?? {},
|
|
16
16
|
verdict: o.verdict ?? null };
|
|
17
17
|
w.io = {
|
|
18
18
|
gh: async (a) => {
|
|
@@ -25,6 +25,8 @@ function world(o = {}) {
|
|
|
25
25
|
if (m) return JSON.stringify(w.runs[m[1]]);
|
|
26
26
|
const wf = /actions\/workflows\/(\d+)\/runs\?branch=([^&]+)/.exec(a[1]);
|
|
27
27
|
if (wf) return JSON.stringify({ workflow_runs: w.workflowRuns[`${wf[1]}:${decodeURIComponent(wf[2])}`] ?? [] });
|
|
28
|
+
const at = /actions\/workflows\/\d+\/runs\?head_sha=([^&]+)/.exec(a[1]);
|
|
29
|
+
if (at) { w.shaQueries = (w.shaQueries ?? 0) + 1; return JSON.stringify({ workflow_runs: w.shaRuns[at[1]] ?? [] }); }
|
|
28
30
|
}
|
|
29
31
|
if (k === "issue comment") { w.comments.push([Number(a[2]), a[a.indexOf("--body") + 1]]); return ""; }
|
|
30
32
|
if (k === "issue close") { w.closed.push([Number(a[2]), a[a.indexOf("--reason") + 1]]); return ""; }
|
|
@@ -100,6 +102,90 @@ describe("pre-filter (#4123)", () => {
|
|
|
100
102
|
assert.deepEqual(w.closed, [[34, "not planned"]]);
|
|
101
103
|
});
|
|
102
104
|
|
|
105
|
+
it("#4305 the CI-green shortcut is skipped for intermittent, reopened and same-SHA-flaky CI failures", async () => {
|
|
106
|
+
const link = "Run: https://github.com/o/r/actions/runs/500";
|
|
107
|
+
const green = (issue, extra = {}) => world({ issues: { 34: { title: "shell-compat failed on staging", body: `Workflow failed.\n${link}`, ...issue } },
|
|
108
|
+
runs: { 500: { id: 500, workflow_id: 7, name: "shell-compat", head_branch: "staging", head_sha: "1111111111111111", created_at: "2026-10-10T00:00:00Z", html_url: "https://github.com/o/r/actions/runs/500" } },
|
|
109
|
+
workflowRuns: { "7:staging": [{ id: 600, conclusion: "success", created_at: "2026-10-12T00:00:00Z", html_url: "https://github.com/o/r/actions/runs/600", head_sha: "8d9f6ba0aaaaaaaa" }] }, ...extra });
|
|
110
|
+
const bot = { author: { login: "rapiercraft-forgedock[bot]" }, authorAssociation: "NONE", body: "<!-- FORGE:PREFILTER -->\n<!-- FORGE:VALIDATE -->\n## Pre-filter: CI failure no longer reproduces" };
|
|
111
|
+
const stranger = { author: { login: "someone" }, authorAssociation: "NONE", body: bot.body };
|
|
112
|
+
const cases = [
|
|
113
|
+
["intermittent wording", green({ body: `\`a.test.sh\` fails intermittently on macOS.\n${link}` }), /issue says the failure is intermittent/, "flaky"],
|
|
114
|
+
["flaky label", green({ labels: [{ name: "flaky" }] }), /flaky label/, "flaky"],
|
|
115
|
+
["reopened after a pre-filter close", green({ comments: [bot] }), /reopened after a pre-filter close/, "reopened"],
|
|
116
|
+
["failed and passed at the same SHA", green({}, { shaRuns: { "8d9f6ba0aaaaaaaa": [{ id: 601, conclusion: "failure" }, { id: 600, conclusion: "success" }] } }), /failed and passed at 8d9f6ba0aaaa/, "same-sha"],
|
|
117
|
+
];
|
|
118
|
+
for (const [name, w, why, reason] of cases) {
|
|
119
|
+
const r = await pf(w, 34);
|
|
120
|
+
assert.equal(r.closed, false, name); assert.notEqual(r.kind, "ci-green", name);
|
|
121
|
+
assert.deepEqual(w.closed, [], name); assert.deepEqual(w.comments, [], name); assert.equal(w.runner.length, 0, name);
|
|
122
|
+
assert.ok(r.detail.startsWith(`skipped CI-green shortcut: ${reason} (`), `${name}: ${r.detail}`); assert.match(r.detail, why, name);
|
|
123
|
+
assert.match(r.detail, /no already-fixed signal$/, name);
|
|
124
|
+
}
|
|
125
|
+
// wording and the reopen marker need no runs lookup; an untrusted marker comment is no reopen, and a green-only SHA still closes
|
|
126
|
+
const quiet = green({ body: `fails intermittently ${link}` });
|
|
127
|
+
await pf(quiet, 34); assert.equal(quiet.shaQueries ?? 0, 0);
|
|
128
|
+
const forged = green({ comments: [stranger] }, { shaRuns: { "8d9f6ba0aaaaaaaa": [{ id: 600, conclusion: "success" }] } });
|
|
129
|
+
const r = await pf(forged, 34);
|
|
130
|
+
assert.equal(r.kind, "ci-green"); assert.equal(forged.shaQueries, 1);
|
|
131
|
+
// an unreadable same-SHA lookup is no proof either: no shortcut
|
|
132
|
+
const down = green();
|
|
133
|
+
const gh0 = down.io.gh; down.io.gh = async (a) => (/head_sha=/.test(a[1] ?? "") ? (() => { throw new Error("HTTP 502"); })() : gh0(a));
|
|
134
|
+
assert.equal((await pf(down, 34)).closed, false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("#4316 a bare \"sometimes\" is prose, not flaky wording; \"fails sometimes\" and \"sometimes fails\" are", () => {
|
|
138
|
+
assert.equal(flakyReason({ title: "deploy check failed", body: "This sometimes happens after a deploy." }), null);
|
|
139
|
+
assert.equal(flakyReason({ title: "x", body: "Sometimes we rerun it by hand." }), null);
|
|
140
|
+
for (const body of ["the job fails sometimes on macOS", "it sometimes fails on macOS", "shell-compat failed sometimes", "intermittent timeout", "a flaky test"]) {
|
|
141
|
+
assert.equal(flakyReason({ title: "ci failed", body })?.reason, "flaky", body);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
it("#4315 a comment shaped like #4235's real pre-filter comment is the pipeline's own: a reopened CI-failure issue is kept for a lane", async () => {
|
|
145
|
+
const link = "Run: https://github.com/o/r/actions/runs/500";
|
|
146
|
+
const green = (issue, extra = {}) => world({ issues: { 34: { title: "shell-compat failed on staging", body: `Workflow failed.\n${link}`, ...issue } },
|
|
147
|
+
runs: { 500: { id: 500, workflow_id: 7, name: "shell-compat", head_branch: "staging", head_sha: "1111111111111111", created_at: "2026-10-10T00:00:00Z", html_url: "https://github.com/o/r/actions/runs/500" } },
|
|
148
|
+
workflowRuns: { "7:staging": [{ id: 600, conclusion: "success", created_at: "2026-10-12T00:00:00Z", html_url: "https://github.com/o/r/actions/runs/600", head_sha: "8d9f6ba0aaaaaaaa" }] },
|
|
149
|
+
shaRuns: { "8d9f6ba0aaaaaaaa": [{ id: 600, conclusion: "success" }] }, ...extra });
|
|
150
|
+
// the shape `gh issue view --json comments` returns for the ForgeDock App on #4235: no `[bot]`, CONTRIBUTOR, marker first
|
|
151
|
+
const body = "<!-- FORGE:PREFILTER -->\n<!-- FORGE:VALIDATE -->\n## Pre-filter: CI failure no longer reproduces\n**Workflow:** shell-compat on `staging`";
|
|
152
|
+
const real = { author: { login: "rapiercraft-forgedock" }, authorAssociation: "CONTRIBUTOR", body };
|
|
153
|
+
const w = green({ comments: [real] });
|
|
154
|
+
const r = await pf(w, 34);
|
|
155
|
+
assert.equal(r.closed, false); assert.notEqual(r.kind, "ci-green");
|
|
156
|
+
assert.match(r.detail, /reopened after a pre-filter close/);
|
|
157
|
+
assert.deepEqual(w.closed, []); assert.deepEqual(w.comments, []);
|
|
158
|
+
// the token's own user (`gh api user`) counts too, cached once per makePrefilter batch; a CONTRIBUTOR stranger does not
|
|
159
|
+
const mine = green({ comments: [{ ...real, author: { login: "Op-User" } }] });
|
|
160
|
+
let userCalls = 0; const gh0 = mine.io.gh;
|
|
161
|
+
mine.io.gh = async (a) => (a[0] === "api" && a[1] === "user" ? (userCalls++, "op-user\n") : gh0(a));
|
|
162
|
+
const run = makePrefilter({ repo: "o/r", cwd: "/repo", io: mine.io });
|
|
163
|
+
assert.match((await run(34, "staging")).detail, /reopened after a pre-filter close/);
|
|
164
|
+
await run(34, "staging"); assert.equal(userCalls, 1);
|
|
165
|
+
const stranger = green({ comments: [{ ...real, author: { login: "someone" } }] });
|
|
166
|
+
assert.equal((await pf(stranger, 34)).kind, "ci-green");
|
|
167
|
+
// BUG-4: the marker is posted only after a successful close; a failed close posts no marker
|
|
168
|
+
const order = [];
|
|
169
|
+
const ok = green(); const g2 = ok.io.gh;
|
|
170
|
+
ok.io.gh = async (a) => { if (["issue close", "issue comment"].includes(a.slice(0, 2).join(" "))) order.push(a[1]); return g2(a); };
|
|
171
|
+
assert.equal((await pf(ok, 34)).closed, true); assert.deepEqual(order, ["close", "comment"]);
|
|
172
|
+
const fails = green(); const g3 = fails.io.gh;
|
|
173
|
+
fails.io.gh = async (a) => (a.slice(0, 2).join(" ") === "issue close" ? (() => { throw new Error("HTTP 403"); })() : g3(a));
|
|
174
|
+
const f = await pf(fails, 34);
|
|
175
|
+
assert.equal(f.closed, false); assert.deepEqual(fails.comments, []); assert.match(f.detail, /pre-filter error/);
|
|
176
|
+
// #4314: a failing comment after a successful close still counts as closed (no lane), via retry, then a marker-only comment
|
|
177
|
+
const flaky = (n, big) => { const x = green(); const g = x.io.gh; let calls = 0;
|
|
178
|
+
x.io.gh = async (a) => { if (a.slice(0, 2).join(" ") === "issue comment") { calls++; if (calls <= n || (big && a[a.indexOf("--body") + 1].includes("## Pre-filter"))) throw new Error("HTTP 502"); } return g(a); };
|
|
179
|
+
return x; };
|
|
180
|
+
const once = flaky(1); const r1 = await pf(once, 34);
|
|
181
|
+
assert.equal(r1.closed, true); assert.equal(once.comments.length, 1); assert.doesNotMatch(r1.detail, /no marker/);
|
|
182
|
+
const tiny = flaky(0, true); const r2 = await pf(tiny, 34);
|
|
183
|
+
assert.equal(r2.closed, true); assert.equal(tiny.comments.length, 1); assert.match(tiny.comments[0][1], /^<!-- FORGE:PREFILTER -->/);
|
|
184
|
+
assert.match(flakyReason({ comments: [{ body: tiny.comments[0][1], author: { login: "forgedock-app[bot]" }, authorAssociation: "NONE" }] }, ["forgedock-app"])?.reason ?? "", /reopened/);
|
|
185
|
+
const dead = flaky(99); const r3 = await pf(dead, 34);
|
|
186
|
+
assert.equal(r3.closed, true); assert.equal(dead.runner.length, 0); assert.deepEqual(dead.closed, [[34, "not planned"]]); assert.match(r3.detail, /no marker comment/);
|
|
187
|
+
});
|
|
188
|
+
|
|
103
189
|
it("a CI-failure issue whose workflow is still red (or whose latest run is the failing one) goes to a lane", async () => {
|
|
104
190
|
const base = { issues: { 34: { title: "ci failed", body: "https://github.com/o/r/actions/runs/500" } },
|
|
105
191
|
runs: { 500: { id: 500, workflow_id: 7, head_branch: "staging", created_at: "2026-10-10T00:00:00Z" } } };
|
|
@@ -8,6 +8,7 @@ import { readFileSync } from "node:fs";
|
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
9
|
import { orchestrate, formatOutcomeLists, OUTCOME_LINE_MAX } from "./orchestrate.mjs";
|
|
10
10
|
import { RECOVER_HOW } from "./lean.mjs";
|
|
11
|
+
import { recoveryEvidence } from "./render.mjs";
|
|
11
12
|
import { RECOVERABLE_CODES } from "../engine/outcome-codes.mjs";
|
|
12
13
|
import { runFromCli, orchestrateFromCli } from "../engine-cli.mjs";
|
|
13
14
|
// orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir.
|
|
@@ -17,7 +18,7 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
|
|
|
17
18
|
// runOne: `script[n]` is a list of results, one per launch of issue n (the last repeats); ctx is recorded.
|
|
18
19
|
const batch = (script, bodies = {}, extra = {}) => {
|
|
19
20
|
const calls = [], decisions = [];
|
|
20
|
-
const p = orchestrate({ issues: Object.keys(script).map(Number), parallel: 3, recovery: { maxAttempts: 1 },
|
|
21
|
+
const p = orchestrate({ issues: Object.keys(script).map(Number), parallel: 3, recovery: { maxAttempts: extra.maxAttempts ?? 1 },
|
|
21
22
|
readBody: async (n) => bodies[n] ?? "",
|
|
22
23
|
runOne: async (n, _lane, ctx = {}) => {
|
|
23
24
|
calls.push({ n, recover: ctx.recover ?? null });
|
|
@@ -37,7 +38,7 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
|
|
|
37
38
|
const r = byIssue(rows);
|
|
38
39
|
assert.equal(r[1].outcome, "merged"); assert.equal(r[1].recovered, "ci.red");
|
|
39
40
|
assert.equal(r[2].outcome, "merged", "the dependent was not skipped");
|
|
40
|
-
assert.deepEqual(calls.filter((c) => c.n === 1).map((c) => c.recover), [null, { code: "ci.red", attempt: 1 }]);
|
|
41
|
+
assert.deepEqual(calls.filter((c) => c.n === 1).map((c) => c.recover), [null, { code: "ci.red", attempt: 1, final: true }]);
|
|
41
42
|
assert.ok(calls.findIndex((c) => c.n === 2) > calls.findLastIndex((c) => c.n === 1), "the dependent ran after the recovered merge");
|
|
42
43
|
const d = decisions.find((x) => x.issue === 1);
|
|
43
44
|
assert.match(d.what, /recovery lane for ci\.red/); assert.match(d.why, /Required check/); assert.ok(d.alternative);
|
|
@@ -138,7 +139,7 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
|
|
|
138
139
|
const ctxs = [];
|
|
139
140
|
const rows = await orchestrate({ issues: [1], store, recovery: { maxAttempts: 1 }, readBody: async () => "",
|
|
140
141
|
runOne: async (n, l, ctx) => { ctxs.push(ctx.recover); return NH("ci.red"); } });
|
|
141
|
-
assert.deepEqual(ctxs, [{ code: "ci.red", attempt: 1 }]);
|
|
142
|
+
assert.deepEqual(ctxs, [{ code: "ci.red", attempt: 1, final: true }]);
|
|
142
143
|
assert.equal(rows[0].outcome, "needs-human"); assert.equal(rows[0].recovery.code, "ci.red");
|
|
143
144
|
assert.deepEqual(state.recovering, {});
|
|
144
145
|
});
|
|
@@ -160,7 +161,7 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
|
|
|
160
161
|
await runFromCli(["7", "--lane", "staging", "--repo", "o/r", "--recover", "base.conflict", "--recover-attempt", "1"], { dir: tmpDir("core-rec-"), emitTelemetry: async () => {}, leanIo: {}, io: { gh: async () => "o/r\n" },
|
|
161
162
|
leanModule: { runCore: async (o) => { got.push(o.recover); return { terminalReason: "merged" }; } } });
|
|
162
163
|
} finally { console.log = l; }
|
|
163
|
-
assert.deepEqual(got, [{ code: "base.conflict", attempt: 1 }]);
|
|
164
|
+
assert.deepEqual(got, [{ code: "base.conflict", attempt: 1, final: true }]);
|
|
164
165
|
// The batch: a ci.red lane is relaunched in-process with recover, the decision is recorded and the report lists it.
|
|
165
166
|
const leanIo = {
|
|
166
167
|
gh: async (a) => (a.includes("milestone") ? JSON.stringify({ milestone: null }) : JSON.stringify({ body: "" })),
|
|
@@ -174,7 +175,7 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
|
|
|
174
175
|
io: { gh: async () => "o/r\n" }, leanIo, sched: null, orchestration: { breaker: null, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 } }, decide: (d) => decisions.push(d),
|
|
175
176
|
leanModule: { runCore: async (o) => { runs.push(o.recover ?? null); return o.recover ? { terminalReason: "merged", pr: 55 } : { terminalReason: "needs-human", code: "ci.red", detail: "Required check(s) failed on PR #55: test" }; } } });
|
|
176
177
|
} finally { console.log = l; }
|
|
177
|
-
assert.deepEqual(runs, [null, { code: "ci.red", attempt: 1 }]);
|
|
178
|
+
assert.deepEqual(runs, [null, { code: "ci.red", attempt: 1, final: true }]);
|
|
178
179
|
assert.equal(res.ok, true);
|
|
179
180
|
assert.ok(decisions.some((d) => d.issue === 5 && /recovery lane for ci\.red/.test(d.what)));
|
|
180
181
|
assert.ok(logs.includes("recovered: #5 (ci.red)"), logs.join("\n"));
|
|
@@ -189,6 +190,130 @@ describe("in-batch recovery of mechanical needs-human (#4267)", () => {
|
|
|
189
190
|
assert.match(step3, /one question list[\s\S]*once, at the end of the batch/);
|
|
190
191
|
assert.match(spec, /recovered: #A \(ci\.red\) #B \(base\.conflict\)/);
|
|
191
192
|
});
|
|
193
|
+
|
|
194
|
+
it("#4302: with maxAttempts 2 only the last attempt is final; a non-final attempt is not relabelled while its successor runs", async () => {
|
|
195
|
+
const relabels = [];
|
|
196
|
+
const conf = { maxAttempts: 2, conflicting: async () => true, relabelNeedsHuman: async (x) => { relabels.push(x); } };
|
|
197
|
+
const pend = { terminalReason: "ci-pending", pr: 9, detail: "CI still running" };
|
|
198
|
+
const seen = [];
|
|
199
|
+
const { rows, calls } = await batch({ 1: [NH("base.conflict"), pend, pend] }, {}, { ...conf, onUpdate: (r) => seen.push([r.outcome, relabels.length]) });
|
|
200
|
+
assert.deepEqual(calls.map((c) => c.recover), [null, { code: "base.conflict", attempt: 1, final: false }, { code: "base.conflict", attempt: 2, final: true }]);
|
|
201
|
+
assert.equal(rows[0].outcome, "ci-pending");
|
|
202
|
+
assert.ok(seen.filter(([o]) => o === "recovering").every(([, k]) => k === 0), "no relabel between attempts");
|
|
203
|
+
assert.deepEqual(relabels, [], "the final lane relabels ci-pending itself; the runner does not");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("#4302: a non-final attempt that ends needs-human/ci-pending without a successor is relabelled by the runner, once", async () => {
|
|
207
|
+
const relabels = [];
|
|
208
|
+
const rl = { maxAttempts: 2, relabelNeedsHuman: async (x) => { relabels.push(x); } };
|
|
209
|
+
// ci-pending on a PR that does not conflict is not recoverable: no attempt 2.
|
|
210
|
+
const a = await batch({ 1: [NH("ci.red"), { terminalReason: "ci-pending", pr: 9, detail: "CI still running" }] }, {}, { ...rl, conflicting: async () => false });
|
|
211
|
+
assert.equal(a.calls.length, 2); assert.equal(a.rows[0].outcome, "ci-pending");
|
|
212
|
+
assert.equal(relabels.length, 1); assert.match(relabels[0].evidence, /Recovery pass for `ci\.red` \(attempt 1\) did not merge: it ended `ci-pending`/);
|
|
213
|
+
// needs-human with a human code is never recovered: no attempt 2.
|
|
214
|
+
relabels.length = 0;
|
|
215
|
+
const b = await batch({ 1: [NH("ci.red"), NH("builder.blocked", "needs credentials")] }, {}, rl);
|
|
216
|
+
assert.equal(b.calls.length, 2); assert.equal(b.rows[0].outcome, "needs-human");
|
|
217
|
+
assert.equal(relabels.length, 1); assert.match(relabels[0].evidence, /ended `needs-human` \(`builder\.blocked`\)/);
|
|
218
|
+
// maxAttempts 1: the only attempt is final, so the lane relabels and the runner does not (unchanged).
|
|
219
|
+
relabels.length = 0;
|
|
220
|
+
const c = await batch({ 1: [NH("ci.red"), NH("builder.blocked")] }, {}, { ...rl, maxAttempts: 1 });
|
|
221
|
+
assert.deepEqual(c.calls.map((x) => x.recover), [null, { code: "ci.red", attempt: 1, final: true }]);
|
|
222
|
+
assert.deepEqual(relabels, []);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("#4315: a recovery queued when the batch stops is relabelled needs-human with the full reason", () => {
|
|
226
|
+
const LONG = `ci.red: ${"x".repeat(400)} END-OF-REASON`;
|
|
227
|
+
// issue 1 ends ci.red twice (original lane + non-final attempt 1/2); `stop` ends the batch before attempt 2 launches
|
|
228
|
+
const stopped = async (stop) => {
|
|
229
|
+
const relabels = [], calls = [];
|
|
230
|
+
let tick; const secondRun = new Promise((r) => { tick = r; });
|
|
231
|
+
const rows = await orchestrate({ issues: stop.issues ?? [1], parallel: 2, recovery: { maxAttempts: 2 }, readBody: async () => "",
|
|
232
|
+
relabelNeedsHuman: async (x) => { relabels.push(x); },
|
|
233
|
+
runOne: async (n, _l, ctx = {}) => {
|
|
234
|
+
calls.push({ n, recover: ctx.recover ?? null });
|
|
235
|
+
if (n === 2) { await secondRun; return { terminalReason: "engine-error", detail: "boom" }; }
|
|
236
|
+
if (calls.filter((c) => c.n === 1).length === 2) tick();
|
|
237
|
+
await new Promise((r) => setImmediate(r));
|
|
238
|
+
return NH("ci.red", LONG);
|
|
239
|
+
}, ...stop.opts(calls) });
|
|
240
|
+
return { rows, relabels, calls: calls.filter((c) => c.n === 1) };
|
|
241
|
+
};
|
|
242
|
+
const check = ({ rows, relabels, calls }, cause) => {
|
|
243
|
+
assert.deepEqual(calls.map((c) => c.recover), [null, { code: "ci.red", attempt: 1, final: false }], "attempt 2 never launched");
|
|
244
|
+
assert.equal(relabels.length, 1); assert.equal(relabels[0].issue, 1);
|
|
245
|
+
assert.match(relabels[0].evidence, /Recovery pass for `ci\.red` \(attempt 1\) did not merge: it ended `needs-human` \(`ci\.red`\)/);
|
|
246
|
+
assert.ok(relabels[0].evidence.includes(LONG), "full reason, not 300 chars");
|
|
247
|
+
assert.match(relabels[0].evidence, /Recovery attempt 2\/2 for `ci\.red` was queued, but the batch stopped \(/);
|
|
248
|
+
assert.match(relabels[0].evidence, cause);
|
|
249
|
+
const r1 = byIssue(rows)[1];
|
|
250
|
+
assert.equal(r1.outcome, "held"); assert.equal(r1.recovery.code, "ci.red"); assert.equal(r1.recovery.attempts, 2);
|
|
251
|
+
assert.match(formatOutcomeLists(rows).join("\n"), /unrecovered: #1/);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
it("breaker", async () => {
|
|
255
|
+
check(await stopped({ issues: [1, 2], opts: () => ({ breaker: { errors: 1, window_min: 30 } }) }), /circuit breaker/);
|
|
256
|
+
});
|
|
257
|
+
it("execution budget (on_budget stop)", async () => {
|
|
258
|
+
check(await stopped({ opts: (calls) => ({ remote: { max: 3, isRemote: () => true, admit: () => (calls.length >= 2 ? { ok: false, reason: "cap $5 reached" } : { ok: true }) } }) }),
|
|
259
|
+
/execution budget: cap \$5 reached/);
|
|
260
|
+
});
|
|
261
|
+
it("drift hold", async () => {
|
|
262
|
+
check(await stopped({ opts: (calls) => ({ drift: { holds: () => (calls.length >= 2 ? "packages/ui" : null) } }) }), /drift: packages\/ui flagged/);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("a resume of a lost run that does not relaunch it relabels once; a second resume does not repeat it", async () => {
|
|
266
|
+
let state = { v: 1, order: [1], deps: { 1: [] }, pending: [], results: {}, running: { 1: { startedAt: 1, pid: 999 } }, recoveries: { 1: 2 },
|
|
267
|
+
recovering: { 1: { code: "ci.red", attempt: 2, detail: LONG, outcome: "needs-human", result_code: "ci.red", prev_attempt: 1 } },
|
|
268
|
+
breaker: { at: 1, detail: "circuit breaker: 3 engine-errors within 30 min; admissions stopped" } };
|
|
269
|
+
const store = { load: () => state, save: (s) => { state = s; }, log: () => {} };
|
|
270
|
+
const relabels = [], ran = [];
|
|
271
|
+
const go = () => orchestrate({ issues: [1], store, recovery: { maxAttempts: 2 }, readBody: async () => "", adopt: async () => null,
|
|
272
|
+
relabelNeedsHuman: async (x) => { relabels.push(x); }, runOne: async (n) => { ran.push(n); return { terminalReason: "merged" }; } });
|
|
273
|
+
const rows = await go();
|
|
274
|
+
assert.deepEqual(ran, []); assert.equal(rows[0].outcome, "held");
|
|
275
|
+
assert.equal(relabels.length, 1); assert.ok(relabels[0].evidence.includes(LONG));
|
|
276
|
+
assert.match(relabels[0].evidence, /attempt 1\) did not merge.*[\s\S]*circuit breaker/);
|
|
277
|
+
assert.deepEqual(state.relabelled, [1]);
|
|
278
|
+
await go();
|
|
279
|
+
assert.equal(relabels.length, 1, "relabelled once across resumes");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("a resume that relaunches the lost recovery lane does not relabel; the lane gets ctx.recover", async () => {
|
|
283
|
+
let state = { v: 1, order: [1], deps: { 1: [] }, pending: [], results: {}, running: { 1: { startedAt: 1, pid: 999 } }, recoveries: { 1: 2 },
|
|
284
|
+
recovering: { 1: { code: "ci.red", attempt: 2, detail: LONG, outcome: "needs-human", prev_attempt: 1 } }, relabelled: [1] };
|
|
285
|
+
const store = { load: () => state, save: (s) => { state = s; }, log: () => {} };
|
|
286
|
+
const relabels = [], ctxs = [];
|
|
287
|
+
const rows = await orchestrate({ issues: [1], store, recovery: { maxAttempts: 2 }, readBody: async () => "", adopt: async () => null,
|
|
288
|
+
relabelNeedsHuman: async (x) => { relabels.push(x); }, runOne: async (n, l, ctx) => { ctxs.push(ctx.recover); return { terminalReason: "merged" }; } });
|
|
289
|
+
assert.deepEqual(ctxs, [{ code: "ci.red", attempt: 2, final: true }]);
|
|
290
|
+
assert.equal(rows[0].outcome, "merged"); assert.deepEqual(relabels, []);
|
|
291
|
+
assert.equal(state.relabelled, undefined);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("a store from before #4315 (no detail, no relabelled) still resumes and relabels with the code", async () => {
|
|
295
|
+
let state = { v: 1, order: [1], deps: { 1: [] }, pending: [1], results: {}, running: {}, recoveries: { 1: 1 }, recovering: { 1: { code: "ci.red", attempt: 1 } },
|
|
296
|
+
breaker: { at: 1, detail: "circuit breaker: tripped" } };
|
|
297
|
+
const store = { load: () => state, save: (s) => { state = s; }, log: () => {} };
|
|
298
|
+
const relabels = [];
|
|
299
|
+
const rows = await orchestrate({ issues: [1], store, recovery: { maxAttempts: 2 }, readBody: async () => "",
|
|
300
|
+
relabelNeedsHuman: async (x) => { relabels.push(x); }, runOne: async () => ({ terminalReason: "merged" }) });
|
|
301
|
+
assert.equal(rows[0].outcome, "held"); assert.equal(rows[0].recovery.detail, "ci.red");
|
|
302
|
+
assert.equal(relabels.length, 1); assert.match(relabels[0].evidence, /The lane did not merge: it ended `needs-human`/);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("#4302: --recover-nonfinal reaches runCore as final: false; without it a recovery lane is final", async () => {
|
|
307
|
+
const got = [];
|
|
308
|
+
const l = console.log; console.log = () => {};
|
|
309
|
+
try {
|
|
310
|
+
for (const extra of [["--recover-nonfinal"], []]) {
|
|
311
|
+
await runFromCli(["7", "--lane", "staging", "--repo", "o/r", "--recover", "ci.red", "--recover-attempt", "1", ...extra], { dir: tmpDir("core-rec-"), emitTelemetry: async () => {}, leanIo: {}, io: { gh: async () => "o/r\n" },
|
|
312
|
+
leanModule: { runCore: async (o) => { got.push(o.recover); return { terminalReason: "merged" }; } } });
|
|
313
|
+
}
|
|
314
|
+
} finally { console.log = l; }
|
|
315
|
+
assert.deepEqual(got, [{ code: "ci.red", attempt: 1, final: false }, { code: "ci.red", attempt: 1, final: true }]);
|
|
316
|
+
});
|
|
192
317
|
});
|
|
193
318
|
|
|
194
319
|
describe("#4298 staging-review follow-ups: recovery", () => {
|
|
@@ -197,7 +322,7 @@ describe("#4298 staging-review follow-ups: recovery", () => {
|
|
|
197
322
|
for (const c of RECOVERABLE_CODES) assert.ok(String(RECOVER_HOW[c]).length > 20, c);
|
|
198
323
|
});
|
|
199
324
|
|
|
200
|
-
it("a recovered row reports the first attempt's cost and minutes summed with the recovery's; the lane sees only {code, attempt}", async () => {
|
|
325
|
+
it("a recovered row reports the first attempt's cost and minutes summed with the recovery's; the lane sees only {code, attempt, final}", async () => {
|
|
201
326
|
let t = 0; const seen = [];
|
|
202
327
|
const script = { 1: [{ terminalReason: "needs-human", code: "ci.red", detail: "red", cost_usd: 0.5 }, { terminalReason: "merged", pr: 9, cost_usd: 0.25 }],
|
|
203
328
|
2: [{ terminalReason: "needs-human", code: "ci.red", detail: "red", cost_usd: 1 }, { terminalReason: "needs-human", code: "ci.red", detail: "red again", cost_usd: 2 }] };
|
|
@@ -207,7 +332,7 @@ describe("#4298 staging-review follow-ups: recovery", () => {
|
|
|
207
332
|
const r = Object.fromEntries(rows.map((x) => [x.issue, x]));
|
|
208
333
|
assert.equal(r[1].outcome, "merged"); assert.equal(r[1].cost_usd, 0.75); assert.equal(r[1].minutes, 6);
|
|
209
334
|
assert.equal(r[2].outcome, "needs-human"); assert.equal(r[2].cost_usd, 3); assert.equal(r[2].minutes, 2);
|
|
210
|
-
assert.deepEqual(seen.filter(Boolean), [{ code: "ci.red", attempt: 1 }, { code: "ci.red", attempt: 1 }]);
|
|
335
|
+
assert.deepEqual(seen.filter(Boolean), [{ code: "ci.red", attempt: 1, final: true }, { code: "ci.red", attempt: 1, final: true }]);
|
|
211
336
|
});
|
|
212
337
|
|
|
213
338
|
it("formatOutcomeLists caps a detail at OUTCOME_LINE_MAX", () => {
|
|
@@ -228,3 +353,67 @@ describe("#4298 staging-review follow-ups: recovery", () => {
|
|
|
228
353
|
assert.match(spec, /resumes every daemon-hosted batch that has no report yet/);
|
|
229
354
|
});
|
|
230
355
|
});
|
|
356
|
+
|
|
357
|
+
describe("#4304 staging-review follow-ups: recovery", () => {
|
|
358
|
+
it("a recovery row counts the pre-filter cost once; a single pass still includes it", async () => {
|
|
359
|
+
const script = { 1: [{ terminalReason: "needs-human", code: "ci.red", detail: "red", cost_usd: 1 }, { terminalReason: "needs-human", code: "ci.red", detail: "red again", cost_usd: 1 }],
|
|
360
|
+
2: [{ terminalReason: "merged", pr: 4, cost_usd: 1 }] };
|
|
361
|
+
const k = { 1: 0, 2: 0 };
|
|
362
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 1, recovery: { maxAttempts: 1 }, readBody: async () => "", now: () => 0,
|
|
363
|
+
prefilter: async () => ({ closed: false, cost_usd: 0.1 }), runOne: async (n) => script[n][k[n]++] });
|
|
364
|
+
const r = Object.fromEntries(rows.map((x) => [x.issue, x]));
|
|
365
|
+
assert.equal(k[1], 2, "issue 1 ran a recovery lane");
|
|
366
|
+
assert.equal(r[1].cost_usd, 2.1);
|
|
367
|
+
assert.equal(r[2].cost_usd, 1.1);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const runBatchWith = async (enginePath) => {
|
|
371
|
+
const leanIo = {
|
|
372
|
+
gh: async (a) => (a.includes("milestone") ? JSON.stringify({ milestone: null }) : JSON.stringify({ body: "" })),
|
|
373
|
+
git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : ""),
|
|
374
|
+
};
|
|
375
|
+
const runs = [], logs = [], l = console.log;
|
|
376
|
+
console.log = (x) => logs.push(String(x));
|
|
377
|
+
try {
|
|
378
|
+
await orchestrateFromCli(["5", "--repo", "o/r", "--no-prefilter"], { dir: tmpDir("core-rec-"), batchRoot: tmpDir("core-rec-batch-"), allowTmpfs: true, emitTelemetry: async () => {},
|
|
379
|
+
io: { gh: async () => "o/r\n" }, leanIo, sched: null, enginePath, orchestration: { breaker: null, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 } }, decide: () => {},
|
|
380
|
+
relabelNeedsHuman: async () => {},
|
|
381
|
+
leanModule: { runCore: async (o) => { runs.push(o.recover ?? null); return { terminalReason: "needs-human", code: "ci.red", detail: "Required check(s) failed on PR #55: test" }; } } });
|
|
382
|
+
} finally { console.log = l; }
|
|
383
|
+
return { runs, logs };
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
it("engine.path full turns batch recovery off (run-issue rejects --recover there); core keeps it", async () => {
|
|
387
|
+
const full = await runBatchWith("full");
|
|
388
|
+
assert.deepEqual(full.runs, [null]);
|
|
389
|
+
assert.ok(full.logs.some((x) => /recovery: off \(engine\.path is full/.test(x)), full.logs.join("\n"));
|
|
390
|
+
const core = await runBatchWith("core");
|
|
391
|
+
assert.deepEqual(core.runs, [null, { code: "ci.red", attempt: 1, final: true }]);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe("#4316 one shared recovery evidence text", () => {
|
|
396
|
+
it("lane, settle and batch-exit relabels render the same evidence shape", () => {
|
|
397
|
+
const long = `${"x".repeat(320)}\nsecond line`;
|
|
398
|
+
assert.equal(recoveryEvidence({ code: "ci.red", attempt: 1, outcome: "needs-human", resultCode: "ci.red", detail: " red check\nmore " }),
|
|
399
|
+
"Recovery pass for `ci.red` (attempt 1) did not merge: it ended `needs-human` (`ci.red`): red check. The issue stays `needs-human`.");
|
|
400
|
+
assert.equal(recoveryEvidence({ code: "ci.red", attempt: 2, outcome: "ci-pending", detail: "" }),
|
|
401
|
+
"Recovery pass for `ci.red` (attempt 2) did not merge: it ended `ci-pending`. The issue stays `needs-human`.");
|
|
402
|
+
assert.equal(recoveryEvidence({ code: "c", attempt: 1, outcome: "o", detail: long }).includes(`: ${"x".repeat(300)}. The issue`), true, "first line, 300 chars");
|
|
403
|
+
const queued = recoveryEvidence({ code: "base.conflict", attempt: 0, outcome: undefined, resultCode: "base.conflict", detail: long, full: true, queued: 1, cause: "breaker", recoveryMax: 2 });
|
|
404
|
+
assert.equal(queued, `The lane did not merge: it ended \`needs-human\` (\`base.conflict\`): ${long}\n\n`
|
|
405
|
+
+ "Recovery attempt 1/2 for `base.conflict` was queued, but the batch stopped (breaker) before it launched. The issue stays `needs-human`.");
|
|
406
|
+
assert.match(recoveryEvidence({ code: "c", attempt: 1, outcome: "o", queued: 2, cause: "batch stopped" }), /^Recovery pass for `c` \(attempt 1\) did not merge: it ended `o`\n\nRecovery attempt 2\/2 /);
|
|
407
|
+
});
|
|
408
|
+
it("/orchestrate spec names the pre-filter's flaky exceptions and recovery being off on engine.path full", () => {
|
|
409
|
+
const spec = readFileSync(new URL("../../commands/orchestrate.md", import.meta.url), "utf8");
|
|
410
|
+
assert.match(spec, /unless it is flaky \(wording or `flaky` label\), was reopened after a pre-filter close, or\s+the workflow failed and passed at the same SHA/);
|
|
411
|
+
assert.match(spec, /recovery is off when `engine\.path: full`/);
|
|
412
|
+
});
|
|
413
|
+
it("orchestrate.mjs and lean.mjs build no copy of their own", () => {
|
|
414
|
+
for (const f of ["orchestrate.mjs", "lean.mjs"]) {
|
|
415
|
+
const src = readFileSync(new URL(`./${f}`, import.meta.url), "utf8");
|
|
416
|
+
assert.equal((src.match(/did not merge/g) ?? []).length, 0, f);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
});
|
package/bin/lean/render.mjs
CHANGED
|
@@ -50,6 +50,19 @@ export function capComment(body, max = MAX_COMMENT_CHARS) {
|
|
|
50
50
|
const note = "\n\n_[truncated: comment exceeded the size cap]_";
|
|
51
51
|
return s.slice(0, max - note.length) + note;
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* #4297/#4316: the one evidence text for a recovery that did not merge (lane, settle and batch-exit relabel). `attempt` 0 is
|
|
55
|
+
* the first lane; the detail is its first line capped at 300 chars unless `full`. `queued` (the attempt that never launched)
|
|
56
|
+
* adds why the batch stopped before it.
|
|
57
|
+
*/
|
|
58
|
+
export function recoveryEvidence({ code, attempt, outcome, resultCode, detail, full = false, queued = 0, cause = "", recoveryMax = 0 }) {
|
|
59
|
+
const how = attempt ? `Recovery pass for \`${code}\` (attempt ${attempt}) did not merge` : "The lane did not merge";
|
|
60
|
+
const d = String(detail ?? "").trim();
|
|
61
|
+
const shown = full ? d : d.split("\n")[0].slice(0, 300);
|
|
62
|
+
const head = `${how}: it ended \`${outcome ?? "needs-human"}\`${resultCode ? ` (\`${resultCode}\`)` : ""}${shown ? `: ${shown}` : ""}`;
|
|
63
|
+
return queued ? `${head}\n\nRecovery attempt ${queued}/${recoveryMax || queued} for \`${code}\` was queued, but the batch stopped (${cause}) before it launched. The issue stays \`needs-human\`.`
|
|
64
|
+
: `${head}. The issue stays \`needs-human\`.`;
|
|
65
|
+
}
|
|
53
66
|
const usable = (md) => sanitizeMd(md).replace(/\s+/g, " ").length >= 20;
|
|
54
67
|
|
|
55
68
|
/** Text of the `### heading` section (up to the next heading), or "". */
|
package/bin/lean/testmap.mjs
CHANGED
|
@@ -59,6 +59,23 @@ export function selectCommands(map, files) {
|
|
|
59
59
|
return out;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* #4315: the pre-PR checks are the learned commands (in order) plus each map-selected command they lack; compared
|
|
64
|
+
* whitespace-collapsed, so the map can only add checks, never remove one. `source`: "learned" when the map added
|
|
65
|
+
* nothing, "test-map" when there were no learned commands, else "test-map+learned".
|
|
66
|
+
* @returns {{commands:string[], source:string, added:number}}
|
|
67
|
+
*/
|
|
68
|
+
export function mergeChecks(selected, learned) {
|
|
69
|
+
const norm = (c) => String(c ?? "").trim().replace(/\s+/g, " ");
|
|
70
|
+
const commands = [], seen = new Set();
|
|
71
|
+
const add = (c) => { const t = String(c ?? "").trim(), k = norm(t); if (!k || seen.has(k)) return false; seen.add(k); commands.push(t); return true; };
|
|
72
|
+
for (const c of Array.isArray(learned) ? learned : []) add(c);
|
|
73
|
+
const base = commands.length;
|
|
74
|
+
let added = 0;
|
|
75
|
+
for (const c of Array.isArray(selected) ? selected : []) if (add(c)) added++;
|
|
76
|
+
return { commands, added, source: !added ? "learned" : base ? "test-map+learned" : "test-map" };
|
|
77
|
+
}
|
|
78
|
+
|
|
62
79
|
/** The path of the first repo-facts file under `dirs` that exists, or null; never throws. */
|
|
63
80
|
export async function factsPath(dirs, exists) {
|
|
64
81
|
if (typeof exists !== "function") return null;
|