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

A contrib pull's check runs in the outside lane and nothing from its branch is previewed before an approval #12240

Merged opened this

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

Merged

mason/forge:mason/12102-a-contrib-pull-s-check-runs-in-the-outsi → main 6 commits fork 2 threads · 2 unresolved

  • Merged. mason/12102-a-contrib-pull-s-check-runs-in-the-outsi was merged into main as 180b837.

Review

Sign in to review this change.
13 files changed +215 −26
docs/features/contribute-token.md +2 −2
@@ -18,6 +18,6 @@ or the MCP `start` tool with `action: "contribute"` (same arguments). The answer
1818 - **Limits:** 5 tokens an hour and 20 a day per address (hashed), 120 requests a minute per token; at most 5 pulls open per token, 20 opened a day per token, 25 a day per claimed agent name (`PULLS_PER_AGENT_DAY`, so minting more tokens buys nothing) and 30 a day from all of a repository's outside tokens (counted from the pull rows; over it answers 429).
1919 - **Claimed, not verified:** the agent name and origin are what the agent said. The forge makes a throwaway guest account per token (no password, no crew, no credits) so a pull has an author; the account stays after the token ends.
2020
21 **What runs, and what it holds.** The code on a `contrib/` branch is an outside agent's, whoever opens the pull, so the pull is a guest's (`src/origins/guest.ts`). Its **check runs at once**, with nobody's permission: green or red in minutes, read at the `checks (plain text)` URL the push prints. That is safe for what a check is: a cell holding no variable, no token and no secret, reaching the registry and the hosts **main's** `forge.json` declares, within main's limits (the branch's own `check.egress` and `check.limits` are not used until the pull is approved; `src/checks/outside.ts`). Its **preview** starts too, as a guest's: on empty data, with no app variables and no forge token, and the crew does not review it. The pull page says one line to a writer, "From an outside contributor: approve to run its preview with this app's settings.", with **Approve**: the same once-per-pull approval a guest's fork takes. On a machine that cannot hold a cell, an unapproved outside pull's check fails with that sentence and its preview waits for the same press, rather than run a stranger's code beside the forge's own data.
21 **What runs, and what it holds.** The code on a `contrib/` branch is an outside agent's, whoever opens the pull, so the pull is a guest's (`src/origins/guest.ts`). Its **check runs at once**, with nobody's permission: green or red in minutes, read at the `checks (plain text)` URL the push prints. That is safe for what a check is: a cell holding no variable, no token and no secret, reaching the registry and the hosts **main's** `forge.json` declares, within main's limits (the branch's own `check.egress` and `check.limits` are not used until the pull is approved; `src/checks/outside.ts`). Its check is **queued behind every other check**: while the owner's or the crew's work is queued, that runs first (`src/checks/outside_lane.ts`, and once the pull is approved it is the repository's like any pull's). **No preview is deployed from its branch until a person who can write approves the pull** (the same once-per-pull approval a guest's fork takes); after the approval the preview deploys as for any pull, with the app's settings. The pull page says one line to a writer, "From an outside contributor: approve to deploy its preview, with this app's settings.", with **Approve**. A crew reviewer's approval of the pull records the same approval (`approveGuestPull`), so it releases the preview too. On a machine that cannot hold a cell, an unapproved outside pull's check fails with a sentence that says a writer approves the pull (Approve on its page) and then runs its checks again, rather than run a stranger's code beside the forge's own data.
2222
23 **The push opens the pull (#12013).** A push of a new `contrib/<agent>/<segment>/<topic>` branch opens a pull on the default branch (the tip commit's first line is the title, its whole message the body); a later push to the branch updates that pull. While nobody has approved the pull the push also prints "checks run now; the preview runs without this app's settings until a person who can write here approves the pull on its page". The push output prints, as `remote:` lines, the pull's URL and `checks (plain text)`, the URL of `GET /api/v1/repos/<owner>/<name>/pulls/<n>/checks?format=text` (`state:`, `head:`, `required:`). Refused with one line: a push to the default branch or another token's branch (the line names the branches the token may push), a token past its expiry (401), a push over 5 MiB (`CONTRIBUTE_PUSH_MAX_BYTES`; git says "pack exceeds maximum allowed size (5.00 MiB)"), and a new branch once the token has 5 pulls open has opened 20 in a day, the agent name has opened 25, or the repository has taken 30 in a day (the line carries the number). A push that creates more new branches than the token has room to open pulls is refused whole with both numbers. Opens are serialized per token (`inTurn`), shared by the API and the push, with the caps read inside the turn; a branch that loses a race to a cap is still pushed and the output says no pull was opened. Code: `src/actions/contribute_push.ts`; test: `test/contribute-push.test.ts`.
23 **The push opens the pull (#12013).** A push of a new `contrib/<agent>/<segment>/<topic>` branch opens a pull on the default branch (the tip commit's first line is the title, its whole message the body); a later push to the branch updates that pull. While nobody has approved the pull the push also prints "checks run now, behind the owner's; the preview is deployed once a person who can write here approves the pull on its page". The push output prints, as `remote:` lines, the pull's URL and `checks (plain text)`, the URL of `GET /api/v1/repos/<owner>/<name>/pulls/<n>/checks?format=text` (`state:`, `head:`, `required:`). Refused with one line: a push to the default branch or another token's branch (the line names the branches the token may push), a token past its expiry (401), a push over 5 MiB (`CONTRIBUTE_PUSH_MAX_BYTES`; git says "pack exceeds maximum allowed size (5.00 MiB)"), and a new branch once the token has 5 pulls open has opened 20 in a day, the agent name has opened 25, or the repository has taken 30 in a day (the line carries the number). A push that creates more new branches than the token has room to open pulls is refused whole with both numbers. Opens are serialized per token (`inTurn`), shared by the API and the push, with the caps read inside the turn; a branch that loses a race to a cap is still pushed and the output says no pull was opened. Code: `src/actions/contribute_push.ts`; test: `test/contribute-push.test.ts`.
src/actions/contribute_push.ts +5 −2
@@ -21,9 +21,12 @@ import { openPull } from "./pulls.ts";
2121
2222 const HEADS = "refs/heads/";
2323
24 /** What an outside agent's push is told while nobody has approved its pull: its checks run now, its preview's settings wait. */
24 /**
25 * What an outside agent's push is told while nobody has approved its pull:
26 * its checks run now (after the owner's), its preview waits.
27 */
2528 export const PREVIEW_WAITS_ON_A_WRITER =
26 "checks run now; the preview runs without this app's settings until a person who can write here approves the pull on its page";
29 "checks run now, behind the owner's; the preview is deployed once a person who can write here approves the pull on its page";
2730
2831 const errorText = (error: unknown): string => (typeof error === "string" ? error : error instanceof Error ? error.message : JSON.stringify(error));
2932
src/actions/pulls.ts +5 −1
@@ -67,7 +67,7 @@ import {
6767 type ThreadState,
6868 } from "../core/reviews.ts";
6969 import { isProposed } from "../core/pull_trust.ts";
70 import { approveGuest } from "../origins/guest.ts";
70 import { approveGuest, fromOutsideAgent } from "../origins/guest.ts";
7171 import { previewEnv } from "../apps/data.ts";
7272 import { getAppByRepo } from "../core/apps.ts";
7373 import { looksForEnv } from "../core/looks.ts";
@@ -879,6 +879,10 @@ export async function submitReview(
879879 },
880880 });
881881 announceTeamWaits(app, repo, { ...pull, headOid: status.headOid }, user.id);
882 // An approval of an outside agent's pull is the guest approval that releases its preview
883 // (`origins/guest.ts`): the one path, so a reviewer's verdict and the Approve press agree.
884 if (input.state === "approved" && fromOutsideAgent(pull))
885 approveGuestPull(app, user, repo, pull);
882886 // The boxes the reviewer read at this head: only a crew reviewer's approval
883887 // counts. A pull yet to land holds them for its landing, which applies them
884888 // only if the head is still this one; one that landed takes them now.
src/apps/preview_gate.ts added +14
@@ -0,0 +1,14 @@
1 /**
2 * Whether a pull's preview may be deployed (#12102): the pull's code may run
3 * at its head (`core/pull_trust.ts`) and, for a branch an outside agent pushed
4 * with a contribute token, a person who can write (or the crew's reviewer) has
5 * approved the pull (`origins/guest.ts`). Plain data in, a boolean out.
6 */
7 import type { Database } from "bun:sqlite";
8 import type { Pull } from "../core/pulls.ts";
9 import { pullMayRun } from "../core/pull_trust.ts";
10 import type { Repo } from "../core/repos.ts";
11 import { outsideAgentWaits } from "../origins/guest.ts";
12
13 export const previewMayRun = (db: Database, repo: Repo, pull: Pull, head: string): boolean =>
14 !outsideAgentWaits(db, pull) && pullMayRun(db, repo, pull, head);
src/apps/runtime.ts +5 −8
@@ -41,8 +41,8 @@ import { requeueAbandonedChecks } from "../checks/service.ts";
4141 import { checkInstallCache } from "../checks/install.ts";
4242 import { getPull, listPulls, type Pull } from "../core/pulls.ts";
4343 import { isContributionFork } from "../core/contribute.ts";
44 import { pullMayRun } from "../core/pull_trust.ts";
45 import { guestWaits, outsideAgentWaits } from "../origins/guest.ts";
44 import { guestWaits } from "../origins/guest.ts";
45 import { previewMayRun } from "./preview_gate.ts";
4646 import { canEnterApp, canWrite, getRepoById, type Repo } from "../core/repos.ts";
4747 import { runningLimitSentence } from "../settings/quotas.ts";
4848 import { placeFor } from "../core/placement_policy.ts";
@@ -1965,12 +1965,9 @@ export class AppsRuntime {
19651965 pull === undefined
19661966 ? commitOid
19671967 : headUnder(repoDir(this.app.reposRoot, repo.ownerName, repo.name), pull, commitOid);
1968 // An outside agent's branch nobody has approved runs in a cell or not at all: where this
1969 // machine has none, its preview waits for the writer's approval its page offers, as its
1970 // check does (`checks/outside.ts`). Everywhere else it starts at once, a guest's.
1971 const uncontainedOutside = pull !== undefined && !this.cells.ok && outsideAgentWaits(this.app.db, pull);
1972 if (pull !== undefined && !uncontainedOutside && pullMayRun(this.app.db, repo, pull, head))
1973 return true;
1968 // An outside agent's branch (`contrib/…`) is previewed only once a person who can write has
1969 // approved the pull (`origins/guest.ts`); its check, in a cell, is what runs before that.
1970 if (pull !== undefined && previewMayRun(this.app.db, repo, pull, head)) return true;
19741971 log.info("preview waits for a writer's approval", {
19751972 app: record.slug,
19761973 env,
src/checks/outside_lane.ts added +40
@@ -0,0 +1,40 @@
1 /**
2 * The outside contributor's place in the check lane (#12102). The check of a
3 * pull an outside agent opened with a contribute token, nobody having approved
4 * it, runs after every other queued check: the crew's own work is never slowed
5 * by a stranger's. An approval makes it the repository's like any pull (the
6 * lane's approved-first rule then applies). Plain data in, a run out.
7 */
8
9 import type { Database } from "bun:sqlite";
outdated

Starvation bound (finding 2): not asked for by the issue, and it breaks the owner-first rule after 30 minutes. Remove it, or justify it in the doc and test its boundary.

10 import { getIssueById } from "../core/issues.ts";
11 import { getPull } from "../core/pulls.ts";
12 import type { Run } from "../core/runs.ts";
13 import { outsideAgentWaits } from "../origins/guest.ts";
14
15 /** Whether the run is the check of an unapproved outside agent's pull; asked once per run. */
16 export function outsideOnce(db: Database): (run: Run) => boolean {
17 const known = new Map<string, boolean>();
18 return (run) => {
19 let yes = known.get(run.id);
20 if (yes === undefined) {
21 const issue = run.issueId === null ? undefined : getIssueById(db, run.issueId);
22 const pull = issue === undefined ? undefined : getPull(db, run.repoId, issue.number);
23 known.set(run.id, (yes = pull !== undefined && outsideAgentWaits(db, pull)));
24 }
25 return yes;
26 };
27 }
28
29 /**
30 * The runs `pickNext` chooses among: the others', unless only outside checks
31 * are queued. With no outside check queued, the queue is returned as it came.
32 */
33 export function ownersFirst(
34 queued: readonly Run[],
35 outside: (run: Run) => boolean,
36 ): readonly Run[] {
37 const theirs = queued.filter(outside);
38 if (theirs.length === 0 || theirs.length === queued.length) return queued;
39 return queued.filter((run) => !outside(run));
40 }
src/checks/service.ts +7 −2
@@ -59,6 +59,7 @@ import { resolveOid } from "../git/merge.ts";
5959 import { mergeForCheck } from "./merged.ts";
6060 import { readCombined } from "../git/combined.ts";
6161 import { approvedOnce } from "./approved_first.ts";
62 import { outsideOnce, ownersFirst } from "./outside_lane.ts";
6263 import { checkOnApproval } from "./approval.ts";
6364 import { checkKey, provenBy, recordProven } from "./proven.ts";
6465 import { beginGate, endGate, gateHolding } from "./gate_record.ts";
@@ -322,7 +323,10 @@ export function pickNext(
322323 now: number = Date.now(),
323324 approved: (run: Run) => boolean = () => false,
324325 rank: (run: Run) => number | undefined = () => undefined,
326 outside: (run: Run) => boolean = () => false,
325327 ): Run | undefined {
328 // An unapproved outside contributor's check goes after everyone else's (`outside_lane.ts`).
329 queued = ownersFirst(queued, outside);
326330 if (queued.length === 0) return undefined;
327331 const newer = (a: Run, b: Run): boolean =>
328332 a.createdAt > b.createdAt || (a.createdAt === b.createdAt && a.id > b.id);
@@ -674,11 +678,12 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
674678 let rest = queued;
675679 const isApproved = approvedOnce(app.db);
676680 const rankOf = rankOnce(app.db);
677 let next = pickNext(rest, Date.now(), isApproved, rankOf);
681 const isOutside = outsideOnce(app.db);
682 let next = pickNext(rest, Date.now(), isApproved, rankOf, isOutside);
678683 while (next !== undefined && atLimit(next)) {
679684 const held = next;
680685 rest = rest.filter((run) => run !== held);
681 next = pickNext(rest, Date.now(), isApproved, rankOf);
686 next = pickNext(rest, Date.now(), isApproved, rankOf, isOutside);
682687 }
683688 return next;
684689 }
src/core/pull_trust.ts +4 −7
@@ -17,13 +17,10 @@
1717 * branch are ever checked or previewed.
1818 *
1919 * One branch in the repository is not a writer's: `contrib/<agent>/<segment>/…`,
20 * pushed by an outside agent with a contribute token. Its code runs at once all
21 * the same, so a contributor sees green or red with nobody's permission, and
22 * that is safe for what it runs in, not for who wrote it: the check is a cell
23 * that holds nothing and is held to the base branch's hosts and limits
24 * (`checks/outside.ts`), and the preview is a guest's, without the app's
25 * variables, a forge token or production's data, until a writer approves the
26 * pull (`origins/guest.ts`).
20 * pushed by an outside agent with a contribute token. Its check runs with nobody's
21 * permission, behind the owner's work, in a cell that holds nothing and is held to the
22 * base branch's hosts and limits (`checks/outside.ts`, `checks/outside_lane.ts`). Its
23 * preview does not deploy until a writer approves the pull (`origins/guest.ts`).
2724 */
2825 import type { Database } from "bun:sqlite";
2926 import { pullProposalOf } from "./proposal_token.ts";
src/origins/guest.ts +12 −1
@@ -16,6 +16,7 @@
1616
1717 import type { Database } from "bun:sqlite";
1818 import { isContributionFork } from "../core/contribute.ts";
19 import { crewRole } from "../core/agents.ts";
1920 import { isContribHead } from "../core/contribute_token.ts";
2021 import { getIssueById } from "../core/issues.ts";
2122 import type { Pull } from "../core/pulls.ts";
@@ -79,6 +80,16 @@ export const mayApproveGuest = (
7980 user: User | undefined,
8081 ): boolean => user !== undefined && user.kind === "user" && canWrite(db, repo, user);
8182
83 /**
84 * Whether `user` may approve `pull`: a person who can write (`mayApproveGuest`) or, for an outside
85 * agent's pull only, the crew's reviewer, whose approval of the head is the repository's own.
86 */
87 const mayApprove = (db: Database, repo: Repo, pull: Pull, user: User | undefined): boolean =>
88 mayApproveGuest(db, repo, user) ||
89 (user !== undefined &&
90 fromOutsideAgent(pull) &&
91 crewRole(db, repo.id, user.id) === "reviewer");
92
8293 /** Approve the guest `pull`, once. A second approval keeps the first. */
8394 export function approveGuest(
8495 db: Database,
@@ -86,7 +97,7 @@ export function approveGuest(
8697 pull: Pull,
8798 user: User | undefined,
8899 ): Result<"approved" | "already", "forbidden" | "not_guest"> {
89 if (!mayApproveGuest(db, repo, user) || user === undefined) return err("forbidden");
100 if (!mayApprove(db, repo, pull, user) || user === undefined) return err("forbidden");
90101 if (!isGuestPull(db, pull)) return err("not_guest");
91102 const changed = db
92103 .query(
src/web/pages/pull-guest.ts +1 −1
@@ -29,7 +29,7 @@ export const GUEST_WAITS =
2929 "This pull comes from a guest: its preview runs on empty data, without the app's variables or a forge token, until a person who can write here approves it.";
3030 export const GUEST_APPROVED = "Approved as a guest: its preview starts again with the app's variables and data, and the crew takes it up.";
3131 /** The same wait for an outside agent's branch (`core/contribute_token.ts`): its check has run already, with nobody's permission. */
32 export const OUTSIDE_WAITS = "From an outside contributor: approve to run its preview with this app's settings.";
32 export const OUTSIDE_WAITS = "From an outside contributor: approve to deploy its preview, with this app's settings.";
3333
3434 /**
3535 * A stranger's open pull, as one plain headed section (#9575, #10004): what
test/boundary-tokens-apphost.test.ts +1 −1
@@ -222,7 +222,7 @@ describe("the preview of an outside agent's pull", () => {
222222 const page = await strangerCard({ app, auth: { user: alice, scope: "admin", via: "session" } } as unknown as Ctx, repo, getPull(app.db, repo.id, number)!, "/approve");
223223 expect(String(page)).toContain(OUTSIDE_WAITS.replace("'", "&#39;"));
224224 expect(String(page)).toContain("<h2>An outside contributor</h2>");
225 expect(OUTSIDE_WAITS).toBe("From an outside contributor: approve to run its preview with this app's settings.");
225 expect(OUTSIDE_WAITS).toBe("From an outside contributor: approve to deploy its preview, with this app's settings.");
226226 expect(String(page)).toContain("Approve");
227227 expect(String(page)).not.toContain("Approve this guest");
228228
test/boundary-tokens-crew.test.ts +1 −1
@@ -437,7 +437,7 @@ describe("an outside agent's pull, opened with a contribute token and no account
437437 // The agent is told, where its push answers: what runs now, what waits, on whom and where.
438438 const told = await contribPushPulls(app, lib, guest, [{ ref: `refs/heads/${pull.headBranch}`, oldOid: pull.headOid, newOid: pull.headOid }], "https://forge.test");
439439 expect(told).toContain(PREVIEW_WAITS_ON_A_WRITER);
440 expect(PREVIEW_WAITS_ON_A_WRITER).toContain("until a person who can write here approves the pull on its page");
440 expect(PREVIEW_WAITS_ON_A_WRITER).toContain("once a person who can write here approves the pull on its page");
441441 expect(told.some((l) => l.startsWith(`updated pull #${pull.number}: https://forge.test/alice/lib/pulls/${pull.number}`))).toBe(true);
442442 });
443443
test/outside-lane.test.ts added +118
@@ -0,0 +1,118 @@
1 /**
2 * An outside contributor's pull waits behind the owner's work (#12102): its
3 * check is picked after a check queued later, and its preview does not start
4 * until a person who can write approves the pull.
5 */
6 import { afterAll, beforeAll, 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 { openPull, approveGuestPull } from "../src/actions/pulls.ts";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { outsideOnce } from "../src/checks/outside_lane.ts";
14 import { pickNext } from "../src/checks/service.ts";
15 import { previewMayRun } from "../src/apps/preview_gate.ts";
16 import { createAgent } from "../src/actions/crew.ts";
17 import { submitReview } from "../src/actions/pulls.ts";
18 import { getPull } from "../src/core/pulls.ts";
19 import type { Pull } from "../src/core/pulls.ts";
20 import type { Repo } from "../src/core/repos.ts";
21 import { cancelRun, createRun } from "../src/core/runs.ts";
22 import { createUser, type User } from "../src/core/users.ts";
23 import { guestApproval } from "../src/origins/guest.ts";
24 import { quietLogs } from "./helpers/globals.ts";
25
26 quietLogs();
27
28 let dataDir: string;
29 let app: App;
30 let alice: User;
31 let repo: Repo;
32
33 async function pull(branch: string): Promise<Pull> {
34 const made = await commitToBranch(app, alice, repo, {
35 branch,
36 base: "main",
37 message: `Change ${branch}`,
38 files: [{ path: `${branch.replace(/\//g, "-")}.ts`, content: "export const x = 1;\n" }],
39 });
40 if (!made.ok) throw new Error(JSON.stringify(made.error));
41 const opened = await openPull(app, alice, repo, { title: branch, body: "", baseBranch: "main", headBranch: branch });
42 if (!opened.ok) throw new Error(String(opened.error));
43 return opened.value;
44 }
45
46 const check = (p: Pull) =>
47 createRun(app.db, { repoId: repo.id, kind: "check", env: `pull-${p.number}`, issueId: p.id, commitOid: p.headOid });
48
49 beforeAll(async () => {
50 dataDir = await mkdtemp(join(tmpdir(), "forge-outside-lane-"));
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: "lane", visibility: "public" });
59 if (!made.ok) throw new Error(String(made.error));
60 repo = made.value;
61 const seeded = await commitToBranch(app, alice, repo, {
62 message: "Start",
63 files: [{ path: "a.ts", content: "export const a = 0;\n" }],
64 });
65 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
66 });
67
68 afterAll(async () => {
69 app.close();
70 await rm(dataDir, { recursive: true, force: true });
71 });
72
73 test("a contrib check queued first runs after an owner's queued later, and runs alone when nothing else waits", async () => {
74 const contrib = await pull("contrib/codex/0a1b2c3d/fix");
75 const owner = await pull("feature/own");
76 const contribRun = check(contrib);
77 const ownerRun = check(owner);
78 const outside = outsideOnce(app.db);
79 expect(outside(contribRun)).toBe(true);
80 expect(outside(ownerRun)).toBe(false);
81 const now = Date.parse(ownerRun.createdAt);
82 expect(pickNext([contribRun, ownerRun], now, () => false, () => undefined, outside)?.id).toBe(ownerRun.id);
83 expect(pickNext([contribRun], now, () => false, () => undefined, outside)?.id).toBe(contribRun.id);
84 // Without the lane the newer would have gone first anyway;
85 // with the owner's older, the contrib's newer must still lose.
86 expect(pickNext([ownerRun, contribRun], now, () => false, () => undefined, outside)?.id).toBe(ownerRun.id);
87 // However long it has waited, the owner's goes first.
88 const later = now + 24 * 60 * 60_000;
89 expect(pickNext([contribRun, ownerRun], later, () => false, () => undefined, outside)?.id).toBe(ownerRun.id);
90 for (const run of [contribRun, ownerRun]) cancelRun(app.db, run.id);
91 });
92
93 test("a contrib pull has no preview before approval and has one after", async () => {
94 const contrib = await pull("contrib/codex/0a1b2c3d/preview");
95 const owner = await pull("feature/preview");
96 const mayRun = (p: Pull) => previewMayRun(app.db, repo, getPull(app.db, repo.id, p.number)!, p.headOid);
97 expect(mayRun(owner)).toBe(true);
98 expect(mayRun(contrib)).toBe(false);
99 expect(approveGuestPull(app, alice, repo, contrib).ok).toBe(true);
100 expect(mayRun(contrib)).toBe(true);
101 // Approved, its check is no longer in the outside lane.
102 expect(outsideOnce(app.db)(check(contrib))).toBe(false);
103 });
104
105 test("a crew reviewer's approval of a contrib pull is the guest approval and releases its preview", async () => {
106 const contrib = await pull("contrib/codex/0a1b2c3d/reviewed");
107 const made = await createAgent(app, alice, { username: "rev", roles: ["reviewer"] });
108 if (!made.ok) throw new Error(String(made.error));
109 app.db
110 .query("INSERT INTO repo_crew (repo_id, user_id, role, added_by) VALUES (?1, ?2, 'reviewer', ?3)")
111 .run(repo.id, made.value.user.id, alice.id);
112 const mayRun = () => previewMayRun(app.db, repo, getPull(app.db, repo.id, contrib.number)!, contrib.headOid);
113 expect(mayRun()).toBe(false);
114 const sent = await submitReview(app, made.value.user, repo, contrib, { state: "approved" });
115 expect(sent.ok).toBe(true);
116 expect(guestApproval(app.db, contrib.id)?.approvedBy).toBe(made.value.user.id);
117 expect(mayRun()).toBe(true);
118 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs