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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2232-pillar-1-command-a-proposed-pull-s-grant → main 5 commits fork 2 threads · 2 unresolved
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 | ||
| 98 | 98 | else is refused with one line) and `POST …/pulls` from one of them, with a |
| 99 | 99 | body of at most 64 KiB and 20 requests a minute. Every other request is a |
| 100 | 100 | 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 | |
| 103 | 103 | opened with it says "proposed by child … from …" on every tab of the pull's |
| 104 | 104 | page, in the terminal's pull view, and as `proposal` in the pull's JSON |
| 105 | 105 | (`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 | ||
| 303 | 303 | written through to, or imported from, the origin never counts — and a new push |
| 304 | 304 | waits for a new approval. |
| 305 | 305 | |
| 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, | |
| 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 | ||
| 306 | 313 | Connect one at **Settings → Origins** (or `A` then `o` in the terminal). Which |
| 307 | 314 | way in you get is a property of the forge, not a preference: |
| 308 | 315 |
src/actions/pulls.ts +48
| @@ -55,6 +55,8 @@ import { | ||
| 55 | 55 | type Side, |
| 56 | 56 | type ThreadState, |
| 57 | 57 | } from "../core/reviews.ts"; |
| 58 | import { isProposed } from "../core/pull_trust.ts"; | |
| 59 | import { grantRun } from "../core/run_grant.ts"; | |
| 58 | 60 | import { commitIdentity } from "../core/commit_identity.ts"; |
| 59 | 61 | import { getUserById, getUserByName, type User } from "../core/users.ts"; |
| 60 | 62 | import type { MergeStrategy } from "../git/merge.ts"; |
| @@ -752,3 +754,49 @@ export async function approveHere( | ||
| 752 | 754 | }); |
| 753 | 755 | return recorded; |
| 754 | 756 | } |
| 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 { | ||
| 844 | 844 | return; |
| 845 | 845 | } |
| 846 | 846 | // An approval is what lets a reader's pull run: deploy its head now. |
| 847 | case "pull.run_granted": | |
| 847 | 848 | case "pull.reviewed": { |
| 848 | 849 | // A writer asking for changes may withdraw what let the preview run: |
| 849 | 850 | // if the code it serves may no longer run, it stops now, not at its |
| 850 | 851 | // 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 | ) { | |
| 852 | 856 | const pull = this.pullFromEvent(repo, event); |
| 853 | 857 | if (!pull) return; |
| 854 | 858 | const env = previewEnv(pull.number); |
| @@ -861,7 +865,11 @@ export class AppsRuntime { | ||
| 861 | 865 | await this.teardown(record, env); |
| 862 | 866 | return; |
| 863 | 867 | } |
| 864 | if (event.payload["state"] !== "approved") return; | |
| 868 | if ( | |
| 869 | event.kind === "pull.reviewed" && | |
| 870 | event.payload["state"] !== "approved" | |
| 871 | ) | |
| 872 | return; | |
| 865 | 873 | const pull = this.pullFromEvent(repo, event); |
| 866 | 874 | if (!pull || pull.state !== "open") return; |
| 867 | 875 | 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 { | ||
| 987 | 987 | return; |
| 988 | 988 | } |
| 989 | 989 | // An approval is what lets a reader's pull run: check its head now. |
| 990 | case "pull.run_granted": | |
| 990 | 991 | case "pull.reviewed": { |
| 991 | 992 | 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 | ) | |
| 993 | 998 | return; |
| 994 | 999 | const pull = getPull(app.db, repo.id, number); |
| 995 | 1000 | 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"; | ||
| 21 | 21 | import { getIssueById, getPull, type Pull } from "./pulls.ts"; |
| 22 | 22 | import { canWrite, getRepoById, onCrew, type Repo } from "./repos.ts"; |
| 23 | 23 | import { listReviews } from "./reviews.ts"; |
| 24 | import { runGrantedAt } from "./run_grant.ts"; | |
| 24 | 25 | import { getUserById, type User } from "./users.ts"; |
| 25 | 26 | |
| 26 | 27 | const trusted = (db: Database, repo: Repo, user: User | undefined): boolean => |
| 27 | 28 | canWrite(db, repo, user) || onCrew(db, repo, user); |
| 28 | 29 | |
| 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 | ||
| 29 | 35 | /** Whether `pull`'s code at `headOid` may run here. */ |
| 30 | 36 | export function pullMayRun( |
| 31 | 37 | db: Database, |
| @@ -33,9 +39,7 @@ export function pullMayRun( | ||
| 33 | 39 | pull: Pull, |
| 34 | 40 | headOid: string, |
| 35 | 41 | ): boolean { |
| 36 | const proposed = | |
| 37 | pull.headBranch.startsWith("child/") || | |
| 38 | pullProposalOf(db, pull.id) !== undefined; | |
| 42 | const proposed = isProposed(db, pull); | |
| 39 | 43 | if (!proposed && !pull.crossRepo && pull.originHeadRepo === null) return true; |
| 40 | 44 | if (!proposed && pull.crossRepo && pull.originHeadRepo === null) { |
| 41 | 45 | const author = getUserById(db, pull.authorId); |
| @@ -48,9 +52,40 @@ export function pullMayRun( | ||
| 48 | 52 | ) |
| 49 | 53 | return true; |
| 50 | 54 | } |
| 55 | if (proposed && grantStands(db, repo, pull, headOid)) return true; | |
| 51 | 56 | return approvedAt(db, repo, pull, headOid); |
| 52 | 57 | } |
| 53 | 58 | |
| 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 | ||
| 54 | 89 | /** |
| 55 | 90 | * A person — never an agent — who can write the repository approved this |
| 56 | 91 | * 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"; | ||
| 18 | 18 | import { crewRole } from "../core/agents.ts"; |
| 19 | 19 | import { canRead, canWrite, type Repo } from "../core/repos.ts"; |
| 20 | 20 | import type { Issue } from "../core/issues.ts"; |
| 21 | import { isProposed } from "../core/pull_trust.ts"; | |
| 21 | 22 | import { openPullsClosing, type Pull } from "../core/pulls.ts"; |
| 22 | 23 | import { getUserById, getUserByName, type User } from "../core/users.ts"; |
| 23 | 24 | |
| @@ -224,6 +225,9 @@ export function unadoptedAmong( | ||
| 224 | 225 | */ |
| 225 | 226 | export function landsOnItsOwn(db: Database, repo: Repo, pull: Pull): boolean { |
| 226 | 227 | 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; | |
| 227 | 231 | const author = getUserById(db, pull.authorId); |
| 228 | 232 | if (author === undefined) return false; |
| 229 | 233 | if (author.kind === "agent") |
src/design/activity.ts +1
| @@ -104,6 +104,7 @@ const WORDS: Record<string, string> = { | ||
| 104 | 104 | "pull.reopened": "reopened a pull request", |
| 105 | 105 | "pull.commented": "commented on a pull request", |
| 106 | 106 | "pull.reviewed": "reviewed a pull request", |
| 107 | "pull.run_granted": "let a pull request's head run", | |
| 107 | 108 | "pull.labeled": "labelled a pull request", |
| 108 | 109 | "pull.held": "held a pull request", |
| 109 | 110 | "pull.released": "released a pull request", |
src/http/api.ts +6 −3
| @@ -117,7 +117,7 @@ import { | ||
| 117 | 117 | refreshPullHead, |
| 118 | 118 | resolveReviewThread, |
| 119 | 119 | submitReview, |
| 120 | approveHere, | |
| 120 | approveToRun, | |
| 121 | 121 | MAX_REVIEW_LINES, |
| 122 | 122 | type ReviewInput, |
| 123 | 123 | } from "../actions/pulls.ts"; |
| @@ -3601,7 +3601,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3601 | 3601 | if (denied) return denied; |
| 3602 | 3602 | const found = loadPull(ctx); |
| 3603 | 3603 | if ("error" in found) return found.error; |
| 3604 | const done = await approveHere( | |
| 3604 | const done = await approveToRun( | |
| 3605 | 3605 | ctx.app, |
| 3606 | 3606 | ctx.auth.user, |
| 3607 | 3607 | found.repo, |
| @@ -3624,7 +3624,10 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3624 | 3624 | return apiError("not_open", 422, "this pull is closed"); |
| 3625 | 3625 | return respondAccess(done.error, 422); |
| 3626 | 3626 | } |
| 3627 | return json(done.value, 201); | |
| 3627 | return json( | |
| 3628 | "id" in done.value ? done.value : { granted: true, ...done.value }, | |
| 3629 | 201, | |
| 3630 | ); | |
| 3628 | 3631 | }, |
| 3629 | 3632 | ); |
| 3630 | 3633 |
src/tui/screens/pull.ts +18 −6
| @@ -32,7 +32,7 @@ import { | ||
| 32 | 32 | } from "../../actions/issues.ts"; |
| 33 | 33 | import { |
| 34 | 34 | addReview, |
| 35 | approveHere, | |
| 35 | approveToRun as letItRun, | |
| 36 | 36 | mergePull, |
| 37 | 37 | pullThreads, |
| 38 | 38 | resolveReviewThread, |
| @@ -48,7 +48,7 @@ import { | ||
| 48 | 48 | import { proposedByText } from "../../core/proposal_token.ts"; |
| 49 | 49 | import { getAppByRepo } from "../../core/apps.ts"; |
| 50 | 50 | 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"; | |
| 52 | 52 | import { getPull, pullDir, variantSiblings, type Pull } from "../../core/pulls.ts"; |
| 53 | 53 | import { voteVariant } from "../../actions/variants.ts"; |
| 54 | 54 | import { getRepoById, type Repo } from "../../core/repos.ts"; |
| @@ -615,7 +615,7 @@ export function pullScreen( | ||
| 615 | 615 | return ( |
| 616 | 616 | f.user.kind === "user" && |
| 617 | 617 | row !== undefined && |
| 618 | row.originHeadRepo !== null && | |
| 618 | (row.originHeadRepo !== null || isProposed(f.db, row)) && | |
| 619 | 619 | row.state === "open" && |
| 620 | 620 | row.mergedAt === null && |
| 621 | 621 | (facts?.headOid ?? "") !== "" && |
| @@ -630,13 +630,18 @@ export function pullScreen( | ||
| 630 | 630 | const row = getPull(f.db, target.id, number); |
| 631 | 631 | if (row === undefined) return; |
| 632 | 632 | 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); | |
| 634 | 634 | if (!done.ok) { |
| 635 | 635 | ctx.toast(errorText(String(done.error)), "danger"); |
| 636 | 636 | return; |
| 637 | 637 | } |
| 638 | 638 | 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 | ); | |
| 640 | 645 | }); |
| 641 | 646 | }; |
| 642 | 647 | |
| @@ -710,7 +715,14 @@ export function pullScreen( | ||
| 710 | 715 | binding(["R"], "review"), |
| 711 | 716 | ...(mayVote() ? [binding(["+", "-"], "like/dislike a variant")] : []), |
| 712 | 717 | ...(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 |
||
| 722 | ? "let it run here" | |
| 723 | : "approve to run here", | |
| 724 | ), | |
| 725 | ] | |
| 714 | 726 | : []), |
| 715 | 727 | binding(["a"], "assign"), |
| 716 | 728 | ...(state() === "conflicts" |
src/web/live.ts +1 −1
| @@ -297,7 +297,7 @@ export const liveClientJs = /* js */ ` | ||
| 297 | 297 | 'push', 'repo.created', 'repo.forked', |
| 298 | 298 | 'issue.opened', 'issue.closed', 'issue.reopened', 'issue.commented', 'issue.labeled', 'issue.edited', |
| 299 | 299 | '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', | |
| 301 | 301 | 'branch.created', 'branch.deleted', |
| 302 | 302 | 'label.created', 'label.updated', 'label.deleted', |
| 303 | 303 | 'run.queued', 'run.started', 'run.finished', 'app.serving', |
src/web/pages/pull.ts +12 −9
| @@ -17,7 +17,7 @@ import { | ||
| 17 | 17 | pullThreads, |
| 18 | 18 | resolveReviewThread, |
| 19 | 19 | submitReview, |
| 20 | approveHere, | |
| 20 | approveToRun, | |
| 21 | 21 | } from "../../actions/pulls.ts"; |
| 22 | 22 | import { readFileAt } from "../../apps/checkout.ts"; |
| 23 | 23 | import { parseCheck } from "../../apps/manifest.ts"; |
| @@ -33,7 +33,7 @@ import { | ||
| 33 | 33 | type PullReviewStatus, |
| 34 | 34 | type PullStatus, |
| 35 | 35 | } from "../../core/pulls.ts"; |
| 36 | import { pullMayRun } from "../../core/pull_trust.ts"; | |
| 36 | import { isProposed, pullMayRun } from "../../core/pull_trust.ts"; | |
| 37 | 37 | import { checkSummary, type CheckSummary } from "../../core/runs.ts"; |
| 38 | 38 | import { |
| 39 | 39 | anchorKey, |
| @@ -1003,21 +1003,22 @@ export function approveHereCard( | ||
| 1003 | 1003 | if ( |
| 1004 | 1004 | user === undefined || |
| 1005 | 1005 | user.kind !== "user" || |
| 1006 | pull.originHeadRepo === null || | |
| 1006 | (pull.originHeadRepo === null && !isProposed(ctx.app.db, pull)) || | |
| 1007 | 1007 | pull.state !== "open" || |
| 1008 | 1008 | pull.mergedAt !== null || |
| 1009 | 1009 | !canWrite(ctx.app.db, repo, user) || |
| 1010 | 1010 | pullMayRun(ctx.app.db, repo, pull, headOid) |
| 1011 | 1011 | ) |
| 1012 | 1012 | return html``; |
| 1013 | const proposed = isProposed(ctx.app.db, pull); | |
| 1013 | 1014 | return card( |
| 1014 | 1015 | html`<form method="post" action="${action}"> |
| 1015 | 1016 | <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."} | |
| 1019 | 1020 | </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" })} | |
| 1021 | 1022 | </form>`, |
| 1022 | 1023 | { |
| 1023 | 1024 | extra: "mb", |
| @@ -1035,7 +1036,7 @@ export async function pullApproveHereSubmit(ctx: Ctx): Promise<Response> { | ||
| 1035 | 1036 | const form = await readForm(ctx, "rw"); |
| 1036 | 1037 | if (form instanceof Response) return form; |
| 1037 | 1038 | const dest = `${repoBase(found.repo)}/pulls/${found.pull.number}`; |
| 1038 | const done = await approveHere( | |
| 1039 | const done = await approveToRun( | |
| 1039 | 1040 | ctx.app, |
| 1040 | 1041 | ctx.auth.user, |
| 1041 | 1042 | found.repo, |
| @@ -1056,7 +1057,9 @@ export async function pullApproveHereSubmit(ctx: Ctx): Promise<Response> { | ||
| 1056 | 1057 | : "That pull request is no longer here.", |
| 1057 | 1058 | }); |
| 1058 | 1059 | 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.", | |
| 1060 | 1063 | }); |
| 1061 | 1064 | } |
| 1062 | 1065 |
src/web/pages/thread.ts +5
| @@ -149,6 +149,11 @@ const EVENT_LOOK: Record<string, { icon: IconName; tone: Tone; verb: string }> = | ||
| 149 | 149 | verb: "reviewed a line", |
| 150 | 150 | }, |
| 151 | 151 | "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 | }, | |
| 152 | 157 | "pull.edited": { icon: "pencil", tone: "default", verb: "edited this" }, |
| 153 | 158 | }; |
| 154 | 159 |
test/live.test.ts +24
| @@ -11,6 +11,7 @@ import { openIssue } from "../src/actions/issues.ts"; | ||
| 11 | 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 12 | 12 | import { createSession } from "../src/core/sessions.ts"; |
| 13 | 13 | import { createUser, type User } from "../src/core/users.ts"; |
| 14 | import { liveClientJs } from "../src/web/live.ts"; | |
| 14 | 15 | import { serve, type Servers } from "../src/http/server.ts"; |
| 15 | 16 | |
| 16 | 17 | async function readEvents( |
| @@ -132,4 +133,27 @@ describe("the viewer's stream", () => { | ||
| 132 | 133 | expect(page).toContain('id="live-apps"'); |
| 133 | 134 | expect(page).toContain("new EventSource('/events')"); |
| 134 | 135 | }); |
| 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 | }); | |
| 135 | 159 | }); |
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 | }); |
new above
offline
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".