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

On a new forge a finished change goes live by itself: today it waits forever… #11668

Merged opened this in answer to

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

Merged

mason/forge:mason/11666-on-a-new-forge-a-finished-change-goes-li → main 8 commits fork 6 threads · 6 unresolved

  • Merged. mason/11666-on-a-new-forge-a-finished-change-goes-li was merged into main as 0bfc9d0.

Review

Sign in to review this change.
26 files changed +249 −46
docs/features/reviews-off-lands-on-green.md added +24
@@ -0,0 +1,24 @@
1 # Reviews are something an owner turns on (#11666)
2
3 A green pull in an app whose owner has **not** asked for reviews, and where no reviewer is seated, lands by itself. No review is sent and nothing waits for one.
4
5 **This changes the default.** Before, a green agent pull with no reviewer seated waited for a person forever. Now, with **Require review** off (the default) and no reviewer on the crew, it lands once its check passes. A new forge's first app has reviews off, so its first change goes live on green. An owner who wants a second look before anything lands turns reviews on, as below.
outdated

The doc confirms the default flips for every app with reviews unasked. Make landing-by-itself an explicit setting, and say in this doc how to turn it on.

6
7 ## How to turn reviews on
8
9 Either one keeps every agent pull waiting for an approving review:
10
11 - In the app's settings, turn on **Require review**. Agent pulls then wait for an approving review, from a person or a seated reviewer.
12 - Or seat a reviewer on the crew. Reviews stay on wherever a reviewer is seated, whatever the setting says.
13
14 ## What still stops a pull with reviews off
15
16 A conflict, a red check, a hold, the access wall, and a `changes requested` verdict on the head. Only the wait for a review is gone. The pull's page, the terminal and the MCP `pull` tool read "Next: queue, green, reviews are off: it lands".
17
18 ## One rule
19
20 `reviewsOff` in `src/crew/review.ts`: `requireReview` is off **and** no reviewer is seated. The gate (`reviewed`), the review waits (`reviewWaitsFor`) and the next-actor line (`nextAsking`, used by `nextOfPull` and `stepsOfStored`) all ask it, so they cannot drift.
21
22 ## Cost
23
24 The seated reviewers are read only when the answer would otherwise be "waiting for the reviewer", and only when **Require review** is off: at most once per pull page, at most once per batch of pulls. The pull page loads no crew otherwise, so its budget in `test/pages-budget.test.ts` is one higher for that read; the batch's is unchanged.
src/crew/autonomy.ts +8 −3
@@ -152,7 +152,7 @@ import { recordQueueFlakes } from "../checks/flake.ts";
152152 import { readVitals, vitalsMisses, vitalsWords } from "../flow/vitals.ts";
153153 import { moveRequested, tallyRequested, tallyWords } from "./requested.ts";
154154 import { passLanded } from "./box_pass.ts";
155 import { reviewersOf, sendToReview, sweepReviews } from "./review.ts";
155 import { reviewersForRepos, reviewsOff, sendToReview, sweepReviews } from "./review.ts";
156156 import { lastWaits, noteWaits, tallyWaits, type Wait, waitWords } from "./waits.ts";
157157 import { bootCounts } from "../kernel/boots.ts";
158158
@@ -1951,12 +1951,17 @@ export async function reviewed(
19511951 // An agent's pull ships only on an approving review — the crew's reviewer
19521952 // or a person's. With no reviewer, or a review that lapsed, it waits for
19531953 // a person (`waitsForPerson` puts it where a person looks); never by lapse.
1954 // An app whose owner has not asked for reviews lands a green pull without
1955 // one (#11666); a verdict already on the head was handled above.
1956 const seated = reviewersForRepos(app.db, [repo.id]);
outdated

Duplicates the condition in review.ts and next_actor.ts; extract one named predicate.

outdated

High: unsafe default. This passes every green agent pull, including from an agent that was prompt-injected, to production unreviewed, for every app with default settings. Gate it behind an explicit owner opt-in (a separate setting), not the absence of requireReview.

1957 if (reviewsOff(app.db, repo, seated)) return "pass";
1958 const reviewers = seated.get(repo.id)?.length ?? 0;
19541959 if (pull.authorIsAgent) {
1955 if (reviewersOf(app.db, repo.id).length > 0) await sendToReview(app, repo, pull);
1960 if (reviewers > 0) await sendToReview(app, repo, pull);
19561961 return "hold";
19571962 }
19581963 // A person's own pull is not held for a review that never comes.
1959 if (reviewersOf(app.db, repo.id).length === 0) return "pass";
1964 if (reviewers === 0) return "pass";
19601965 const stage = await sendToReview(app, repo, pull);
19611966 return stage === "lapsed" ? "lapsed" : stage === "none" ? "pass" : "hold";
19621967 }
src/crew/review.ts +20 −2
@@ -133,6 +133,20 @@ export function pickReviewer(
133133 export const reviewersOf = (db: Database, repoId: string): Agent[] =>
134134 reviewersForRepos(db, [repoId]).get(repoId) ?? [];
135135
136 /**
137 * Whether reviews are off for a repository (#11666): its owner has not asked
138 * for them (`requireReview`) and no reviewer is seated. A green pull then
139 * lands without one, and nothing waits for one. The one rule: the gate, the
140 * waits and the next-actor line all ask it here. The seated reviewers are
141 * read only when the owner has not asked; a caller that already read them
142 * (`reviewersForRepos`) passes them in.
143 */
144 export const reviewsOff = (
145 db: Database,
146 repo: Pick<Repo, "id" | "requireReview">,
outdated

Clean: !repo.requireReview is checked here and again inside reviewsOff. One function, one check. Take the reviewer count lazily if the read matters.

147 seated?: ReadonlyMap<string, readonly Agent[]>,
148 ): boolean => !repo.requireReview && ((seated ?? reviewersForRepos(db, [repo.id])).get(repo.id)?.length ?? 0) === 0;
149
136150 /** `reviewersOf` for many repositories at once, by repo; one with none is absent. */
137151 export const reviewersForRepos = (db: Database, repoIds: readonly string[]): Map<string, Agent[]> =>
138152 crewWithRole(db, repoIds, "reviewer");
@@ -374,7 +388,11 @@ export function reviewWaitsFor(
374388 handovers?: ReadonlyMap<string, Handover>,
375389 ): Map<string, ReviewWaits> {
376390 const out = new Map<string, ReviewWaits>();
377 const agents = pulls.filter((p) => p.pull.authorIsAgent);
391 // An app with reviews off waits on none (#11666).
392 const seated = reviewersForRepos(db, pulls.map((p) => p.repo.id));
393 const agents = pulls.filter(
394 (p) => p.pull.authorIsAgent && !reviewsOff(db, p.repo, seated),
395 );
378396 const held = holdsFor(db, agents.map((p) => p.pull.id));
379397 const unheld = agents.filter((p) => !held.has(p.pull.id));
380398 const seen = reviewSummariesFor(db, unheld.map((p) => p.pull));
@@ -388,7 +406,7 @@ export function reviewWaitsFor(
388406 // answer differently.
389407 const over = handovers ?? handoversOf(db, open.map((p) => ({ id: p.pull.id, repo: p.repo })));
390408 const histories = reviewAttemptsFor(db, open.map((p) => p.pull));
391 const reviewers = reviewersForRepos(db, open.map((p) => p.repo.id));
409 const reviewers = seated;
392410 for (const { repo, pull } of open) {
393411 const handover = over.get(pull.id);
394412 const history = histories.get(pull.id);
src/flow/next_actor.ts +32 −9
@@ -9,6 +9,8 @@ import { getHold } from "../core/holds.ts";
99 import type { Pull } from "../core/pulls.ts";
1010 import { checkSummary, type RunState } from "../core/runs.ts";
1111 import { reviewSummary } from "../core/reviews.ts";
12 import type { Repo } from "../core/repos.ts";
13 import { reviewsOff } from "../crew/review.ts";
1214 import { wallAsk, wallWords } from "./wall_wait.ts";
1315
1416 export type Actor = "builder" | "reviewer" | "queue" | "person";
@@ -30,6 +32,8 @@ export interface NextInput {
3032 readonly pushedSince: number;
3133 /** What the access wall holds this head for, in plain words (#7847): a person decides it. */
3234 readonly wall?: readonly string[] | null;
35 /** Reviews are off for the app (`reviewsOff`): a green pull lands without one. */
36 readonly reviewOff?: boolean;
3337 }
3438
3539 const WAITING = {
@@ -51,28 +55,47 @@ export function nextActor(i: NextInput): Next {
5155 if (i.check === "ok") return { who: "queue", why: "approved and green" };
5256 return { who: "queue", why: `approved, ${WAITING[i.check]}` };
outdated

Wording: the line says the pull lands by itself even when no one has read it. Once the default is opt-in, make this read the same way. Keep it plain (docs/WRITING.md).

5357 }
58 if (i.reviewOff === true) {
59 if (i.check === "ok") return { who: "queue", why: "green, reviews are off: it lands" };
60 return { who: "queue", why: i.check === "none" ? "no check yet" : WAITING[i.check] };
61 }
5462 return {
5563 who: "reviewer",
5664 why: i.pushedSince > 0 ? "pushed since the last review" : "no review yet",
5765 };
5866 }
5967
68 /**
69 * `nextActor`, asking whether reviews are off only when the answer would be
70 * a wait on the reviewer: that is the one answer reviews being off changes,
71 * so the seated reviewers are read then and only then (#11666).
72 */
73 export function nextAsking(i: NextInput, off: () => boolean): Next {
74 const next = nextActor(i);
75 return next.who === "reviewer" && off() ? nextActor({ ...i, reviewOff: true }) : next;
76 }
77
6078 /** The next actor of an open pull at `headOid`; null once it is merged or closed. */
6179 export function nextOfPull(
6280 db: Database,
6381 pull: Pull,
6482 live: { readonly headOid: string; readonly conflicts: number },
83 /** The pull's repository; without one, reviews count as asked for. */
84 repo: Pick<Repo, "id" | "requireReview"> | undefined,
6585 ): Next | null {
outdated

Two extra queries per pull page render (budget raised to cover it) and a third spelling of the rule. Take the repo and reviewer count from the caller.

6686 if (pull.state !== "open" || pull.mergedAt !== null) return null;
6787 const reviews = reviewSummary(db, pull.id, live.headOid);
6888 const hold = getHold(db, pull.id);
69 return nextActor({
70 held: hold === undefined ? null : { by: hold.heldByName, why: hold.why },
71 conflicted: live.conflicts > 0,
72 check: checkSummary(db, pull.id, live.headOid).state,
73 changesRequested: reviews.changesRequested,
74 approvals: reviews.approvals,
75 pushedSince: reviews.stale,
76 wall: wallWords(db, pull.id, live.headOid),
77 });
89 return nextAsking(
90 {
91 held: hold === undefined ? null : { by: hold.heldByName, why: hold.why },
92 conflicted: live.conflicts > 0,
93 check: checkSummary(db, pull.id, live.headOid).state,
94 changesRequested: reviews.changesRequested,
95 approvals: reviews.approvals,
96 pushedSince: reviews.stale,
97 wall: wallWords(db, pull.id, live.headOid),
98 },
99 () => repo !== undefined && reviewsOff(db, repo),
100 );
78101 }
src/flow/path.ts +18 −11
@@ -19,7 +19,8 @@ import { getPull, type Pull } from "../core/pulls.ts";
1919 import type { Repo } from "../core/repos.ts";
2020 import { reviewSummariesFor, reviewSummary } from "../core/reviews.ts";
2121 import { checksForHeads, checkSummary, type RunState } from "../core/runs.ts";
22 import { nextActor, nextOfPull, type Next } from "./next_actor.ts";
22 import { reviewsOff } from "../crew/review.ts";
23 import { nextAsking, nextOfPull, type Next } from "./next_actor.ts";
2324 import { wallWordsOf } from "./wall_wait.ts";
2425
2526 export const STEPS = ["check", "review", "land", "live"] as const;
@@ -107,7 +108,7 @@ export function stepOfPull(
107108 repo: Repo,
108109 pull: Pull,
109110 live: { readonly headOid: string; readonly conflicts: number },
110 next: Next | null = pull.state === "open" && pull.mergedAt === null ? nextOfPull(db, pull, live) : null,
111 next: Next | null = pull.state === "open" && pull.mergedAt === null ? nextOfPull(db, pull, live, repo) : null,
111112 ): PathStep | null {
112113 if (pull.mergedAt !== null) {
113114 const deploys = pull.baseBranch === repo.defaultBranch && getAppByRepo(db, repo.id) !== undefined;
@@ -171,19 +172,25 @@ export function stepsOfStored(db: Database, repo: Repo, pulls: readonly Pull[]):
171172 const checks = checksForHeads(db, new Map(open.map((p) => [p.id, p.headOid])));
172173 const holds = holdsFor(db, ids);
173174 const walls = latestDecisionsFor(db, ids, "land");
175 // Asked at most once for the batch, and only if a pull would wait on a review (#11666).
176 let off: boolean | undefined;
177 const reviewOff = (): boolean => (off ??= reviewsOff(db, repo));
174178 for (const pull of open) {
175179 const r = reviews.get(pull.id)!;
176180 const check = checks.get(pull.id)?.[0]?.state ?? "none";
177181 const hold = holds.get(pull.id);
178 const next = nextActor({
179 held: hold === undefined ? null : { by: hold.heldByName, why: hold.why },
180 conflicted: false,
181 check,
182 changesRequested: r.changesRequested,
183 approvals: r.approvals,
184 pushedSince: r.stale,
185 wall: wallWordsOf(walls.get(pull.id), pull.headOid),
186 });
182 const next = nextAsking(
183 {
184 held: hold === undefined ? null : { by: hold.heldByName, why: hold.why },
185 conflicted: false,
186 check,
187 changesRequested: r.changesRequested,
188 approvals: r.approvals,
189 pushedSince: r.stale,
190 wall: wallWordsOf(walls.get(pull.id), pull.headOid),
191 },
192 reviewOff,
193 );
187194 out.set(pull.id, pathStep({ state: "open", check, approvals: r.approvals, next }));
188195 }
189196 const merged = pulls.filter((p) => p.mergedAt !== null);
src/flow/why.ts +1 −1
@@ -213,7 +213,7 @@ export function whyPull(
213213 ): Why {
214214 if (pull.mergedAt !== null) return { cause: null, settled: "merged" };
215215 if (pull.state !== "open") return { cause: null, settled: "closed" };
216 const next = nextOfPull(app.db, pull, live);
216 const next = nextOfPull(app.db, pull, live, repo);
217217 if (next === null) return { cause: null };
218218 const check = checkSummary(app.db, pull.id, live.headOid).state;
219219 const wall = next.who === "person" ? wallWords(app.db, pull.id, live.headOid) : null;
src/http/mcp/tools/pull.ts +1 −1
@@ -74,7 +74,7 @@ export const tool: Tool = {
7474 next: nextOfPull(ctx.app.db, pull, {
7575 headOid: pull.headOid,
7676 conflicts: state?.conflicts?.length ?? 0,
77 }),
77 }, repo),
7878 step: stepOfPull(ctx.app.db, repo, pull, {
7979 headOid: pull.headOid,
8080 conflicts: state?.conflicts?.length ?? 0,
src/tui/screens/pull.ts +1 −1
@@ -399,7 +399,7 @@ export function pullScreen(
399399 : nextOfPull(f.db, row, {
400400 headOid: row.headOid || (facts?.headOid ?? ""),
401401 conflicts: facts?.conflicts.length ?? 0,
402 });
402 }, settings);
403403 step =
404404 row === undefined || settings === undefined
405405 ? null
src/web/studio/change.ts +1 −1
@@ -226,7 +226,7 @@ export function changeHead(
226226 pull: Pull,
227227 live: { readonly headOid: string; readonly conflicts: number } | null = null,
228228 ): Html {
229 const next = live === null ? null : nextOfPull(ctx.app.db, pull, live);
229 const next = live === null ? null : nextOfPull(ctx.app.db, pull, live, repo);
230230 const step =
231231 live === null && pull.mergedAt === null
232232 ? null
test/asks-counts.test.ts +2
@@ -39,6 +39,7 @@ import { handoverOf, handoversOf } from "../src/crew/guard.ts";
3939 import { serve, type Servers } from "../src/http/server.ts";
4040 import { id } from "../src/kernel/id.ts";
4141 import { quiet, statements } from "./helpers/statements.ts";
42 import { askForReviews } from "./helpers/reviews.ts";
4243
4344 const ZERO = "0".repeat(40);
4445
@@ -94,6 +95,7 @@ describe("home's asks are read grouped, and once", () => {
9495 description: name,
9596 });
9697 if (!made.ok) throw new Error(`seed repo ${name}: ${made.error}`);
98 askForReviews(app.db, made.value.id);
9799 for (const agent of [builder, reviewer]) addCrew(app.db, made.value.id, agent.id, owner.id);
98100 seatAs(app.db, made.value.id, reviewer.id, "reviewer");
99101 app.db.query("UPDATE repo SET autonomy = ?2 WHERE id = ?1").run(made.value.id, autonomy);
test/crew-now-review-waits.test.ts +27 −5
@@ -9,6 +9,10 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
99 import { mkdtemp, rm } from "node:fs/promises";
1010 import { tmpdir } from "node:os";
1111 import { join } from "node:path";
12 import { reviewed } from "../src/crew/autonomy.ts";
13 import { reviewWaitsFor } from "../src/crew/review.ts";
14 import { getRepoById } from "../src/core/repos.ts";
15 import { getPull } from "../src/core/pulls.ts";
1216 import { crewNow } from "../src/actions/crew_now.ts";
1317 import { createAgent } from "../src/actions/crew.ts";
1418 import { openPull } from "../src/actions/pulls.ts";
@@ -20,6 +24,7 @@ import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
2024 import { getUserByName, type User } from "../src/core/users.ts";
2125 import { seedAdmin } from "./helpers/admin.ts";
2226 import { quietLogs } from "./helpers/globals.ts";
27 import { askForReviews } from "./helpers/reviews.ts";
2328
2429 quietLogs();
2530
@@ -32,20 +37,20 @@ describe("crewNow's review waits", () => {
3237 let n = 0;
3338
3439 /** An agent's pull whose check went green two hours ago, with no reviewer on the crew. */
35 const greenAgentPull = async () => {
40 const greenAgentPull = async (into: Repo = repo) => {
3641 const branch = `b${++n}`;
37 const c = await commitToBranch(app, ada, repo, {
42 const c = await commitToBranch(app, ada, into, {
3843 branch,
3944 base: "main",
4045 message: branch,
4146 files: [{ path: `${branch}.md`, content: "x\n" }],
4247 });
4348 if (!c.ok) throw new Error(JSON.stringify(c.error));
44 const o = await openPull(app, ada, repo, { title: `pull ${branch}`, body: "", baseBranch: "main", headBranch: branch });
49 const o = await openPull(app, ada, into, { title: `pull ${branch}`, body: "", baseBranch: "main", headBranch: branch });
4550 if (!o.ok) throw new Error(String(o.error));
4651 app.db.query("UPDATE issue SET author_id = ?2 WHERE id = ?1").run(o.value.id, agentId);
4752 const run = createRun(app.db, {
48 repoId: repo.id,
53 repoId: into.id,
4954 kind: "check",
5055 issueId: o.value.id,
5156 env: `pull-${o.value.number}`,
@@ -82,7 +87,9 @@ describe("crewNow's review waits", () => {
8287 ada = made.value;
8388 const r = await createRepo(app, ada, { name: "garden", visibility: "private" });
8489 if (!r.ok) throw new Error(String(r.error));
85 repo = r.value;
90 // Reviews are something an owner turns on (#11666).
91 askForReviews(app.db, r.value.id);
92 repo = getRepoById(app.db, r.value.id)!;
8693 createAppRecord(app.db, { repoId: repo.id, slug: "garden", access: "private" });
8794 const seeded = await commitToBranch(app, ada, repo, {
8895 message: "seed",
@@ -113,4 +120,19 @@ describe("crewNow's review waits", () => {
113120 expect(nine.rows.filter((r) => r.why === "review")).toHaveLength(9);
114121 expect(nine.statements).toBe(one.statements);
115122 }, 30_000);
123
124 test("in a repository with reviews off, a green agent pull waits on nobody and the gate passes it (#11666)", async () => {
125 const made = await createRepo(app, ada, { name: "meadow", visibility: "private" });
126 if (!made.ok) throw new Error(String(made.error));
127 const meadow = made.value;
128 const seeded = await commitToBranch(app, ada, meadow, { message: "seed", files: [{ path: "README.md", content: "# meadow\n" }] });
129 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
130 const pull = getPull(app.db, meadow.id, (await greenAgentPull(meadow)).number)!;
131 expect(await reviewed(app, meadow, pull)).toBe("pass");
132 expect(reviewWaitsFor(app.db, [{ repo: meadow, pull }]).get(pull.id)).toBeUndefined();
133 // The same pull's twin where reviews are asked for still waits, as before.
134 const asked = getPull(app.db, repo.id, (await greenAgentPull()).number)!;
135 expect(await reviewed(app, repo, asked)).toBe("hold");
136 expect(reviewWaitsFor(app.db, [{ repo, pull: asked }]).get(asked.id)?.person?.why).toBe("no reviewer");
137 }, 30_000);
116138 });
test/crew-red-outside-diff.test.ts +2
@@ -30,6 +30,7 @@ import type { User } from "../src/core/users.ts";
3030 import type { Decider } from "../src/crew/decide.ts";
3131 import { appendRunLog, readRunLog } from "../src/runs/log.ts";
3232 import { startStandInCrew, stopStandInCrew, type StandInCrew } from "./helpers/crew-standin.ts";
33 import { askForReviews } from "./helpers/reviews.ts";
3334
3435 const IMPACT = new Map([
3536 ["test/people.test.ts", ["src/people.ts"]],
@@ -83,6 +84,7 @@ describe("the crew, with a red outside the diff", () => {
8384 (app.config as { decider?: Decider }).decider = decider;
8485 // Parking is autonomy's: the repository runs on its own, on alice's word.
8586 app.db.run("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?1 WHERE id = ?2", [alice.id, repo.id]);
87 askForReviews(app.db, repo.id);
8688 const checkout = mkdtempSync(join(tmpdir(), "forge-outside-diff-"));
8789 writeFileSync(join(checkout, IMPACT_FILE), formatImpact(IMPACT));
8890 expect(keepImpact(app.config.dataDir, repo.id, checkout)).toBe("impact map kept: 3 tests");
test/follow-sources.test.ts +2
@@ -42,6 +42,7 @@ import { runGit } from "../src/git/env.ts";
4242 import { repoDir } from "../src/git/repos.ts";
4343 import { serve } from "../src/http/server.ts";
4444 import { mirrorOutbound, quietLogs } from "./helpers/globals.ts";
45 import { askForReviews } from "./helpers/reviews.ts";
4546
4647 quietLogs();
4748 mirrorOutbound({ allow: ["127.0.0.1"] });
@@ -163,6 +164,7 @@ describe("following several sources", () => {
163164 /** A repository whose main holds f.txt = `mainText`, and a commit tree builder. */
164165 async function repoWith(name: string, mainText: string) {
165166 const r = ok(await createRepo(app, alice, { name, visibility: "public" }));
167 askForReviews(app.db, r.id);
166168 const d = repoDir(app.reposRoot, r.ownerName, r.name);
167169 const git = async (args: string[], input?: string) =>
168170 ok(await runGit(args, { cwd: d, env: ident, ...(input === undefined ? {} : { stdin: input }) })).trim();
test/helpers/reviews.ts added +13
@@ -0,0 +1,13 @@
1 /**
2 * Reviews are something an owner turns on (#11666): with them off and no
3 * reviewer seated, a green pull lands by itself. A test whose expectation is
4 * a wait on a review asks for reviews here, in one place, rather than
5 * writing the column itself.
6 */
7
8 import type { Database } from "bun:sqlite";
9
10 /** Turn `requireReview` on (or, with `on` false, off) for a repository. */
11 export function askForReviews(db: Database, repoId: string, on = true): void {
12 db.query("UPDATE repo SET require_review = ?2 WHERE id = ?1").run(repoId, on ? 1 : 0);
13 }
test/mcp-flow-why.test.ts +2
@@ -19,6 +19,7 @@ import { movingRow } from "../src/tui/screens/about.ts";
1919 import { CAUSES, firstCause, type WhyFacts } from "../src/flow/why.ts";
2020 import { serve, type Servers } from "../src/http/server.ts";
2121 import { promote } from "./helpers/members.ts";
22 import { askForReviews } from "./helpers/reviews.ts";
2223
2324 describe("the list of causes", () => {
2425 const cases: [string, WhyFacts][] = [
@@ -238,6 +239,7 @@ describe("MCP flow about a pull", () => {
238239 if (!made.ok) throw new Error(String(made.error));
239240 repo = made.value;
240241 await put("main", "top\nbase\n");
242 askForReviews(app.db, repo.id);
241243 const bot = await createAgent(app, alice, { username: "bot" });
242244 if (!bot.ok) throw new Error(String(bot.error));
243245 expect(addToCrew(app, alice, repo, "bot").ok).toBe(true);
test/mcp-pulls-pile.test.ts +3
@@ -17,6 +17,7 @@ import { createUser, getUserByName } from "../src/core/users.ts";
1717 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1818 import { serve, type Servers } from "../src/http/server.ts";
1919 import { quietLogs } from "./helpers/globals.ts";
20 import { askForReviews } from "./helpers/reviews.ts";
2021
2122 quietLogs();
2223
@@ -65,6 +66,8 @@ beforeAll(async () => {
6566 }));
6667 };
6768 const pub = ok(await createRepo(app.db, app.reposRoot, { ownerId: ada.id, name: "svc", visibility: "public" }));
69 // Reviews are something an owner turns on (#11666): with them off these pulls would land.
70 askForReviews(app.db, pub.id);
6871 const priv = ok(await createRepo(app.db, app.reposRoot, { ownerId: ada.id, name: "vault", visibility: "private" }));
6972 for (const repo of [pub, priv])
7073 ok(await commitToBranch(app, ada, repo, { message: "seed", files: [{ path: "a.txt", content: "a\n" }] }));
test/mcp.test.ts +2
@@ -35,6 +35,7 @@ import { showBoard } from "../src/actions/boards.ts";
3535 import { panelData } from "../src/boards/panel_data.ts";
3636 import { listVia } from "./mcp-list.ts";
3737 import { adminOpenapi } from "./helpers/openapi-doc.ts";
38 import { askForReviews } from "./helpers/reviews.ts";
3839
3940 /** tools/list's order: the tools by name, then `report`. */
4041 const sortedThenReport = (names: string[]): string[] => [
@@ -120,6 +121,7 @@ describe("the forge over MCP", () => {
120121 });
121122 if (!made.ok) throw new Error(String(made.error));
122123 game = made.value;
124 askForReviews(app.db, game.id);
123125 await commitToBranch(app, alice, game, {
124126 message: "seed",
125127 files: [{ path: "README.md", content: "# game\n" }],
test/next_actor.test.ts +23 −1
@@ -1,5 +1,5 @@
11 import { describe, expect, test } from "bun:test";
2 import { nextActor, type NextInput } from "../src/flow/next_actor.ts";
2 import { nextActor, nextAsking, type NextInput } from "../src/flow/next_actor.ts";
33
44 const base: NextInput = {
55 held: null,
@@ -25,9 +25,31 @@ const cases: [string, Partial<NextInput>, string, string][] = [
2525 // The access wall (#7847): a person decides, however approved and green.
2626 ["walled, approved and green", { approvals: 1, check: "ok", wall: ["anyone signed out could read pack records"] }, "person", "the access wall holds it for a person to decide: anyone signed out could read pack records. Allow it (merge) or send it back"],
2727 ["walled, sent back", { approvals: 1, check: "ok", changesRequested: 1, wall: ["anyone signed out could read pack records"] }, "builder", "changes were asked on this head"],
28 // Reviews off (#11666): nobody waits for a review, but a verdict on the head still counts.
29 ["reviews off, green", { check: "ok", reviewOff: true }, "queue", "green, reviews are off: it lands"],
30 ["reviews off, no check", { reviewOff: true }, "queue", "no check yet"],
31 ["reviews off, running", { check: "running", reviewOff: true }, "queue", "its check is running"],
32 ["reviews off, changes asked", { changesRequested: 1, check: "ok", reviewOff: true }, "builder", "changes were asked on this head"],
33 ["reviews off, red", { check: "failed", reviewOff: true }, "builder", "its check is red"],
2834 ];
2935
outdated

Mid-file aliased imports; merge into the existing imports and the cases table.

3036 describe("nextActor", () => {
3137 for (const [name, input, who, why] of cases)
3238 test(name, () => expect(nextActor({ ...base, ...input })).toEqual({ who: who as never, why }));
3339 });
40
41 describe("nextAsking (#11666)", () => {
42 test("asks whether reviews are off only when the pull would wait on the reviewer", () => {
43 let asked = 0;
44 const off = () => (asked++, true);
45 expect(nextAsking({ ...base, check: "ok", approvals: 1 }, off)).toEqual({ who: "queue", why: "approved and green" });
46 expect(nextAsking({ ...base, check: "failed" }, off).who).toBe("builder");
47 expect(asked).toBe(0);
48 expect(nextAsking({ ...base, check: "ok" }, off)).toEqual({ who: "queue", why: "green, reviews are off: it lands" });
49 expect(asked).toBe(1);
50 });
51
52 test("with reviews asked for, the reviewer still acts next", () => {
53 expect(nextAsking({ ...base, check: "ok" }, () => false)).toEqual({ who: "reviewer", why: "no review yet" });
54 });
55 });
test/pages-budget.test.ts +1 −1
@@ -37,7 +37,7 @@ const DEFAULT = { statements: 60, git: 8 };
3737 const HEAVIER: Record<string, { statements?: number; git?: number }> = {
3838 "/:owner/:name/issues/:number": { statements: 70 },
3939 "/:owner/:name/next": { statements: 64 },
40 "/:owner/:name/pulls/:number": { statements: 110 }, // +1: the header Talk count (#10327); +1: the landing line (#10860)
40 "/:owner/:name/pulls/:number": { statements: 111 }, // +1: the header Talk count (#10327); +1: the landing line (#10860); +1: whether a reviewer is seated, read only when Require review is off and the pull would otherwise wait on one; the page loads no crew besides (#11666)
4141 "/board": { statements: 70 }, // +6: the money line (#510) — one settings read, then the month's and the 30 days' meter, storage and ledger reads; constant in the number of apps
4242 "/settings/capacity": { statements: 71 },
4343 };
test/path-steps-batch.test.ts +4 −1
@@ -18,6 +18,7 @@ import { stepOfStored, stepsOfStored } from "../src/flow/path.ts";
1818 import { quietLogs } from "./helpers/globals.ts";
1919 import { statements } from "./helpers/statements.ts";
2020 import { expectWork } from "./helpers/work.ts";
21 import { askForReviews } from "./helpers/reviews.ts";
2122
2223 quietLogs();
2324
@@ -67,7 +68,9 @@ beforeAll(async () => {
6768 bob = await user("bob");
6869 const made = await createRepo(app, alice, { name: "steps", visibility: "public" });
6970 if (!made.ok) throw new Error(String(made.error));
70 repo = made.value;
71 // These pulls wait on a review, as reviews asked for say (#11666).
72 askForReviews(app.db, made.value.id);
73 repo = { ...made.value, requireReview: true };
7174 const record = createAppRecord(app.db, { repoId: repo.id, slug: "steps", access: "public" });
7275 if (!record.ok) throw new Error(record.error);
7376 });
test/pipeline-path.test.ts +2
@@ -15,6 +15,7 @@ import { getRepoById } from "../src/core/repos.ts";
1515 import { pathStep, stepOfPull, STEPS } from "../src/flow/path.ts";
1616 import { serve, type Servers } from "../src/http/server.ts";
1717 import { quietLogs } from "./helpers/globals.ts";
18 import { askForReviews } from "./helpers/reviews.ts";
1819
1920 quietLogs();
2021
@@ -90,6 +91,7 @@ beforeAll(async () => {
9091 for (const name of ["open", "merged"]) {
9192 const repo = await createRepo(app, alice, { name, visibility: "public" });
9293 if (!repo.ok) throw new Error(String(repo.error));
94 askForReviews(app.db, repo.value.id);
9395 for (const [branch, file] of [[undefined, "forge.json"], ["topic", "t.txt"]] as const) {
9496 const done = await commitToBranch(app, alice, repo.value, {
9597 ...(branch === undefined ? {} : { branch }),
test/pull-next-line.test.ts +2
@@ -10,6 +10,7 @@ import { createSession } from "../src/core/sessions.ts";
1010 import { createUser, type User } from "../src/core/users.ts";
1111 import { serve, type Servers } from "../src/http/server.ts";
1212 import { quietLogs } from "./helpers/globals.ts";
13 import { askForReviews } from "./helpers/reviews.ts";
1314
1415 quietLogs();
1516
@@ -45,6 +46,7 @@ beforeAll(async () => {
4546 for (const name of ["open", "merged"]) {
4647 const repo = await createRepo(app, alice, { name, visibility: "public" });
4748 if (!repo.ok) throw new Error(String(repo.error));
49 askForReviews(app.db, repo.value.id);
4850 for (const [branch, file] of [[undefined, "forge.json"], ["topic", "t.txt"]] as const) {
4951 const done = await commitToBranch(app, alice, repo.value, {
5052 ...(branch === undefined ? {} : { branch }),
test/pull-next-tui.test.ts +29 −6
@@ -5,6 +5,7 @@ import { join } from "node:path";
55 import { openPull } from "../src/actions/pulls.ts";
66 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
77 import { createApp, type App } from "../src/app.ts";
8 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
89 import { createUser, type User } from "../src/core/users.ts";
910 import { createForgeApp } from "../src/tui/forge.ts";
1011 import { pullScreen } from "../src/tui/screens/pull.ts";
@@ -12,6 +13,7 @@ import { local } from "../src/tui/screens/where.ts";
1213 import { headlessTerminal } from "../src/tui/term/terminal.ts";
1314 import { quietLogs } from "./helpers/globals.ts";
1415 import { settleApp } from "./helpers/tui.ts";
16 import { askForReviews } from "./helpers/reviews.ts";
1517
1618 quietLogs();
1719
@@ -39,9 +41,11 @@ afterAll(async () => {
3941 await rm(dataDir, { recursive: true, force: true });
4042 });
4143
42 test("the terminal's pull screen says who acts next, as the page does", async () => {
43 const repo = await createRepo(app, alice, { name: "lib", visibility: "public" });
44 /** A repository with a `topic` pull open; reviews are asked for only when `reviews` says so (#11666). */
45 async function openTopic(name: string, reviews: boolean) {
46 const repo = await createRepo(app, alice, { name, visibility: "public" });
4447 if (!repo.ok) throw new Error(String(repo.error));
48 askForReviews(app.db, repo.value.id, reviews);
4549 for (const branch of ["main", "topic"]) {
4650 const done = await commitToBranch(app, alice, repo.value, {
4751 branch,
@@ -56,6 +60,11 @@ test("the terminal's pull screen says who acts next, as the page does", async ()
5660 headBranch: "topic",
5761 });
5862 if (!opened.ok) throw new Error(String(opened.error));
63 return { repo: repo.value, pull: opened.value };
64 }
65
66 /** The pull screen's text once it says `wanted`, then the screen is stopped. */
67 async function screenSaying(repo: Awaited<ReturnType<typeof openTopic>>, wanted: string): Promise<string> {
5968 const { app: tui, session } = createForgeApp(
6069 app,
6170 { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:alice", user: alice },
@@ -63,16 +72,30 @@ test("the terminal's pull screen says who acts next, as the page does", async ()
6372 );
6473 tui.start();
6574 try {
66 tui.ctx.push(pullScreen(session, tui.ctx, local(session, repo.value), opened.value.number));
75 tui.ctx.push(pullScreen(session, tui.ctx, local(session, repo.repo), repo.pull.number));
6776 const end = Date.now() + 20_000;
68 while (!tui.screen.toText().includes("next: reviewer, no review yet")) {
77 while (!tui.screen.toText().includes(wanted)) {
6978 if (Date.now() > end) throw new Error(tui.screen.toText());
7079 await settleApp(tui);
7180 await Bun.sleep(20);
7281 }
73 // The page's step line, too: this pull is at review.
74 if (!tui.screen.toText().includes("step: Review — ")) throw new Error(tui.screen.toText());
82 return tui.screen.toText();
7583 } finally {
7684 tui.stop();
7785 }
86 }
87
88 test("the terminal's pull screen says who acts next, as the page does", async () => {
89 const text = await screenSaying(await openTopic("lib", true), "next: reviewer, no review yet");
90 // The page's step line, too: this pull is at review.
91 if (!text.includes("step: Review — ")) throw new Error(text);
92 });
93
94 test("with reviews off, a green pull's screen says it lands by itself, as the page does (#11666)", async () => {
95 const topic = await openTopic("lib-off", false);
96 const run = createRun(app.db, { repoId: topic.repo.id, kind: "check", issueId: topic.pull.id, commitOid: topic.pull.headOid });
97 claimRun(app.db, run.id);
98 finishRun(app.db, run.id, { state: "ok", summary: "green" });
99 const text = await screenSaying(topic, "next: queue, green, reviews are off: it lands");
100 if (text.includes("next: reviewer")) throw new Error(text);
78101 });
test/review-gate.test.ts +17 −1
@@ -6,7 +6,7 @@
66 * before.
77 */
88 import { seatAs } from "./helpers/seat.ts";
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
1010 import { mkdtemp, rm } from "node:fs/promises";
1111 import { tmpdir } from "node:os";
1212 import { join } from "node:path";
@@ -28,6 +28,7 @@ import { createUser, getUserByName, type User } from "../src/core/users.ts";
2828 import { landIfGreen, settleAgentRun } from "../src/crew/autonomy.ts";
2929 import { sendToReview, waitsForPerson } from "../src/crew/review.ts";
3030 import { askLinesFor } from "../src/web/studio/asks.ts";
31 import { askForReviews } from "./helpers/reviews.ts";
3132
3233 describe("the review gate", () => {
3334 let dataDir: string;
@@ -37,6 +38,10 @@ describe("the review gate", () => {
3738 let quill: User;
3839 let repo: Repo;
3940 const fresh = () => getRepoById(app.db, repo.id)!;
41 /** Whether the owner asks for reviews (#11666); each test starts with them off. */
42 const askReviews = (on: boolean) =>
43 askForReviews(app.db, repo.id, on);
44 afterEach(() => askReviews(false));
4045 const reviewRuns = (pull: Pull) =>
4146 listRuns(app.db, repo.id, { issueId: pull.id }).filter(
4247 (r) => r.reason === "review",
@@ -203,6 +208,7 @@ describe("the review gate", () => {
203208 test("with no reviewer on the crew an agent's pull waits for a person and says why", async () => {
204209 seatAs(app.db, repo.id, quill.id, "builder");
205210 const pull = await greenPull(mason, "no-reviewer");
211 askReviews(true); // with them off this pull would land (#11666)
206212 expect(await landIfGreen(app, fresh(), pull)).toBe(false);
207213 expect(reviewRuns(pull)).toHaveLength(0);
208214 expect(waitsForPerson(app.db, repo.id, getPull(app.db, repo.id, pull.number)!)?.why).toBe("no reviewer");
@@ -210,6 +216,16 @@ describe("the review gate", () => {
210216 expect(await landIfGreen(app, fresh(), pull)).toBe(true);
211217 });
212218
219 test("with reviews off, a green agent pull lands; changes asked on its head still hold it (#11666)", async () => {
220 seatAs(app.db, repo.id, quill.id, "builder");
221 const landing = await greenPull(mason, "reviews-off");
222 expect(await landIfGreen(app, fresh(), landing)).toBe(true);
223 const asked = await greenPull(mason, "reviews-off-changes");
224 submitReview(app.db, { issueId: asked.id, authorId: alice.id, state: "changes_requested", commitOid: asked.headOid });
225 expect(await landIfGreen(app, fresh(), asked)).toBe(false);
226 expect(getPull(app.db, repo.id, asked.number)?.mergedAt).toBeNull();
227 });
228
213229 test("a person's own pull is unchanged: a review that lapses does not hold it; it lands, and says so", async () => {
214230 seatAs(app.db, repo.id, quill.id, "reviewer");
215231 const pull = await greenPull(alice, "person-unanswered");
test/review-waits.test.ts +11 −1
@@ -6,7 +6,7 @@
66 * who only reads.
77 */
88
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
1010 import { mkdtemp, rm } from "node:fs/promises";
1111 import { tmpdir } from "node:os";
1212 import { join } from "node:path";
@@ -23,6 +23,7 @@ import { landIfGreen } from "../src/crew/autonomy.ts";
2323 import { serve, type Servers } from "../src/http/server.ts";
2424 import { askLinesFor } from "../src/web/studio/asks.ts";
2525 import { quietLogs } from "./helpers/globals.ts";
26 import { askForReviews } from "./helpers/reviews.ts";
2627
2728 quietLogs();
2829
@@ -35,6 +36,12 @@ describe("an agent pull that waits for a person's review says so", () => {
3536 let alice: User;
3637 let bob: User;
3738 let repo: Repo;
39 /** Whether the owner asks for reviews (#11666), in the row and in the copy the tests pass on. */
40 const setReviews = (on: boolean) => {
41 askForReviews(app.db, repo.id, on);
42 repo = { ...repo, requireReview: on };
43 };
44 afterEach(() => setReviews(true));
3845 let aliceCookie = "";
3946 let bobCookie = "";
4047 let aliceRo = "";
@@ -141,6 +148,7 @@ describe("an agent pull that waits for a person's review says so", () => {
141148 });
142149 if (!made.ok) throw new Error(String(made.error));
143150 repo = made.value;
151 setReviews(true);
144152 await commitToBranch(app, alice, repo, {
145153 message: "seed",
146154 files: [{ path: "README.md", content: "# engine\n" }],
@@ -180,6 +188,8 @@ describe("an agent pull that waits for a person's review says so", () => {
180188
181189 test("with no reviewer the agent's pull does not land, and a person's own does", async () => {
182190 expect(await landIfGreen(app, repo, waiting)).toBe(false);
191 // A person's own pull lands on green in an app whose owner has reviews off (#11666).
192 setReviews(false);
183193 expect(await landIfGreen(app, repo, own)).toBe(true);
184194 });
185195
test/seat-slots.test.ts +1 −1
@@ -80,7 +80,7 @@ beforeAll(async () => {
8080 const id = (n: string) => getUserByName(app.db, n)!.id;
8181 seatAs(app.db, repo.id, id("atlas"), "planner");
8282 seatAs(app.db, repo.id, id("judge"), "reviewer");
83 ok(await updateRepoSettings(app, alice, repo, { autonomy: "autonomous", autonomyDirection: "Make the yard rounder.", autonomyCeilingUsd: 10, autonomySlots: 2, autonomyBy: alice.id }));
83 ok(await updateRepoSettings(app, alice, repo, { autonomy: "autonomous", requireReview: true, autonomyDirection: "Make the yard rounder.", autonomyCeilingUsd: 10, autonomySlots: 2, autonomyBy: alice.id }));
8484 const res = await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username: "alice", password: PASSWORD }), redirect: "manual" });
8585 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
8686 admin = createToken(app.db, alice.id, "admin", "admin").token;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs