Public: anyone can read this; the crew can change it.
A pull the access wall holds says so: it stops reading "approved and green" and "moving" #7899
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7847-a-pull-the-access-wall-holds-says-so-it → main 3 commits fork 3 threads · 3 unresolved
README.md +2
| @@ -94,6 +94,8 @@ An issue's title and body are edited on its page, with MCP `edit`, or with `e` o | ||
| 94 | 94 | |
| 95 | 95 | Reports from outside (sent with no account) are decided in the terminal too: open the repository as a person who writes it, press `:` and pick "Reports (N)"; beside each title is when it was sent, the page it came from and its words. `a` accepts the one under the cursor as an issue and says its number, `d` dismisses it after a confirm, and one someone else already decided says "this report was already decided". The row shows only while reports wait, and only to a person — not an agent — who can write the repository (`src/tui/screens/reports.ts`). |
| 96 | 96 | |
| 97 | **A pull the access wall holds** (a pull whose `contract.json` opens a door wider, or does not read) says a person decides, in plain words: "anyone signed out could read address records". Its pull page and Files tab show the card; a writer (and only a writer) also gets **Allow it and merge** (the merge route, `mergePull`) and **Send it back** with an optional visible reason (the review-state route, a request for changes); readers see the card without forms. In the terminal's pull screen a writer presses `w` for the same two answers in one dialog (`Send it back`, `Allow it and merge`, squash), through `submitReview` and `mergePull`, which check the write. MCP `flow`, the flow alarm and the audit page's "at the wall" rows carry the same words. | |
| 98 | ||
| 97 | 99 | **Landing past a stopped gate** (#7790): when the last three merge-queue gates of the forge's own repository went red for one cause and nothing has landed since, home, `/boards` and the terminal's Flow dialog (owners only) say `Landing has stopped: …`. Only then can an owner (repository admin, a person, never an agent) land one named pull without its checks, with a reason of 8 to 500 characters that goes on the pull's trail and the outbox (`pull.bypassed`): the **Land past the gate** form on the pull's Files step, `L` on the terminal's pull screen (reason, then a confirm), or MCP `land_past_gate` (planned unless `dryRun: false`). Review and conflicts still hold. `docs/features/gate-bypass.md`. |
| 98 | 100 | |
| 99 | 101 | **Taking a shipped pull back**: `POST /api/v1/repos/:owner/:name/pulls/:number/take-back` (a person who can write here, `rw` token scope; a session needs a same-origin request) opens a new pull, `Revert "<title>"` from the branch `revert-<n>` into the default branch, that undoes exactly what the shipped pull merged. Nothing lands until a person merges it. It takes no body and answers 201 with the new pull, as `POST …/pulls` answers one. Refusals: an agent or a reader gets 403 `forbidden`; a read-only token gets 403; a pull that is unknown or that you may not see gets 404 `not_found`; 409 `not_shipped` (it never merged), 409 `already_reverted`, 409 `mirrored` (the origin owns the history); 409 `conflict` when the undo no longer applies cleanly, with the conflicting files in `message`, and nothing is left behind. A failure of the forge's own git is 500 `git_failed` with a fixed message; git's words go to the server log. The pull page's **Take back** is the same verb (`revertPull` in `src/actions/revert.ts`). The terminal's pull screen does not offer it yet. |
docs/MCP.md +1 −1
| @@ -86,7 +86,7 @@ for a tunnel and a forge that sleeps. | ||
| 86 | 86 | | `flag_set` | `{ name, value?, clear? }`: set a declared flag's live value (`value` is `{ forge?, users?, roles?, percent? }`) or `clear` it to its default, with the Flags page's checks and refusals; a forge admin's (never an agent), an admin token; answers `{ name, value, says }` | yes | |
| 87 | 87 | | `flag_winner` | `{ name, winner }`: make an A/B flag's winning variant (`on` or `off`, only the one the read-out offers) its default for everyone, opening the clean-up issue first; a forge admin with an admin token; answers `{ name, winner, issue, says }` | yes | |
| 88 | 88 | | `flags` | no input: every declared flag with why it exists, its default, the issue that owns removing it and its live `value` (absent when unset), as `readFlags` answers; read-only, a forge admin's and never an agent's; setting is `flag_set` | | |
| 89 | | `flow` | with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing`; with `{ repo, issue? or pull? }` (a writer of the repository), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders | | | |
| 89 | | `flow` | with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing`; with `{ repo, issue? or pull? }` (a writer of the repository), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of wall (a person decides new access), ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders | | | |
| 90 | 90 | | `follow_mode` | `{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only | yes | |
| 91 | 91 | | `following` | where a fork stands against what it follows, and what the crew is doing about it | | |
| 92 | 92 | | `forge_proposals` | `{ repo }`: what this forge proposed to its parent, newest first; `reachable` is false if the parent did not answer | | |
src/contract/changes.ts +8 −4
| @@ -94,6 +94,8 @@ export interface Crossing { | ||
| 94 | 94 | readonly repo: Repo; |
| 95 | 95 | readonly pull: { readonly number: number; readonly title: string }; |
| 96 | 96 | readonly cells: readonly string[]; |
| 97 | /** The same, in plain words: "anyone signed out could read pack records" (#7847). */ | |
| 98 | readonly plain: readonly string[]; | |
| 97 | 99 | } |
| 98 | 100 | |
| 99 | 101 | /** Every wall crossing not yet answered, across the repositories given. */ |
| @@ -111,15 +113,17 @@ export function crossingsWaiting( | ||
| 111 | 113 | const repo = byId.get(d.repoId); |
| 112 | 114 | if (issue === undefined || repo === undefined) continue; |
| 113 | 115 | if (issue.state !== "open") continue; |
| 114 | const state = d.state as { cells?: unknown } | null; | |
| 115 | const cells = Array.isArray(state?.cells) | |
| 116 | ? state.cells.filter((c): c is string => typeof c === "string") | |
| 117 | : []; | |
| 116 | const state = d.state as { cells?: unknown; plain?: unknown } | null; | |
| 117 | const strings = (v: unknown): string[] => | |
| 118 | Array.isArray(v) ? v.filter((c): c is string => typeof c === "string") : []; | |
| 119 | const cells = strings(state?.cells); | |
| 120 | const plain = strings(state?.plain); | |
| 118 | 121 | out.push({ |
| 119 | 122 | decision: d, |
| 120 | 123 | repo, |
| 121 | 124 | pull: { number: issue.number, title: issue.title }, |
| 122 | 125 | cells, |
| 126 | plain: plain.length > 0 ? plain : cells, | |
| 123 | 127 | }); |
| 124 | 128 | } |
| 125 | 129 | return out; |
src/contract/policy.ts +21 −2
| @@ -22,6 +22,8 @@ export interface Loosened { | ||
| 22 | 22 | readonly answer: Cell["answer"]; |
| 23 | 23 | readonly from: Cell["answer"] | "absent"; |
| 24 | 24 | readonly to: Cell["answer"]; |
| 25 | /** `as` is the role everyone holds: a stranger, signed out. */ | |
| 26 | readonly floor?: boolean; | |
| 25 | 27 | } |
| 26 | 28 | |
| 27 | 29 | /** Rank: what a cell lets through, most to least. */ |
| @@ -56,9 +58,14 @@ export function loosened( | ||
| 56 | 58 | if (decided.has(c.resource)) { |
| 57 | 59 | const from = was.get(key) ?? "no"; |
| 58 | 60 | if (RANK[c.answer] > RANK[from]) |
| 59 | out.push({ ...c, from: was.has(key) ? from : "absent", to: c.answer }); | |
| 61 | out.push({ | |
| 62 | ...c, | |
| 63 | from: was.has(key) ? from : "absent", | |
| 64 | to: c.answer, | |
| 65 | ...(c.as === floor ? { floor: true } : {}), | |
| 66 | }); | |
| 60 | 67 | } else if (c.as === floor && c.answer !== "no") |
| 61 | out.push({ ...c, from: "absent", to: c.answer }); | |
| 68 | out.push({ ...c, from: "absent", to: c.answer, floor: true }); | |
| 62 | 69 | } |
| 63 | 70 | return out; |
| 64 | 71 | } |
| @@ -98,3 +105,15 @@ export async function wallOf( | ||
| 98 | 105 | /** One sentence per crossing, as the pull page and the ledger say it. */ |
| 99 | 106 | export const wallSentence = (l: Loosened): string => |
| 100 | 107 | `${l.resource}.${l.verb} as ${l.as}: ${l.from === "absent" ? "new, and" : `was ${l.from}, now`} ${l.to}`; |
| 108 | ||
| 109 | const VERB_WORDS: Record<Cell["verb"], string> = { | |
| 110 | create: "create", | |
| 111 | read: "read", | |
| 112 | update: "change", | |
| 113 | delete: "delete", | |
| 114 | list: "list", | |
| 115 | }; | |
| 116 | ||
| 117 | /** One crossing in plain words: "anyone signed out could read packs". */ | |
| 118 | export const wallPlain = (l: Loosened): string => | |
| 119 | `${l.floor === true ? "anyone signed out" : `any ${l.as}`} could ${VERB_WORDS[l.verb]} ${l.to === "own" ? "their own " : ""}${l.resource} records`; | |
src/crew/autonomy.ts +2 −1
| @@ -61,7 +61,7 @@ import { decideAndRecord } from "./ledger.ts"; | ||
| 61 | 61 | import { sentinelTick } from "./sentinel.ts"; |
| 62 | 62 | import { triageTick } from "./triage.ts"; |
| 63 | 63 | import { liveTriage, none as noTriage, tickFields } from "./livetriage.ts"; |
| 64 | import { wallOf, wallSentence } from "../contract/policy.ts"; | |
| 64 | import { wallOf, wallPlain, wallSentence } from "../contract/policy.ts"; | |
| 65 | 65 | import { |
| 66 | 66 | getIssue, |
| 67 | 67 | getIssueById, |
| @@ -2028,6 +2028,7 @@ async function admit( | ||
| 2028 | 2028 | state: { |
| 2029 | 2029 | head: pull.headOid, |
| 2030 | 2030 | cells, |
| 2031 | plain: wall.unsound ?? wall.loosened.map(wallPlain), | |
| 2031 | 2032 | unsound: wall.unsound !== undefined, |
| 2032 | 2033 | }, |
| 2033 | 2034 | answer: "wall", |
src/flow/alarm_stuck.ts +15 −2
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | import type { Database } from "bun:sqlite"; |
| 12 | 12 | import type { App } from "../app.ts"; |
| 13 | 13 | import { reviewSummariesFor } from "../core/reviews.ts"; |
| 14 | import { wallWords } from "./wall_wait.ts"; | |
| 14 | 15 | |
| 15 | 16 | export const STUCK_MINUTES = 30; |
| 16 | 17 | |
| @@ -21,6 +22,8 @@ export interface StuckAlarm { | ||
| 21 | 22 | /** The pull it names, `owner/name` and number; set only when fired. */ |
| 22 | 23 | readonly repo?: string; |
| 23 | 24 | readonly number?: number; |
| 25 | /** Set when the access wall holds it: a person must decide, in plain words (#7847). */ | |
| 26 | readonly wall?: readonly string[]; | |
| 24 | 27 | readonly says: string; |
| 25 | 28 | } |
| 26 | 29 | |
| @@ -118,17 +121,27 @@ export function alarmStuck(from: App | Database, now: Date, scope: StuckScope = | ||
| 118 | 121 | ) |
| 119 | 122 | .all(scope.repoId ?? null, scope.publicOnly ? 1 : 0); |
| 120 | 123 | const verdicts = reviewSummariesFor(db, pulls.map((p) => ({ id: p.id, headOid: p.head_oid }))); |
| 121 | let oldest: { repo: string; number: number; last: Last } | undefined; | |
| 124 | let oldest: { repo: string; number: number; last: Last; head: string; id: string } | undefined; | |
| 122 | 125 | for (const p of pulls) { |
| 123 | 126 | const s = verdicts.get(p.id); |
| 124 | 127 | if (s === undefined || s.approvals === 0 || s.changesRequested > 0) continue; |
| 125 | 128 | const last = lastEvent(db, p); |
| 126 | 129 | if (last === undefined || now.getTime() - Date.parse(last.at) < STUCK_MINUTES * 60_000) continue; |
| 127 | if (oldest === undefined || last.at < oldest.last.at) oldest = { repo: p.repo, number: p.number, last }; | |
| 130 | if (oldest === undefined || last.at < oldest.last.at) oldest = { repo: p.repo, number: p.number, last, head: p.head_oid, id: p.id }; | |
| 128 | 131 | } |
| 129 | 132 | if (oldest === undefined) return { fired: false, says: "No approved pull is sitting still." }; |
| 130 | 133 | const { repo, number, last } = oldest; |
| 131 | 134 | const hhmm = new Date(last.at).toISOString().slice(11, 16); |
| 135 | const wall = wallWords(db, oldest.id, oldest.head); | |
| 136 | if (wall !== null) | |
| 137 | return { | |
| 138 | fired: true, | |
| 139 | since: last.at, | |
| 140 | repo, | |
| 141 | number, | |
| 142 | wall, | |
| 143 | says: `${repo}#${number} is approved and waits for a person: ${wall.join("; ")}. Allow it (merge) or send it back.`, | |
| 144 | }; | |
| 132 | 145 | return { |
| 133 | 146 | fired: true, |
| 134 | 147 | since: last.at, |
src/flow/next_actor.ts +5
| @@ -10,6 +10,7 @@ import type { Pull } from "../core/pulls.ts"; | ||
| 10 | 10 | import { latestProofRun } from "../core/proof_runs.ts"; |
| 11 | 11 | import { checkSummary, type RunState } from "../core/runs.ts"; |
| 12 | 12 | import { reviewSummary } from "../core/reviews.ts"; |
| 13 | import { wallAsk, wallWords } from "./wall_wait.ts"; | |
| 13 | 14 | |
| 14 | 15 | export type Actor = "builder" | "reviewer" | "queue" | "person"; |
| 15 | 16 | |
| @@ -30,6 +31,8 @@ export interface NextInput { | ||
| 30 | 31 | readonly pushedSince: number; |
| 31 | 32 | /** What the pull's proof on this head is doing when it is not green (#7706): "waits: no check slot is spare". */ |
| 32 | 33 | readonly proof?: string | null; |
| 34 | /** What the access wall holds this head for, in plain words (#7847): a person decides it. */ | |
| 35 | readonly wall?: readonly string[] | null; | |
| 33 | 36 | } |
| 34 | 37 | |
| 35 | 38 | const WAITING = { |
| @@ -46,6 +49,7 @@ export function nextActor(i: NextInput): Next { | ||
| 46 | 49 | if (i.changesRequested > 0) |
| 47 | 50 | return { who: "builder", why: "changes were asked on this head" }; |
| 48 | 51 | if (i.check === "failed") return { who: "builder", why: "its check is red" }; |
| 52 | if (i.wall != null && i.wall.length > 0) return { who: "person", why: wallAsk(i.wall) }; | |
| 49 | 53 | if (i.approvals > 0) { |
| 50 | 54 | if (i.check === "ok" && i.proof) return { who: "queue", why: `approved and green, but its proof ${i.proof}` }; |
| 51 | 55 | if (i.check === "ok") return { who: "queue", why: "approved and green" }; |
| @@ -92,5 +96,6 @@ export function nextOfPull( | ||
| 92 | 96 | approvals: reviews.approvals, |
| 93 | 97 | pushedSince: reviews.stale, |
| 94 | 98 | proof: proofWords(db, pull, live.headOid), |
| 99 | wall: wallWords(db, pull.id, live.headOid), | |
| 95 | 100 | }); |
| 96 | 101 | } |
src/flow/wall_wait.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | /** | |
| 2 | * A pull the access wall holds (#7847): green, maybe approved, and waiting | |
| 3 | * for a person to allow the new access or send it back. The crew's land | |
| 4 | * sweep writes a `land` decision answered "wall" for the head it stopped | |
| 5 | * on; this reads it, so every place that says who acts next says the same. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { latestDecision } from "../core/decisions.ts"; | |
| 10 | ||
| 11 | const strings = (v: unknown): string[] => | |
| 12 | Array.isArray(v) ? v.filter((c): c is string => typeof c === "string") : []; | |
| 13 | ||
| 14 | /** What the wall holds `headOid` for, in plain words; null when it holds nothing there. */ | |
| 15 | export function wallWords(db: Database, pullId: string, headOid: string): string[] | null { | |
| 16 | const d = latestDecision(db, pullId, "land"); | |
| 17 | if (d === undefined || d.answer !== "wall" || d.outcome !== null) return null; | |
| 18 | const state = d.state as { head?: unknown; plain?: unknown; cells?: unknown } | null; | |
| 19 | if (state === null || typeof state !== "object" || state.head !== headOid) return null; | |
| 20 | const said = strings(state.plain); | |
| 21 | return said.length > 0 ? said : strings(state.cells); | |
| 22 | } | |
| 23 | ||
| 24 | /** The sentence a person reads: what is new, and what they may do. */ | |
| 25 | export const wallAsk = (words: readonly string[]): string => | |
| 26 | `the access wall holds it for a person to decide: ${words.join("; ")}. Allow it (merge) or send it back`; |
src/flow/why.ts +13
| @@ -20,8 +20,10 @@ import { standings } from "../core/standing.ts"; | ||
| 20 | 20 | import { ceilingHitAt, forgeCeiling } from "./ceiling.ts"; |
| 21 | 21 | import { heldNow, mapHolds } from "../crew/held.ts"; |
| 22 | 22 | import { nextOfPull, type Actor } from "./next_actor.ts"; |
| 23 | import { wallWords } from "./wall_wait.ts"; | |
| 23 | 24 | |
| 24 | 25 | export const CAUSES = [ |
| 26 | "wall", | |
| 25 | 27 | "ceiling", |
| 26 | 28 | "hot_file", |
| 27 | 29 | "blocked_by", |
| @@ -46,6 +48,8 @@ export interface WhyFacts { | ||
| 46 | 48 | readonly noHeadroom?: boolean; |
| 47 | 49 | readonly redCheck?: boolean; |
| 48 | 50 | readonly waitingReview?: boolean; |
| 51 | /** What the access wall holds the pull for, in plain words (#7847). */ | |
| 52 | readonly wall?: readonly string[]; | |
| 49 | 53 | } |
| 50 | 54 | |
| 51 | 55 | export interface Cause { |
| @@ -58,6 +62,13 @@ export interface Cause { | ||
| 58 | 62 | const agentActs = (f: WhyFacts): boolean => f.actor === "builder" || f.actor === "reviewer"; |
| 59 | 63 | |
| 60 | 64 | const RULES: Readonly<Record<CauseName, (f: WhyFacts) => Omit<Cause, "name"> | null>> = { |
| 65 | wall: (f) => | |
| 66 | f.wall !== undefined && f.wall.length > 0 | |
| 67 | ? { | |
| 68 | says: `The access wall holds it for a person: ${f.wall.join("; ")}.`, | |
| 69 | clears: "a person allows it (merges the pull) or sends it back (requests changes).", | |
| 70 | } | |
| 71 | : null, | |
| 61 | 72 | ceiling: (f) => |
| 62 | 73 | f.ceilingUsd !== undefined && agentActs(f) |
| 63 | 74 | ? { |
| @@ -200,10 +211,12 @@ export function whyPull( | ||
| 200 | 211 | const next = nextOfPull(app.db, pull, live); |
| 201 | 212 | if (next === null) return { cause: null }; |
| 202 | 213 | const check = checkSummary(app.db, pull.id, live.headOid).state; |
| 214 | const wall = next.who === "person" ? wallWords(app.db, pull.id, live.headOid) : null; | |
| 203 | 215 | const cause = firstCause({ |
| 204 | 216 | actor: next.who, |
| 205 | 217 | redCheck: next.who === "builder" && check === "failed", |
| 206 | 218 | waitingReview: next.who === "reviewer", |
| 219 | ...(wall === null ? {} : { wall }), | |
| 207 | 220 | ...shared, |
| 208 | 221 | }); |
| 209 | 222 | return { cause, next }; |
src/http/mcp/tools/flow.ts +1 −1
| @@ -21,7 +21,7 @@ const said = (subject: string, why: Why) => | ||
| 21 | 21 | |
| 22 | 22 | export const tool: Tool = { |
| 23 | 23 | name: "flow", |
| 24 | doc: "with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing`; with `{ repo, issue? or pull? }` (a writer of the repository), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders", | |
| 24 | doc: "with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing`; with `{ repo, issue? or pull? }` (a writer of the repository), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of wall (a person decides new access), ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders", | |
| 25 | 25 | description: |
| 26 | 26 | "Pipeline (admin); with `repo` and `issue` or `pull`: why it is not moving.", |
| 27 | 27 | inputSchema: { |
src/tui/screens/pull.ts +55
| @@ -66,6 +66,7 @@ import { nextOfPull, type Next } from "../../flow/next_actor.ts"; | ||
| 66 | 66 | import { getPull, pullDir, variantSiblings, type Pull } from "../../core/pulls.ts"; |
| 67 | 67 | import { voteVariant } from "../../actions/variants.ts"; |
| 68 | 68 | import { getRepoById, type Repo } from "../../core/repos.ts"; |
| 69 | import { wallOf, wallPlain, type Wall } from "../../contract/policy.ts"; | |
| 69 | 70 | import { checkSummary, type CheckSummary } from "../../core/runs.ts"; |
| 70 | 71 | import { err, ok } from "../../kernel/result.ts"; |
| 71 | 72 | import { getUsersByIds } from "../../core/users.ts"; |
| @@ -268,6 +269,8 @@ export function pullScreen( | ||
| 268 | 269 | const mayWrite = (): boolean => src.capabilities.write && repo !== undefined; |
| 269 | 270 | /** The hold that stands on this pull; undefined when none does. */ |
| 270 | 271 | let hold: Hold | undefined; |
| 272 | /** What the access wall holds this open pull for, read from git (#7847). */ | |
| 273 | let wall: Wall | undefined; | |
| 271 | 274 | /** `h` is a person's key: an agent's hold is refused by the action, so it is not offered. */ |
| 272 | 275 | const mayHold = (): boolean => |
| 273 | 276 | mayWrite() && f.user?.kind === "user" && issueRow !== undefined; |
| @@ -302,6 +305,14 @@ export function pullScreen( | ||
| 302 | 305 | threads = row === undefined ? [] : readThreads(row); |
| 303 | 306 | issueRow = repo === undefined ? undefined : getIssue(f.db, repo.id, number); |
| 304 | 307 | hold = row === undefined ? undefined : getHold(f.db, row.id); |
| 308 | wall = | |
| 309 | repo !== undefined && row !== undefined && row.state === "open" && row.mergedAt === null && facts !== null | |
| 310 | ? await (async () => { | |
| 311 | const dir = await pullDir(f.app.db, f.app.reposRoot, repo, row); | |
| 312 | const base = await resolveOid(dir, `refs/heads/${row.baseBranch}`); | |
| 313 | return base === undefined ? undefined : wallOf(dir, base, facts.headOid); | |
| 314 | })() | |
| 315 | : undefined; | |
| 305 | 316 | // The app half: the preview environment (in memory, from the runtime), |
| 306 | 317 | // the checks at the live head (a row), and what the crew has done here. |
| 307 | 318 | const record = repo === undefined ? undefined : getAppByRepo(f.db, repo.id); |
| @@ -676,6 +687,23 @@ export function pullScreen( | ||
| 676 | 687 | ctx.toast(resolved ? "thread resolved" : "thread reopened", "success"); |
| 677 | 688 | }; |
| 678 | 689 | |
| 690 | /** The wall's other answer: a request for changes, as `R` would send it. */ | |
| 691 | const sendBack = (): void => { | |
| 692 | if (repo === undefined) return; | |
| 693 | const target = repo; | |
| 694 | const row = getPull(f.db, target.id, number); | |
| 695 | if (row === undefined) return; | |
| 696 | runAsync(ctx, async () => { | |
| 697 | const sent = await submitReview(f.app, f.user, target, row, { state: "changes_requested" }); | |
| 698 | if (!sent.ok) { | |
| 699 | ctx.toast(errorText(String(sent.error)), "danger"); | |
| 700 | return; | |
| 701 | } | |
| 702 | await reload(); | |
| 703 | ctx.toast("sent back", "success"); | |
| 704 | }); | |
| 705 | }; | |
| 706 | ||
| 679 | 707 | const merge = (strategy: "merge" | "squash"): void => { |
| 680 | 708 | if (repo === undefined) return; |
| 681 | 709 | const target = repo; |
| @@ -920,6 +948,7 @@ export function pullScreen( | ||
| 920 | 948 | ? [binding(["v"], "see the conflict blocks")] |
| 921 | 949 | : []), |
| 922 | 950 | binding(["m"], "merge", { disabled: state() !== "ready" }), |
| 951 | ...(wall !== undefined && mayWrite() ? [binding(["w"], "the access wall: allow or send back")] : []), | |
| 923 | 952 | ...(mayLandPast() ? [binding(["L"], "land past the gate")] : []), |
| 924 | 953 | ...(tab === 1 && view.threadHere() !== null |
| 925 | 954 | ? [ |
| @@ -1261,6 +1290,32 @@ export function pullScreen( | ||
| 1261 | 1290 | ); |
| 1262 | 1291 | return true; |
| 1263 | 1292 | } |
| 1293 | // `w` on a pull the access wall holds: the same two decisions as the | |
| 1294 | // page's card, through `mergePull` and `submitReview`, which check the write. | |
| 1295 | if (e.key === "w" && mayWrite() && wall !== undefined) { | |
| 1296 | const held = wall; | |
| 1297 | ctx.push( | |
| 1298 | confirmScreen(ctx, screen, { | |
| 1299 | title: `Access wall: #${number}`, | |
| 1300 | body: [ | |
| 1301 | held.unsound !== undefined | |
| 1302 | ? "This pull's contract does not read; an app built on it does not boot." | |
| 1303 | : "This pull opens access wider; a person decides.", | |
| 1304 | ...(held.unsound ?? held.loosened.map(wallPlain)), | |
| 1305 | ], | |
| 1306 | actions: [ | |
| 1307 | { label: "Cancel", variant: "ghost" }, | |
| 1308 | { label: "Send it back", variant: "danger" }, | |
| 1309 | { label: "Allow it and merge" }, | |
| 1310 | ], | |
| 1311 | onChoose: (choice) => { | |
| 1312 | if (choice === 2) merge("squash"); | |
| 1313 | if (choice === 1) sendBack(); | |
| 1314 | }, | |
| 1315 | }), | |
| 1316 | ); | |
| 1317 | return true; | |
| 1318 | } | |
| 1264 | 1319 | // `C` asks for the check again at the head, on the base as it is now. |
| 1265 | 1320 | if (e.key === "C" && mayWrite() && repo !== undefined && f.user !== undefined) { |
| 1266 | 1321 | const user = f.user; |
src/web/pages/audit.ts +1 −4
| @@ -501,10 +501,7 @@ function crossingsCard(crossings: readonly Crossing[]): Html { | ||
| 501 | 501 | <span class="row-meta" |
| 502 | 502 | >${name(c.repo)} #${c.pull.number} · waiting since |
| 503 | 503 | ${time(c.decision.createdAt)} · |
| 504 | ${c.cells.length === 1 | |
| 505 | ? "one door opens" | |
| 506 | : `${c.cells.length} doors open`} | |
| 507 | wider</span | |
| 504 | ${c.plain.join("; ")}</span | |
| 508 | 505 | > |
| 509 | 506 | </span> |
| 510 | 507 | <span class="row-trail" |
src/web/pages/pull.ts +1 −1
| @@ -999,7 +999,7 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> { | ||
| 999 | 999 | >`, |
| 1000 | 1000 | )} |
| 1001 | 1001 | </p> |
| 1002 | ${when(wall, (w) => wallCard(w))} | |
| 1002 | ${when(wall, (w) => wallCard(w, mayMerge ? thread : undefined))} | |
This page gates the buttons on |
||
| 1003 | 1003 | ${holdCard(ctx, repo, pull, { act: false })} |
| 1004 | 1004 | ${teamsCard(teamVerdicts(ctx.app.db, { ...pull, headOid: status.headOid }))} |
| 1005 | 1005 | ${mergeBox(ctx, repo, pull, status, mayMerge, by, check, isApp, tier, stale)} |
src/web/studio/change.ts +25 −4
| @@ -31,7 +31,7 @@ import { | ||
| 31 | 31 | } from "../../core/pulls.ts"; |
| 32 | 32 | import { canAdmin, canWrite, type Repo } from "../../core/repos.ts"; |
| 33 | 33 | import { listReviews, type Review } from "../../core/reviews.ts"; |
| 34 | import { wallOf, wallSentence, type Wall } from "../../contract/policy.ts"; | |
| 34 | import { wallOf, wallPlain, wallSentence, type Wall } from "../../contract/policy.ts"; | |
| 35 | 35 | import { checkSummary } from "../../core/runs.ts"; |
| 36 | 36 | import type { Ctx } from "../../http/context.ts"; |
| 37 | 37 | import { |
| @@ -105,7 +105,7 @@ export function pullContext(ctx: Ctx): { repo: Repo; pull: Pull } | Response { | ||
| 105 | 105 | * so, in the same sentences the ledger keeps, and leaves the decision to |
| 106 | 106 | * the person reading. |
| 107 | 107 | */ |
| 108 | export function wallCard(wall: Wall): Html { | |
| 108 | export function wallCard(wall: Wall, act?: string): Html { | |
| 109 | 109 | return card( |
| 110 | 110 | html`<p class="muted small"> |
| 111 | 111 | ${wall.unsound !== undefined |
| @@ -116,7 +116,28 @@ export function wallCard(wall: Wall): Html { | ||
| 116 | 116 | ${(wall.unsound ?? wall.loosened.map(wallSentence)).map( |
| 117 | 117 | (line) => html`<div>${line}</div>`, |
| 118 | 118 | )} |
| 119 | </div>`, | |
| 119 | </div>${when( | |
| 120 | act, | |
| 121 | (thread) => html`<p class="small"> | |
| 122 | ${wall.unsound !== undefined | |
| 123 | ? "This one does not boot." | |
| 124 | : `If you allow it, ${wall.loosened.map(wallPlain).join("; ")}.`} | |
| 125 | </p> | |
| 126 | <div class="bar mt"> | |
| 127 | <form method="post" action="${thread}/merge"> | |
| 128 | <input type="hidden" name="strategy" value="squash" /> | |
| 129 | ${btn("Allow it and merge", { variant: "primary", icon: "merge" })} | |
| 130 | </form> | |
| 131 | <form method="post" action="${thread}/review-state"> | |
| 132 | <input type="hidden" name="state" value="changes_requested" /> | |
| 133 | <label class="small" | |
| 134 | >Why (optional) | |
| 135 | <input name="body" autocomplete="off" /> | |
outdated
The send-back reason is a hard-coded string in a hidden input. Posting it as the review body makes every send-back say the same thing and hides the reason from the person clicking. Offer a visible optional text field, or leave the body empty if the route allows it. Also confirm the review-state route accepts a form post here and does the same-origin check. |
||
| 136 | </label> | |
| 137 | ${btn("Send it back", { variant: "danger" })} | |
| 138 | </form> | |
| 139 | </div>`, | |
| 140 | )}`, | |
| 120 | 141 | { |
| 121 | 142 | tone: "attn", |
| 122 | 143 | extra: "wall mt", |
| @@ -610,7 +631,7 @@ async function changeConversation( | ||
| 610 | 631 | ${when(runLine, (l) => l)} ${holdCard(ctx, repo, pull, { act: true })} |
| 611 | 632 | ${approveHereCard(ctx, repo, pull, headOid, `${thread}/approve-here`)} |
| 612 | 633 | ${teamsCard(teamVerdicts(db, { ...pull, headOid }))} |
| 613 | ${when(wall, (w) => wallCard(w))} ${decisionSection} | |
| 634 | ${when(wall, (w) => wallCard(w, ctx.auth.user !== undefined && canWrite(db, repo, ctx.auth.user) ? thread : undefined))} ${decisionSection} | |
| 614 | 635 | ${when(visionPreview, (v) => visionPreviewCard(v))} |
| 615 | 636 | ${takeBackSection} |
| 616 | 637 | ${when(record, (r) => lookCard(ctx, repo, r, pull, pull.headOid))} |
test/audit.test.ts +5
| @@ -342,6 +342,7 @@ describe("the audit reads every app's policy from git", () => { | ||
| 342 | 342 | state: { |
| 343 | 343 | head: pull.value.headOid, |
| 344 | 344 | cells: ["note.delete as anon: was no, now yes"], |
| 345 | plain: ["anyone signed out could delete note records"], | |
| 345 | 346 | }, |
| 346 | 347 | answer: "wall", |
| 347 | 348 | p: 1, |
| @@ -353,6 +354,9 @@ describe("the audit reads every app's policy from git", () => { | ||
| 353 | 354 | expect(waiting[0]!.cells).toEqual([ |
| 354 | 355 | "note.delete as anon: was no, now yes", |
| 355 | 356 | ]); |
| 357 | expect(waiting[0]!.plain).toEqual([ | |
| 358 | "anyone signed out could delete note records", | |
| 359 | ]); | |
| 356 | 360 | |
| 357 | 361 | // Two probes, the second disagreeing with the first. |
| 358 | 362 | recordProbe(app.db, record.id, { |
| @@ -383,6 +387,7 @@ describe("the audit reads every app's policy from git", () => { | ||
| 383 | 387 | expect(body).toContain("What changed"); |
| 384 | 388 | expect(body).toContain("1 pull waits"); |
| 385 | 389 | expect(body).toContain("Anyone may delete"); |
| 390 | expect(body).toContain("anyone signed out could delete note records"); | |
| 386 | 391 | expect(body).toContain("only the signed-in read"); |
| 387 | 392 | expect(body).toContain("2 doors narrower"); |
| 388 | 393 | expect(body).toContain("1 door wider"); |
test/flow-alarm-stuck.test.ts +17
| @@ -85,6 +85,23 @@ describe("alarmStuck", () => { | ||
| 85 | 85 | expect(a.says).toBe(`alice/r#${num} has sat approved for 31 minutes; its last event was a red check at 11:29.`); |
| 86 | 86 | }); |
| 87 | 87 | |
| 88 | test("a walled pull says a person must decide, in plain words (#7847)", () => { | |
| 89 | reset(); | |
| 90 | const num = pull({ eventMin: 31 }); | |
| 91 | const wall = (head: string): void => | |
| 92 | void app.db.run( | |
| 93 | "INSERT INTO decision (id, kind, repo_id, issue_id, state, answer, p, by, ms) VALUES (?1, 'land', 'r', ?2, ?3, 'wall', 1, 'wall', 0)", | |
| 94 | [`d${num}${head}`, `p${num}`, JSON.stringify({ head, cells: ["pack.read as anon: new, and yes"], plain: ["anyone signed out could read pack records"] })], | |
| 95 | ); | |
| 96 | wall("other"); | |
| 97 | expect(alarmStuck(app, NOW).wall).toBeUndefined(); | |
| 98 | app.db.run("DELETE FROM decision"); | |
| 99 | wall("h"); | |
| 100 | const a = alarmStuck(app, NOW); | |
| 101 | expect(a.wall).toEqual(["anyone signed out could read pack records"]); | |
| 102 | expect(a.says).toContain("waits for a person: anyone signed out could read pack records"); | |
| 103 | }); | |
| 104 | ||
| 88 | 105 | test("an event 29 minutes ago does not fire", () => { |
| 89 | 106 | reset(); |
| 90 | 107 | pull({ eventMin: 29 }); |
test/mcp-flow-why.test.ts +29
| @@ -22,6 +22,7 @@ import { promote } from "./helpers/members.ts"; | ||
| 22 | 22 | |
| 23 | 23 | describe("the list of causes", () => { |
| 24 | 24 | const cases: [string, WhyFacts][] = [ |
| 25 | ["wall", { actor: "person", wall: ["anyone signed out could read pack records"] }], | |
| 25 | 26 | ["ceiling", { actor: "builder", ceilingUsd: 20 }], |
| 26 | 27 | ["hot_file", { actor: "builder", hotFile: "src/a.ts is in 3 open pulls" }], |
| 27 | 28 | ["blocked_by", { actor: "builder", blockedBy: [4, 9] }], |
| @@ -265,6 +266,34 @@ describe("MCP flow about a pull", () => { | ||
| 265 | 266 | expect(r).toMatchObject({ moving: false, cause: "red_check", next: { who: "builder" } }); |
| 266 | 267 | }); |
| 267 | 268 | |
| 269 | test("a walled pull, approved and green, is not moving and not the queue's (#7847)", async () => { | |
| 270 | const n = await pullOn("walled"); | |
| 271 | const p = getPull(app.db, repo.id, n)!; | |
| 272 | const live = p.headOid; | |
| 273 | const run = createRun(app.db, { repoId: repo.id, kind: "check", issueId: p.id, env: `pull-${n}`, commitOid: live }); | |
| 274 | claimRun(app.db, run.id); | |
| 275 | finishRun(app.db, run.id, { state: "ok", summary: "green" }); | |
| 276 | const bot = { id: alice.id }; | |
| 277 | app.db.run( | |
| 278 | "INSERT INTO review (id, issue_id, author_id, state, commit_oid, created_at) VALUES ('rvw', ?1, ?2, 'approved', ?3, ?4)", | |
| 279 | [p.id, bot.id, live, new Date().toISOString()], | |
| 280 | ); | |
| 281 | // Before the wall speaks it reads as the queue's, as it always did. | |
| 282 | expect(await call({ repo: "alice/lib", pull: n })).toMatchObject({ next: { who: "queue" } }); | |
| 283 | app.db.run( | |
| 284 | "INSERT INTO decision (id, kind, repo_id, issue_id, state, answer, p, by, ms) VALUES ('dw', 'land', ?1, ?2, ?3, 'wall', 1, 'wall', 0)", | |
| 285 | [repo.id, p.id, JSON.stringify({ head: live, cells: ["pack.read as anon: new, and yes"], plain: ["anyone signed out could read pack records"] })], | |
| 286 | ); | |
| 287 | const r = await call({ repo: "alice/lib", pull: n }); | |
| 288 | expect(r).toMatchObject({ moving: false, cause: "wall", next: { who: "person" } }); | |
| 289 | expect(r.says).toContain("anyone signed out could read pack records"); | |
| 290 | expect(r.next.why).toContain("anyone signed out could read pack records"); | |
| 291 | // A push is a new head: the wall's word is about the old one. | |
| 292 | app.db.query("UPDATE pull SET head_oid = ?2 WHERE issue_id = ?1").run(p.id, "0".repeat(40)); | |
| 293 | app.db.run("UPDATE decision SET state = ?1 WHERE id = 'dw'", [JSON.stringify({ head: "1".repeat(40), cells: [], plain: [] })]); | |
| 294 | expect((await call({ repo: "alice/lib", pull: n })).cause).not.toBe("wall"); | |
| 295 | }); | |
| 296 | ||
| 268 | 297 | test("a merged pull is merged and a closed one is closed, neither moving", async () => { |
| 269 | 298 | const m = getPull(app.db, repo.id, await pullOn("gone"))!; |
| 270 | 299 | app.db.query("UPDATE pull SET merged_at = ?2 WHERE issue_id = ?1").run(m.id, new Date().toISOString()); |
test/next_actor.test.ts +3
| @@ -23,6 +23,9 @@ const cases: [string, Partial<NextInput>, string, string][] = [ | ||
| 23 | 23 | ["approved, queued", { approvals: 1, check: "queued" }, "queue", "approved, its check is queued"], |
| 24 | 24 | ["approved, cancelled", { approvals: 1, check: "cancelled" }, "queue", "approved, its check was cancelled"], |
| 25 | 25 | ["approved, running", { approvals: 1, check: "running" }, "queue", "approved, its check is running"], |
| 26 | // The access wall (#7847): a person decides, however approved and green. | |
| 27 | ["walled, approved and green", { approvals: 1, check: "ok", wall: ["anyone signed out could read pack records"] }, "person", "the access wall holds it for a person to decide: anyone signed out could read pack records. Allow it (merge) or send it back"], | |
| 28 | ["walled, sent back", { approvals: 1, check: "ok", changesRequested: 1, wall: ["anyone signed out could read pack records"] }, "builder", "changes were asked on this head"], | |
| 26 | 29 | ]; |
| 27 | 30 | |
| 28 | 31 | describe("nextActor", () => { |
test/policy.test.ts +15 −1
| @@ -25,7 +25,7 @@ import { | ||
| 25 | 25 | } from "../src/core/decisions.ts"; |
| 26 | 26 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| 27 | 27 | import { readContract, type Contract } from "../src/contract/can.ts"; |
| 28 | import { loosened, wallOf, wallSentence } from "../src/contract/policy.ts"; | |
| 28 | import { loosened, wallOf, wallPlain, wallSentence } from "../src/contract/policy.ts"; | |
| 29 | 29 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 30 | 30 | import { createUser, type User } from "../src/core/users.ts"; |
| 31 | 31 | import { repoDir } from "../src/git/repos.ts"; |
| @@ -68,6 +68,20 @@ describe("what counts as loosening", () => { | ||
| 68 | 68 | ).toEqual(["note.delete as user: was own, now yes"]); |
| 69 | 69 | }); |
| 70 | 70 | |
| 71 | test("a crossing reads in plain words (#7847)", () => { | |
| 72 | expect(loosened(undefined, NOTES).map(wallPlain)).toEqual([ | |
| 73 | "anyone signed out could read note records", | |
| 74 | "anyone signed out could list note records", | |
| 75 | ]); | |
| 76 | expect(loosened(NOTES, withCan({ delete: "user" })).map(wallPlain)).toEqual([ | |
| 77 | "any user could delete note records", | |
| 78 | ]); | |
| 79 | // No naive plural, and the own cell says whose. | |
| 80 | expect( | |
| 81 | wallPlain({ resource: "address", verb: "update", as: "user", answer: "own", from: "no", to: "own", floor: false } as never), | |
| 82 | ).toBe("any user could change their own address records"); | |
| 83 | }); | |
| 84 | ||
| 71 | 85 | test("tightening, and the same table, loosen nothing", () => { |
| 72 | 86 | expect(loosened(NOTES, withCan({ read: "user", list: "user" }))).toEqual( |
| 73 | 87 | [], |
test/wall-card.test.ts added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | /** The wall card on a pull offers one button to allow the new access or send it back (#7847). */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { wallCard } from "../src/web/studio/change.ts"; | |
| 4 | ||
| 5 | const wall = { | |
| 6 | loosened: [ | |
| 7 | { resource: "pack", verb: "read", as: "anon", answer: "yes", from: "absent", to: "yes", floor: true }, | |
| 8 | ], | |
| 9 | } as const; | |
| 10 | ||
| 11 | test("a writer is offered allow and send back, in plain words", () => { | |
| 12 | const html = String(wallCard(wall as never, "/a/b/pulls/3")); | |
| 13 | expect(html).toContain("anyone signed out could read pack records"); | |
| 14 | expect(html).toContain('action="/a/b/pulls/3/merge"'); | |
| 15 | expect(html).toContain('action="/a/b/pulls/3/review-state"'); | |
| 16 | expect(html).toContain("changes_requested"); | |
| 17 | }); | |
| 18 | ||
| 19 | test("a reader is offered no button", () => { | |
| 20 | expect(String(wallCard(wall as never))).not.toContain("<form"); | |
| 21 | }); |
test/wall-doors.test.ts added +160
| @@ -0,0 +1,160 @@ | ||
| 1 | /** The access wall's two answers reach a writer on both pages and in the terminal, and nobody else (#7847). */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { openPull } from "../src/actions/pulls.ts"; | |
| 8 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { getPull } from "../src/core/pulls.ts"; | |
| 10 | import { listReviews } from "../src/core/reviews.ts"; | |
| 11 | import { createSession } from "../src/core/sessions.ts"; | |
| 12 | import { createUser, type User } from "../src/core/users.ts"; | |
| 13 | import type { Repo } from "../src/core/repos.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 16 | import { pullScreen } from "../src/tui/screens/pull.ts"; | |
| 17 | import { local } from "../src/tui/screens/where.ts"; | |
| 18 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 19 | import { wallCard } from "../src/web/studio/change.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | import { settleApp } from "./helpers/tui.ts"; | |
| 22 | ||
| 23 | quietLogs(); | |
| 24 | ||
| 25 | const contract = (read: string) => | |
| 26 | JSON.stringify({ | |
| 27 | roles: { | |
| 28 | anon: { above: [], held: { by: "everyone" } }, | |
| 29 | user: { above: ["anon"], held: { by: "identity", role: "user" } }, | |
| 30 | admin: { above: ["user"], held: { by: "identity", role: "admin" } }, | |
| 31 | }, | |
| 32 | resources: { | |
| 33 | address: { can: { create: "user", read, update: "admin", delete: "admin", list: read } }, | |
| 34 | }, | |
| 35 | }); | |
| 36 | ||
| 37 | describe("the wall's answers, by door", () => { | |
| 38 | let dataDir: string; | |
| 39 | let app: App; | |
| 40 | let servers: Servers; | |
| 41 | let alice: User; | |
| 42 | let bob: User; | |
| 43 | let repo: Repo; | |
| 44 | let aliceCookie: string; | |
| 45 | let bobCookie: string; | |
| 46 | let n = 0; | |
| 47 | ||
| 48 | const wallPull = async () => { | |
| 49 | const branch = `open-${++n}`; | |
| 50 | const made = await commitToBranch(app, alice, repo, { | |
| 51 | branch, | |
| 52 | message: branch, | |
| 53 | files: [{ path: "contract.json", content: contract("anon") }], | |
| 54 | }); | |
| 55 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 56 | const opened = await openPull(app, alice, repo, { title: branch, baseBranch: "main", headBranch: branch }); | |
| 57 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 58 | return opened.value; | |
| 59 | }; | |
| 60 | const page = async (path: string, cookie?: string) => | |
| 61 | (await fetch(`${servers.url}/alice/notes${path}`, { headers: cookie ? { cookie } : {} })).text(); | |
| 62 | const drive = async (who: User, number: number, keys: string[]) => { | |
| 63 | const terminal = headlessTerminal(160, 44); | |
| 64 | const { app: tui, session } = createForgeApp( | |
| 65 | app, | |
| 66 | { sshUsername: who.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: who }, | |
| 67 | { terminal, exitHooks: false }, | |
| 68 | ); | |
| 69 | tui.start(); | |
| 70 | await settleApp(tui); | |
| 71 | tui.push(pullScreen(session, tui.ctx, local(session, repo), number)); | |
| 72 | await settleApp(tui); | |
| 73 | const frames = [tui.screen.toText()]; | |
| 74 | for (const k of keys) { | |
| 75 | terminal.feed(k); | |
| 76 | await settleApp(tui); | |
| 77 | frames.push(tui.screen.toText()); | |
| 78 | } | |
| 79 | tui.stop(); | |
| 80 | return frames; | |
| 81 | }; | |
| 82 | ||
| 83 | beforeAll(async () => { | |
| 84 | dataDir = await mkdtemp(join(tmpdir(), "forge-wall-doors-")); | |
| 85 | app = createApp({ dataDir, port: 0 }); | |
| 86 | servers = await serve(app); | |
| 87 | const mk = async (username: string) => { | |
| 88 | const u = await createUser(app.db, { | |
| 89 | origin: "seed", | |
| 90 | trusted: true, | |
| 91 | username, | |
| 92 | email: `${username}@forge.local`, | |
| 93 | password: "a-good-passphrase", | |
| 94 | adminIfFirst: username === "alice", | |
| 95 | }); | |
| 96 | if (!u.ok) throw new Error(u.error); | |
| 97 | return u.value; | |
| 98 | }; | |
| 99 | alice = await mk("alice"); | |
| 100 | bob = await mk("bob"); | |
| 101 | aliceCookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 102 | bobCookie = `forge_session=${createSession(app.db, bob.id).token}`; | |
| 103 | const made = await createRepo(app, alice, { name: "notes", visibility: "public" }); | |
| 104 | if (!made.ok) throw new Error(String(made.error)); | |
| 105 | repo = made.value; | |
| 106 | const seeded = await commitToBranch(app, alice, repo, { | |
| 107 | message: "a contract", | |
| 108 | files: [{ path: "contract.json", content: contract("user") }], | |
| 109 | }); | |
| 110 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 111 | }, 30_000); | |
| 112 | ||
| 113 | afterAll(async () => { | |
| 114 | servers.stop(); | |
| 115 | app.close(); | |
| 116 | await rm(dataDir, { recursive: true, force: true }); | |
| 117 | }); | |
| 118 | ||
| 119 | test("the pull page and the Files tab hand the forms to a writer alone", async () => { | |
| 120 | const p = await wallPull(); | |
| 121 | for (const path of [`/pulls/${p.number}`, `/pulls/${p.number}/files`]) { | |
| 122 | const mine = await page(path, aliceCookie); | |
| 123 | expect(mine).toContain("anyone signed out could read address records"); | |
| 124 | expect(mine).toContain(`action="/alice/notes/pulls/${p.number}/merge"`); | |
| 125 | expect(mine).toContain(`action="/alice/notes/pulls/${p.number}/review-state"`); | |
| 126 | expect(mine).toContain("Allow it and merge"); | |
| 127 | expect(mine).toContain('name="body"'); | |
| 128 | expect(mine).not.toContain("Sent back at the access wall"); | |
| 129 | for (const reader of [await page(path, bobCookie), await page(path)]) { | |
| 130 | expect(reader).toContain("address.read as anon"); | |
| 131 | expect(reader).not.toContain("Allow it and merge"); | |
| 132 | expect(reader).not.toContain("Send it back"); | |
| 133 | } | |
| 134 | } | |
| 135 | }); | |
| 136 | ||
| 137 | test("a contract that does not read is worded as unsound", () => { | |
| 138 | const html = String(wallCard({ loosened: [], unsound: ["contract.json is not JSON"] }, "/a/b/pulls/1")); | |
| 139 | expect(html).toContain("does not boot"); | |
| 140 | expect(html).toContain("contract.json is not JSON"); | |
| 141 | expect(html).not.toContain("If you allow it"); | |
| 142 | }); | |
| 143 | ||
| 144 | test("the terminal: w offers both answers to a writer, sends back, and offers nothing to a reader", async () => { | |
| 145 | const p = await wallPull(); | |
| 146 | expect((await drive(bob, p.number, ["w"])).at(-1)).not.toContain("Send it back"); | |
| 147 | const dialog = (await drive(alice, p.number, ["w"])).at(-1)!; | |
| 148 | expect(dialog).toContain("Send it back"); | |
| 149 | expect(dialog).toContain("Allow it and merge"); | |
| 150 | expect(dialog).toContain("anyone signed out could read address records"); | |
| 151 | await drive(alice, p.number, ["w", "\x1b[C", "\r"]); | |
| 152 | expect(listReviews(app.db, getPull(app.db, repo.id, p.number)!.id).map((r) => r.state)).toEqual(["changes_requested"]); | |
| 153 | }, 60_000); | |
| 154 | ||
| 155 | test("the terminal: Allow it and merge lands the pull", async () => { | |
| 156 | const p = await wallPull(); | |
| 157 | await drive(alice, p.number, ["w", "\x1b[C", "\x1b[C", "\r"]); | |
| 158 | expect(getPull(app.db, repo.id, p.number)!.mergedAt).not.toBeNull(); | |
| 159 | }, 60_000); | |
| 160 | }); |
${l.resource}sis a naive plural: a resource likeaddressorcategoryreads wrongly. Prefer 'axresource' phrasing, or the resource name as written. Also add a test for theownandunsoundwording.