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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/12102-a-contrib-pull-s-check-runs-in-the-outsi → main 6 commits fork 2 threads · 2 unresolved
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 | ||
| 18 | 18 | - **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). |
| 19 | 19 | - **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. |
| 20 | 20 | |
| 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. | |
| 22 | 22 | |
| 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"; | ||
| 21 | 21 | |
| 22 | 22 | const HEADS = "refs/heads/"; |
| 23 | 23 | |
| 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 | */ | |
| 25 | 28 | 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"; | |
| 27 | 30 | |
| 28 | 31 | const errorText = (error: unknown): string => (typeof error === "string" ? error : error instanceof Error ? error.message : JSON.stringify(error)); |
| 29 | 32 |
src/actions/pulls.ts +5 −1
| @@ -67,7 +67,7 @@ import { | ||
| 67 | 67 | type ThreadState, |
| 68 | 68 | } from "../core/reviews.ts"; |
| 69 | 69 | import { isProposed } from "../core/pull_trust.ts"; |
| 70 | import { approveGuest } from "../origins/guest.ts"; | |
| 70 | import { approveGuest, fromOutsideAgent } from "../origins/guest.ts"; | |
| 71 | 71 | import { previewEnv } from "../apps/data.ts"; |
| 72 | 72 | import { getAppByRepo } from "../core/apps.ts"; |
| 73 | 73 | import { looksForEnv } from "../core/looks.ts"; |
| @@ -879,6 +879,10 @@ export async function submitReview( | ||
| 879 | 879 | }, |
| 880 | 880 | }); |
| 881 | 881 | 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); | |
| 882 | 886 | // The boxes the reviewer read at this head: only a crew reviewer's approval |
| 883 | 887 | // counts. A pull yet to land holds them for its landing, which applies them |
| 884 | 888 | // 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"; | ||
| 41 | 41 | import { checkInstallCache } from "../checks/install.ts"; |
| 42 | 42 | import { getPull, listPulls, type Pull } from "../core/pulls.ts"; |
| 43 | 43 | 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"; | |
| 46 | 46 | import { canEnterApp, canWrite, getRepoById, type Repo } from "../core/repos.ts"; |
| 47 | 47 | import { runningLimitSentence } from "../settings/quotas.ts"; |
| 48 | 48 | import { placeFor } from "../core/placement_policy.ts"; |
| @@ -1965,12 +1965,9 @@ export class AppsRuntime { | ||
| 1965 | 1965 | pull === undefined |
| 1966 | 1966 | ? commitOid |
| 1967 | 1967 | : 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; | |
| 1974 | 1971 | log.info("preview waits for a writer's approval", { |
| 1975 | 1972 | app: record.slug, |
| 1976 | 1973 | 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"; | |
| 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"; | ||
| 59 | 59 | import { mergeForCheck } from "./merged.ts"; |
| 60 | 60 | import { readCombined } from "../git/combined.ts"; |
| 61 | 61 | import { approvedOnce } from "./approved_first.ts"; |
| 62 | import { outsideOnce, ownersFirst } from "./outside_lane.ts"; | |
| 62 | 63 | import { checkOnApproval } from "./approval.ts"; |
| 63 | 64 | import { checkKey, provenBy, recordProven } from "./proven.ts"; |
| 64 | 65 | import { beginGate, endGate, gateHolding } from "./gate_record.ts"; |
| @@ -322,7 +323,10 @@ export function pickNext( | ||
| 322 | 323 | now: number = Date.now(), |
| 323 | 324 | approved: (run: Run) => boolean = () => false, |
| 324 | 325 | rank: (run: Run) => number | undefined = () => undefined, |
| 326 | outside: (run: Run) => boolean = () => false, | |
| 325 | 327 | ): Run | undefined { |
| 328 | // An unapproved outside contributor's check goes after everyone else's (`outside_lane.ts`). | |
| 329 | queued = ownersFirst(queued, outside); | |
| 326 | 330 | if (queued.length === 0) return undefined; |
| 327 | 331 | const newer = (a: Run, b: Run): boolean => |
| 328 | 332 | a.createdAt > b.createdAt || (a.createdAt === b.createdAt && a.id > b.id); |
| @@ -674,11 +678,12 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 674 | 678 | let rest = queued; |
| 675 | 679 | const isApproved = approvedOnce(app.db); |
| 676 | 680 | 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); | |
| 678 | 683 | while (next !== undefined && atLimit(next)) { |
| 679 | 684 | const held = next; |
| 680 | 685 | rest = rest.filter((run) => run !== held); |
| 681 | next = pickNext(rest, Date.now(), isApproved, rankOf); | |
| 686 | next = pickNext(rest, Date.now(), isApproved, rankOf, isOutside); | |
| 682 | 687 | } |
| 683 | 688 | return next; |
| 684 | 689 | } |
src/core/pull_trust.ts +4 −7
| @@ -17,13 +17,10 @@ | ||
| 17 | 17 | * branch are ever checked or previewed. |
| 18 | 18 | * |
| 19 | 19 | * 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`). | |
| 27 | 24 | */ |
| 28 | 25 | import type { Database } from "bun:sqlite"; |
| 29 | 26 | import { pullProposalOf } from "./proposal_token.ts"; |
src/origins/guest.ts +12 −1
| @@ -16,6 +16,7 @@ | ||
| 16 | 16 | |
| 17 | 17 | import type { Database } from "bun:sqlite"; |
| 18 | 18 | import { isContributionFork } from "../core/contribute.ts"; |
| 19 | import { crewRole } from "../core/agents.ts"; | |
| 19 | 20 | import { isContribHead } from "../core/contribute_token.ts"; |
| 20 | 21 | import { getIssueById } from "../core/issues.ts"; |
| 21 | 22 | import type { Pull } from "../core/pulls.ts"; |
| @@ -79,6 +80,16 @@ export const mayApproveGuest = ( | ||
| 79 | 80 | user: User | undefined, |
| 80 | 81 | ): boolean => user !== undefined && user.kind === "user" && canWrite(db, repo, user); |
| 81 | 82 | |
| 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 | ||
| 82 | 93 | /** Approve the guest `pull`, once. A second approval keeps the first. */ |
| 83 | 94 | export function approveGuest( |
| 84 | 95 | db: Database, |
| @@ -86,7 +97,7 @@ export function approveGuest( | ||
| 86 | 97 | pull: Pull, |
| 87 | 98 | user: User | undefined, |
| 88 | 99 | ): 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"); | |
| 90 | 101 | if (!isGuestPull(db, pull)) return err("not_guest"); |
| 91 | 102 | const changed = db |
| 92 | 103 | .query( |
src/web/pages/pull-guest.ts +1 −1
| @@ -29,7 +29,7 @@ export const GUEST_WAITS = | ||
| 29 | 29 | "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."; |
| 30 | 30 | 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."; |
| 31 | 31 | /** 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."; | |
| 33 | 33 | |
| 34 | 34 | /** |
| 35 | 35 | * 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", () => { | ||
| 222 | 222 | const page = await strangerCard({ app, auth: { user: alice, scope: "admin", via: "session" } } as unknown as Ctx, repo, getPull(app.db, repo.id, number)!, "/approve"); |
| 223 | 223 | expect(String(page)).toContain(OUTSIDE_WAITS.replace("'", "'")); |
| 224 | 224 | 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."); | |
| 226 | 226 | expect(String(page)).toContain("Approve"); |
| 227 | 227 | expect(String(page)).not.toContain("Approve this guest"); |
| 228 | 228 |
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 | ||
| 437 | 437 | // The agent is told, where its push answers: what runs now, what waits, on whom and where. |
| 438 | 438 | const told = await contribPushPulls(app, lib, guest, [{ ref: `refs/heads/${pull.headBranch}`, oldOid: pull.headOid, newOid: pull.headOid }], "https://forge.test"); |
| 439 | 439 | 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"); | |
| 441 | 441 | expect(told.some((l) => l.startsWith(`updated pull #${pull.number}: https://forge.test/alice/lib/pulls/${pull.number}`))).toBe(true); |
| 442 | 442 | }); |
| 443 | 443 |
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 | }); |
new above
offline
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.