Public: anyone can read this; the crew can change it.
Finish before starting: an approved pull with a conflict is brought up to date before any new issue is picked #3683
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/3682-finish-before-starting-an-approved-pull → main 1 commit fork
4 files
changed +416
−3
docs/PLANNING.md +6 −1
| @@ -420,7 +420,12 @@ asked, under **Settings → Autonomy**: | ||
| 420 | 420 | person: a pull whose check stays red past its tries, or whose branch |
| 421 | 421 | could not be brought up to date, is _parked_ — closed with the reason, |
| 422 | 422 | its issue put back on the map unassigned with the same note — and the |
| 423 | crew moves on; the planner's next pass splits or rewrites a parked | |
| 423 | crew moves on. An approved pull is finished before anything starts: a | |
| 424 | slot freed goes first to an approved pull that no longer merges, then | |
| 425 | to a pull with changes requested, then to a new issue, and one whose | |
| 426 | two updates ended without a push is handed to the person who turned | |
| 427 | autonomy on — assigned to them, not closed (`crew/finishing.ts`; the | |
| 428 | tick line says `finishing=N` and `conflict="N waiting"`). The planner's next pass splits or rewrites a parked | |
| 424 | 429 | issue, and one parked twice is handed to the planner (below). The planner |
| 425 | 430 | keeps a standing reserve of startable work — two issues per builder |
| 426 | 431 | slot, never fewer than three — and is asked whenever fewer than that can |
src/crew/autonomy.ts +13 −2
| @@ -115,6 +115,7 @@ import { | ||
| 115 | 115 | markReaders, |
| 116 | 116 | } from "./direct.ts"; |
| 117 | 117 | import { crewOffInPreview } from "./preview.ts"; |
| 118 | import { approvedOnHead, finishApproved, finishFields } from "./finishing.ts"; | |
| 118 | 119 | import { moveRequested, tallyRequested, tallyWords } from "./requested.ts"; |
| 119 | 120 | import { reviewersOf, sendToReview, sweepReviews } from "./review.ts"; |
| 120 | 121 | import { lastWaits, noteWaits, tallyWaits, type Wait, waitWords } from "./waits.ts"; |
| @@ -1139,6 +1140,9 @@ export async function autonomyTick( | ||
| 1139 | 1140 | if (repo.archivedAt !== null) continue; |
| 1140 | 1141 | try { |
| 1141 | 1142 | void startSweep(app, repo, sweep); |
| 1143 | // First of all: a slot freed goes to an approved pull that no longer | |
| 1144 | // merges, before a changes-requested one or a new issue (#3682). | |
| 1145 | const finishing = await finishApproved(app, repo, freeSlots(app, repo, now)); | |
| 1142 | 1146 | // Quick, and before the picks: a pull main moved under is brought up |
| 1143 | 1147 | // to date within this tick, not after a landing gate (#2039). |
| 1144 | 1148 | await sweepConflicts(app, repo).catch((error) => |
| @@ -1185,6 +1189,8 @@ export async function autonomyTick( | ||
| 1185 | 1189 | held: heldWords(held), |
| 1186 | 1190 | active: status.active, |
| 1187 | 1191 | slots: status.slots, |
| 1192 | // Approved pulls being brought up to date, and those still waiting (#3682). | |
| 1193 | ...finishFields(finishing), | |
| 1188 | 1194 | ...(requested.length === 0 |
| 1189 | 1195 | ? {} |
| 1190 | 1196 | : { |
| @@ -1443,7 +1449,8 @@ export function releaseConflicted( | ||
| 1443 | 1449 | * Every tick, each open crew pull that no longer merges gets a |
| 1444 | 1450 | * bring-up-to-date run (one in flight per pull). After two finished |
| 1445 | 1451 | * attempts, or 24 h since the first, it is closed and its issue is ready |
| 1446 | * again. Returns how many pulls were acted on. | |
| 1452 | * again. An approved pull is not this sweep's but `finishApproved`'s. | |
| 1453 | * Returns how many pulls were acted on. | |
| 1447 | 1454 | */ |
| 1448 | 1455 | export async function sweepConflicts(app: App, repo: Repo): Promise<number> { |
| 1449 | 1456 | if (repo.autonomy !== "autonomous") return 0; |
| @@ -1498,6 +1505,9 @@ export async function sweepConflicts(app: App, repo: Repo): Promise<number> { | ||
| 1498 | 1505 | continue; |
| 1499 | 1506 | } |
| 1500 | 1507 | const files = seen.files; |
| 1508 | // An approved pull is finished first, and handed to a person rather | |
| 1509 | // than closed: the tick's finishing pass has it (`finishing.ts`). | |
| 1510 | if (approvedOnHead(app.db, pull)) continue; | |
| 1501 | 1511 | const issue = getIssueById(app.db, pull.id); |
| 1502 | 1512 | if (!issue || issue.state !== "open") continue; |
| 1503 | 1513 | if (activeRunFor(app.db, "agent", issue.id) !== undefined) continue; |
| @@ -1687,7 +1697,8 @@ async function admit( | ||
| 1687 | 1697 | if (!clean.ok) { |
| 1688 | 1698 | if (clean.error.kind === "conflict") { |
| 1689 | 1699 | conflicted.set(pull.id, between); |
| 1690 | bringUpToDate(app, repo, pull, clean.error.files); | |
| 1700 | // An approved one waits for the tick's finishing pass and its slot. | |
| 1701 | if (!approvedOnHead(app.db, pull)) bringUpToDate(app, repo, pull, clean.error.files); | |
| 1691 | 1702 | } |
| 1692 | 1703 | return err("conflict"); |
| 1693 | 1704 | } |
src/crew/finishing.ts added +202
| @@ -0,0 +1,202 @@ | ||
| 1 | /** | |
| 2 | * Finish before starting (#3682): an approved pull that no longer merges | |
| 3 | * is nearly done work, and it is brought up to date before any new issue | |
| 4 | * takes a builder slot. | |
| 5 | * | |
| 6 | * Every tick, first of the passes that spend slots, for each open crew | |
| 7 | * pull approved on its head with no change requested that conflicts with | |
| 8 | * its base: | |
| 9 | * | |
| 10 | * - With a run already in flight on it, it is finishing: nothing more. | |
| 11 | * - With a slot free, a conflict run (the #2039 resolver brief: merge the | |
| 12 | * base, resolve keeping both intents, re-run the check) is queued for | |
| 13 | * its builder — the agent on it, else its author, else any builder — | |
| 14 | * and it takes that slot. | |
| 15 | * - After two such runs ended without a push (the head still carries the | |
| 16 | * approval, so neither pushed), it is handed to a person: assigned to | |
| 17 | * whoever turned autonomy on, so it is on their review list, with the | |
| 18 | * conflict named. Never retried forever, and never closed: the work was | |
| 19 | * approved. | |
| 20 | * | |
| 21 | * Changes requested come after (`requested.ts`), then new issues | |
| 22 | * (`driveRepo`). The conflict sweep and the green sweep leave approved | |
| 23 | * pulls to this pass (`approvedOnHead`). | |
| 24 | */ | |
| 25 | ||
| 26 | import type { Database } from "bun:sqlite"; | |
| 27 | import type { App } from "../app.ts"; | |
| 28 | import { addComment, assignIssue } from "../actions/issues.ts"; | |
| 29 | import { getAgent, listCrewRoles, type Agent } from "../core/agents.ts"; | |
| 30 | import { getHold } from "../core/holds.ts"; | |
| 31 | import { getIssueById, type Issue } from "../core/issues.ts"; | |
| 32 | import { listPulls, pullDir, type Pull } from "../core/pulls.ts"; | |
| 33 | import type { Repo } from "../core/repos.ts"; | |
| 34 | import { reviewSummary } from "../core/reviews.ts"; | |
| 35 | import { activeRunFor } from "../core/runs.ts"; | |
| 36 | import { getUserById } from "../core/users.ts"; | |
| 37 | import { mergeable, resolveOid } from "../git/merge.ts"; | |
| 38 | import { createLog } from "../kernel/log.ts"; | |
| 39 | import { CHECK_FAILED_LIMIT, lastHumanWord, queueAgentRun } from "./dispatch.ts"; | |
| 40 | import { HANDOVER_NOTE } from "./guard.ts"; | |
| 41 | import { pickBuilder } from "./route.ts"; | |
| 42 | ||
| 43 | const log = createLog("crew"); | |
| 44 | ||
| 45 | /** What the pass did with one approved, conflicted pull. */ | |
| 46 | export type FinishMove = | |
| 47 | | { readonly pull: number; readonly did: "queued update"; readonly builder: string } | |
| 48 | | { readonly pull: number; readonly did: "in flight" } | |
| 49 | | { readonly pull: number; readonly did: "handed to a person"; readonly to: string } | |
| 50 | | { readonly pull: number; readonly did: "not moved"; readonly why: string }; | |
| 51 | ||
| 52 | /** Whether the pull's head carries an approval and no change requested: what lands once it merges. */ | |
| 53 | export function approvedOnHead(db: Database, pull: Pull): boolean { | |
| 54 | const seen = reviewSummary(db, pull.id, pull.headOid); | |
| 55 | return seen.approvals > 0 && seen.changesRequested === 0; | |
| 56 | } | |
| 57 | ||
| 58 | /** The tick line's two numbers: pulls being brought up to date, and the rest still waiting. */ | |
| 59 | export function finishFields(moves: readonly FinishMove[]): { finishing?: number; conflict?: string } { | |
| 60 | const finishing = moves.filter((m) => m.did === "queued update" || m.did === "in flight").length; | |
| 61 | const waiting = moves.length - finishing; | |
| 62 | return { | |
| 63 | ...(finishing === 0 ? {} : { finishing }), | |
| 64 | ...(waiting === 0 ? {} : { conflict: `${waiting} waiting` }), | |
| 65 | }; | |
| 66 | } | |
| 67 | ||
| 68 | /** Pull id → the tips git last read, and the files in conflict (null: clean or nothing to add). */ | |
| 69 | const read = new Map<string, { key: string; files: string[] | null }>(); | |
| 70 | ||
| 71 | /** The files the pull conflicts on with its base; undefined when it merges. */ | |
| 72 | async function conflictOf(app: App, repo: Repo, pull: Pull): Promise<string[] | undefined> { | |
| 73 | const dir = await pullDir(app.db, app.reposRoot, repo, pull); | |
| 74 | const baseTip = await resolveOid(dir, `refs/heads/${pull.baseBranch}`); | |
| 75 | if (baseTip === undefined) return undefined; | |
| 76 | // Git is asked once per pair of tips; until one moves, the answer stands. | |
| 77 | const key = `${pull.headOid}:${baseTip}`; | |
| 78 | let seen = read.get(pull.id); | |
| 79 | if (seen?.key !== key) { | |
| 80 | // A clean merge is nothing to do here, and one that leaves the base's | |
| 81 | // own tree is superseded, which the conflict sweep closes. | |
| 82 | const reading = await mergeable(dir, baseTip, pull.headOid); | |
| 83 | seen = { key, files: !reading.ok && reading.error.kind === "conflict" ? reading.error.files : null }; | |
| 84 | read.set(pull.id, seen); | |
| 85 | } | |
| 86 | return seen.files ?? undefined; | |
| 87 | } | |
| 88 | ||
| 89 | /** | |
| 90 | * Bring every approved, conflicted crew pull in `repo` up to date, taking | |
| 91 | * at most `free` builder slots. Returns what happened to each, oldest | |
| 92 | * pull first. | |
| 93 | */ | |
| 94 | export async function finishApproved(app: App, repo: Repo, free: number): Promise<FinishMove[]> { | |
| 95 | if (repo.autonomy !== "autonomous" || repo.autonomyBy === null) return []; | |
| 96 | const moves: FinishMove[] = []; | |
| 97 | const builders = listCrewRoles(app.db, repo.id) | |
| 98 | .filter((c) => c.role === "builder") | |
| 99 | .map((c) => c.agent); | |
| 100 | const open = listPulls(app.db, repo.id, { sealed: true, state: "open", limit: 50 }); | |
| 101 | const ids = new Set(open.map((p) => p.id)); | |
| 102 | for (const id of read.keys()) if (!ids.has(id)) read.delete(id); | |
| 103 | let slots = free; | |
| 104 | for (const pull of open.sort((a, b) => a.number - b.number)) { | |
| 105 | if (!pull.authorIsAgent || pull.mergedAt !== null) continue; | |
| 106 | if (getHold(app.db, pull.id) !== undefined) continue; | |
| 107 | if (!approvedOnHead(app.db, pull)) continue; | |
| 108 | const files = await conflictOf(app, repo, pull); | |
| 109 | if (files === undefined) continue; | |
| 110 | const issue = getIssueById(app.db, pull.id); | |
| 111 | if (!issue || issue.state !== "open") continue; | |
| 112 | const move = finishOne(app, repo, pull, issue, files, builders, slots); | |
| 113 | if (move.did === "queued update") slots--; | |
| 114 | moves.push(move); | |
| 115 | say(repo, pull, move); | |
| 116 | } | |
| 117 | return moves; | |
| 118 | } | |
| 119 | ||
| 120 | function finishOne( | |
| 121 | app: App, | |
| 122 | repo: Repo, | |
| 123 | pull: Pull, | |
| 124 | issue: Issue, | |
| 125 | files: readonly string[], | |
| 126 | builders: readonly Agent[], | |
| 127 | slots: number, | |
| 128 | ): FinishMove { | |
| 129 | const not = (why: string): FinishMove => ({ pull: pull.number, did: "not moved", why }); | |
| 130 | if (activeRunFor(app.db, "agent", pull.id) !== undefined) return { pull: pull.number, did: "in flight" }; | |
| 131 | // A person's name on it is a person holding it — after a hand-over too. | |
| 132 | if (issue.assigneeId !== null && getAgent(app.db, issue.assigneeId) === undefined) | |
| 133 | return not("a person has it"); | |
| 134 | const builder = | |
| 135 | builders.find((b) => b.user.id === issue.assigneeId) ?? | |
| 136 | builders.find((b) => b.user.id === issue.authorId) ?? | |
| 137 | pickBuilder(issue, builders, new Map()); | |
| 138 | if (builder === undefined) return not("no builder on the crew"); | |
| 139 | if (failedUpdates(app, issue, builder) >= CHECK_FAILED_LIMIT) return handOver(app, repo, pull, issue, files); | |
| 140 | if (slots <= 0) return not("waits for a builder slot"); | |
| 141 | const run = queueAgentRun(app, repo, issue, builder, "conflict", undefined, { | |
| 142 | notBefore: new Date().toISOString(), | |
| 143 | attempt: 0, | |
| 144 | summary: `main moved: ${files.join(", ")}`, | |
| 145 | }); | |
| 146 | if (run === undefined) return not(`${builder.user.username}'s run was not queued (ceiling or breaker)`); | |
| 147 | return { pull: pull.number, did: "queued update", builder: builder.user.username }; | |
| 148 | } | |
| 149 | ||
| 150 | /** | |
| 151 | * Conflict runs that ended since a person last spoke on the pull. Each one | |
| 152 | * left the approved head as it was, so none of them pushed. | |
| 153 | */ | |
| 154 | function failedUpdates(app: App, issue: Issue, builder: Agent): number { | |
| 155 | return app.db | |
| 156 | .query<{ n: number }, [string, string]>( | |
| 157 | `SELECT COUNT(*) AS n FROM run | |
| 158 | WHERE kind = 'agent' AND reason = 'conflict' AND issue_id = ?1 | |
| 159 | AND created_at > ?2 AND state IN ('ok','failed')`, | |
| 160 | ) | |
| 161 | .get(issue.id, lastHumanWord(app, issue, builder))!.n; | |
| 162 | } | |
| 163 | ||
| 164 | /** Two updates failed: the pull is the person's who turned autonomy on, with the conflict named. */ | |
| 165 | function handOver(app: App, repo: Repo, pull: Pull, issue: Issue, files: readonly string[]): FinishMove { | |
| 166 | const not = (why: string): FinishMove => ({ pull: pull.number, did: "not moved", why }); | |
| 167 | const by = getUserById(app.db, repo.autonomyBy!); | |
| 168 | if (by === undefined) return not("nobody to hand it to"); | |
| 169 | const assigned = assignIssue(app, by, repo, issue, by); | |
| 170 | if (!assigned.ok) return not(`could not hand it to ${by.username}: ${assigned.error}`); | |
| 171 | addComment( | |
| 172 | app, | |
| 173 | by, | |
| 174 | repo, | |
| 175 | issue, | |
| 176 | `${HANDOVER_NOTE} this pull is approved, but ${CHECK_FAILED_LIMIT} runs to bring it up to date with ${pull.baseBranch} ended without a push, so no more start here. Conflicting files: ${files.join(", ")}. Merge ${pull.baseBranch} into it, or say what to try and assign it back to the crew. (The forge posts this under your name; you did not write it.)`, | |
| 177 | ); | |
| 178 | log.info("approved pull handed to a person: could not be brought up to date", { | |
| 179 | repo: `${repo.ownerName}/${repo.name}`, | |
| 180 | pull: pull.number, | |
| 181 | to: by.username, | |
| 182 | }); | |
| 183 | return { pull: pull.number, did: "handed to a person", to: by.username }; | |
| 184 | } | |
| 185 | ||
| 186 | /** The last why-not said for a pull, so a stall is said once until it changes. */ | |
| 187 | const said = new Map<string, string>(); | |
| 188 | ||
| 189 | function say(repo: Repo, pull: Pull, move: FinishMove): void { | |
| 190 | const where = { repo: `${repo.ownerName}/${repo.name}`, pull: pull.number }; | |
| 191 | const key = `${repo.id}:${pull.number}`; | |
| 192 | if (move.did === "queued update") { | |
| 193 | said.delete(key); | |
| 194 | log.info("approved pull conflicts: brought up to date first", { ...where, builder: move.builder }); | |
| 195 | return; | |
| 196 | } | |
| 197 | if (move.did !== "not moved") return; | |
| 198 | const line = `${pull.headOid}:${move.why}`; | |
| 199 | if (said.get(key) === line) return; | |
| 200 | said.set(key, line); | |
| 201 | log.info(`approved pull conflicts: not moved, ${move.why}`, where); | |
| 202 | } |
test/finishing.test.ts added +195
| @@ -0,0 +1,195 @@ | ||
| 1 | /** | |
| 2 | * Finish before starting (#3682): with one slot, an approved pull that no | |
| 3 | * longer merges is brought up to date before a ready issue is picked; two | |
| 4 | * updates that end without a push hand it to a person, on their review | |
| 5 | * list, instead of closing it or trying again. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, spyOn, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 13 | import { openIssue } from "../src/actions/issues.ts"; | |
| 14 | import { openPull } from "../src/actions/pulls.ts"; | |
| 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 16 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 17 | import { getIssueById, listComments, type Issue } from "../src/core/issues.ts"; | |
| 18 | import type { Pull } from "../src/core/pulls.ts"; | |
| 19 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 20 | import { submitReview } from "../src/core/reviews.ts"; | |
| 21 | import { claimRun, finishRun, listRuns } from "../src/core/runs.ts"; | |
| 22 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 23 | import { autonomyTick } from "../src/crew/autonomy.ts"; | |
| 24 | import { finishFields } from "../src/crew/finishing.ts"; | |
| 25 | import { HANDOVER_NOTE } from "../src/crew/guard.ts"; | |
| 26 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 27 | ||
| 28 | let dataDir: string; | |
| 29 | let app: App; | |
| 30 | let servers: Servers; | |
| 31 | let alice: User; | |
| 32 | let mason: User; | |
| 33 | let repo: Repo; | |
| 34 | let pull: Pull; | |
| 35 | let ready: Issue; | |
| 36 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 37 | const conflicts = () => | |
| 38 | listRuns(app.db, repo.id, { kind: "agent" }).filter((r) => r.issueId === pull.id && r.reason === "conflict"); | |
| 39 | const picked = () => listRuns(app.db, repo.id, { kind: "agent" }).filter((r) => r.issueId === ready.id); | |
| 40 | ||
| 41 | /** One tick, with the green sweep left out, and the tick line it said. */ | |
| 42 | async function tick(): Promise<string> { | |
| 43 | const lines: string[] = []; | |
| 44 | const spy = spyOn(console, "info").mockImplementation((line: unknown) => { | |
| 45 | lines.push(String(line)); | |
| 46 | }); | |
| 47 | try { | |
| 48 | await autonomyTick(app, new Date(), async () => 0); | |
| 49 | } finally { | |
| 50 | spy.mockRestore(); | |
| 51 | } | |
| 52 | return lines.find((l) => l.includes("[autonomy] tick") && l.includes("did=")) ?? ""; | |
| 53 | } | |
| 54 | ||
| 55 | const fail = (id: string) => { | |
| 56 | claimRun(app.db, id); | |
| 57 | finishRun(app.db, id, { state: "failed", summary: "could not merge" }); | |
| 58 | }; | |
| 59 | ||
| 60 | beforeAll(async () => { | |
| 61 | dataDir = await mkdtemp(join(tmpdir(), "forge-finishing-")); | |
| 62 | app = createApp({ dataDir, port: 0 }); | |
| 63 | servers = await serve(app); | |
| 64 | const a = await createUser(app.db, { | |
| 65 | origin: "seed", | |
| 66 | trusted: true, | |
| 67 | username: "alice", | |
| 68 | email: "[email protected]", | |
| 69 | password: "a-good-passphrase", | |
| 70 | adminIfFirst: true, | |
| 71 | }); | |
| 72 | if (!a.ok) throw new Error(a.error); | |
| 73 | alice = a.value; | |
| 74 | const made = await createRepo(app, alice, { name: "engine", visibility: "private" }); | |
| 75 | if (!made.ok) throw new Error(String(made.error)); | |
| 76 | repo = made.value; | |
| 77 | const seeded = await commitToBranch(app, alice, repo, { | |
| 78 | message: "Start", | |
| 79 | files: [{ path: "shared.ts", content: "export const shared = 0;\n" }], | |
| 80 | }); | |
| 81 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 82 | for (const name of ["mason", "quill"]) { | |
| 83 | const agent = await createAgent(app, alice, { username: name }); | |
| 84 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 85 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); | |
| 86 | } | |
| 87 | mason = getUserByName(app.db, "mason")!; | |
| 88 | const quill = getUserByName(app.db, "quill")!; | |
| 89 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); | |
| 90 | ||
| 91 | const branch = await commitToBranch(app, alice, repo, { | |
| 92 | branch: "feature/shared", | |
| 93 | base: "main", | |
| 94 | message: "Shared, the branch's way", | |
| 95 | files: [{ path: "shared.ts", content: "export const shared = 'branch';\n" }], | |
| 96 | }); | |
| 97 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 98 | const opened = await openPull(app, mason, repo, { | |
| 99 | title: "Shared the branch's way", | |
| 100 | body: "", | |
| 101 | baseBranch: "main", | |
| 102 | headBranch: "feature/shared", | |
| 103 | }); | |
| 104 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 105 | pull = opened.value; | |
| 106 | const approved = submitReview(app.db, { | |
| 107 | issueId: pull.id, | |
| 108 | authorId: quill.id, | |
| 109 | state: "approved", | |
| 110 | commitOid: pull.headOid, | |
| 111 | }); | |
| 112 | if (!approved.ok) throw new Error(approved.error); | |
| 113 | const moved = await commitToBranch(app, alice, repo, { | |
| 114 | branch: "main", | |
| 115 | message: "Shared, main's way", | |
| 116 | files: [{ path: "shared.ts", content: "export const shared = 'main';\n" }], | |
| 117 | }); | |
| 118 | if (!moved.ok) throw new Error(JSON.stringify(moved.error)); | |
| 119 | const issue = openIssue(app, alice, repo, { title: "Something new" }); | |
| 120 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 121 | ready = issue.value; | |
| 122 | await Bun.sleep(5); | |
| 123 | app.db | |
| 124 | .query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2, autonomy_slots = 1 WHERE id = ?1") | |
| 125 | .run(repo.id, alice.id); | |
| 126 | }); | |
| 127 | ||
| 128 | afterAll(async () => { | |
| 129 | servers.stop(); | |
| 130 | app.close(); | |
| 131 | await rm(dataDir, { recursive: true, force: true }); | |
| 132 | }); | |
| 133 | ||
| 134 | test("with one slot, the approved conflicted pull is brought up to date before the ready issue is picked", async () => { | |
| 135 | const line = await tick(); | |
| 136 | expect(conflicts()).toHaveLength(1); | |
| 137 | expect(conflicts()[0]!.actorId).toBe(mason.id); | |
| 138 | expect(conflicts()[0]!.summary).toBe("main moved: shared.ts"); | |
| 139 | expect(picked()).toHaveLength(0); | |
| 140 | expect(getIssueById(app.db, ready.id)!.assigneeId).toBeNull(); | |
| 141 | expect(line).toContain("did=full"); | |
| 142 | expect(line).toContain("finishing=1"); | |
| 143 | // In flight: the next tick queues no second, and still picks nothing. | |
| 144 | await tick(); | |
| 145 | expect(conflicts()).toHaveLength(1); | |
| 146 | expect(picked()).toHaveLength(0); | |
| 147 | }); | |
| 148 | ||
| 149 | test("two updates that end without a push hand the pull to a person, open and on their review list", async () => { | |
| 150 | fail(conflicts()[0]!.id); | |
| 151 | await tick(); | |
| 152 | expect(conflicts()).toHaveLength(2); | |
| 153 | expect(picked()).toHaveLength(0); | |
| 154 | ||
| 155 | fail(conflicts().find((r) => r.state === "queued")!.id); | |
| 156 | const line = await tick(); | |
| 157 | // Not closed by the conflict sweep, not tried a third time: a person's. | |
| 158 | const after = getIssueById(app.db, pull.id)!; | |
| 159 | expect(after.state).toBe("open"); | |
| 160 | expect(after.assigneeId).toBe(alice.id); | |
| 161 | expect(conflicts()).toHaveLength(2); | |
| 162 | const notes = listComments(app.db, pull.id).filter((c) => c.body.startsWith(HANDOVER_NOTE)); | |
| 163 | expect(notes).toHaveLength(1); | |
| 164 | expect(notes[0]!.body).toContain("shared.ts"); | |
| 165 | expect(line).toContain('conflict="1 waiting"'); | |
| 166 | // The slot it no longer needs goes to the ready issue. | |
| 167 | expect(getIssueById(app.db, ready.id)!.assigneeId).toBe(mason.id); | |
| 168 | ||
| 169 | // Said once: another tick adds no second note and no run. | |
| 170 | await tick(); | |
| 171 | expect(listComments(app.db, pull.id).filter((c) => c.body.startsWith(HANDOVER_NOTE))).toHaveLength(1); | |
| 172 | expect(conflicts()).toHaveLength(2); | |
| 173 | ||
| 174 | const signin = await fetch(`${servers.url}/signin`, { | |
| 175 | method: "POST", | |
| 176 | redirect: "manual", | |
| 177 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 178 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 179 | }); | |
| 180 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 181 | const asks = await (await fetch(`${servers.url}/asks`, { headers: { cookie } })).text(); | |
| 182 | expect(asks).toContain("Shared the branch's way"); | |
| 183 | }); | |
| 184 | ||
| 185 | test("the tick line's fields: finishing counts what moves, conflict what waits", () => { | |
| 186 | expect(finishFields([])).toEqual({}); | |
| 187 | expect( | |
| 188 | finishFields([ | |
| 189 | { pull: 1, did: "queued update", builder: "mason" }, | |
| 190 | { pull: 2, did: "in flight" }, | |
| 191 | { pull: 3, did: "not moved", why: "waits for a builder slot" }, | |
| 192 | { pull: 4, did: "handed to a person", to: "alice" }, | |
| 193 | ]), | |
| 194 | ).toEqual({ finishing: 2, conflict: "2 waiting" }); | |
| 195 | }); |
new above
offline