@decohere/ci-tools 0.0.1-dev.112 → 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.
- package/package.json +2 -2
- package/src/audit/upsert-issue.mjs +5 -39
- package/src/bench/bench.mjs +5 -5
- package/src/bench/file-regressions.mjs +15 -14
- package/src/bench/history.mjs +64 -41
- package/src/bench/nightly.mjs +118 -0
- package/src/bench/regression-issue.mjs +15 -22
- package/src/github/credential-nudge.mjs +22 -97
- package/src/github/oidc-bootstrap-nudge.mjs +24 -182
- package/src/lib/github-issue.mjs +73 -0
- package/src/pages/bench-page.mjs +13 -3
- package/tests/bench/history.test.mjs +58 -30
- package/tests/bench/regression-issue.test.mjs +12 -20
- package/tests/github/credential-nudge.test.mjs +53 -2
- package/tests/github/oidc-bootstrap-nudge.test.mjs +14 -29
- package/tests/lib/github-issue.test.mjs +74 -0
- package/tests/pages/bench-page.test.mjs +17 -0
- package/src/audit/upsert-clickup.mjs +0 -117
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
|
182
|
+
export function upsertRotationIssue(credential, options, upsert = upsertIssue) {
|
|
232
183
|
const { expiresYmd, kind, daysLeft, rejected } = options;
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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 =
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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 ? "
|
|
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
|
|
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
|
|
15
|
-
return
|
|
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});
|
|
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
|
|
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
|
|
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
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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 ? "
|
|
189
|
+
`${result.created ? "Opened" : "Updated"} issue #${result.number} ${result.url}\n`,
|
|
345
190
|
);
|
|
346
|
-
if (
|
|
347
|
-
process.stdout.write(`Assigned
|
|
191
|
+
if (assignees.length > 0) {
|
|
192
|
+
process.stdout.write(`Assigned ${assignees.join(", ")}\n`);
|
|
348
193
|
} else {
|
|
349
194
|
process.stdout.write(
|
|
350
|
-
|
|
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, `
|
|
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
|
+
}
|
package/src/pages/bench-page.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
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("
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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: "
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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("
|
|
233
|
-
expect(
|
|
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(
|
|
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("
|
|
241
|
-
|
|
242
|
-
expect(
|
|
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("
|
|
246
|
-
|
|
247
|
-
|
|
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
|
});
|