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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/9592-the-radar-reads-upstream-releases-on-a-s → main 4 commits fork
7 files
changed +321
−2
docs/features/flows.md +5 −1
| @@ -18,4 +18,8 @@ | ||
| 18 | 18 | - **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. |
| 19 | 19 | - **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. |
| 20 | 20 | |
| 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 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { JOBS } from "./jobs.ts"; |
| 8 | import { RADAR_NAME, RADAR_SLOT } from "./radar.ts"; | |
| 8 | 9 | import { type Last, readLast } from "./stamps.ts"; |
| 9 | 10 | |
| 10 | 11 | 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 } | ||
| 17 | 18 | } |
| 18 | 19 | |
| 19 | 20 | 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 | ]; | |
| 21 | 25 | } |
src/nightly/service.ts +30
| @@ -16,6 +16,7 @@ import { getRepo } from "../core/repos.ts"; | ||
| 16 | 16 | import { createLog } from "../kernel/log.ts"; |
| 17 | 17 | import { ownRepoPath } from "../kernel/own_repo.ts"; |
| 18 | 18 | import { JOBS, type Job } from "./jobs.ts"; |
| 19 | import { radarNight, RADAR_NAME, RADAR_SLOT } from "./radar.ts"; | |
| 19 | 20 | import { proposeQuarantine } from "./propose.ts"; |
| 20 | 21 | import { type Ran, runJob } from "./run.ts"; |
| 21 | 22 | import { lastSlot, owed, untilNext } from "./slots.ts"; |
| @@ -33,6 +34,8 @@ export interface Doing { | ||
| 33 | 34 | export interface NightlyHandle { |
| 34 | 35 | /** Run one job now, as its slot would. */ |
| 35 | 36 | readonly run: (name: Job["name"]) => Promise<void>; |
| 37 | /** Run the radar's night now, as its slot would. */ | |
| 38 | readonly radar: () => Promise<void>; | |
| 36 | 39 | readonly stop: () => Promise<void>; |
| 37 | 40 | } |
| 38 | 41 | |
| @@ -68,6 +71,8 @@ export function startNightly( | ||
| 68 | 71 | readonly forgeUrl: string; |
| 69 | 72 | readonly token: string | undefined; |
| 70 | 73 | readonly doing?: Doing; |
| 74 | /** The radar's night: a test replaces it. */ | |
| 75 | readonly radar?: (signal: AbortSignal) => Promise<string>; | |
| 71 | 76 | readonly now?: () => number; |
| 72 | 77 | }, |
| 73 | 78 | ): NightlyHandle | undefined { |
| @@ -110,6 +115,30 @@ export function startNightly( | ||
| 110 | 115 | return going as Promise<void>; |
| 111 | 116 | }; |
| 112 | 117 | |
| 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 | ||
| 113 | 142 | const wait = (job: Job): void => { |
| 114 | 143 | const timer = setTimeout(() => { |
| 115 | 144 | timers.delete(timer); |
| @@ -125,6 +154,7 @@ export function startNightly( | ||
| 125 | 154 | } |
| 126 | 155 | return { |
| 127 | 156 | run, |
| 157 | radar: runRadarNight, | |
| 128 | 158 | stop: async () => { |
| 129 | 159 | abort.abort(); |
| 130 | 160 | for (const t of timers) clearTimeout(t); |
src/web/pages/resilience.ts +1
| @@ -38,6 +38,7 @@ export function nightlyHtml(ctx: Ctx): Html { | ||
| 38 | 38 | const lines = nightlyLines(ctx.app.config.dataDir); |
| 39 | 39 | return html`<ul id="nightly" class="muted"> |
| 40 | 40 | ${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>`)} | |
| 41 | 42 | </ul>`; |
| 42 | 43 | } |
| 43 | 44 |
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 | }); |