Public: anyone can read this; the crew can change it.
Retirement issues are checked against main before they are handed out: one whose class or rule is already gone is closed with the commit that removed it #5985
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5983-retirement-issues-are-checked-against-ma → main 1 commit fork
3 files
changed +217
−2
src/crew/autonomy.ts +38 −2
| @@ -99,8 +99,10 @@ import { given, pendingReplans } from "../core/limb_replan.ts"; | ||
| 99 | 99 | import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts"; |
| 100 | 100 | import { atRunCap, type Held, heldBy, heldWords, replannable } from "./held.ts"; |
| 101 | 101 | import { NO_PULL, shunnedBy, stallReason, unstickOwed } from "./stalls.ts"; |
| 102 | import { BY_CREW, DONE_LIKE } from "../core/done_on_main.ts"; | |
| 102 | import { alreadyDone, BY_CREW, DONE_LIKE } from "../core/done_on_main.ts"; | |
| 103 | import { repoDir } from "../git/repos.ts"; | |
| 103 | 104 | import { ALREADY_NOTE, alreadyDoneOids } from "./already.ts"; |
| 105 | import { removedBy, retiredNames } from "./retired.ts"; | |
| 104 | 106 | import { interruptionOf } from "./interrupted.ts"; |
| 105 | 107 | import { |
| 106 | 108 | PARK_NOTE, |
| @@ -388,7 +390,13 @@ export async function driveRepo( | ||
| 388 | 390 | const shunned = shunnedBy(app.db, repo, issue.id, now); |
| 389 | 391 | return crew.filter((c) => c.role !== "builder" || !shunned.has(c.agent.user.id)); |
| 390 | 392 | }; |
| 391 | const pickable = ready.filter((r) => crewFor(r.issue).some((c) => c.role === "builder")); | |
| 393 | const pickable = ( | |
| 394 | await withoutRetired( | |
| 395 | app, | |
| 396 | repo, | |
| 397 | ready.filter((r) => crewFor(r.issue).some((c) => c.role === "builder")), | |
| 398 | ) | |
| 399 | ); | |
| 392 | 400 | const next = await nextOf(app, repo, pickable); |
| 393 | 401 | if (next !== undefined) { |
| 394 | 402 | const routed = await routeIssue(app, repo, next.issue, crewFor(next.issue), inFlight); |
| @@ -1367,6 +1375,34 @@ export function readyState( | ||
| 1367 | 1375 | ); |
| 1368 | 1376 | } |
| 1369 | 1377 | |
| 1378 | /** | |
| 1379 | * The ready issues, less those that retire names already gone from the base | |
| 1380 | * (`retired.ts`): each gets the already-done note, once, and is not handed out. | |
| 1381 | */ | |
| 1382 | async function withoutRetired( | |
| 1383 | app: App, | |
| 1384 | repo: Repo, | |
| 1385 | ready: readonly Ranked[], | |
| 1386 | ): Promise<Ranked[]> { | |
| 1387 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 1388 | const kept: Ranked[] = []; | |
| 1389 | for (const r of ready) { | |
| 1390 | const names = retiredNames(r.issue); | |
| 1391 | if (names.length === 0) { | |
| 1392 | kept.push(r); | |
| 1393 | continue; | |
| 1394 | } | |
| 1395 | const oids = await removedBy(dir, repo.defaultBranch, names); | |
| 1396 | if (oids.length === 0) { | |
| 1397 | kept.push(r); | |
| 1398 | continue; | |
| 1399 | } | |
| 1400 | if (!alreadyDone(app.db, [r.issue.id]).has(r.issue.id)) | |
| 1401 | noteAlreadyDone(app, repo, r.issue, oids); | |
| 1402 | } | |
| 1403 | return kept; | |
| 1404 | } | |
| 1405 | ||
| 1370 | 1406 | async function nextOf( |
| 1371 | 1407 | app: App, |
| 1372 | 1408 | repo: Repo, |
src/crew/retired.ts added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | /** | |
| 2 | * A retirement issue already carried out. An issue whose title says a class | |
| 3 | * or rule "leaves the stylesheet" or "leaves §5d" can be handed out after | |
| 4 | * the work landed (#4691, #4616): the run then closes with nothing to | |
| 5 | * change. Before the picker hands one out, the names in it are looked for | |
| 6 | * on the base branch; when none is left, the crew writes the already-done | |
| 7 | * note with the commit that last touched them instead. | |
| 8 | * | |
| 9 | * Names are the backticked `.class` tokens of the title and body, nothing | |
| 10 | * else; an issue without them is never judged. Docs and tests may keep | |
| 11 | * saying a name after it is gone from the code, so they are not searched. | |
| 12 | */ | |
| 13 | ||
| 14 | import { runGit } from "../git/env.ts"; | |
| 15 | ||
| 16 | const RETIRES = /\bleaves?\s+(?:the\s+stylesheet|§\s*5d)/i; | |
| 17 | const NAME = /`(\.[A-Za-z_][\w-]*)`/g; | |
| 18 | ||
| 19 | /** The most names one issue is checked for, and commits it is answered with. */ | |
| 20 | const MOST = 20; | |
| 21 | const COMMITS = 5; | |
| 22 | ||
| 23 | /** The `.class` names a retirement issue lists; empty when it is not one. */ | |
| 24 | export function retiredNames(issue: { title: string; body: string }): string[] { | |
| 25 | if (!RETIRES.test(issue.title)) return []; | |
| 26 | const found = new Set<string>(); | |
| 27 | for (const m of `${issue.title}\n${issue.body}`.matchAll(NAME)) found.add(m[1]!); | |
| 28 | return [...found].slice(0, MOST); | |
| 29 | } | |
| 30 | ||
| 31 | const escape = (s: string): string => s.replace(/[.\\^$*+?()[\]{}|]/g, "\\$&"); | |
| 32 | ||
| 33 | /** | |
| 34 | * The commits that last touched these names, when none of them is left on | |
| 35 | * `branch` in the bare repository at `dir`. Empty when any name remains, | |
| 36 | * when git cannot say, or when no commit is found to name. | |
| 37 | */ | |
| 38 | export async function removedBy( | |
| 39 | dir: string, | |
| 40 | branch: string, | |
| 41 | names: readonly string[], | |
| 42 | ): Promise<string[]> { | |
| 43 | if (names.length === 0) return []; | |
| 44 | const ref = `refs/heads/${branch}`; | |
| 45 | for (const name of names) { | |
| 46 | const left = await runGit( | |
| 47 | ["grep", "-q", "-E", "-e", `${escape(name)}([^A-Za-z0-9_-]|$)`, ref, "--", ".", ":!docs", ":!test"], | |
| 48 | { cwd: dir }, | |
| 49 | ); | |
| 50 | // Exit 1 is "no match"; any other failure is "cannot say". | |
| 51 | if (left.ok || left.error.code !== 1) return []; | |
| 52 | } | |
| 53 | const oids: string[] = []; | |
| 54 | for (const name of names) { | |
| 55 | const last = await runGit(["log", "-n", "1", "--format=%H", `-S${name}`, ref], { cwd: dir }); | |
| 56 | if (last.ok && /^[0-9a-f]{40}$/.test(last.value) && !oids.includes(last.value)) | |
| 57 | oids.push(last.value); | |
| 58 | } | |
| 59 | return oids.slice(0, COMMITS); | |
| 60 | } |
test/crew-retired.test.ts added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | /** | |
| 2 | * A retirement issue is checked against the base before it is handed out: | |
| 3 | * one whose classes are gone gets the already-done note and is skipped, one | |
| 4 | * whose classes remain is handed out, and one naming none is handed out. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 12 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; | |
| 13 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 14 | import { alreadyDone } from "../src/core/done_on_main.ts"; | |
| 15 | import { getIssueById, type Issue } from "../src/core/issues.ts"; | |
| 16 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 17 | import { createUser, type User } from "../src/core/users.ts"; | |
| 18 | import { driveRepo } from "../src/crew/autonomy.ts"; | |
| 19 | import { retiredNames } from "../src/crew/retired.ts"; | |
| 20 | import { resolveOid } from "../src/git/merge.ts"; | |
| 21 | import { repoDir } from "../src/git/repos.ts"; | |
| 22 | ||
| 23 | describe("retiredNames", () => { | |
| 24 | test("only backticked .class tokens of a retirement title", () => { | |
| 25 | const body = "Done when `.a` and `.b-c` go; not `plain` or `x.y`."; | |
| 26 | expect(retiredNames({ title: "`.a` leaves the stylesheet", body })).toEqual([".a", ".b-c"]); | |
| 27 | expect(retiredNames({ title: "`.a` leaves §5d", body: "" })).toEqual([".a"]); | |
| 28 | expect(retiredNames({ title: "Add `.a`", body })).toEqual([]); | |
| 29 | expect(retiredNames({ title: "Rules leave the stylesheet", body: "none" })).toEqual([]); | |
| 30 | }); | |
| 31 | }); | |
| 32 | ||
| 33 | describe("the picker and retirement issues", () => { | |
| 34 | let dataDir: string; | |
| 35 | let app: App; | |
| 36 | let alice: User; | |
| 37 | let repo: Repo; | |
| 38 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 39 | let removal = ""; | |
| 40 | ||
| 41 | const ask = (title: string, body = ""): Issue => { | |
| 42 | const o = openIssue(app, alice, fresh(), { title, body }); | |
| 43 | if (!o.ok) throw new Error(String(o.error)); | |
| 44 | return o.value; | |
| 45 | }; | |
| 46 | const drop = (issue: Issue) => | |
| 47 | setIssueState(app, alice, fresh(), getIssueById(app.db, issue.id)!, "closed", { reason: "dropped" }); | |
| 48 | ||
| 49 | beforeAll(async () => { | |
| 50 | dataDir = await mkdtemp(join(tmpdir(), "forge-retired-")); | |
| 51 | app = createApp({ dataDir, port: 0 }); | |
| 52 | const a = await createUser(app.db, { | |
| 53 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 54 | password: "a-good-passphrase", adminIfFirst: true, | |
| 55 | }); | |
| 56 | if (!a.ok) throw new Error(a.error); | |
| 57 | alice = a.value; | |
| 58 | const made = await createRepo(app, alice, { name: "styles", visibility: "private" }); | |
| 59 | if (!made.ok) throw new Error(String(made.error)); | |
| 60 | repo = made.value; | |
| 61 | const first = await commitToBranch(app, alice, repo, { | |
| 62 | message: "Start", | |
| 63 | files: [{ path: "src/app.css", content: ".old { color: red }\n.keep { color: blue }\n" }], | |
| 64 | }); | |
| 65 | if (!first.ok) throw new Error(JSON.stringify(first.error)); | |
| 66 | const second = await commitToBranch(app, alice, repo, { | |
| 67 | message: "Retire .old", | |
| 68 | files: [{ path: "src/app.css", content: ".keep { color: blue }\n" }], | |
| 69 | }); | |
| 70 | if (!second.ok) throw new Error(JSON.stringify(second.error)); | |
| 71 | removal = (await resolveOid(repoDir(app.reposRoot, repo.ownerName, repo.name), `refs/heads/${repo.defaultBranch}`))!; | |
| 72 | const agent = await createAgent(app, alice, { username: "mason" }); | |
| 73 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 74 | expect(addToCrew(app, alice, repo, "mason").ok).toBe(true); | |
| 75 | const on = await updateRepoSettings(app, alice, repo, { | |
| 76 | autonomy: "autonomous", | |
| 77 | autonomyDirection: "Retire the old rules.", | |
| 78 | autonomyCeilingUsd: 10, | |
| 79 | autonomySlots: 1, | |
| 80 | autonomyBy: alice.id, | |
| 81 | }); | |
| 82 | expect(on.ok).toBe(true); | |
| 83 | }); | |
| 84 | ||
| 85 | afterAll(async () => { | |
| 86 | app.close(); | |
| 87 | await rm(dataDir, { recursive: true, force: true }); | |
| 88 | }); | |
| 89 | ||
| 90 | test("classes already gone: the note names the removing commit and nothing is handed out", async () => { | |
| 91 | const gone = ask("`.old` leaves the stylesheet", "Done when `.old` is gone."); | |
| 92 | expect(await driveRepo(app, fresh())).not.toBe("picked"); | |
| 93 | expect(getIssueById(app.db, gone.id)!.assigneeId).toBeNull(); | |
| 94 | expect(alreadyDone(app.db, [gone.id]).get(gone.id)).toEqual([removal]); | |
| 95 | // Said once, however many ticks follow. | |
| 96 | await driveRepo(app, fresh()); | |
| 97 | const notes = app.db | |
| 98 | .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM comment WHERE issue_id = ?1 AND body LIKE 'Done on main already%'") | |
| 99 | .get(gone.id)!; | |
| 100 | expect(notes.n).toBe(1); | |
| 101 | drop(gone); | |
| 102 | }); | |
| 103 | ||
| 104 | test("classes that remain: the issue is handed out", async () => { | |
| 105 | const kept = ask("`.keep` leaves the stylesheet", "Done when `.keep` is gone."); | |
| 106 | expect(await driveRepo(app, fresh())).toBe("picked"); | |
| 107 | expect(getIssueById(app.db, kept.id)!.assigneeId).not.toBeNull(); | |
| 108 | expect(alreadyDone(app.db, [kept.id]).size).toBe(0); | |
| 109 | }); | |
| 110 | ||
| 111 | test("no names: the issue is handed out as before", async () => { | |
| 112 | const kept = getIssueById(app.db, (await Promise.resolve(ask("Old rules leave the stylesheet", "No names here."))).id)!; | |
| 113 | // The previous test's issue holds the one slot; free it first. | |
| 114 | app.db.query("UPDATE issue SET assignee_id = NULL WHERE assignee_id IS NOT NULL").run(); | |
| 115 | app.db.query("UPDATE issue SET state = 'closed' WHERE id != ?1").run(kept.id); | |
| 116 | expect(await driveRepo(app, fresh())).toBe("picked"); | |
| 117 | expect(getIssueById(app.db, kept.id)!.assigneeId).not.toBeNull(); | |
| 118 | }); | |
| 119 | }); |
new above
offline