forgedock 1.30.0 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +409 -38
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/hooks/reread-nudge.mjs +65 -0
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +338 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +150 -15
  21. package/bin/lean/conformance.test.mjs +175 -3
  22. package/bin/lean/context.mjs +128 -44
  23. package/bin/lean/context.test.mjs +157 -5
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +48 -15
  31. package/bin/lean/explore.mjs +47 -0
  32. package/bin/lean/explore.test.mjs +61 -0
  33. package/bin/lean/facts.mjs +84 -0
  34. package/bin/lean/facts.test.mjs +66 -0
  35. package/bin/lean/fixround.mjs +90 -0
  36. package/bin/lean/fixround.test.mjs +117 -0
  37. package/bin/lean/heavy.mjs +35 -2
  38. package/bin/lean/heavy.test.mjs +32 -2
  39. package/bin/lean/lean.mjs +712 -134
  40. package/bin/lean/lean.test.mjs +1626 -37
  41. package/bin/lean/ledger.mjs +5 -3
  42. package/bin/lean/ledger.test.mjs +18 -0
  43. package/bin/lean/light.mjs +135 -0
  44. package/bin/lean/light.test.mjs +173 -0
  45. package/bin/lean/lightlane.mjs +219 -0
  46. package/bin/lean/lightlane.test.mjs +237 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +303 -41
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +138 -13
  54. package/bin/lean/precheck.test.mjs +118 -1
  55. package/bin/lean/prefilter.mjs +220 -5
  56. package/bin/lean/prefilter.test.mjs +260 -4
  57. package/bin/lean/preflight.mjs +108 -3
  58. package/bin/lean/preflight.test.mjs +48 -1
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +104 -13
  62. package/bin/lean/render.mjs +7 -3
  63. package/bin/lean/resume.mjs +67 -0
  64. package/bin/lean/resume.test.mjs +84 -0
  65. package/bin/lean/review-trigger.mjs +12 -0
  66. package/bin/lean/review-trigger.test.mjs +17 -1
  67. package/bin/lean/routing.mjs +74 -0
  68. package/bin/lean/routing.test.mjs +67 -0
  69. package/bin/lean/sched.mjs +16 -1
  70. package/bin/lean/sched.test.mjs +175 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +170 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +296 -13
  78. package/bin/lean/sequence.test.mjs +263 -2
  79. package/bin/lean/toolchain.mjs +60 -15
  80. package/bin/lean/toolchain.test.mjs +120 -3
  81. package/bin/lean/toolstats.mjs +72 -0
  82. package/bin/lean/toolstats.test.mjs +47 -0
  83. package/bin/lean/verifier.mjs +232 -0
  84. package/bin/lean/verifier.test.mjs +163 -0
  85. package/bin/lean/verify.mjs +72 -10
  86. package/bin/lean/verify.test.mjs +68 -3
  87. package/bin/review-staging.mjs +533 -0
  88. package/bin/runner.mjs +82 -13
  89. package/bin/stats/aggregate.mjs +87 -2
  90. package/bin/stats/efficiency.mjs +335 -0
  91. package/bin/stats/efficiency.test.mjs +266 -0
  92. package/bin/stats/github-source.mjs +8 -3
  93. package/bin/stats/index.mjs +91 -6
  94. package/bin/stats/local-source.mjs +145 -8
  95. package/bin/stats/query.mjs +181 -3
  96. package/bin/stats/render.mjs +22 -0
  97. package/bin/stats/stats.test.mjs +403 -8
  98. package/commands/orchestrate.md +3 -1
  99. package/commands/review-pr-agents/protocols.md +1 -1
  100. package/commands/review-pr-staging.md +84 -831
  101. package/commands/review-pr.md +1 -1
  102. package/commands/work-on/build/implement.md +1 -0
  103. package/commands/work-on/build/validate.md +11 -1
  104. package/docs/CONFIG.md +80 -5
  105. package/docs/OUTCOME-CODES.md +2 -1
  106. package/docs/STATS.md +66 -1
  107. package/docs/TELEMETRY.md +1 -1
  108. package/docs/eval/corpus/lightlane-v1.json +55 -0
  109. package/docs/eval/lightlane-bench.md +91 -0
  110. package/docs/spec/review-protocol.md +1 -1
  111. package/package.json +1 -1
  112. package/scripts/bench-lightlane.mjs +286 -0
  113. package/scripts/ci-local/lib.mjs +20 -0
  114. package/scripts/ci-local/plan.mjs +2 -12
  115. package/scripts/ci-local/scoping.mjs +10 -1
  116. package/scripts/ci-local.test.sh +2 -0
  117. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  118. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  119. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  120. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  121. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  122. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  123. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  124. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  125. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  126. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  127. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  128. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  129. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  130. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  131. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  132. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  133. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  134. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  135. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  136. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  137. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  139. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  140. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  144. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  145. package/scripts/forge-root.test.sh +2 -2
  146. package/scripts/publish-staging-sync.test.sh +62 -4
  147. package/scripts/review-finalize.sh +17 -6
  148. package/scripts/review-finalize.test.sh +47 -0
  149. package/scripts/review-pr-arguments.test.sh +13 -1
  150. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  151. package/scripts/review-pr-gate2.test.sh +6 -14
  152. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  153. package/scripts/review-pr-staging-scope.test.sh +39 -33
  154. package/scripts/review-prepare.sh +73 -6
  155. package/scripts/review-prepare.test.sh +85 -0
  156. package/scripts/review-staging-post.sh +293 -0
  157. package/scripts/review-staging-post.test.sh +143 -0
  158. package/scripts/review-staging-pre.sh +401 -0
  159. package/scripts/review-staging-pre.test.sh +150 -0
  160. package/scripts/symbol-index.mjs +26 -7
  161. package/scripts/symbol-index.py +55 -9
  162. package/scripts/trusted-comments.test.sh +1 -1
@@ -6,6 +6,7 @@
6
6
  * failed jobs' log tails for the builder's fix loop. All GitHub effects go through the injected io.
7
7
  */
8
8
  import { ciPollDelayMs } from "../engine.mjs";
9
+ import { parseE2e } from "./e2e.mjs";
9
10
 
10
11
  export const VERIFY_MODES = Object.freeze(["local", "ci", "hybrid"]);
11
12
  /** Changed files that never need the CI matrix (docs-only lanes skip CI verification). */
@@ -29,8 +30,10 @@ const OK = ["success", "neutral", "skipped"];
29
30
 
30
31
  /**
31
32
  * forge.yaml `verify:` section (block style; parseForgeYaml gives strings) → {mode, workflow, pathsInput, skipPaths,
32
- * maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun}. Invalid values fall back to the defaults; mode defaults to local (today's
33
- * behaviour); checkRun (`check_run`, #4270) is true only for the literal `true`.
33
+ * maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun, checks, postMerge, duplicateExports, e2e}. Invalid values fall back to the defaults; mode defaults to local (today's
34
+ * behaviour); checkRun (`check_run`, #4270) is true only for the literal `true`; checks (#4387) is the optional allow-list
35
+ * of check names that gate CI verification, or null; e2e is the e2e merge gate (#4403, e2e.mjs parseE2e). #4406: postMerge (`post_merge`) is the command run on
36
+ * the merged tree at base-sync before the push (null: the lane's scoped typecheck); duplicateExports is false only for the literal `false`.
34
37
  */
35
38
  export function parseVerify(sec = {}) {
36
39
  const s = sec && typeof sec === "object" ? sec : {};
@@ -47,7 +50,12 @@ export function parseVerify(sec = {}) {
47
50
  const tt = Number(str(s.precheck_total_seconds));
48
51
  const precheckTotalSeconds = str(s.precheck_total_seconds) !== "" && Number.isInteger(tt) && tt >= 1 && tt <= 7200 ? tt : DEFAULT_PRECHECK_TOTAL_SECONDS;
49
52
  const checkRun = str(s.check_run).toLowerCase() === "true";
50
- return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun };
53
+ const rawChecks = Array.isArray(s.checks) ? s.checks : str(s.checks).split(",");
54
+ const names = rawChecks.map((x) => String(x ?? "").trim()).filter(Boolean);
55
+ const checks = names.length ? [...new Set(names)] : null;
56
+ const postMerge = str(s.post_merge) || null;
57
+ const duplicateExports = str(s.duplicate_exports).toLowerCase() !== "false";
58
+ return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun, checks, postMerge, duplicateExports, e2e: parseE2e(s) };
51
59
  }
52
60
 
53
61
  const globRe = (g) => new RegExp(`^${g.split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*")}$`);
@@ -83,15 +91,55 @@ export async function collectFailedLogs(io, failedChecks) {
83
91
  }
84
92
  return capLogs(out);
85
93
  }
94
+ const RANK = { failure: 2, pending: 1, success: 0 };
95
+ /**
96
+ * #4387: one check per name at its latest attempt: the highest check-run id (ids grow monotonically, so a rerun or a later
97
+ * push/pull_request run of the same job wins). A name with any entry lacking a numeric id keeps its worst state.
98
+ */
99
+ export function latestPerName(checks) {
100
+ const by = new Map();
101
+ for (const c of checks ?? []) { if (!by.has(c?.name)) by.set(c?.name, []); by.get(c?.name).push(c); }
102
+ return [...by.values()].map((xs) => xs.every((c) => Number.isFinite(Number(c?.id)) && c?.id !== null && c?.id !== "")
103
+ ? xs.reduce((a, b) => (Number(b.id) > Number(a.id) ? b : a))
104
+ : xs.reduce((a, b) => ((RANK[b?.state] ?? 1) > (RANK[a?.state] ?? 1) ? b : a)));
105
+ }
106
+ /**
107
+ * #4387: the check names that gate CI verification: forge.yaml `verify.checks`, else the base branch's required checks
108
+ * under `merge_wait: required`, else null (every check gates, as before). An unreadable or empty required set is null.
109
+ */
110
+ export async function ciGate({ io, lane, mergeWait, verify }) {
111
+ if (verify?.checks?.length) return [...verify.checks];
112
+ if (mergeWait !== "required" || !io?.requiredChecks) return null;
113
+ let req = [];
114
+ try { req = (await io.requiredChecks(lane)) ?? []; } catch { req = []; }
115
+ return Array.isArray(req) && req.length ? req : null;
116
+ }
117
+ /** #4387: the `rerunOf` of the re-verify after a rerun of `v`'s failed jobs: [{name, id}] (commit checks) or {runId, attempt}. */
118
+ export const rerunTarget = (v) => (v?.failedChecks ? v.failedChecks.map((c) => ({ name: c.name, id: c.id ?? null })) : { runId: v?.runId ?? null, attempt: v?.attempt ?? null });
119
+ /**
120
+ * #4387: rerun the failed jobs of a red verification once (`io.rerunFailedJobs`, `gh run rerun <id> --failed`), so a
121
+ * no-commit fix round can tell a flake from a real failure. Returns {ok, runs, jobs, detail}; never throws.
122
+ */
123
+ export async function rerunFailedOnce({ io, v }) {
124
+ const jobs = (v?.failures ?? []).map((f) => f.job);
125
+ const runs = v?.failedChecks ? [...new Set(v.failedChecks.map((c) => runIdOf(c.url)).filter(Boolean))] : v?.runId != null ? [String(v.runId)] : [];
126
+ if (!io?.rerunFailedJobs) return { ok: false, runs, jobs, detail: "no rerun seam" };
127
+ if (!runs.length) return { ok: false, runs, jobs, detail: "no Actions run to rerun" };
128
+ try { for (const id of runs) await io.rerunFailedJobs(id); }
129
+ catch (e) { return { ok: false, runs, jobs, detail: `rerun could not start: ${String(e?.stderr ?? e?.message ?? e).split("\n")[0].slice(0, 200)}` }; }
130
+ return { ok: true, runs, jobs, detail: `reran ${runs.length} run(s)` };
131
+ }
86
132
  const sameSha = (a, b) => Boolean(a && b) && (String(a).startsWith(b) || String(b).startsWith(a));
87
133
 
88
134
  /**
89
135
  * One CI verification of `head` on `branch`. Returns {result: pass|fail|timeout|unavailable|skipped, failures, runUrl,
90
136
  * detail, runId}. `push` is called before any CI call (never for a skipped lane); `exclude` lists run ids already judged,
91
137
  * so a re-dispatch on the same head never reuses an old run; `onPoll` runs before each wait (lease renew, progress).
138
+ * #4387: `gate` (ciGate) limits which commit checks decide; each name counts at its latest attempt. `rerunOf`
139
+ * (rerunTarget) makes the re-verify after a rerun wait for newer attempts than the ones judged red.
92
140
  */
93
141
  export async function runCiVerify({ io, branch, head, changedFiles, verify, ciMaxMinutes = 360, now = () => Date.now(), sleep, random = Math.random,
94
- push = async () => {}, exclude = [], onPoll = async () => {}, graceMs = DEFAULT_GRACE_MS }) {
142
+ push = async () => {}, exclude = [], onPoll = async () => {}, graceMs = DEFAULT_GRACE_MS, gate = null, rerunOf = null }) {
95
143
  const v = verify ?? parseVerify({});
96
144
  const files = verifiableFiles(changedFiles);
97
145
  if (skipsCi(files, v.skipPaths)) return { result: "skipped", failures: [], runUrl: null, detail: `all ${files.length} changed file(s) match skip_paths` };
@@ -105,27 +153,41 @@ export async function runCiVerify({ io, branch, head, changedFiles, verify, ciMa
105
153
  catch (e) { return unavailable(`workflow ${v.workflow} could not be dispatched: ${String(e?.stderr ?? e?.message ?? e).split("\n")[0].slice(0, 200)}`); }
106
154
  } else if (!io.commitChecks) return unavailable("no commit-checks seam");
107
155
  const skip = new Set(exclude.map(String));
156
+ const rerunRun = rerunOf && !Array.isArray(rerunOf) && rerunOf.runId != null ? rerunOf : null;
157
+ if (rerunRun) skip.delete(String(rerunRun.runId));
158
+ const staleIds = new Map(Array.isArray(rerunOf) ? rerunOf.filter((c) => c?.id != null).map((c) => [c.name, Number(c.id)]) : []);
159
+ const gateSet = Array.isArray(gate) && gate.length ? new Set(gate) : null;
108
160
  for (let i = 0; ; i++) {
109
161
  if (v.workflow) {
110
162
  let runs = [];
111
163
  try { runs = (await io.workflowRuns({ workflow: v.workflow, branch })) ?? []; } catch { runs = []; }
112
164
  const run = runs.find((r) => sameSha(r?.headSha, head) && !skip.has(String(r?.databaseId)));
113
- if (run && String(run.status).toLowerCase() === "completed") {
165
+ const stale = run && rerunRun && String(run.databaseId) === String(rerunRun.runId) && !(Number(run.attempt) > Number(rerunRun.attempt ?? 0));
166
+ if (run && !stale && String(run.status).toLowerCase() === "completed") {
114
167
  const c = String(run.conclusion ?? "").toLowerCase();
115
168
  if (OK.includes(c)) return { result: "pass", failures: [], runUrl: run.url ?? null, runId: run.databaseId, detail: `${v.workflow} ${c}` };
116
169
  let log = "";
117
170
  try { log = String((await io.failedLogs?.(run.databaseId)) ?? ""); } catch { log = ""; }
118
- return { result: "fail", failures: capLogs([{ job: run.name ?? v.workflow, url: run.url ?? null, log }]), runUrl: run.url ?? null, runId: run.databaseId, detail: `${v.workflow} ${c || "failed"}` };
171
+ return { result: "fail", failures: capLogs([{ job: run.name ?? v.workflow, url: run.url ?? null, log }]), runUrl: run.url ?? null, runId: run.databaseId, attempt: run.attempt ?? null, detail: `${v.workflow} ${c || "failed"}` };
119
172
  }
120
173
  if (!run && now() - startedAt >= graceMs) return unavailable(`no ${v.workflow} run for ${String(head).slice(0, 12)} appeared within ${Math.round(graceMs / 60000)} min`);
121
174
  } else {
122
175
  let checks = [];
123
176
  try { checks = ((await io.commitChecks(head)) ?? []).filter((c) => c?.name !== VERIFY_CHECK_NAME); } catch { checks = []; }
124
177
  if (!checks.length && now() - startedAt >= graceMs) return unavailable(`no checks reported for ${String(head).slice(0, 12)} within ${Math.round(graceMs / 60000)} min`);
125
- if (checks.length && checks.every((c) => c.state !== "pending")) {
126
- const failed = checks.filter((c) => c.state === "failure");
127
- if (!failed.length) return { result: "pass", failures: [], runUrl: null, detail: `${checks.length} check(s) green` };
128
- return { result: "fail", failures: await collectFailedLogs(io, failed), runUrl: failed[0].url ?? null, detail: `${failed.length} check(s) failed: ${failed.map((c) => c.name).join(", ")}` };
178
+ const latest = latestPerName(checks).map((c) => (staleIds.has(c.name) && !(Number(c.id) > staleIds.get(c.name)) ? { ...c, state: "pending" } : c));
179
+ // #4387: only gating checks decide; gate names not reported yet are waited on for the grace period, and with none reported every check gates.
180
+ const gating = gateSet ? latest.filter((c) => gateSet.has(c.name)) : [];
181
+ const relevant = gating.length ? gating : latest;
182
+ const missing = gateSet ? [...gateSet].filter((n) => !latest.some((c) => c.name === n)) : [];
183
+ const waitMissing = missing.length && now() - startedAt < graceMs;
184
+ if (relevant.length && !waitMissing && relevant.every((c) => c.state !== "pending")) {
185
+ const failed = relevant.filter((c) => c.state === "failure");
186
+ const ignored = gating.length ? latest.filter((c) => !gateSet.has(c.name) && c.state === "failure").map((c) => c.name) : [];
187
+ const note = ignored.length ? `; ignored non-gating: ${ignored.join(", ")}` : "";
188
+ if (!failed.length) return { result: "pass", failures: [], runUrl: null, detail: `${relevant.length} ${gating.length ? "gating " : ""}check(s) green${note}` };
189
+ return { result: "fail", failures: await collectFailedLogs(io, failed), failedChecks: failed.map((c) => ({ name: c.name, id: c.id ?? null, url: c.url ?? null })), runUrl: failed[0].url ?? null,
190
+ detail: `${failed.length} check(s) failed: ${failed.map((c) => c.name).join(", ")}${note}` };
129
191
  }
130
192
  }
131
193
  if (now() - startedAt >= ciMaxMinutes * 60000) return { result: "timeout", failures: [], runUrl: null, detail: `CI still pending after ${ciMaxMinutes} min` };
@@ -2,8 +2,9 @@
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 { parseVerify, matchesGlob, skipsCi, capLogs, runCiVerify, collectFailedLogs, verifyReport, DEFAULT_SKIP_PATHS, LOG_CAP, VERIFY_CHECK_NAME, VERIFY_TEXT_CAP } from "./verify.mjs";
5
+ import { parseVerify, matchesGlob, skipsCi, capLogs, runCiVerify, collectFailedLogs, verifyReport, DEFAULT_SKIP_PATHS, LOG_CAP, VERIFY_CHECK_NAME, VERIFY_TEXT_CAP, latestPerName, ciGate, rerunFailedOnce, rerunTarget } from "./verify.mjs";
6
6
  import { precheck } from "./conformance.mjs";
7
+ import { parseE2e } from "./e2e.mjs";
7
8
 
8
9
  const HEAD = "abc123abc123abc123";
9
10
  /** Fake io + clock: `runs` is a list of successive workflowRuns answers (the last repeats); `checks` likewise. */
@@ -22,15 +23,22 @@ function fake(o = {}) {
22
23
 
23
24
  describe("verify-by-CI config (#4167)", () => {
24
25
  it("parseVerify defaults to local and rejects invalid values", () => {
25
- assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false });
26
+ assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false, checks: null, postMerge: null, duplicateExports: true, e2e: parseE2e({}) });
26
27
  const v = parseVerify({ mode: "ci", workflow: ".github/workflows/verify.yml", paths_input: "changed_paths", skip_paths: "*.md, docs/** ,", max_rounds: "3" });
27
- assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false });
28
+ assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false, checks: null, postMerge: null, duplicateExports: true, e2e: parseE2e({}) });
28
29
  assert.equal(parseVerify({ mode: "remote" }).mode, "local");
29
30
  assert.equal(parseVerify({ workflow: "x; rm -rf /" }).workflow, null);
30
31
  assert.equal(parseVerify({ max_rounds: "-1" }).maxRounds, 2);
31
32
  assert.equal(parseVerify({ max_rounds: "0" }).maxRounds, 0);
32
33
  assert.equal(parseVerify({ mode: "hybrid" }).mode, "hybrid");
33
34
  });
35
+ it("#4406 post_merge is a trimmed command or null; duplicate_exports is off only for the literal false", () => {
36
+ const v = parseVerify({ post_merge: " pnpm -r typecheck ", duplicate_exports: "false" });
37
+ assert.equal(v.postMerge, "pnpm -r typecheck");
38
+ assert.equal(v.duplicateExports, false);
39
+ assert.equal(parseVerify({ post_merge: " " }).postMerge, null);
40
+ assert.equal(parseVerify({ duplicate_exports: "no" }).duplicateExports, true);
41
+ });
34
42
  it("globs: a pattern without / matches the basename anywhere; ** spans directories", () => {
35
43
  assert.ok(matchesGlob("README.md", "*.md")); assert.ok(matchesGlob("pkg/a/CHANGELOG.md", "*.md"));
36
44
  assert.ok(matchesGlob("docs/guide/x.png", "docs/**")); assert.ok(!matchesGlob("src/docs/x.ts", "docs/**"));
@@ -94,6 +102,63 @@ describe("runCiVerify (#4167)", () => {
94
102
  });
95
103
  });
96
104
 
105
+ describe("CI verification gate, latest attempt and flake rerun (#4387)", () => {
106
+ const CI = parseVerify({ mode: "ci" });
107
+ const run = (id) => `https://github.com/o/r/actions/runs/${id}/job/1`;
108
+ it("#4387 required checks gate: a red non-required storybook check passes when required lint and test are green", async () => {
109
+ const f = fake({ checks: [[{ name: "lint", state: "success", id: 1, url: run(1) }, { name: "test", state: "success", id: 2, url: run(1) }, { name: "storybook (web)", state: "failure", id: 3, url: run(2) }]] });
110
+ const r = await runCiVerify(f.args({ verify: CI, gate: ["lint", "test"] }));
111
+ assert.equal(r.result, "pass"); assert.match(r.detail, /ignored non-gating: storybook \(web\)/); assert.deepEqual(f.logsFor, []);
112
+ const g = fake({ checks: [[{ name: "lint", state: "failure", id: 1, url: run(1) }, { name: "test", state: "success", id: 2, url: run(1) }, { name: "storybook (web)", state: "failure", id: 3, url: run(2) }]] });
113
+ const red = await runCiVerify(g.args({ verify: CI, gate: ["lint", "test"] }));
114
+ assert.equal(red.result, "fail"); assert.deepEqual(red.failures.map((x) => x.job), ["lint"]);
115
+ assert.deepEqual(red.failedChecks, [{ name: "lint", id: 1, url: run(1) }]);
116
+ });
117
+ it("#4387 latest attempt per name: a later green run of a check counts as green; a later red one as red", async () => {
118
+ const f = fake({ checks: [[{ name: "test", state: "failure", id: 10, url: run(5) }, { name: "test", state: "success", id: 11, url: run(6) }, { name: "lint", state: "success", id: 9 }]] });
119
+ assert.equal((await runCiVerify(f.args({ verify: CI }))).result, "pass");
120
+ const g = fake({ checks: [[{ name: "test", state: "success", id: 10, url: run(5) }, { name: "test", state: "failure", id: 11, url: run(6) }]] });
121
+ assert.equal((await runCiVerify(g.args({ verify: CI }))).result, "fail");
122
+ // without ids the worst state of a name wins (unchanged conservative behaviour)
123
+ assert.deepEqual(latestPerName([{ name: "t", state: "failure" }, { name: "t", state: "success" }]).map((c) => c.state), ["failure"]);
124
+ });
125
+ it("#4387 a gate name not reported yet is waited on for the grace period; with none reported every check gates", async () => {
126
+ const f = fake({ checks: [[{ name: "lint", state: "success", id: 1 }], [{ name: "lint", state: "success", id: 1 }, { name: "test", state: "failure", id: 2, url: run(3) }]] });
127
+ assert.equal((await runCiVerify(f.args({ verify: CI, gate: ["lint", "test"] }))).result, "fail");
128
+ const g = fake({ checks: [[{ name: "storybook", state: "failure", id: 1, url: run(4) }]] });
129
+ const r = await runCiVerify(g.args({ verify: CI, gate: ["lint", "test"] }));
130
+ assert.equal(r.result, "fail"); assert.ok(g.clock >= 5 * 60000, "waited for the grace period first");
131
+ });
132
+ it("#4387 verify.checks overrides which checks gate CI verification", async () => {
133
+ const v = parseVerify({ mode: "ci", checks: "lint, test ," });
134
+ assert.deepEqual(v.checks, ["lint", "test"]);
135
+ assert.deepEqual(parseVerify({ checks: ["a", " b"] }).checks, ["a", "b"]);
136
+ const io = { requiredChecks: async () => ["build"] };
137
+ assert.deepEqual(await ciGate({ io, lane: "staging", mergeWait: "required", verify: v }), ["lint", "test"]);
138
+ assert.deepEqual(await ciGate({ io, lane: "staging", mergeWait: "required", verify: CI }), ["build"]);
139
+ assert.equal(await ciGate({ io, lane: "staging", mergeWait: "all", verify: CI }), null);
140
+ assert.equal(await ciGate({ io: { requiredChecks: async () => { throw new Error("404"); } }, lane: "staging", mergeWait: "required", verify: CI }), null);
141
+ const f = fake({ checks: [[{ name: "lint", state: "success", id: 1 }, { name: "test", state: "success", id: 2 }, { name: "build", state: "failure", id: 3, url: run(1) }]] });
142
+ assert.equal((await runCiVerify(f.args({ verify: v, gate: await ciGate({ io, lane: "staging", mergeWait: "required", verify: v }) }))).result, "pass");
143
+ });
144
+ it("#4387 rerunFailedOnce reruns each failed Actions run once; the re-verify waits for newer attempts", async () => {
145
+ const reran = [];
146
+ const v = { failures: [{ job: "test" }, { job: "types" }], failedChecks: [{ name: "test", id: 4, url: run(55) }, { name: "types", id: 5, url: run(55) }] };
147
+ const rr = await rerunFailedOnce({ io: { rerunFailedJobs: async (id) => { reran.push(id); } }, v });
148
+ assert.deepEqual([rr.ok, rr.runs, rr.jobs, reran], [true, ["55"], ["test", "types"], ["55"]]);
149
+ assert.equal((await rerunFailedOnce({ io: {}, v })).ok, false);
150
+ assert.match((await rerunFailedOnce({ io: { rerunFailedJobs: async () => { throw new Error("run too old"); } }, v })).detail, /run too old/);
151
+ const f = fake({ checks: [[{ name: "test", state: "failure", id: 4, url: run(55) }], [{ name: "test", state: "pending", id: 6 }], [{ name: "test", state: "success", id: 6 }]] });
152
+ const r = await runCiVerify(f.args({ verify: CI, rerunOf: rerunTarget({ failedChecks: [{ name: "test", id: 4 }] }) }));
153
+ assert.equal(r.result, "pass"); assert.ok(f.clock > 0, "the stale red attempt was not judged");
154
+ });
155
+ it("#4387 workflow mode: the re-verify waits for the same run's next attempt", async () => {
156
+ const f = fake({ runs: [[{ databaseId: 7, headSha: HEAD, status: "completed", conclusion: "failure", attempt: 1 }], [{ databaseId: 7, headSha: HEAD, status: "completed", conclusion: "success", attempt: 2 }]] });
157
+ const r = await runCiVerify(f.args({ exclude: [7], rerunOf: rerunTarget({ runId: 7, attempt: 1 }) }));
158
+ assert.equal(r.result, "pass"); assert.equal(r.runId, 7);
159
+ });
160
+ });
161
+
97
162
  describe("merge-time fix rounds and declared check commands (#4240)", () => {
98
163
  it("collectFailedLogs fetches one log per Actions run and caps the total", async () => {
99
164
  const fetched = [];