Public: anyone can read this; the crew can change it.

The radar reads upstream releases on a schedule and files an issue when an update is worth taking, with the reason and the risk #9609

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/9592-the-radar-reads-upstream-releases-on-a-s → main 4 commits fork

  • Merged. torque/9592-the-radar-reads-upstream-releases-on-a-s was merged into main as 56aae4e.

Review

Sign in to review this change.
7 files changed +321 −2
docs/features/flows.md +5 −1
@@ -18,4 +18,8 @@
1818 - **Forks.** Each fork out reads *fork, takes updates* or *fork, told of updates* (its `fork_follow` mode), and its last-moved time is its newest push (its creation until it has one). A fork following its parent is the only "follower" the forge records; there is no table of people following a repository.
1919 - **Forges that follow.** A child forge holding a proposal token for the repository is a *forge follows* line (its origin without userinfo or query; last use of the token, else when it was minted; *token ended* once expired). Only people who can write to the repository see these lines, page and terminal alike.
2020
21 Left out: the CHANGELOG sections between pin and newest (dropped until a lookup can read one within a byte bound); a schedule and the issue the crew files from a report; code scanning (and the release block on a new high finding); per-line buttons (actions are listed under the columns); counting a fork's commits ahead of its parent; GitHub release tags; the all-repositories admin view. Each is its own pull on top of `dep_latest` and `readFlows`.
21 Left out: the CHANGELOG sections between pin and newest (dropped until a lookup can read one within a byte bound); the nightly radar is below; code scanning (and the release block on a new high finding); per-line buttons (actions are listed under the columns); counting a fork's commits ahead of its parent; GitHub release tags; the all-repositories admin view. Each is its own pull on top of `dep_latest` and `readFlows`.
22
23 ## The nightly radar (#9592)
24
25 Every night at 05:07 UTC (`src/nightly/radar.ts`, scheduled by `src/nightly/service.ts` with the other nights; off in a preview or without `FORGE_TOKEN`) the radar reads upstream for every unarchived repository that pins packages and files what is worth taking (`src/flows/file.ts`): one issue per dependency, `Update <name> to <version>`, whose newest stable release is above the pin. The body says what changed, why it helps and what could break, by the size of the gap. Never a duplicate: a dependency with an open update issue gets none, and a release already filed (even if closed) is not filed again. On an autonomous repository the issue is assigned to a builder on the crew (the crew's own pick, so the week's pace still holds it) and the body says the crew takes it; otherwise it is assigned to the repository's owner and waits for a person. **Running it now:** there is no button that files; *Read upstream releases now* only refreshes the readings, and the filing happens at the night's slot (the same `radarNight` a test calls). The issues are authored as the repository's owner, like every other job's (flake hunt, culprit, keeper). Resilience shows when it last ran and what it filed.
src/flows/file.ts added +102
@@ -0,0 +1,102 @@
1 /**
2 * The radar files what is worth taking (#9592): one issue per dependency
3 * whose newest upstream release is a stable version above the pin, saying in
4 * plain words what changed, why it is useful and what could break. Never a
5 * duplicate: a dependency with an open issue gets none, and a release already
6 * filed (open or closed) is not filed again. Plain data in; issues out.
7 */
8
9 import type { App } from "../app.ts";
10 import { assignIssue } from "../actions/issues.ts";
11 import { crewWithRole } from "../core/agents.ts";
12 import { createIssue, type Issue } from "../core/issues.ts";
13 import { getUserById } from "../core/users.ts";
14 import type { Repo } from "../core/repos.ts";
15 import type { Gap } from "./semver.ts";
16 import { parseSemver } from "./semver.ts";
17 import type { DepLine } from "./radar.ts";
18
19 const WHAT: Record<Exclude<Gap, "none">, string> = {
20 patch: "a patch release: bug fixes only, no new features by the semver promise.",
21 minor: "a minor release: new features that keep what exists working, by the semver promise.",
22 major: "a major release: the maintainers say it may change or remove what you use.",
23 };
24 const WHY: Record<Exclude<Gap, "none">, string> = {
25 patch: "Fixes (often security fixes) reach this repository at almost no cost.",
26 minor: "New capabilities and fixes arrive while the old way keeps working.",
27 major: "Staying on an old major means missing its fixes; a major left too long gets harder to take.",
28 };
29 const RISK: Record<Exclude<Gap, "none">, string> = {
30 patch: "Low: a fix can still change behaviour someone relied on. Run the tests.",
31 minor: "Moderate: a deprecation or a changed default may show up. Run the tests and read the changelog.",
32 major: "High: expect breaking changes to the API. Read the migration notes before changing the pin, and change it in its own pull.",
33 };
34
35 /** The title that names a dependency; an open issue with this prefix is the one to leave alone. */
36 export const titlePrefix = (name: string): string => `Update ${name} to `;
37
38 /** Whether this line is a release worth an issue: a stable version above the pin. */
39 export function worthTaking(line: DepLine): line is DepLine & { gap: Exclude<Gap, "none"> } {
40 const latest = parseSemver(line.latest);
41 return line.gap !== "none" && line.error === "" && latest !== undefined && latest.pre === "";
42 }
43
44 export function updateBody(line: DepLine & { gap: Exclude<Gap, "none"> }, autonomous: boolean): string {
45 return [
46 `The radar read upstream: \`${line.name}\` is pinned at ${line.pinned} (\`${line.range}\` in ${line.dev ? "devDependencies" : "dependencies"}) and the newest release is ${line.latest}.`,
47 "",
48 `**What changed.** ${line.pinned} to ${line.latest} is ${WHAT[line.gap]}`,
49 `**Why it is useful.** ${WHY[line.gap]}${line.dev ? " It is a development dependency, so it does not reach what runs in production." : ""}`,
50 `**What could break.** ${RISK[line.gap]}`,
51 "",
52 autonomous
53 ? "This repository is autonomous: the crew takes it from here and proposes the change as a pull."
54 : "This repository is not autonomous: it waits for a person to take it or close it.",
55 ].join("\n");
56 }
57
58 /**
59 * Route a filed issue the way the forge already does: on an autonomous
60 * repository it goes to a builder on the crew (assigning an agent is what
61 * starts a run; `pick` marks it the crew's own pick so the week's pace still
62 * holds it); otherwise it is assigned to the repository's owner, a person.
63 * Without a builder (or an owner who is a person) it stays unassigned.
64 */
65 function route(app: App, repo: Repo, issue: Issue): void {
66 const owner = getUserById(app.db, repo.ownerId);
67 if (owner === undefined || owner.kind !== "user") return;
68 if (repo.autonomy === "autonomous") {
69 const builder = crewWithRole(app.db, [repo.id], "builder").get(repo.id)?.[0];
70 if (builder !== undefined) assignIssue(app, owner, repo, issue, builder.user, undefined, true);
71 return;
72 }
73 assignIssue(app, owner, repo, issue, owner);
74 }
75
76 /** File an issue for each line worth taking that has none; returns the numbers filed. */
77 export function fileUpdates(app: App, repo: Repo, lines: readonly DepLine[]): number[] {
78 const filed: number[] = [];
79 for (const line of lines) {
80 if (!worthTaking(line)) continue;
81 const prefix = titlePrefix(line.name);
82 const title = `${prefix}${line.latest}`;
83 const held = app.db
84 .query<{ n: number }, [string, string, string]>(
85 `SELECT COUNT(*) AS n FROM issue WHERE repo_id = ?1 AND kind = 'issue'
86 AND ((state = 'open' AND substr(title, 1, length(?2)) = ?2) OR title = ?3)`,
87 )
88 .get(repo.id, prefix, title);
89 if (held === null || held.n > 0) continue;
90 const made = createIssue(app.db, {
91 repoId: repo.id,
92 authorId: repo.ownerId,
93 title,
94 body: updateBody(line, repo.autonomy === "autonomous"),
95 });
96 if (!made.ok) continue;
97 app.bus.append({ kind: "issue.opened", repoId: repo.id, actorId: repo.ownerId, payload: { number: made.value.number } });
98 route(app, repo, made.value);
99 filed.push(made.value.number);
100 }
101 return filed;
102 }
src/nightly/radar.ts added +41
@@ -0,0 +1,41 @@
1 /**
2 * The radar's night (#9592): for every repository with pinned packages, read
3 * upstream and file the updates worth taking (`flows/file.ts`). In-process,
4 * no cell: the registry lookup is the only outbound call. Returns one
5 * sentence for the nightly record.
6 */
7
8 import type { App } from "../app.ts";
9 import { getRepoById } from "../core/repos.ts";
10 import { fileUpdates } from "../flows/file.ts";
11 import { npmLookup, readRadar, refreshRadar, type Lookup } from "../flows/radar.ts";
12 import { pinsOf } from "../flows/run.ts";
13 import { createLog } from "../kernel/log.ts";
14 import type { Slot } from "./slots.ts";
15
16 const log = createLog("nightly");
17
18 /** Where the radar sits among the nights, after the others have finished. */
19 export const RADAR_SLOT: Slot = { hour: 5, minute: 7 };
20 export const RADAR_NAME = "radar";
21
22 export async function radarNight(app: App, lookup: Lookup = npmLookup, signal?: AbortSignal): Promise<string> {
23 const ids = app.db.query<{ id: string }, []>("SELECT id FROM repo WHERE archived_at IS NULL ORDER BY id").all();
24 let repos = 0;
25 const filed: string[] = [];
26 for (const { id } of ids) {
27 if (signal?.aborted) break;
28 const repo = getRepoById(app.db, id);
29 if (repo === undefined) continue;
30 try {
31 const pins = await pinsOf(app, repo);
32 if (pins.length === 0) continue;
33 repos++;
34 await refreshRadar(app.db, repo.id, pins, lookup);
35 for (const n of fileUpdates(app, repo, readRadar(app.db, repo.id, pins))) filed.push(`${repo.ownerName}/${repo.name}#${n}`);
36 } catch (error) {
37 log.warn("radar failed for a repository", { repo: id, error: String(error) });
38 }
39 }
40 return `read ${repos} repositor${repos === 1 ? "y" : "ies"}; ${filed.length === 0 ? "filed nothing" : `filed ${filed.join(", ")}`}.`;
41 }
src/nightly/says.ts +5 −1
@@ -5,6 +5,7 @@
55 */
66
77 import { JOBS } from "./jobs.ts";
8 import { RADAR_NAME, RADAR_SLOT } from "./radar.ts";
89 import { type Last, readLast } from "./stamps.ts";
910
1011 const when = (ms: number): string => `${new Date(ms).toISOString().slice(0, 16).replace("T", " ")} UTC`;
@@ -17,5 +18,8 @@ export function nightlyLine(name: string, slot: { hour: number; minute: number }
1718 }
1819
1920 export function nightlyLines(dataDir: string): string[] {
20 return JOBS.map((job) => nightlyLine(job.name, job.slot, readLast(dataDir, job.name)));
21 return [
22 ...JOBS.map((job) => nightlyLine(job.name, job.slot, readLast(dataDir, job.name))),
23 nightlyLine(RADAR_NAME, RADAR_SLOT, readLast(dataDir, RADAR_NAME)),
24 ];
2125 }
src/nightly/service.ts +30
@@ -16,6 +16,7 @@ import { getRepo } from "../core/repos.ts";
1616 import { createLog } from "../kernel/log.ts";
1717 import { ownRepoPath } from "../kernel/own_repo.ts";
1818 import { JOBS, type Job } from "./jobs.ts";
19 import { radarNight, RADAR_NAME, RADAR_SLOT } from "./radar.ts";
1920 import { proposeQuarantine } from "./propose.ts";
2021 import { type Ran, runJob } from "./run.ts";
2122 import { lastSlot, owed, untilNext } from "./slots.ts";
@@ -33,6 +34,8 @@ export interface Doing {
3334 export interface NightlyHandle {
3435 /** Run one job now, as its slot would. */
3536 readonly run: (name: Job["name"]) => Promise<void>;
37 /** Run the radar's night now, as its slot would. */
38 readonly radar: () => Promise<void>;
3639 readonly stop: () => Promise<void>;
3740 }
3841
@@ -68,6 +71,8 @@ export function startNightly(
6871 readonly forgeUrl: string;
6972 readonly token: string | undefined;
7073 readonly doing?: Doing;
74 /** The radar's night: a test replaces it. */
75 readonly radar?: (signal: AbortSignal) => Promise<string>;
7176 readonly now?: () => number;
7277 },
7378 ): NightlyHandle | undefined {
@@ -110,6 +115,30 @@ export function startNightly(
110115 return going as Promise<void>;
111116 };
112117
118 // The radar is in-process, so it needs no job of steps; it keeps a slot and a record like the others.
119 const radar = options.radar ?? ((signal: AbortSignal) => radarNight(app, undefined, signal));
120 const runRadarNight = (): Promise<void> => {
121 const slot = lastSlot(RADAR_SLOT, now());
122 const turn = going.then(async () => {
123 if (abort.signal.aborted) return;
124 const startedAt = now();
125 writeLast(app.config.dataDir, RADAR_NAME, { slot, startedAt, says: "began" });
126 const said = await radar(abort.signal);
127 writeLast(app.config.dataDir, RADAR_NAME, { slot, startedAt, finishedAt: now(), says: said });
128 });
129 going = turn.catch((error) => log.error("failed", { job: RADAR_NAME, error: String(error) }));
130 return going as Promise<void>;
131 };
132 const waitRadar = (): void => {
133 const timer = setTimeout(() => {
134 timers.delete(timer);
135 void runRadarNight().finally(() => !abort.signal.aborted && waitRadar());
136 }, untilNext(RADAR_SLOT, now()) + 1_000);
137 timers.add(timer);
138 };
139 if (owed(RADAR_SLOT, readLast(app.config.dataDir, RADAR_NAME)?.slot, now())) void runRadarNight();
140 waitRadar();
141
113142 const wait = (job: Job): void => {
114143 const timer = setTimeout(() => {
115144 timers.delete(timer);
@@ -125,6 +154,7 @@ export function startNightly(
125154 }
126155 return {
127156 run,
157 radar: runRadarNight,
128158 stop: async () => {
129159 abort.abort();
130160 for (const t of timers) clearTimeout(t);
src/web/pages/resilience.ts +1
@@ -38,6 +38,7 @@ export function nightlyHtml(ctx: Ctx): Html {
3838 const lines = nightlyLines(ctx.app.config.dataDir);
3939 return html`<ul id="nightly" class="muted">
4040 ${JOBS.map((job, i) => html`<li>${lines[i] ?? ""} <a href="${runPath(job.name)}">Output</a></li>`)}
41 ${lines.slice(JOBS.length).map((line) => html`<li>${line}</li>`)}
4142 </ul>`;
4243 }
4344
test/radar-night.test.ts added +137
@@ -0,0 +1,137 @@
1 /** The radar's night (#9592): a fake registry, a new release files one issue, a second run files none. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { seedAdmin } from "./helpers/admin.ts";
9 import { getRepoById, type Repo } from "../src/core/repos.ts";
10 import { addCrew, createAgentRow } from "../src/core/agents.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { fileUpdates, updateBody } from "../src/flows/file.ts";
13 import { readRadar, refreshRadar, type Lookup } from "../src/flows/radar.ts";
14 import { readPins } from "../src/flows/pins.ts";
15 import { radarNight } from "../src/nightly/radar.ts";
16 import { startNightly } from "../src/nightly/service.ts";
17 import { nightlyLines } from "../src/nightly/says.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19
20 quietLogs();
21
22 let app: App;
23 let dir: string;
24 let repo: Repo;
25 beforeAll(async () => {
26 dir = await mkdtemp(join(tmpdir(), "radar-night-"));
27 app = createApp({ dataDir: dir, port: 0 });
28 const owner = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
29 if (!owner.ok) throw new Error("no owner");
30 const made = await createRepo(app, owner.value, { name: "site", visibility: "public" });
31 if (!made.ok) throw new Error("no repo");
32 repo = made.value;
33 const files = [{ path: "package.json", content: JSON.stringify({ dependencies: { left: "1.2.3", mid: "^2.0.0" } }) }];
34 const seeded = await commitToBranch(app, owner.value, repo, { message: "seed", files });
35 if (!seeded.ok) throw new Error("no seed");
36 });
37 afterAll(async () => {
38 app.close();
39 await rm(dir, { recursive: true, force: true });
40 });
41
42 const PINS = readPins(JSON.stringify({ dependencies: { left: "1.2.3", mid: "^2.0.0", same: "3.0.0", rc: "1.0.0" }, devDependencies: { dev: "0.1.0" } }));
43 const registry = (latest: Record<string, string>): Lookup => async (name) => (latest[name] ? { latest: latest[name]! } : "unknown");
44 const issuesOf = () =>
45 app.db.query<{ title: string; body: string }, [string]>("SELECT title, body FROM issue WHERE repo_id = ? AND title LIKE 'Update %' ORDER BY title").all(repo.id);
46
47 async function night(latest: Record<string, string>): Promise<number[]> {
48 await refreshRadar(app.db, repo.id, PINS, registry(latest));
49 return fileUpdates(app, repo, readRadar(app.db, repo.id, PINS));
50 }
51
52 describe("the radar files updates", () => {
53 test("a new release files one issue per dependency, a second run none", async () => {
54 const latest = { left: "1.2.4", mid: "3.0.0", same: "3.0.0", rc: "2.0.0-rc.1", dev: "0.2.0" };
55 const first = await night(latest);
56 expect(first).toHaveLength(3);
57 expect(issuesOf().map((i) => i.title)).toEqual(["Update dev to 0.2.0", "Update left to 1.2.4", "Update mid to 3.0.0"]);
58 expect(await night(latest)).toEqual([]);
59 expect(issuesOf()).toHaveLength(3);
60 });
61
62 test("the body says what changed, why it helps and what could break", () => {
63 const body = issuesOf().find((i) => i.title.startsWith("Update mid"))!.body;
64 expect(body).toContain("**What changed.**");
65 expect(body).toContain("major release");
66 expect(body).toContain("**Why it is useful.**");
67 expect(body).toContain("**What could break.** High");
68 });
69
70 test("a newer release while one is open files nothing; once closed, the newer release files again", async () => {
71 expect(await night({ left: "1.3.0" })).toEqual([]);
72 app.db.query("UPDATE issue SET state = 'closed' WHERE repo_id = ? AND title = 'Update left to 1.2.4'").run(repo.id);
73 expect(await night({ left: "1.2.4" })).toEqual([]); // already filed once, however it closed
74 expect(await night({ left: "1.3.0" })).toHaveLength(1);
75 });
76
77 test("autonomy decides who the issue is for", () => {
78 const line = readRadar(app.db, repo.id, PINS).find((l) => l.name === "left")!;
79 const w = line as typeof line & { gap: "minor" };
80 expect(updateBody(w, true)).toContain("the crew takes it");
81 expect(updateBody(w, false)).toContain("waits for a person");
82 });
83
84 test("an unreachable registry files nothing", async () => {
85 const before = issuesOf().length;
86 expect(await night({})).toEqual([]);
87 expect(issuesOf()).toHaveLength(before);
88 });
89 });
90
91 async function seedAgent(a: App, r: Repo, username: string): Promise<string> {
92 const u = await createUser(a.db, { origin: "seed", username, email: `${username}@a.invalid`, agent: true });
93 if (!u.ok) throw new Error("agent");
94 createAgentRow(a.db, { userId: u.value.id, roles: ["builder"], createdBy: r.ownerId });
95 addCrew(a.db, r.id, u.value.id, r.ownerId, "builder");
96 return u.value.id;
97 }
98
99 describe("the radar routes what it files", () => {
100 const assignee = (title: string) =>
101 app.db
102 .query<{ assignee_id: string | null }, [string, string]>("SELECT assignee_id FROM issue WHERE repo_id = ? AND title = ?")
103 .get(repo.id, title)?.assignee_id ?? null;
104
105 test("a non-autonomous repository's issue is assigned to its owner, a person", async () => {
106 app.db.query("DELETE FROM issue WHERE repo_id = ?").run(repo.id);
107 await night({ left: "1.2.4" });
108 expect(assignee("Update left to 1.2.4")).toBe(repo.ownerId);
109 });
110
111 test("an autonomous repository's issue is assigned to a builder on the crew", async () => {
112 const builder = await seedAgent(app, repo, "radar-builder");
113 app.db.query("UPDATE repo SET autonomy = 'autonomous' WHERE id = ?").run(repo.id);
114 app.db.query("DELETE FROM issue WHERE repo_id = ?").run(repo.id);
115 const fresh = getRepoById(app.db, repo.id)!;
116 await refreshRadar(app.db, repo.id, PINS, registry({ left: "1.2.4" }));
117 fileUpdates(app, fresh, readRadar(app.db, repo.id, PINS));
118 expect(assignee("Update left to 1.2.4")).toBe(builder);
119 app.db.query("UPDATE repo SET autonomy = 'assisted' WHERE id = ?").run(repo.id);
120 });
121 });
122
123 describe("the nightly service", () => {
124 test("radarNight reads every repository with pins and says what it filed", async () => {
125 app.db.query("DELETE FROM issue WHERE repo_id = ?").run(repo.id);
126 const said = await radarNight(app, registry({ left: "1.2.3", mid: "2.1.0" }));
127 expect(said).toMatch(/^read 1 repository; filed alice\/site#\d+\.$/);
128 expect(await radarNight(app, registry({ left: "1.2.3", mid: "2.1.0" }))).toBe("read 1 repository; filed nothing.");
129 });
130
131 test("the radar keeps a slot and a record like the other nights", async () => {
132 const handle = startNightly(app, { forgeUrl: "http://x", token: "t", radar: async () => "read 1 repository; filed nothing.", doing: { run: async () => ({ error: "off" }), propose: async () => undefined } })!;
133 await handle.radar();
134 await handle.stop();
135 expect(nightlyLines(dir).some((l) => l.startsWith("radar:") && l.includes("filed nothing"))).toBe(true);
136 });
137 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs