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

Pillar 1 (Command): a proposed pull's grant reads as itself in the terminal hint and live on the page #2233

Merged opened this

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

Merged

scout/forge:scout/2232-pillar-1-command-a-proposed-pull-s-grant → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/2232-pillar-1-command-a-proposed-pull-s-grant was merged into main as 261d63d.

Review

Sign in to review this change.
16 files changed +466 −27
README.md +9 −2
@@ -98,8 +98,8 @@ Over HTTPS it pushes only `child/<name>/…` branches of this repository (anythi
9898 else is refused with one line) and `POST …/pulls` from one of them, with a
9999 body of at most 64 KiB and 20 requests a minute. Every other request is a
100100 403, decided in `src/http/proposal_gate.ts` before any handler runs. A proposed pull
101 runs no check and no preview until a writer approves its head (a new push
102 waits for a new approval), and a bare push to `child/…` starts none. A pull
101 runs no check and no preview until a writer approves its head or lets it run
102 (**Let it run here**, which is not a review; a new push waits again), and a bare push to `child/…` starts none. A pull
103103 opened with it says "proposed by child … from …" on every tab of the pull's
104104 page, in the terminal's pull view, and as `proposal` in the pull's JSON
105105 (`GET …/pulls/:number`, and MCP's `pull` tool).
@@ -303,6 +303,13 @@ on the pull page, **Approve to run here**; in the terminal, `A` on the pull
303303 written through to, or imported from, the origin never counts — and a new push
304304 waits for a new approval.
305305
306 A pull a child forge proposed upstream waits the same way, and the writer who
307 minted the child's token may let it run: **Let it run here** on the pull page,

The code (letRun → requireWriter) lets any writer grant, not only the minter. Suggest: "any writer, including the one who minted the child's token, may let it run".

308 `A` in the terminal, or the same `POST …/approve-here`, which answers
309 `{granted: true, commitOid}`. That grant is recorded apart from reviews: it
310 never counts toward `require_review` or the merge gate, a new push needs a new
311 grant, and a proposed pull never lands on its own, even on an autonomous repo.
312
306313 Connect one at **Settings → Origins** (or `A` then `o` in the terminal). Which
307314 way in you get is a property of the forge, not a preference:
308315
src/actions/pulls.ts +48
@@ -55,6 +55,8 @@ import {
5555 type Side,
5656 type ThreadState,
5757 } from "../core/reviews.ts";
58 import { isProposed } from "../core/pull_trust.ts";
59 import { grantRun } from "../core/run_grant.ts";
5860 import { commitIdentity } from "../core/commit_identity.ts";
5961 import { getUserById, getUserByName, type User } from "../core/users.ts";
6062 import type { MergeStrategy } from "../git/merge.ts";
@@ -752,3 +754,49 @@ export async function approveHere(
752754 });
753755 return recorded;
754756 }
757
758 /**
759 * A writer — the one who minted the proposal token included — lets a
760 * proposed pull's exact head run. Recorded apart from reviews: it never
761 * counts toward `require_review` or the merge gate, and a new head waits.
762 */
763 export async function letRun(
764 app: App,
765 actor: User | undefined,
766 repo: Repo,
767 pull: Pull,
768 ): Promise<Result<{ commitOid: string }, ApproveHereError>> {
769 const writer = requireWriter(app, actor, repo);
770 if (!writer.ok) return writer;
771 const user = writer.value;
772 if (user.kind !== "user") return err("forbidden");
773 if (pull.repoId !== repo.id) return err("not_found");
774 if (!isProposed(app.db, pull)) return err("not_foreign");
775 if (pull.state !== "open" || pull.mergedAt !== null) return err("not_open");
776 const status = await pullStatus(
777 await pullDir(app.db, app.reposRoot, repo, pull),
778 pull,
779 );
780 if (status.headOid === "") return err("not_found");
781 grantRun(app.db, pull.id, status.headOid, user.id);
782 // Not a review event: it wakes the check and the preview, and nothing that
783 // lands pulls.
784 app.bus.append({
785 kind: "pull.run_granted",
786 repoId: repo.id,
787 actorId: user.id,
788 payload: { number: pull.number, commit: status.headOid },
789 });
790 return ok({ commitOid: status.headOid });
791 }
792
793 /** The one door every surface calls: let a pull that waits for a writer run. */
794 export const approveToRun = (
795 app: App,
796 actor: User | undefined,
797 repo: Repo,
798 pull: Pull,
799 ): Promise<Result<{ commitOid: string }, ApproveHereError>> =>
800 isProposed(app.db, pull)
801 ? letRun(app, actor, repo, pull)
802 : approveHere(app, actor, repo, pull);
src/apps/runtime.ts +10 −2
@@ -844,11 +844,15 @@ export class AppsRuntime {
844844 return;
845845 }
846846 // An approval is what lets a reader's pull run: deploy its head now.
847 case "pull.run_granted":
847848 case "pull.reviewed": {
848849 // A writer asking for changes may withdraw what let the preview run:
849850 // if the code it serves may no longer run, it stops now, not at its
850851 // next sleep or restart.
851 if (event.payload["state"] === "changes_requested") {
852 if (
853 event.kind === "pull.reviewed" &&
854 event.payload["state"] === "changes_requested"
855 ) {
852856 const pull = this.pullFromEvent(repo, event);
853857 if (!pull) return;
854858 const env = previewEnv(pull.number);
@@ -861,7 +865,11 @@ export class AppsRuntime {
861865 await this.teardown(record, env);
862866 return;
863867 }
864 if (event.payload["state"] !== "approved") return;
868 if (
869 event.kind === "pull.reviewed" &&
870 event.payload["state"] !== "approved"
871 )
872 return;
865873 const pull = this.pullFromEvent(repo, event);
866874 if (!pull || pull.state !== "open") return;
867875 const head = await this.pullHead(repo, pull);
src/checks/service.ts +6 −1
@@ -987,9 +987,14 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
987987 return;
988988 }
989989 // An approval is what lets a reader's pull run: check its head now.
990 case "pull.run_granted":
990991 case "pull.reviewed": {
991992 const number = event.payload["number"];
992 if (typeof number !== "number" || event.payload["state"] !== "approved")
993 if (typeof number !== "number") return;
994 if (
995 event.kind === "pull.reviewed" &&
996 event.payload["state"] !== "approved"
997 )
993998 return;
994999 const pull = getPull(app.db, repo.id, number);
9951000 if (!pull || pull.state !== "open") return;
src/core/migrations/20261016170000-pull-run-grant.ts added +11
@@ -0,0 +1,11 @@
1 // A writer's leave for one proposed pull's exact head to run. Kept apart
2 // from reviews so it never counts toward a required review.
3 export default `
4 CREATE TABLE pull_run_grant (
5 issue_id TEXT NOT NULL REFERENCES issue(id) ON DELETE CASCADE,
6 commit_oid TEXT NOT NULL,
7 user_id TEXT NOT NULL,
8 granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
9 PRIMARY KEY (issue_id, commit_oid)
10 );
11 `;
src/core/pull_trust.ts +38 −3
@@ -21,11 +21,17 @@ import { pullProposalOf } from "./proposal_token.ts";
2121 import { getIssueById, getPull, type Pull } from "./pulls.ts";
2222 import { canWrite, getRepoById, onCrew, type Repo } from "./repos.ts";
2323 import { listReviews } from "./reviews.ts";
24 import { runGrantedAt } from "./run_grant.ts";
2425 import { getUserById, type User } from "./users.ts";
2526
2627 const trusted = (db: Database, repo: Repo, user: User | undefined): boolean =>
2728 canWrite(db, repo, user) || onCrew(db, repo, user);
2829
30 /** Whether `pull` came in through a child's proposal. */
31 export const isProposed = (db: Database, pull: Pull): boolean =>
32 pull.headBranch.startsWith("child/") ||
33 pullProposalOf(db, pull.id) !== undefined;
34
2935 /** Whether `pull`'s code at `headOid` may run here. */
3036 export function pullMayRun(
3137 db: Database,
@@ -33,9 +39,7 @@ export function pullMayRun(
3339 pull: Pull,
3440 headOid: string,
3541 ): boolean {
36 const proposed =
37 pull.headBranch.startsWith("child/") ||
38 pullProposalOf(db, pull.id) !== undefined;
42 const proposed = isProposed(db, pull);
3943 if (!proposed && !pull.crossRepo && pull.originHeadRepo === null) return true;
4044 if (!proposed && pull.crossRepo && pull.originHeadRepo === null) {
4145 const author = getUserById(db, pull.authorId);
@@ -48,9 +52,40 @@ export function pullMayRun(
4852 )
4953 return true;
5054 }
55 if (proposed && grantStands(db, repo, pull, headOid)) return true;
5156 return approvedAt(db, repo, pull, headOid);
5257 }
5358
59 /**
60 * A grant stands until a person who can write the repository asks for
61 * changes to that head after it: the latest word on the head decides, as it
62 * does for an approval. A grant again, later, stands again.
63 */
64 function grantStands(
65 db: Database,
66 repo: Repo,
67 pull: Pull,
68 headOid: string,
69 ): boolean {
70 const at = runGrantedAt(db, pull.id, headOid);
71 if (at === undefined) return false;
72 return !listReviews(db, pull.id).some((review) => {
73 if (
74 review.commitOid !== headOid ||
75 review.state !== "changes_requested" ||
76 review.originAuthor !== null ||
77 review.createdAt < at
78 )
79 return false;
80 const reviewer = getUserById(db, review.authorId);
81 return (
82 reviewer !== undefined &&
83 reviewer.kind === "user" &&
84 canWrite(db, repo, reviewer)
85 );
86 });
87 }
88
5489 /**
5590 * A person — never an agent — who can write the repository approved this
5691 * head, and no such person asked for changes to it since: the latest of
src/core/run_grant.ts added +34
@@ -0,0 +1,34 @@
1 /**
2 * Leave to run: a writer lets one proposed pull's exact head execute here.
3 * It is not a review — it never counts toward `require_review` or the merge
4 * gate — and it names an oid, so a new push waits for a new grant.
5 */
6 import type { Database } from "bun:sqlite";
7
8 export function grantRun(
9 db: Database,
10 issueId: string,
11 commitOid: string,
12 userId: string,
13 ): void {
14 db.query(
15 `INSERT INTO pull_run_grant (issue_id, commit_oid, user_id) VALUES (?1, ?2, ?3)
16 ON CONFLICT (issue_id, commit_oid) DO UPDATE SET
17 user_id = excluded.user_id,
18 granted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
19 ).run(issueId, commitOid, userId);
20 }
21
22 /** When the head was last let run, or undefined if it never was. */
23 export const runGrantedAt = (
24 db: Database,
25 issueId: string,
26 commitOid: string,
27 ): string | undefined =>
28 commitOid === ""
29 ? undefined
30 : (db
31 .query<{ granted_at: string }, [string, string]>(
32 "SELECT granted_at FROM pull_run_grant WHERE issue_id = ?1 AND commit_oid = ?2",
33 )
34 .get(issueId, commitOid)?.granted_at ?? undefined);
src/crew/direct.ts +4
@@ -18,6 +18,7 @@ import type { Database } from "bun:sqlite";
1818 import { crewRole } from "../core/agents.ts";
1919 import { canRead, canWrite, type Repo } from "../core/repos.ts";
2020 import type { Issue } from "../core/issues.ts";
21 import { isProposed } from "../core/pull_trust.ts";
2122 import { openPullsClosing, type Pull } from "../core/pulls.ts";
2223 import { getUserById, getUserByName, type User } from "../core/users.ts";
2324
@@ -224,6 +225,9 @@ export function unadoptedAmong(
224225 */
225226 export function landsOnItsOwn(db: Database, repo: Repo, pull: Pull): boolean {
226227 if (pull.originAuthor !== null) return false;
228 // A child's code, whoever holds its token: a grant lets it run, a person's
229 // review lands it.
230 if (isProposed(db, pull)) return false;
227231 const author = getUserById(db, pull.authorId);
228232 if (author === undefined) return false;
229233 if (author.kind === "agent")
src/design/activity.ts +1
@@ -104,6 +104,7 @@ const WORDS: Record<string, string> = {
104104 "pull.reopened": "reopened a pull request",
105105 "pull.commented": "commented on a pull request",
106106 "pull.reviewed": "reviewed a pull request",
107 "pull.run_granted": "let a pull request's head run",
107108 "pull.labeled": "labelled a pull request",
108109 "pull.held": "held a pull request",
109110 "pull.released": "released a pull request",
src/http/api.ts +6 −3
@@ -117,7 +117,7 @@ import {
117117 refreshPullHead,
118118 resolveReviewThread,
119119 submitReview,
120 approveHere,
120 approveToRun,
121121 MAX_REVIEW_LINES,
122122 type ReviewInput,
123123 } from "../actions/pulls.ts";
@@ -3601,7 +3601,7 @@ export function apiRoutes(router: Router<Handler>): void {
36013601 if (denied) return denied;
36023602 const found = loadPull(ctx);
36033603 if ("error" in found) return found.error;
3604 const done = await approveHere(
3604 const done = await approveToRun(
36053605 ctx.app,
36063606 ctx.auth.user,
36073607 found.repo,
@@ -3624,7 +3624,10 @@ export function apiRoutes(router: Router<Handler>): void {
36243624 return apiError("not_open", 422, "this pull is closed");
36253625 return respondAccess(done.error, 422);
36263626 }
3627 return json(done.value, 201);
3627 return json(
3628 "id" in done.value ? done.value : { granted: true, ...done.value },
3629 201,
3630 );
36283631 },
36293632 );
36303633
src/tui/screens/pull.ts +18 −6
@@ -32,7 +32,7 @@ import {
3232 } from "../../actions/issues.ts";
3333 import {
3434 addReview,
35 approveHere,
35 approveToRun as letItRun,
3636 mergePull,
3737 pullThreads,
3838 resolveReviewThread,
@@ -48,7 +48,7 @@ import {
4848 import { proposedByText } from "../../core/proposal_token.ts";
4949 import { getAppByRepo } from "../../core/apps.ts";
5050 import { getIssue, type Issue } from "../../core/issues.ts";
51 import { pullMayRun } from "../../core/pull_trust.ts";
51 import { isProposed, pullMayRun } from "../../core/pull_trust.ts";
5252 import { getPull, pullDir, variantSiblings, type Pull } from "../../core/pulls.ts";
5353 import { voteVariant } from "../../actions/variants.ts";
5454 import { getRepoById, type Repo } from "../../core/repos.ts";
@@ -615,7 +615,7 @@ export function pullScreen(
615615 return (
616616 f.user.kind === "user" &&
617617 row !== undefined &&
618 row.originHeadRepo !== null &&
618 (row.originHeadRepo !== null || isProposed(f.db, row)) &&
619619 row.state === "open" &&
620620 row.mergedAt === null &&
621621 (facts?.headOid ?? "") !== "" &&
@@ -630,13 +630,18 @@ export function pullScreen(
630630 const row = getPull(f.db, target.id, number);
631631 if (row === undefined) return;
632632 runAsync(ctx, async () => {
633 const done = await approveHere(f.app, f.user, target, row);
633 const done = await letItRun(f.app, f.user, target, row);
634634 if (!done.ok) {
635635 ctx.toast(errorText(String(done.error)), "danger");
636636 return;
637637 }
638638 await reload();
639 ctx.toast("approved to run here: its check and preview start", "success");
639 ctx.toast(
640 isProposed(f.db, row)
641 ? "let run here (not a review): its check and preview start"
642 : "approved to run here: its check and preview start",
643 "success",
644 );
640645 });
641646 };
642647
@@ -710,7 +715,14 @@ export function pullScreen(
710715 binding(["R"], "review"),
711716 ...(mayVote() ? [binding(["+", "-"], "like/dislike a variant")] : []),
712717 ...(mayApproveHere()
713 ? [binding(["A"], "approve to run here")]
718 ? [
719 binding(
720 ["A"],
721 isProposed(f.db, getPull(f.db, repo!.id, number)!)

Minor: this is a second getPull per render, and it uses repo! and !. mayApproveHere() has already loaded the row, so it could return the proposed flag too (or a small approveLabel() could), and the assertions would go away.

722 ? "let it run here"
723 : "approve to run here",
724 ),
725 ]
714726 : []),
715727 binding(["a"], "assign"),
716728 ...(state() === "conflicts"
src/web/live.ts +1 −1
@@ -297,7 +297,7 @@ export const liveClientJs = /* js */ `
297297 'push', 'repo.created', 'repo.forked',
298298 'issue.opened', 'issue.closed', 'issue.reopened', 'issue.commented', 'issue.labeled', 'issue.edited',
299299 'pull.opened', 'pull.closed', 'pull.reopened', 'pull.merged', 'pull.commented', 'pull.reviewed',
300 'pull.held', 'pull.released', 'pull.edited',
300 'pull.held', 'pull.released', 'pull.edited', 'pull.run_granted',
301301 'branch.created', 'branch.deleted',
302302 'label.created', 'label.updated', 'label.deleted',
303303 'run.queued', 'run.started', 'run.finished', 'app.serving',
src/web/pages/pull.ts +12 −9
@@ -17,7 +17,7 @@ import {
1717 pullThreads,
1818 resolveReviewThread,
1919 submitReview,
20 approveHere,
20 approveToRun,
2121 } from "../../actions/pulls.ts";
2222 import { readFileAt } from "../../apps/checkout.ts";
2323 import { parseCheck } from "../../apps/manifest.ts";
@@ -33,7 +33,7 @@ import {
3333 type PullReviewStatus,
3434 type PullStatus,
3535 } from "../../core/pulls.ts";
36 import { pullMayRun } from "../../core/pull_trust.ts";
36 import { isProposed, pullMayRun } from "../../core/pull_trust.ts";
3737 import { checkSummary, type CheckSummary } from "../../core/runs.ts";
3838 import {
3939 anchorKey,
@@ -1003,21 +1003,22 @@ export function approveHereCard(
10031003 if (
10041004 user === undefined ||
10051005 user.kind !== "user" ||
1006 pull.originHeadRepo === null ||
1006 (pull.originHeadRepo === null && !isProposed(ctx.app.db, pull)) ||
10071007 pull.state !== "open" ||
10081008 pull.mergedAt !== null ||
10091009 !canWrite(ctx.app.db, repo, user) ||
10101010 pullMayRun(ctx.app.db, repo, pull, headOid)
10111011 )
10121012 return html``;
1013 const proposed = isProposed(ctx.app.db, pull);
10131014 return card(
10141015 html`<form method="post" action="${action}">
10151016 <p>
1016 This head is from a fork on the origin, so its check and preview wait
1017 for you. Approving lets them run this exact code here, holding the
1018 repository owner's app token. A new push waits again.
1017 ${proposed
1018 ? "A child forge proposed this head, so its check and preview wait for you. Letting it run is not a review and does not count toward one; it runs this exact code here, holding the repository owner's app token. A new push waits again."
1019 : "This head is from a fork on the origin, so its check and preview wait for you. Approving lets them run this exact code here, holding the repository owner's app token. A new push waits again."}
10191020 </p>
1020 ${btn("Approve to run here", { size: "sm", icon: "check" })}
1021 ${btn(proposed ? "Let it run here" : "Approve to run here", { size: "sm", icon: "check" })}
10211022 </form>`,
10221023 {
10231024 extra: "mb",
@@ -1035,7 +1036,7 @@ export async function pullApproveHereSubmit(ctx: Ctx): Promise<Response> {
10351036 const form = await readForm(ctx, "rw");
10361037 if (form instanceof Response) return form;
10371038 const dest = `${repoBase(found.repo)}/pulls/${found.pull.number}`;
1038 const done = await approveHere(
1039 const done = await approveToRun(
10391040 ctx.app,
10401041 ctx.auth.user,
10411042 found.repo,
@@ -1056,7 +1057,9 @@ export async function pullApproveHereSubmit(ctx: Ctx): Promise<Response> {
10561057 : "That pull request is no longer here.",
10571058 });
10581059 return flashTo(ctx, dest, {
1059 message: "Approved to run here: its check and preview are starting.",
1060 message: isProposed(ctx.app.db, found.pull)
1061 ? "Let run here: its check and preview are starting. This is not a review."
1062 : "Approved to run here: its check and preview are starting.",
10601063 });
10611064 }
10621065
src/web/pages/thread.ts +5
@@ -149,6 +149,11 @@ const EVENT_LOOK: Record<string, { icon: IconName; tone: Tone; verb: string }> =
149149 verb: "reviewed a line",
150150 },
151151 "issue.edited": { icon: "pencil", tone: "default", verb: "edited this" },
152 "pull.run_granted": {
153 icon: "check",
154 tone: "default",
155 verb: "let this head run",
156 },
152157 "pull.edited": { icon: "pencil", tone: "default", verb: "edited this" },
153158 };
154159
test/live.test.ts +24
@@ -11,6 +11,7 @@ import { openIssue } from "../src/actions/issues.ts";
1111 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1212 import { createSession } from "../src/core/sessions.ts";
1313 import { createUser, type User } from "../src/core/users.ts";
14 import { liveClientJs } from "../src/web/live.ts";
1415 import { serve, type Servers } from "../src/http/server.ts";
1516
1617 async function readEvents(
@@ -132,4 +133,27 @@ describe("the viewer's stream", () => {
132133 expect(page).toContain('id="live-apps"');
133134 expect(page).toContain("new EventSource('/events')");
134135 });
136
137 test("a grant to run a pull reaches a page watching that pull", async () => {
138 const made = await createRepo(app, alice, {
139 name: "grants",
140 visibility: "public",
141 });
142 if (!made.ok) throw new Error(String(made.error));
143 const mine = await fetch(`${servers.url}/events`, {
144 headers: { cookie },
145 });
146 app.bus.append({
147 kind: "pull.run_granted",
148 repoId: made.value.id,
149 actorId: alice.id,
150 payload: { number: 7, commit: "abc" },
151 });
152 const [got] = await readEvents(mine, 1, 2_000);
153 expect(got?.data.kind).toBe("pull.run_granted");
154 expect((got?.data.payload as { number: number }).number).toBe(7);
155 // The page subscribes to the kind by name, or the stream's event never
156 // reaches its handler.
157 expect(liveClientJs).toContain("'pull.run_granted'");
158 });
135159 });
test/proposal-run.test.ts added +239
@@ -0,0 +1,239 @@
1 /**
2 * A writer lets a proposed pull's exact head run — the minter included —
3 * and that leave is never a review.
4 */
5
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { approveToRun, mergePull, openPull, submitReview } 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 { startChecks, type ChecksHandle } from "../src/checks/service.ts";
14 import { recordPullProposal } from "../src/core/proposal_token.ts";
15 import { pullMayRun } from "../src/core/pull_trust.ts";
16 import { getPull } from "../src/core/pulls.ts";
17 import type { Repo } from "../src/core/repos.ts";
18 import { submitReview as record } from "../src/core/reviews.ts";
19 import { listRuns } from "../src/core/runs.ts";
20 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
21 import { landIfGreen } from "../src/crew/autonomy.ts";
22 import { landsOnItsOwn } from "../src/crew/direct.ts";
23 import { createForgeApp } from "../src/tui/forge.ts";
24 import { pullScreen } from "../src/tui/screens/pull.ts";
25 import { local } from "../src/tui/screens/where.ts";
26 import { headlessTerminal } from "../src/tui/term/terminal.ts";
27 import { settleApp } from "./helpers/tui.ts";
28 import { createUser, type User } from "../src/core/users.ts";
29 import { quietLogs } from "./helpers/globals.ts";
30
31 quietLogs();
32
33 describe("letting a proposed pull run", () => {
34 let dataDir: string;
35 let app: App;
36 let checks: ChecksHandle;
37 let alice: User;
38 let carol: User;
39 let lib: Repo;
40
41 const push = async (branch: string, file: string) => {
42 const done = await commitToBranch(app, alice, lib, {
43 branch,
44 message: file,
45 files: [{ path: file, content: `${file}\n` }],
46 });
47 if (!done.ok) throw new Error(JSON.stringify(done.error));
48 return done.value.commitOid;
49 };
50
51 beforeAll(async () => {
52 dataDir = await mkdtemp(join(tmpdir(), "forge-proposal-run-"));
53 app = createApp({ dataDir, port: 0, host: "forge.test" });
54 checks = startChecks(app, { forgeUrl: "http://127.0.0.1:9", concurrency: 1 });
55 const people: User[] = [];
56 for (const [username, first] of [["alice", true], ["carol", false]] as const) {
57 const made = await createUser(app.db, {
58 trusted: true,
59 username,
60 email: `${username}@forge.local`,
61 password: "a-good-passphrase",
62 adminIfFirst: first,
63 });
64 if (!made.ok) throw new Error(made.error);
65 people.push(made.value);
66 }
67 [alice, carol] = people as [User, User];
68 const made = await createRepo(app, alice, { name: "lib", visibility: "public" });
69 if (!made.ok) throw new Error(String(made.error));
70 lib = made.value;
71 const declared = await commitToBranch(app, alice, lib, {
72 message: "Declare a check",
73 files: [{ path: "forge.json", content: `{"check": {"run": "true", "install": ""}}\n` }],
74 });
75 if (!declared.ok) throw new Error(JSON.stringify(declared.error));
76 app.db.query("UPDATE repo SET require_review = 1 WHERE id = ?1").run(lib.id);
77 lib = { ...lib, requireReview: true };
78 }, 120_000);
79
80 afterAll(async () => {
81 await checks.stop();
82 app.close();
83 await rm(dataDir, { recursive: true, force: true });
84 });
85
86 test("the minter's grant runs the exact head, is no review, and a new head waits", async () => {
87 const head = await push("child/kid/one", "one.md");
88 const opened = await openPull(app, alice, lib, {
89 title: "Proposed",
90 baseBranch: "main",
91 headBranch: "child/kid/one",
92 });
93 if (!opened.ok) throw new Error(String(opened.error));
94 recordPullProposal(app.db, opened.value.id, { child: "kid", origin: "https://kid.example.com/" });
95 const pull = () => getPull(app.db, lib.id, opened.value.number)!;
96 expect(pullMayRun(app.db, lib, pull(), head)).toBe(false);
97
98 const granted = await approveToRun(app, alice, lib, pull());
99 if (!granted.ok) throw new Error(granted.error);
100 expect(granted.value.commitOid).toBe(head);
101 expect(pullMayRun(app.db, lib, pull(), head)).toBe(true);
102
103 await Bun.sleep(500);
104 const ran = listRuns(app.db, lib.id, { kind: "check", limit: 100 }).filter(
105 (r) => r.env === `pull-${pull().number}` && r.commitOid === head,
106 );
107 expect(ran.length).toBeGreaterThan(0);
108
109 // Not a review: the gate still wants someone else's approval.
110 const refused = await mergePull(app, alice, lib, pull(), "merge");
111 expect(refused.ok).toBe(false);
112 if (!refused.ok) expect(refused.error).toBe("review_required");
113 expect(
114 app.db.query("SELECT COUNT(*) AS n FROM review WHERE issue_id = ?1").get(pull().id),
115 ).toEqual({ n: 0 });
116
117 // A new head is a new question.
118 const next = await push("child/kid/one", "two.md");
119 expect(pullMayRun(app.db, lib, pull(), next)).toBe(false);
120 expect(pullMayRun(app.db, lib, pull(), head)).toBe(true);
121 await approveToRun(app, alice, lib, pull());
122 expect(pullMayRun(app.db, lib, pull(), next)).toBe(true);
123 });
124
125 test("a reader and a guest are refused", async () => {
126 const head = await push("child/kid/two", "x.md");
127 const opened = await openPull(app, alice, lib, {
128 title: "Proposed again",
129 baseBranch: "main",
130 headBranch: "child/kid/two",
131 });
132 if (!opened.ok) throw new Error(String(opened.error));
133 const pull = getPull(app.db, lib.id, opened.value.number)!;
134 const byReader = await approveToRun(app, carol, lib, pull);
135 expect(byReader.ok).toBe(false);
136 const byGuest = await approveToRun(app, undefined, lib, pull);
137 expect(byGuest.ok).toBe(false);
138 expect(pullMayRun(app.db, lib, pull, head)).toBe(false);
139 });
140
141 test("a review from another writer still lets a proposal run", async () => {
142 const head = await push("child/kid/three", "y.md");
143 const opened = await openPull(app, alice, lib, {
144 title: "Third",
145 baseBranch: "main",
146 headBranch: "child/kid/three",
147 });
148 if (!opened.ok) throw new Error(String(opened.error));
149 const pull = getPull(app.db, lib.id, opened.value.number)!;
150 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(carol.id);
151 record(app.db, { issueId: pull.id, authorId: carol.id, state: "approved", commitOid: head });
152 expect(pullMayRun(app.db, lib, pull, head)).toBe(true);
153 });
154
155 test("on an autonomous repo with no required review, a grant and a green check do not land it", async () => {
156 app.db
157 .query("UPDATE repo SET require_review = 0, autonomy = 'autonomous', autonomy_by = ?2 WHERE id = ?1")
158 .run(lib.id, alice.id);
159 const open = { ...lib, requireReview: false, autonomy: "autonomous" as const, autonomyBy: alice.id };
160 // Control: an ordinary writer's branch pull would land on its own.
161 await push("feature/plain", "plain.md");
162 const plain = await openPull(app, alice, open, { title: "Plain", baseBranch: "main", headBranch: "feature/plain" });
163 if (!plain.ok) throw new Error(String(plain.error));
164 expect(landsOnItsOwn(app.db, open, getPull(app.db, lib.id, plain.value.number)!)).toBe(true);
165
166 await push("child/kid/auto", "auto.md");
167 const opened = await openPull(app, alice, open, { title: "Auto", baseBranch: "main", headBranch: "child/kid/auto" });
168 if (!opened.ok) throw new Error(String(opened.error));
169 recordPullProposal(app.db, opened.value.id, { child: "kid", origin: "https://kid.example.com/" });
170 const granted = await approveToRun(app, alice, open, getPull(app.db, lib.id, opened.value.number)!);
171 if (!granted.ok) throw new Error(granted.error);
172 const pull = getPull(app.db, lib.id, opened.value.number)!;
173 const check = createRun(app.db, {
174 repoId: lib.id, kind: "check", issueId: pull.id, env: `pull-${pull.number}`,
175 commitOid: pull.headOid, reason: "pull",
176 });
177 claimRun(app.db, check.id);
178 finishRun(app.db, check.id, { state: "ok", summary: "check passed" });
179 expect(landsOnItsOwn(app.db, open, pull)).toBe(false);
180 expect(await landIfGreen(app, open, pull)).toBe(false);
181 expect(getPull(app.db, lib.id, pull.number)!.mergedAt).toBeNull();
182 });
183
184 test("the terminal's A lets a proposed head run, and it is no review", async () => {
185 const head = await push("child/kid/tty", "tty.md");
186 const opened = await openPull(app, alice, lib, { title: "Tty", baseBranch: "main", headBranch: "child/kid/tty" });
187 if (!opened.ok) throw new Error(String(opened.error));
188 recordPullProposal(app.db, opened.value.id, { child: "kid", origin: "https://kid.example.com/" });
189 const pull = getPull(app.db, lib.id, opened.value.number)!;
190 const terminal = headlessTerminal(140, 44);
191 const { app: tui, session } = createForgeApp(
192 app,
193 { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:alice", user: alice },
194 { terminal, exitHooks: false },
195 );
196 tui.start();
197 try {
198 tui.ctx.push(pullScreen(session, tui.ctx, local(session, lib), pull.number));
199 const end = Date.now() + 20_000;
200 while (!tui.screen.toText().includes("let it run here")) {
201 if (Date.now() > end) throw new Error(tui.screen.toText());
202 await settleApp(tui);
203 await Bun.sleep(20);
204 }
205 expect(pullMayRun(app.db, lib, pull, head)).toBe(false);
206 tui.dispatch({ type: "key", key: "A", ctrl: false, alt: false, shift: true });
207 while (!pullMayRun(app.db, lib, pull, head) && Date.now() < end) await Bun.sleep(20);
208 expect(pullMayRun(app.db, lib, pull, head)).toBe(true);
209 expect(
210 app.db.query("SELECT COUNT(*) AS n FROM review WHERE issue_id = ?1").get(pull.id),
211 ).toEqual({ n: 0 });
212 } finally {
213 tui.stop();
214 }
215 }, 60_000);
216
217 test("a writer's later request for changes on the head withdraws the grant", async () => {
218 app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(carol.id);
219 const head = await push("child/kid/stop", "stop.md");
220 const opened = await openPull(app, alice, lib, { title: "Stop", baseBranch: "main", headBranch: "child/kid/stop" });
221 if (!opened.ok) throw new Error(String(opened.error));
222 recordPullProposal(app.db, opened.value.id, { child: "kid", origin: "https://kid.example.com/" });
223 const pull = () => getPull(app.db, lib.id, opened.value.number)!;
224 const granted = await approveToRun(app, alice, lib, pull());
225 if (!granted.ok) throw new Error(granted.error);
226 expect(pullMayRun(app.db, lib, pull(), head)).toBe(true);
227
228 await Bun.sleep(10);
229 const asked = await submitReview(app, { ...carol, isAdmin: true }, lib, pull(), { state: "changes_requested" });
230 if (!asked.ok) throw new Error(String(asked.error));
231 // What the preview's teardown asks (`mayRun`) is this same question.
232 expect(pullMayRun(app.db, lib, pull(), head)).toBe(false);
233
234 // A fresh grant, after the request, lets it run again.
235 await Bun.sleep(10);
236 await approveToRun(app, alice, lib, pull());
237 expect(pullMayRun(app.db, lib, pull(), head)).toBe(true);
238 });
239 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs