@decohere/ci-tools 0.0.1-dev.111 → 0.0.1-dev.113

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.
@@ -1,15 +1,13 @@
1
1
  import { resolve } from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
+ import { upsertIssue } from "../lib/github-issue.mjs";
3
4
 
4
- const LIST_ID = process.env.CLICKUP_LIST_ID ?? "901220171084";
5
- const API = "https://api.clickup.com/api/v2";
6
5
  const WARN_DAYS = 14;
7
6
  const MS_PER_DAY = 86_400_000;
8
- const CHORE_TYPE = 1004;
9
7
  const DEFAULT_REPO = "typesmiths/decohere";
10
8
 
11
9
  /**
12
- * One profile per credential. Date math, ClickUp upsert, and fail-closed live below.
10
+ * One profile per credential. Date math, GitHub issue upsert, and fail-closed live below.
13
11
  * `ghEnv` unset means repo secret / repo variable (not an Actions environment).
14
12
  *
15
13
  * @typedef {{
@@ -104,7 +102,7 @@ export function credentialProfile(id) {
104
102
  }
105
103
 
106
104
  /** @param {CredentialProfile} credential */
107
- export function taskName(credential, expiresYmd) {
105
+ export function issueTitle(credential, expiresYmd) {
108
106
  return `chore(security): rotate ${credential.secret} (expires ${expiresYmd})`;
109
107
  }
110
108
 
@@ -115,7 +113,7 @@ export function rotationCommands(credential, nextExpiresYmd, repo = resolveRepo(
115
113
  return [
116
114
  credential.mint,
117
115
  "",
118
- "Then from a trusted machine (do not paste the token into Actions or this task):",
116
+ "Then from a trusted machine (do not paste the token into Actions or this issue):",
119
117
  "",
120
118
  "```bash",
121
119
  `gh secret set ${credential.secret} ${flags}--repo ${repo}`,
@@ -123,7 +121,7 @@ export function rotationCommands(credential, nextExpiresYmd, repo = resolveRepo(
123
121
  "```",
124
122
  "",
125
123
  `Revoke the old token: ${credential.revokeUrl}`,
126
- "Mark this task complete, then retry Publish:",
124
+ "Close this issue, then retry Publish:",
127
125
  "",
128
126
  "```bash",
129
127
  `gh run list --repo ${repo} --workflow Publish --limit 5`,
@@ -169,53 +167,6 @@ export function buildDescription(credential, options) {
169
167
  .join("\n");
170
168
  }
171
169
 
172
- async function clickup(method, path, body) {
173
- const key = process.env.CLICKUP_API_KEY;
174
- if (key == null || key.length === 0) {
175
- throw new Error("Missing CLICKUP_API_KEY");
176
- }
177
- const response = await fetch(`${API}${path}`, {
178
- method,
179
- headers: {
180
- Authorization: key,
181
- "Content-Type": "application/json",
182
- },
183
- body: body == null ? undefined : JSON.stringify(body),
184
- });
185
- const text = await response.text();
186
- if (!response.ok) {
187
- throw new Error(
188
- `ClickUp ${method} ${path} failed (${response.status}): ${text.slice(0, 500)}`,
189
- );
190
- }
191
- return text.length === 0 ? undefined : JSON.parse(text);
192
- }
193
-
194
- async function findTaskByName(name) {
195
- for (let page = 0; page < 10; page += 1) {
196
- const qs = new URLSearchParams({
197
- include_closed: "true",
198
- subtasks: "false",
199
- page: String(page),
200
- });
201
- const data = await clickup("GET", `/list/${LIST_ID}/task?${qs}`);
202
- const tasks = Array.isArray(data) ? data : (data?.tasks ?? []);
203
- const match = tasks.find((task) => task.name === name);
204
- if (match) {
205
- return match;
206
- }
207
- if (tasks.length < 100) {
208
- break;
209
- }
210
- }
211
- return null;
212
- }
213
-
214
- async function firstOpenStatus() {
215
- const list = await clickup("GET", `/list/${LIST_ID}`);
216
- return (list.statuses ?? []).find((status) => status.type === "open")?.status;
217
- }
218
-
219
170
  function runUrl() {
220
171
  if (
221
172
  process.env.GITHUB_SERVER_URL &&
@@ -228,51 +179,21 @@ function runUrl() {
228
179
  }
229
180
 
230
181
  /** @param {CredentialProfile} credential */
231
- export async function upsertRotationTask(credential, options) {
182
+ export function upsertRotationIssue(credential, options, upsert = upsertIssue) {
232
183
  const { expiresYmd, kind, daysLeft, rejected } = options;
233
- const name = taskName(credential, expiresYmd ?? "unknown");
234
- const existing = await findTaskByName(name);
235
- const dueMs = parseExpiresAt(expiresYmd);
236
- const payload = {
237
- name,
238
- markdown_description: buildDescription(credential, {
184
+ return upsert({
185
+ title: issueTitle(credential, expiresYmd ?? "unknown"),
186
+ body: buildDescription(credential, {
239
187
  kind,
240
188
  expiresYmd,
241
189
  daysLeft,
242
190
  rejected,
243
191
  runUrl: runUrl(),
244
192
  }),
245
- custom_item_id: CHORE_TYPE,
246
- priority: rejected || kind === "expired" ? 1 : 2,
247
- due_date: dueMs == null ? undefined : dueMs,
248
- due_date_time: false,
249
- };
250
- const assignee = process.env.CLICKUP_ASSIGNEE_ID?.trim();
251
- if (assignee) {
252
- payload.assignees = [Number(assignee)];
253
- }
254
-
255
- if (existing) {
256
- const update = {
257
- markdown_description: payload.markdown_description,
258
- priority: payload.priority,
259
- due_date: payload.due_date,
260
- due_date_time: false,
261
- };
262
- if (existing.closed || existing.status?.type === "closed") {
263
- const status = await firstOpenStatus();
264
- if (status) {
265
- update.status = status;
266
- }
267
- }
268
- await clickup("PUT", `/task/${existing.id}`, update);
269
- return { id: existing.id, url: existing.url, created: false };
270
- }
271
- const created = await clickup("POST", `/list/${LIST_ID}/task`, payload);
272
- return { id: created.id, url: created.url, created: true };
193
+ });
273
194
  }
274
195
 
275
- export async function runNudge(env = process.env) {
196
+ export async function runNudge(env = process.env, upsert = upsertIssue) {
276
197
  const credential = credentialProfile(String(env.NUDGE_CREDENTIAL ?? "").trim());
277
198
  const rejected = env.NUDGE_REJECTED === "1";
278
199
  const expiresYmd = String(env[credential.expiresVar] ?? "").trim();
@@ -290,14 +211,18 @@ export async function runNudge(env = process.env) {
290
211
  }
291
212
 
292
213
  if (rejected || assessment.kind === "warn" || assessment.kind === "expired") {
293
- const result = await upsertRotationTask(credential, {
294
- expiresYmd: expiresYmd || "unknown",
295
- kind: assessment.kind,
296
- daysLeft: assessment.daysLeft,
297
- rejected,
298
- });
214
+ const result = upsertRotationIssue(
215
+ credential,
216
+ {
217
+ expiresYmd: expiresYmd || "unknown",
218
+ kind: assessment.kind,
219
+ daysLeft: assessment.daysLeft,
220
+ rejected,
221
+ },
222
+ upsert,
223
+ );
299
224
  process.stdout.write(
300
- `${result.created ? "Created" : "Updated"} ClickUp ${result.id} ${result.url ?? ""}\n`,
225
+ `${result.created ? "Opened" : "Updated"} issue #${result.number} ${result.url}\n`,
301
226
  );
302
227
  }
303
228
 
@@ -1,22 +1,15 @@
1
1
  import { resolve } from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
+ import { upsertIssue } from "../lib/github-issue.mjs";
3
4
  import { listPublishablePackages } from "./publish-verify.mjs";
4
5
 
5
- const LIST_ID = process.env.CLICKUP_LIST_ID ?? "901220171084";
6
- const API = "https://api.clickup.com/api/v2";
7
6
  const GITHUB_API = process.env.GITHUB_API_URL ?? "https://api.github.com";
8
- const DEPLOYMENT_TYPE = 1005;
9
- const TASK_NAME = "deploy(publish): OIDC bootstrap unpublished packages";
10
- const LEGACY_TASK_NAMES = ["chore(publish): OIDC bootstrap unpublished packages"];
7
+ const ISSUE_TITLE = "deploy(publish): OIDC bootstrap unpublished packages";
11
8
  const BOOTSTRAP_ENV = "npm-oidc-bootstrap";
12
9
  const DEFAULT_REPO = "typesmiths/decohere";
13
10
 
14
- export function taskName() {
15
- return TASK_NAME;
16
- }
17
-
18
- export function bootstrapTaskNames() {
19
- return [TASK_NAME, ...LEGACY_TASK_NAMES];
11
+ export function issueTitle() {
12
+ return ISSUE_TITLE;
20
13
  }
21
14
 
22
15
  /** @param {string | undefined} raw */
@@ -27,14 +20,6 @@ export function parseUnpublished(raw) {
27
20
  .filter(Boolean);
28
21
  }
29
22
 
30
- /** @param {string | undefined} raw */
31
- export function parseAssigneeIds(raw) {
32
- return String(raw ?? "")
33
- .split(/[,\s]+/)
34
- .map((id) => Number(id.trim()))
35
- .filter((id) => Number.isInteger(id) && id > 0);
36
- }
37
-
38
23
  /**
39
24
  * @param {string | undefined} version
40
25
  */
@@ -82,29 +67,6 @@ export function extractGitHubReviewerLogins(environment) {
82
67
  return [...new Set(logins)];
83
68
  }
84
69
 
85
- /**
86
- * @param {string[]} logins
87
- * @param {{ id?: number, username?: string, email?: string }[]} members
88
- */
89
- export function mapLoginsToClickUpIds(logins, members) {
90
- const ids = new Set();
91
- for (const login of logins) {
92
- const key = login.toLowerCase();
93
- for (const member of members) {
94
- const username = String(member.username ?? "").toLowerCase();
95
- const email = String(member.email ?? "").toLowerCase();
96
- const local = email.split("@")[0] ?? "";
97
- if (username === key || local === key || email === key) {
98
- const id = Number(member.id);
99
- if (Number.isInteger(id) && id > 0) {
100
- ids.add(id);
101
- }
102
- }
103
- }
104
- }
105
- return [...ids];
106
- }
107
-
108
70
  /**
109
71
  * @param {{
110
72
  * unpublished: string[],
@@ -156,54 +118,6 @@ export function buildDescription(options) {
156
118
  .join("\n");
157
119
  }
158
120
 
159
- async function clickup(method, path, body) {
160
- const key = process.env.CLICKUP_API_KEY;
161
- if (key == null || key.length === 0) {
162
- throw new Error("Missing CLICKUP_API_KEY");
163
- }
164
- const response = await fetch(`${API}${path}`, {
165
- method,
166
- headers: {
167
- Authorization: key,
168
- "Content-Type": "application/json",
169
- },
170
- body: body == null ? undefined : JSON.stringify(body),
171
- });
172
- const text = await response.text();
173
- if (!response.ok) {
174
- throw new Error(
175
- `ClickUp ${method} ${path} failed (${response.status}): ${text.slice(0, 500)}`,
176
- );
177
- }
178
- return text.length === 0 ? undefined : JSON.parse(text);
179
- }
180
-
181
- async function findTaskByName(names) {
182
- const wanted = new Set(Array.isArray(names) ? names : [names]);
183
- for (let page = 0; page < 10; page += 1) {
184
- const qs = new URLSearchParams({
185
- include_closed: "true",
186
- subtasks: "false",
187
- page: String(page),
188
- });
189
- const data = await clickup("GET", `/list/${LIST_ID}/task?${qs}`);
190
- const tasks = Array.isArray(data) ? data : (data?.tasks ?? []);
191
- const match = tasks.find((task) => wanted.has(task.name));
192
- if (match) {
193
- return match;
194
- }
195
- if (tasks.length < 100) {
196
- break;
197
- }
198
- }
199
- return null;
200
- }
201
-
202
- async function firstOpenStatus() {
203
- const list = await clickup("GET", `/list/${LIST_ID}`);
204
- return (list.statuses ?? []).find((status) => status.type === "open")?.status;
205
- }
206
-
207
121
  function runUrl() {
208
122
  if (
209
123
  process.env.GITHUB_SERVER_URL &&
@@ -238,124 +152,52 @@ export async function githubEnvironmentReviewerLogins(options = {}) {
238
152
  );
239
153
  if (!response.ok) {
240
154
  process.stderr.write(
241
- `Could not list ${environment} reviewers (${response.status}); assigning from CLICKUP_ASSIGNEE_ID only\n`,
155
+ `Could not list ${environment} reviewers (${response.status}); the issue will have no assignees\n`,
242
156
  );
243
157
  return [];
244
158
  }
245
159
  return extractGitHubReviewerLogins(await response.json());
246
160
  }
247
161
 
248
- export async function clickupWorkspaceMembers() {
249
- const data = await clickup("GET", "/team");
250
- const teams = Array.isArray(data?.teams) ? data.teams : [];
251
- const members = [];
252
- for (const team of teams) {
253
- for (const entry of team.members ?? []) {
254
- const user = entry?.user ?? entry;
255
- if (user?.id) {
256
- members.push({
257
- id: Number(user.id),
258
- username: user.username,
259
- email: user.email,
260
- });
261
- }
262
- }
263
- }
264
- return members;
265
- }
266
-
267
- /**
268
- * @param {{ unpublished: string[], packages: { name: string, dir: string, version?: string }[], assigneeIds: number[], repo?: string, runUrl?: string, channel?: string }} options
269
- */
270
- export async function upsertBootstrapTask(options) {
271
- const name = taskName();
272
- const existing = await findTaskByName(bootstrapTaskNames());
273
- const markdown = buildDescription(options);
274
- const payload = {
275
- name,
276
- markdown_description: markdown,
277
- custom_item_id: DEPLOYMENT_TYPE,
278
- priority: 1,
279
- };
280
- if (options.assigneeIds.length > 0 && !existing) {
281
- payload.assignees = options.assigneeIds;
282
- }
283
-
284
- if (existing) {
285
- const update = {
286
- name,
287
- markdown_description: markdown,
288
- custom_item_id: DEPLOYMENT_TYPE,
289
- priority: 1,
290
- };
291
- if (existing.closed || existing.status?.type === "closed") {
292
- const status = await firstOpenStatus();
293
- if (status) {
294
- update.status = status;
295
- }
296
- }
297
- if (options.assigneeIds.length > 0) {
298
- update.assignees = { add: options.assigneeIds };
299
- }
300
- await clickup("PUT", `/task/${existing.id}`, update);
301
- return { id: existing.id, url: existing.url, created: false };
302
- }
303
- const created = await clickup("POST", `/list/${LIST_ID}/task`, payload);
304
- return { id: created.id, url: created.url, created: true };
305
- }
306
-
307
162
  export async function runNudge(env = process.env, io = {}) {
308
163
  const unpublished = parseUnpublished(env.UNPUBLISHED);
309
164
  if (unpublished.length === 0) {
310
- process.stdout.write("No unpublished package names; skipping ClickUp bootstrap task\n");
165
+ process.stdout.write("No unpublished package names; skipping OIDC bootstrap issue\n");
311
166
  return 0;
312
167
  }
313
168
 
314
169
  const root = io.root ?? process.cwd();
315
170
  const packages = (io.listPackages ?? listPublishablePackages)(root);
316
- const logins = await (io.githubReviewers ?? githubEnvironmentReviewerLogins)({
171
+ const assignees = await (io.githubReviewers ?? githubEnvironmentReviewerLogins)({
317
172
  token: env.GITHUB_TOKEN ?? env.GH_TOKEN,
318
173
  repo: env.GITHUB_REPOSITORY,
319
174
  });
320
- const members = await (io.clickupMembers ?? clickupWorkspaceMembers)();
321
- const assigneeIds = [
322
- ...new Set([
323
- ...mapLoginsToClickUpIds(logins, members),
324
- ...parseAssigneeIds(env.CLICKUP_ASSIGNEE_ID),
325
- ]),
326
- ];
327
-
328
- if (logins.length > 0 && mapLoginsToClickUpIds(logins, members).length === 0) {
329
- process.stderr.write(
330
- `GitHub ${BOOTSTRAP_ENV} reviewers (${logins.join(", ")}) did not match a ClickUp member (login ↔ username or email local-part)\n`,
331
- );
332
- }
333
-
334
- const result = await (io.upsert ?? upsertBootstrapTask)({
335
- unpublished,
336
- packages,
337
- assigneeIds,
338
- repo: env.GITHUB_REPOSITORY || DEFAULT_REPO,
339
- runUrl: io.runUrl ?? runUrl(),
340
- channel: env.PUBLISH_CHANNEL || env.CHANNEL || "dev",
175
+ const repo = env.GITHUB_REPOSITORY || DEFAULT_REPO;
176
+
177
+ const result = (io.upsert ?? upsertIssue)({
178
+ title: issueTitle(),
179
+ body: buildDescription({
180
+ unpublished,
181
+ packages,
182
+ repo,
183
+ runUrl: io.runUrl ?? runUrl(),
184
+ channel: env.PUBLISH_CHANNEL || env.CHANNEL || "dev",
185
+ }),
186
+ assignees,
341
187
  });
342
- const url = result.url ?? "";
343
188
  process.stdout.write(
344
- `${result.created ? "Created" : "Updated"} ClickUp ${result.id} ${url}\n`,
189
+ `${result.created ? "Opened" : "Updated"} issue #${result.number} ${result.url}\n`,
345
190
  );
346
- if (assigneeIds.length > 0) {
347
- process.stdout.write(`Assigned ClickUp users ${assigneeIds.join(", ")}\n`);
191
+ if (assignees.length > 0) {
192
+ process.stdout.write(`Assigned ${assignees.join(", ")}\n`);
348
193
  } else {
349
194
  process.stdout.write(
350
- "No ClickUp assignees (add required reviewers on npm-oidc-bootstrap, or set vars.CLICKUP_ASSIGNEE_ID)\n",
195
+ `No assignees (add required reviewers on the ${BOOTSTRAP_ENV} environment)\n`,
351
196
  );
352
197
  }
353
198
  if (env.GITHUB_OUTPUT) {
354
199
  const { appendFileSync } = await import("node:fs");
355
- appendFileSync(env.GITHUB_OUTPUT, `task_url=${url}\n`);
356
- }
357
- if (!url) {
358
- throw new Error("ClickUp bootstrap task has no URL");
200
+ appendFileSync(env.GITHUB_OUTPUT, `issue_url=${result.url}\n`);
359
201
  }
360
202
  return 0;
361
203
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * One GitHub issue per subject, found again by its exact title and updated in place: CI files
3
+ * a reminder once and keeps it current instead of opening a new issue every run. Needs `gh` and
4
+ * GH_TOKEN (or GITHUB_TOKEN) with issues: write.
5
+ */
6
+
7
+ import { spawnSync } from "node:child_process";
8
+
9
+ /**
10
+ * @param {string[]} args
11
+ * @param {string} [input]
12
+ */
13
+ export function gh(args, input) {
14
+ const result = spawnSync("gh", args, { encoding: "utf8", input, env: process.env });
15
+ if (result.status !== 0) {
16
+ throw new Error(result.stderr || result.stdout || `gh ${args.join(" ")} failed`);
17
+ }
18
+ return result.stdout;
19
+ }
20
+
21
+ /**
22
+ * The issue titled exactly `title`, preferring an open one over a closed one.
23
+ *
24
+ * @param {{ number: number, title: string, state: string, url: string }[]} issues
25
+ * @param {string} title
26
+ */
27
+ export function pickIssue(issues, title) {
28
+ const matches = issues.filter((issue) => issue.title === title);
29
+ return matches.find((issue) => issue.state === "OPEN") ?? matches[0] ?? null;
30
+ }
31
+
32
+ /**
33
+ * @param {{ title: string, body: string, assignees?: string[], repo?: string }} options
34
+ * @param {typeof gh} [run]
35
+ * @returns {{ number: number, url: string, created: boolean }}
36
+ */
37
+ export function upsertIssue(options, run = gh) {
38
+ const { title, body, assignees = [] } = options;
39
+ const repo = options.repo ? ["--repo", options.repo] : [];
40
+ const issues = JSON.parse(
41
+ run([
42
+ "issue",
43
+ "list",
44
+ ...repo,
45
+ "--state",
46
+ "all",
47
+ "--limit",
48
+ "200",
49
+ "--search",
50
+ `${title} in:title`,
51
+ "--json",
52
+ "number,title,state,url",
53
+ ]),
54
+ );
55
+ const assign = assignees.flatMap((login) => ["--add-assignee", login]);
56
+ const existing = pickIssue(issues, title);
57
+ if (existing != null) {
58
+ if (existing.state !== "OPEN") {
59
+ run(["issue", "reopen", String(existing.number), ...repo]);
60
+ }
61
+ run(
62
+ ["issue", "edit", String(existing.number), ...repo, "--body-file", "-", ...assign],
63
+ body,
64
+ );
65
+ return { number: existing.number, url: existing.url, created: false };
66
+ }
67
+ const url = run(
68
+ ["issue", "create", ...repo, "--title", title, "--body-file", "-", ...assign],
69
+ body,
70
+ ).trim();
71
+ const number = Number(url.split("/").pop());
72
+ return { number, url, created: true };
73
+ }
@@ -4,7 +4,7 @@
4
4
  * page needs no script and no third-party chart library.
5
5
  */
6
6
 
7
- import { REGRESSION } from "../bench/history.mjs";
7
+ import { REGRESSION, STALE_AFTER_DAYS } from "../bench/history.mjs";
8
8
 
9
9
  const GROUPS = [
10
10
  { prefix: "flux.serve.", title: "flux serve (real process)" },
@@ -49,10 +49,20 @@ function machineName(context) {
49
49
  return `${context.benchEnv ?? "unknown"} · ${context.cpu.model} × ${context.cpu.count}`;
50
50
  }
51
51
 
52
- /** Contexts with at least one run, most recently run first. */
52
+ /**
53
+ * Contexts with a run within STALE_AFTER_DAYS of the newest run anywhere, most recently run first:
54
+ * CI runner types come and go, and one that stopped appearing would otherwise keep a line (#667).
55
+ */
53
56
  function activeContexts(history) {
57
+ const newest = Math.max(
58
+ ...Object.values(history.contexts ?? {})
59
+ .filter((entry) => entry.runs.length > 0)
60
+ .map((entry) => new Date(entry.runs[0].date).getTime()),
61
+ );
62
+ const cutoff = newest - STALE_AFTER_DAYS * 24 * 60 * 60 * 1000;
54
63
  return Object.entries(history.contexts ?? {})
55
64
  .filter(([, entry]) => entry.runs.length > 0)
65
+ .filter(([, entry]) => new Date(entry.runs[0].date).getTime() >= cutoff)
56
66
  .sort((a, b) => new Date(b[1].runs[0].date) - new Date(a[1].runs[0].date))
57
67
  .map(([id, entry], index) => ({
58
68
  id,
@@ -232,7 +242,7 @@ export function buildBenchmarkHtml(
232
242
  )
233
243
  .join("\n");
234
244
  return `<section class="machines"><h2>Machines</h2><ul>${legend}</ul>
235
- <p class="note">Each line is one machine type; runs are only compared on the same one. Shared CI runners are noisy, so read the trend, not a single point. Hover a point for its date, commit and p90. A benchmark more than ${Math.round(REGRESSION.threshold * 100)}% slower than its baseline on ${REGRESSION.confirmRuns} nights running gets an issue: <a href="${escapeHtml(`${repoUrl}/issues?q=is%3Aissue+is%3Aopen+%22perf(bench)%22`)}">open benchmark issues</a>.</p></section>
245
+ <p class="note">Each line is one machine type; runs are only compared on the same one. Shared CI runners are noisy, so read the trend, not a single point. Hover a point for its date, commit and p90. Each night also benchmarks the previous nightly's commit on the same runner, alternating ${REGRESSION.rounds} rounds each; a benchmark more than ${Math.round(REGRESSION.threshold * 100)}% slower in every round gets an issue: <a href="${escapeHtml(`${repoUrl}/issues?q=is%3Aissue+is%3Aopen+%22perf(bench)%22`)}">open benchmark issues</a>.</p></section>
236
246
  ${sections}
237
247
  <section><h2>Latest run</h2>${latestTable(contexts, labels, repoUrl)}</section>`;
238
248
  })();
@@ -4,7 +4,9 @@ import {
4
4
  assertValid,
5
5
  compare,
6
6
  contextId,
7
- findRegressions,
7
+ compareRounds,
8
+ latestRun,
9
+ pruneStale,
8
10
  formatComparison,
9
11
  fromVitest,
10
12
  getContext,
@@ -206,46 +208,72 @@ describe("compare / formatComparison", () => {
206
208
  });
207
209
  });
208
210
 
209
- describe("findRegressions", () => {
210
- // Newest first, like the history: one run per night with this median for "a".
211
- const nights = (...medians) =>
212
- medians.map((median, index) => ({
213
- date: `2026-10-${String(30 - index).padStart(2, "0")}T03:17:00Z`,
214
- sha: `sha${30 - index}`,
215
- results: [result("a", median)],
216
- }));
217
-
218
- it("flags a benchmark over the threshold on the last two nights, against the runs before", () => {
219
- const [regression] = findRegressions(nights(130, 125, 100, 101, 99, 100, 102));
211
+ describe("compareRounds", () => {
212
+ const round = (...medians) => medians.map((median, index) => result(`b${index}`, median));
213
+
214
+ it("flags a benchmark slower on head than base in every round, with both rounds' numbers", () => {
215
+ const [regression] = compareRounds([round(130, 50)], [round(100, 50)], {
216
+ threshold: 0.2,
217
+ rounds: 1,
218
+ });
220
219
  expect(regression).toMatchObject({
221
- id: "a",
222
- baseline: 100,
223
- recent: [
224
- { sha: "sha30", median: 130 },
225
- { sha: "sha29", median: 125 },
226
- ],
227
- lastGood: { sha: "sha28" },
220
+ id: "b0",
221
+ base: 100,
222
+ head: 130,
223
+ rounds: [{ base: 100, head: 130 }],
228
224
  });
229
225
  expect(regression.percent).toBeCloseTo(30);
230
226
  });
231
227
 
232
- it("ignores one slow night, which is as likely the runner as the code", () => {
233
- expect(findRegressions(nights(150, 100, 100, 101, 99, 100))).toEqual([]);
228
+ it("needs every round slower: one slow round is as likely the runner as the code", () => {
229
+ expect(compareRounds([round(130), round(105)], [round(100), round(100)])).toEqual([]);
230
+ expect(compareRounds([round(130), round(126)], [round(100), round(100)])).toHaveLength(1);
234
231
  });
235
232
 
236
233
  it("ignores slowdowns within the threshold", () => {
237
- expect(findRegressions(nights(115, 118, 100, 100, 100))).toEqual([]);
234
+ expect(compareRounds([round(115), round(118)], [round(100), round(100)])).toEqual([]);
235
+ });
236
+
237
+ it("skips benchmarks only one side has, and compares nothing without enough rounds", () => {
238
+ const head = [
239
+ [result("new", 500), result("old", 100)],
240
+ [result("new", 500), result("old", 100)],
241
+ ];
242
+ const base = [[result("old", 100)], [result("old", 100)]];
243
+ expect(compareRounds(head, base)).toEqual([]);
244
+ expect(compareRounds([round(200)], [round(100)])).toEqual([]);
245
+ });
246
+ });
247
+
248
+ describe("pruneStale / latestRun", () => {
249
+ const at = (date, sha) => ({ date, sha, results: [] });
250
+ const history = {
251
+ version: 2,
252
+ contexts: {
253
+ old: { context: ctx, runs: [at("2026-08-12T03:17:00Z", "aaa")] },
254
+ recent: {
255
+ context: ctx,
256
+ runs: [at("2026-10-20T03:17:00Z", "ccc"), at("2026-10-19T03:17:00Z", "bbb")],
257
+ },
258
+ },
259
+ };
260
+
261
+ it("drops machine types with no run in the last 30 days", () => {
262
+ expect(
263
+ Object.keys(pruneStale(history, new Date("2026-10-21T00:00:00Z")).contexts),
264
+ ).toEqual(["recent"]);
265
+ expect(
266
+ Object.keys(pruneStale(history, new Date("2026-09-01T00:00:00Z")).contexts),
267
+ ).toEqual(["old", "recent"]);
238
268
  });
239
269
 
240
- it("waits for enough history to have a baseline", () => {
241
- expect(findRegressions(nights(200, 200, 100, 100))).toEqual([]);
242
- expect(findRegressions(nights(200))).toEqual([]);
270
+ it("prunes when a run is appended", () => {
271
+ const next = appendRun(history, ctx, at("2026-10-21T03:17:00Z", "ddd"));
272
+ expect(Object.keys(next.contexts)).not.toContain("old");
243
273
  });
244
274
 
245
- it("uses only the baselineRuns runs before the slow ones", () => {
246
- // Old runs at 300 would hide the regression if they were in the baseline.
247
- const runs = nights(130, 130, 100, 100, 100, 300, 300, 300, 300, 300);
248
- const options = { threshold: 0.2, confirmRuns: 2, baselineRuns: 3, minBaseline: 3 };
249
- expect(findRegressions(runs, options).map((r) => r.baseline)).toEqual([100]);
275
+ it("finds the newest run on any machine: the commit the previous nightly measured", () => {
276
+ expect(latestRun(history)?.sha).toBe("ccc");
277
+ expect(latestRun({ version: 2, contexts: {} })).toBeUndefined();
250
278
  });
251
279
  });