Public: anyone can read this; the crew can change it.
The gate cannot lock itself out: a known-good gate and a break-glass path #7823
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7790-the-gate-cannot-lock-itself-out-a-known → main 4 commits fork 2 threads · 2 unresolved
16 files
changed +703
−5
README.md +2
| @@ -87,6 +87,8 @@ An issue's title and body are edited on its page, with MCP `edit`, or with `e` o | ||
| 87 | 87 | |
| 88 | 88 | 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`). |
| 89 | 89 | |
| 90 | **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`. | |
| 91 | ||
| 90 | 92 | **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. |
| 91 | 93 | |
| 92 | 94 | The terminal's history says each commit's check, as MCP `commits` with `oid` does: a ✓ or ✗ mark on a log row where the commit's own check on the default branch passed or failed (blank where it has no verdict yet), and a `check:` line under one commit's title: `green`, `red · <first line of the failing step>`, `running`, `superseded` or `not run`, with `· via <sha>` when a later main commit's check answers for it. Nothing shows to a viewer who may not read the repository's runs (`src/checks/commit_check.ts`, `src/tui/screens/commits.ts`). |
docs/MCP.md +1
| @@ -101,6 +101,7 @@ for a tunnel and a forge that sleeps. | ||
| 101 | 101 | | `issue` | `{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, the newest 50 comments (author, authorIsAgent, body, at), `commentsTotal` and `commentsNext` (older ones: `GET …/comments?cursor=`), and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found; draws the `ui://forge/issue` card: title, state, assignee, labels and what it waits for, never the body or comments | | |
| 102 | 102 | | `issue_teams` | `{ repo, number, teams? }`: the crew teams an issue names; with `teams` (at most two slugs, `[]` clears) names them, and each reviews the closing pull | yes | |
| 103 | 103 | | `labels` | `{ repo, number, labels }`: set an issue's or a pull's labels to exactly these (`[]` clears); only labels the repository has, by a writer | yes | |
| 104 | | `land_past_gate` | `{ repo, number, reason, dryRun? }` (an owner, never an agent): land one named pull past the checks when landing has stopped (the forge's own repository, three red gates of one cause); `reason` (8 to 500 characters) is recorded on the pull's trail and the outbox as `pull.bypassed`; planned unless `dryRun` is false; takes an rw token | yes | | |
| 104 | 105 | | `limb` | `{ app, issue, action, reason?, pillar?, operation?, dryRun? }`: defer, resume or close a whole limb as not doing, or undo an operation; plans unless `dryRun` is false, and applying takes an admin token | yes | |
| 105 | 106 | | `limits` | `{ key?, value?, dryRun? }` (forge admin): every limit the forge's owner can hit, with its value, default and bounds; with `key` and `value` (`""` restores the default), planned first (`dryRun` is true unless `false`); a value out of bounds is refused, applying needs an admin-scope token | yes | |
| 106 | 107 | | `make_it_yours` | `{ repo, name?, visibility?, dryRun? }`: a fork of an app you can see, as your own app, following the original; code and history copied, data and secrets not; private unless `visibility` is public (a public original only); at most 5 copies an account a day; plans unless `dryRun` is false | yes | |
docs/design/areas/forms.md +2
| @@ -4,3 +4,5 @@ A form's fields and its buttons sit in two rows, one for what you fill in and on | ||
| 4 | 4 | |
| 5 | 5 | - `.field-row` — a row of `.field`s side by side that wraps on a narrow screen, each at least 160px wide and aligned to the bottom so a labelled input and the button beside it share a baseline. Styled in `style/components.ts`; written by `createForm` in `pages/branches.ts` (the new-branch form), the graph filter in `pages/fleet-graph.ts`, the freeze form in `pages/frozen.ts` and the new-pull form in `pages/pulls.ts`. |
| 6 | 6 | - `.form-actions` — the row of a form's buttons under its fields: 16px above, 8px apart, wrapping. Styled in `style/components.ts`, and in `style/content.ts` for a thread's close reason and "close into" input and for `.comment-form` on a narrow screen; written by nearly every form in `src/web/pages/` (`account.ts`, `settings.ts`, `crew.ts`, `edit.ts`, `issues.ts`, `pulls.ts`, `oauth.ts`, `teams.ts`, `chain.ts`, `everyday.ts`) and by the review comment form in `diff-view.ts`; `client.ts` finds its `.muted` note to report a save. |
| 7 | ||
| 8 | - The gate break-glass (#7790) on the pull's Files step is a plain `field()` with a hint and a `.form-actions` row holding one `btn` in the `danger` variant; no new class. It sits under the disabled primary Merge button, so the page keeps one accent. |
docs/features/gate-bypass.md added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | # The gate cannot lock itself out (#7790) | |
| 2 | ||
| 3 | On 2026-11-01 a bug in the live gate could not be fixed through the gate, and landing stopped for about 4.7 days. | |
| 4 | ||
| 5 | - **It says so.** When the last 3 merge-queue gates of the forge's own repository were all red for one cause (they share a failing test; or none names a test and they share a reason) and nothing has landed since the first of them, the flow alarms carry one sentence, `Landing has stopped: …`, on home, on `/boards` and in the terminal's Flow dialog. Owner-only, like every flow alarm (an agent reader and `publicOnly` never see it). "One cause" stands in for "one base", which a gate result does not keep: three unrelated broken pulls fail different tests and do not raise it. `checks/gate_stopped.ts`. | |
| 6 | - **An owner lands one named pull past it, only while it holds.** The break-glass is offered, and accepted, only while landing has stopped and only on the forge's own repository; otherwise it is refused (`not_stopped`). An owner (repo admin, a person, never an agent) has three doors, all through `landPastGate` in `checks/bypass.ts`: | |
| 7 | - the pull's Files step: a "Land past the gate" form (a labelled reason field and a danger button, plain post, no script needed); | |
| 8 | - MCP: `land_past_gate { repo, number, reason, dryRun: false }` (planned unless `dryRun` is false; the plan checks who, the stop and the reason, as the apply does); | |
| 9 | - the terminal: `L` on the pull screen asks the reason, shows it back in a confirm, then lands. | |
| 10 | The reason (8–500 characters) is written to the pull's trail first, then the pull lands, then `pull.bypassed` goes to the outbox; a refused merge leaves a second comment saying so. Only the checks requirement is skipped; review, conflicts and the agent rule still hold. | |
| 11 | - Test: `test/gate-bypass.test.ts`. | |
| 12 | ||
| 13 | Not done: running the gate from a pinned earlier release rather than the tree being judged. |
src/actions/pulls.ts +3 −1
| @@ -363,6 +363,8 @@ export async function mergePull( | ||
| 363 | 363 | strategy: MergeStrategy, |
| 364 | 364 | /** The merge queue's guard: land only onto this base, as this tree (#2036). */ |
| 365 | 365 | expect?: LandingExpect, |
| 366 | /** `pastGate`: the owner's break-glass (`checks/bypass.ts`) skips the checks requirement. */ | |
| 367 | opts: { readonly pastGate?: boolean } = {}, | |
| 366 | 368 | ): Promise< |
| 367 | 369 | Result<{ mergeOid: string; carried: boolean }, AccessError | MergePullError> |
| 368 | 370 | > { |
| @@ -409,7 +411,7 @@ export async function mergePull( | ||
| 409 | 411 | gate: { |
| 410 | 412 | db: app.db, |
| 411 | 413 | requireReview: repo.requireReview, |
| 412 | requireChecks: repo.requireChecks, | |
| 414 | requireChecks: repo.requireChecks && opts.pastGate !== true, | |
| 413 | 415 | }, |
| 414 | 416 | strategy, |
| 415 | 417 | ...(expect === undefined ? {} : { expect }), |
src/checks/bypass.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** | |
| 2 | * The break-glass (#7790): a gate with a bug cannot judge the pull that fixes | |
| 3 | * it. While landing has stopped (`gate_stopped.ts`, the forge's own repository | |
| 4 | * only), an owner lands one named pull past the checks with one recorded | |
| 5 | * action: a reason, on the pull's trail as a comment and on the outbox as | |
| 6 | * `pull.bypassed`. Review and conflicts still count, and an agent never does | |
| 7 | * it (rule 10). The intent is written to the trail before the merge, so a | |
| 8 | * landing always has its record. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { App } from "../app.ts"; | |
| 12 | import { mergePull } from "../actions/pulls.ts"; | |
| 13 | import { addComment } from "../actions/issues.ts"; | |
| 14 | import { canAdmin, type Repo } from "../core/repos.ts"; | |
| 15 | import type { Pull } from "../core/pulls.ts"; | |
| 16 | import type { User } from "../core/users.ts"; | |
| 17 | import { getIssueById } from "../core/issues.ts"; | |
| 18 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 19 | import { ownLandingStopped } from "./gate_stopped.ts"; | |
| 20 | ||
| 21 | export const MIN_REASON = 8; | |
| 22 | export const MAX_REASON = 500; | |
| 23 | ||
| 24 | /** What `mergePull` refuses with. */ | |
| 25 | type MergeError = Extract<Awaited<ReturnType<typeof mergePull>>, { ok: false }>["error"]; | |
| 26 | ||
| 27 | export type BypassError = | |
| 28 | | { readonly kind: "forbidden" | "not_found" | "reason" | "not_stopped" } | |
| 29 | | { readonly kind: "merge"; readonly error: MergeError }; | |
| 30 | ||
| 31 | /** The reason as the trail will say it, or undefined when it is too short or too long. */ | |
| 32 | export function cleanReason(text: string): string | undefined { | |
| 33 | const reason = text.replace(/\s+/g, " ").trim(); | |
| 34 | return reason.length < MIN_REASON || reason.length > MAX_REASON ? undefined : reason; | |
| 35 | } | |
| 36 | ||
| 37 | /** Whether this person may be offered the break-glass on this repository right now. */ | |
| 38 | export function bypassOffered(app: App, actor: User | undefined, repo: Repo): boolean { | |
| 39 | return ( | |
| 40 | actor !== undefined && | |
| 41 | actor.kind !== "agent" && | |
| 42 | canAdmin(app.db, repo, actor) && | |
| 43 | ownLandingStopped(app.db, repo) !== undefined | |
| 44 | ); | |
| 45 | } | |
| 46 | ||
| 47 | /** What stops the break-glass before any reason is read, or undefined. */ | |
| 48 | export function bypassRefusal(app: App, actor: User | undefined, repo: Repo, pull: Pull): BypassError | undefined { | |
| 49 | if (actor === undefined || actor.kind === "agent" || !canAdmin(app.db, repo, actor)) return { kind: "forbidden" }; | |
| 50 | if (pull.repoId !== repo.id) return { kind: "not_found" }; | |
| 51 | if (ownLandingStopped(app.db, repo) === undefined) return { kind: "not_stopped" }; | |
| 52 | return undefined; | |
| 53 | } | |
| 54 | ||
| 55 | /** The refusal a plan (a dry run) would meet, in order: who, whether stopped, the reason. */ | |
| 56 | export function planBypass(app: App, actor: User | undefined, repo: Repo, pull: Pull, reasonText: string): BypassError | undefined { | |
| 57 | return bypassRefusal(app, actor, repo, pull) ?? (cleanReason(reasonText) === undefined ? { kind: "reason" } : undefined); | |
| 58 | } | |
| 59 | ||
| 60 | /** Land `pull` past the gate. `reasonText` is what the trail will say. */ | |
| 61 | export async function landPastGate( | |
| 62 | app: App, | |
| 63 | actor: User | undefined, | |
| 64 | repo: Repo, | |
| 65 | pull: Pull, | |
| 66 | reasonText: string, | |
| 67 | ): Promise<Result<{ mergeOid: string }, BypassError>> { | |
| 68 | const refused = planBypass(app, actor, repo, pull, reasonText); | |
| 69 | if (refused !== undefined || actor === undefined) return err(refused ?? { kind: "forbidden" }); | |
| 70 | const reason = cleanReason(reasonText)!; | |
| 71 | const issue = getIssueById(app.db, pull.id); | |
| 72 | if (issue !== undefined) | |
| 73 | addComment(app, actor, repo, issue, `Landing past the gate, by @${actor.username}: ${reason}`); | |
| 74 | const merged = await mergePull(app, actor, repo, pull, "squash", undefined, { pastGate: true }); | |
| 75 | if (!merged.ok) { | |
| 76 | if (issue !== undefined) addComment(app, actor, repo, issue, "That did not land: the merge was refused, and nothing changed."); | |
| 77 | return err({ kind: "merge", error: merged.error }); | |
| 78 | } | |
| 79 | app.bus.append({ | |
| 80 | kind: "pull.bypassed", | |
| 81 | repoId: repo.id, | |
| 82 | actorId: actor.id, | |
| 83 | payload: { number: pull.number, reason, mergeOid: merged.value.mergeOid }, | |
| 84 | }); | |
| 85 | return ok({ mergeOid: merged.value.mergeOid }); | |
| 86 | } |
src/checks/gate_stopped.ts added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | /** | |
| 2 | * Landing has stopped (#7790): the last {@link STOPPED_GATES} merge-queue | |
| 3 | * gates of a repository all went red for one cause and nothing has landed | |
| 4 | * since the first of them. "One cause" stands in for "one base" (a gate keeps | |
| 5 | * its candidate's tip, not its base): the three share a failing test, or all | |
| 6 | * failed with no test named and the same reason. Three unrelated broken pulls | |
| 7 | * fail different tests and do not make a stop. A bug in the gate itself looks | |
| 8 | * exactly so, and the gate cannot judge the pull that fixes it; the owner's way | |
| 9 | * past is `checks/bypass.ts`, offered only while this holds, and only for the | |
| 10 | * forge's own repository. Plain data: rows in, one record out. | |
| 11 | */ | |
| 12 | ||
| 13 | import type { Database } from "bun:sqlite"; | |
| 14 | import { getRepo } from "../core/repos.ts"; | |
| 15 | import { ownRepoPath } from "../kernel/own_repo.ts"; | |
| 16 | ||
| 17 | /** How many gates in a row, red on one base, make landing stopped. */ | |
| 18 | export const STOPPED_GATES = 3; | |
| 19 | ||
| 20 | export interface Stopped { | |
| 21 | /** When the earliest of the red gates finished. */ | |
| 22 | readonly since: string; | |
| 23 | readonly gates: number; | |
| 24 | } | |
| 25 | ||
| 26 | /** The repository's stop, if its last gates are all red on a base nothing has landed on since. */ | |
| 27 | export function landingStopped(db: Database, repoId: string): Stopped | undefined { | |
| 28 | const gates = db | |
| 29 | .query<{ state: string; finished_at: string; reason: string | null; tests: string }, [string, number]>( | |
| 30 | "SELECT state, finished_at, reason, tests FROM gate_result WHERE repo_id = ?1 ORDER BY id DESC LIMIT ?2", | |
| 31 | ) | |
| 32 | .all(repoId, STOPPED_GATES); | |
| 33 | if (gates.length < STOPPED_GATES || gates.some((g) => g.state !== "red")) return undefined; | |
| 34 | if (!oneCause(gates)) return undefined; | |
| 35 | const since = gates.reduce((a, g) => (g.finished_at < a ? g.finished_at : a), gates[0]!.finished_at); | |
| 36 | const landed = db | |
| 37 | .query<{ at: string | null }, [string]>( | |
| 38 | `SELECT MAX(p.merged_at) AS at FROM pull p JOIN issue i ON i.id = p.issue_id WHERE i.repo_id = ?1`, | |
| 39 | ) | |
| 40 | .get(repoId); | |
| 41 | if (landed?.at != null && landed.at >= since) return undefined; | |
| 42 | return { since, gates: gates.length }; | |
| 43 | } | |
| 44 | ||
| 45 | /** The failing test names a gate row kept; a row this build cannot read names none. */ | |
| 46 | function failingNames(tests: string): string[] { | |
| 47 | try { | |
| 48 | const list: unknown = JSON.parse(tests); | |
| 49 | if (!Array.isArray(list)) return []; | |
| 50 | return list.flatMap((t: unknown) => | |
| 51 | typeof t === "object" && t !== null && typeof (t as { name?: unknown }).name === "string" ? [(t as { name: string }).name] : [], | |
| 52 | ); | |
| 53 | } catch { | |
| 54 | return []; | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | /** Whether the red gates share one cause: a failing test in all, or no test named and one reason. */ | |
| 59 | function oneCause(gates: readonly { reason: string | null; tests: string }[]): boolean { | |
| 60 | const names = gates.map((g) => new Set(failingNames(g.tests))); | |
| 61 | if (names.every((n) => n.size === 0)) return gates.every((g) => g.reason !== null && g.reason === gates[0]!.reason); | |
| 62 | return [...names[0]!].some((n) => names.every((set) => set.has(n))); | |
| 63 | } | |
| 64 | ||
| 65 | /** The stop, only for the forge's own repository: the one place the break-glass is offered. */ | |
| 66 | export function ownLandingStopped(db: Database, repo: { readonly id: string }): Stopped | undefined { | |
| 67 | const [owner, name] = ownRepoPath().split("/"); | |
| 68 | const own = owner && name ? getRepo(db, owner, name) : undefined; | |
| 69 | return own !== undefined && own.id === repo.id ? landingStopped(db, own.id) : undefined; | |
| 70 | } | |
| 71 | ||
| 72 | /** The one sentence home and the pipeline carry. */ | |
| 73 | export const stoppedSentence = (s: Stopped): string => | |
| 74 | `Landing has stopped: the last ${s.gates} gates were all red on the same base since ${s.since.slice(0, 16).replace("T", " ")} UTC, so no pull can pass; an owner can land one past the gate with a reason.`; |
src/core/event_audience.ts +1
| @@ -70,6 +70,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([ | ||
| 70 | 70 | "pull.reopened", |
| 71 | 71 | "pull.opened", |
| 72 | 72 | "pull.merged", |
| 73 | "pull.bypassed", | |
| 73 | 74 | "issue.opened", |
| 74 | 75 | "spend.reported", |
| 75 | 76 | "forge.proposal", |
src/flow/alarms.ts +6 −1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { landingStopped, stoppedSentence } from "../checks/gate_stopped.ts"; | |
| 10 | 11 | import { redHref, redMain } from "../checks/red_main.ts"; |
| 11 | 12 | import { getRepo } from "../core/repos.ts"; |
| 12 | 13 | import { ownRepoPath } from "../kernel/own_repo.ts"; |
| @@ -16,7 +17,7 @@ import { redMainAlarm } from "./alarm_red_main.ts"; | ||
| 16 | 17 | import { alarmStuck } from "./alarm_stuck.ts"; |
| 17 | 18 | |
| 18 | 19 | export interface FiredAlarm { |
| 19 | readonly key: "red_main" | "stuck" | "idle_crew" | "no_release"; | |
| 20 | readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release"; | |
| 20 | 21 | readonly says: string; |
| 21 | 22 | readonly since: string; |
| 22 | 23 | /** A same-origin path to the item the alarm names. */ |
| @@ -36,6 +37,10 @@ export function firedAlarms( | ||
| 36 | 37 | opts: { readonly publicOnly?: boolean; readonly ceilingUsd?: number | null } = {}, |
| 37 | 38 | ): FiredAlarm[] { |
| 38 | 39 | const out: FiredAlarm[] = []; |
| 40 | const [owner, name] = ownRepoPath().split("/"); | |
| 41 | const own = owner && name ? getRepo(db, owner, name) : undefined; | |
| 42 | const stopped = own && !opts.publicOnly ? landingStopped(db, own.id) : undefined; | |
| 43 | if (stopped) out.push({ key: "landing_stopped", says: stoppedSentence(stopped), since: stopped.since, href: "/boards" }); | |
| 39 | 44 | const red = redMainAlarm(db, now); |
| 40 | 45 | if (red.fired) out.push({ key: "red_main", says: red.says, since: red.since!, href: redMainHref(db) }); |
| 41 | 46 | const stuck = alarmStuck(db, now, opts.publicOnly ? { publicOnly: true } : {}); |
src/http/mcp/tools/index.ts +1
| @@ -71,6 +71,7 @@ export { tool as invites } from "./invites.ts"; | ||
| 71 | 71 | export { tool as issue } from "./issue.ts"; |
| 72 | 72 | export { tool as issue_teams } from "./issue_teams.ts"; |
| 73 | 73 | export { tool as labels } from "./labels.ts"; |
| 74 | export { tool as land_past_gate } from "./land_past_gate.ts"; | |
| 74 | 75 | export { tool as limb } from "./limb.ts"; |
| 75 | 76 | export { tool as limits } from "./limits.ts"; |
| 76 | 77 | export { tool as make_it_yours } from "./make_it_yours.ts"; |
src/http/mcp/tools/land_past_gate.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | import { mayView } from "../../../core/sealed.ts"; | |
| 2 | import { landPastGate, planBypass, type BypassError } from "../../../checks/bypass.ts"; | |
| 3 | import { getPull } from "../../../core/pulls.ts"; | |
| 4 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; | |
| 5 | ||
| 6 | /** | |
| 7 | * The owner's break-glass (#7790) from any MCP client: the pull page's | |
| 8 | * "Land past the gate", in the same words. A person who administers the | |
| 9 | * repository, never an agent; the reason lands on the pull's trail. | |
| 10 | */ | |
| 11 | export const tool: Tool = { | |
| 12 | name: "land_past_gate", | |
| 13 | doc: "`{ repo, number, reason, dryRun? }` (an owner, never an agent): land one named pull past the checks when landing has stopped (the forge's own repository, three red gates of one cause); `reason` (8 to 500 characters) is recorded on the pull's trail and the outbox as `pull.bypassed`; planned unless `dryRun` is false; takes an rw token", | |
| 14 | description: "Land one pull past a broken gate, with a recorded reason; an owner's only.", | |
| 15 | inputSchema: { | |
| 16 | type: "object", | |
| 17 | properties: { | |
| 18 | repo: REPO_ARG, | |
| 19 | number: { type: "integer", minimum: 1 }, | |
| 20 | reason: { type: "string", minLength: 8, maxLength: 500 }, | |
| 21 | dryRun: { type: "boolean" }, | |
| 22 | }, | |
| 23 | required: ["repo", "number", "reason"], | |
| 24 | }, | |
| 25 | writes: true, | |
| 26 | dryByDefault: true, | |
| 27 | async run(ctx, user, args) { | |
| 28 | if (user.kind === "agent") return bad("forbidden", "the crew proposes and an owner lands; an agent may not bypass the gate"); | |
| 29 | const repo = repoArg(ctx, user, args); | |
| 30 | if (isAnswer(repo)) return repo; | |
| 31 | const n = args["number"]; | |
| 32 | const pull = typeof n === "number" && Number.isInteger(n) && n > 0 ? getPull(ctx.app.db, repo.id, n) : undefined; | |
| 33 | if (pull === undefined || !mayView(ctx.app.db, repo, pull, user)) return bad("no_such_pull", `no pull #${String(n)}`); | |
| 34 | const reason = typeof args["reason"] === "string" ? args["reason"] : ""; | |
| 35 | const refused = planBypass(ctx.app, user, repo, pull, reason); | |
| 36 | if (refused !== undefined) return refusal(refused); | |
| 37 | if (args["dryRun"] !== false) | |
| 38 | return good({ dryRun: true, number: pull.number, reason, says: "This is the plan; nothing changed. Send dryRun: false to land past the gate." }); | |
| 39 | const landed = await landPastGate(ctx.app, user, repo, pull, reason); | |
| 40 | if (!landed.ok) return refusal(landed.error); | |
| 41 | return good({ dryRun: false, landed: true, mergeOid: landed.value.mergeOid, says: `Landed #${pull.number} past the gate; the reason is on its trail.` }); | |
| 42 | }, | |
| 43 | }; | |
| 44 | ||
| 45 | function refusal(e: BypassError): ReturnType<typeof bad> { | |
| 46 | switch (e.kind) { | |
| 47 | case "reason": | |
| 48 | return bad("reason", "the reason must be 8 to 500 characters"); | |
| 49 | case "not_stopped": | |
| 50 | return bad("not_stopped", "landing has not stopped; wait for the checks, there is no way past the gate"); | |
| 51 | case "merge": | |
| 52 | return bad("refused", `the merge was refused: ${typeof e.error === "string" ? e.error : e.error.kind}`); | |
| 53 | case "not_found": | |
| 54 | return bad("no_such_pull", "no such pull"); | |
| 55 | default: | |
| 56 | return bad("forbidden", "only an owner may land a pull past the gate"); | |
| 57 | } | |
| 58 | } |
src/http/server.ts +2
| @@ -357,6 +357,7 @@ import { | ||
| 357 | 357 | pullFilesPage, |
| 358 | 358 | pullChecksFragment, |
| 359 | 359 | pullMergeSubmit, |
| 360 | pullLandPastSubmit, | |
| 360 | 361 | pullChecksSubmit, |
| 361 | 362 | pullReviewStateSubmit, |
| 362 | 363 | pullApproveHereSubmit, |
| @@ -1259,6 +1260,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1259 | 1260 | threadResolveSubmit(false), |
| 1260 | 1261 | ); |
| 1261 | 1262 | router.add("POST", "/:owner/:name/pulls/:number/merge", pullMergeSubmit); |
| 1263 | router.add("POST", "/:owner/:name/pulls/:number/land-past", pullLandPastSubmit); | |
| 1262 | 1264 | router.add("POST", "/:owner/:name/pulls/:number/state", (ctx) => |
| 1263 | 1265 | stateSubmit(ctx, "pull"), |
| 1264 | 1266 | ); |
src/tui/screens/pull.ts +59
| @@ -41,6 +41,7 @@ import { | ||
| 41 | 41 | } from "../../actions/pulls.ts"; |
| 42 | 42 | import { holdPull, releasePull } from "../../actions/holds.ts"; |
| 43 | 43 | import { getHold, type Hold } from "../../core/holds.ts"; |
| 44 | import { bypassOffered, cleanReason, landPastGate } from "../../checks/bypass.ts"; | |
| 44 | 45 | import { revertPull } from "../../actions/revert.ts"; |
| 45 | 46 | import { takeBackWords } from "../../actions/revert_words.ts"; |
| 46 | 47 | import { openEnvUrl } from "../../actions/apps.ts"; |
| @@ -691,6 +692,59 @@ export function pullScreen( | ||
| 691 | 692 | }); |
| 692 | 693 | }; |
| 693 | 694 | |
| 695 | /** | |
| 696 | * Whether `L` is on offer: an open pull, an owner who is a person, and | |
| 697 | * landing stopped (`checks/bypass.ts`). The same rule the page's form follows. | |
| 698 | */ | |
| 699 | const mayLandPast = (): boolean => { | |
| 700 | if (repo === undefined || !bypassOffered(f.app, f.user, repo)) return false; | |
| 701 | const row = getPull(f.db, repo.id, number); | |
| 702 | return row !== undefined && row.mergedAt === null && detail?.issue.state === "open"; | |
| 703 | }; | |
| 704 | ||
| 705 | /** `L` — a reason, then a plan to confirm, then the landing; `landPastGate` decides. */ | |
| 706 | const landPast = (): void => { | |
| 707 | if (repo === undefined) return; | |
| 708 | const target = repo; | |
| 709 | ctx.push( | |
| 710 | promptScreen(ctx, screen, { | |
| 711 | title: `Land #${number} past the gate`, | |
| 712 | label: "Why this lands without its checks", | |
| 713 | hint: "Landing has stopped. 8 to 500 characters; it goes on the pull's trail.", | |
| 714 | onSubmit: (value) => { | |
| 715 | const reason = cleanReason(value); | |
| 716 | if (reason === undefined) return "Say why in 8 to 500 characters."; | |
| 717 | // After the prompt closes itself, so the confirm is not the screen it pops. | |
| 718 | return Promise.resolve().then(() => ctx.push( | |
| 719 | confirmScreen(ctx, screen, { | |
| 720 | title: `Land #${number} past the gate`, | |
| 721 | body: [`Land "${getPull(f.db, target.id, number)?.title ?? ""}" without its checks?`, `Reason: ${reason}`, "Review and conflicts still count."], | |
| 722 | actions: [{ label: "Cancel", variant: "ghost" }, { label: "Land past the gate", variant: "danger" }], | |
| 723 | onChoose: (choice) => { | |
| 724 | if (choice !== 1) return; | |
| 725 | runAsync(ctx, async () => { | |
| 726 | const row = getPull(f.db, target.id, number); | |
| 727 | if (row === undefined) return; | |
| 728 | const landed = await landPastGate(f.app, f.user, target, row, reason); | |
| 729 | if (!landed.ok) { | |
| 730 | const e = landed.error; | |
| 731 | ctx.toast( | |
| 732 | e.kind === "merge" ? errorText(String(typeof e.error === "string" ? e.error : e.error.kind)) : `not landed: ${e.kind.replace("_", " ")}`, | |
| 733 | "danger", | |
| 734 | ); | |
| 735 | return; | |
| 736 | } | |
| 737 | await reload(); | |
| 738 | ctx.toast(`landed past the gate as ${landed.value.mergeOid.slice(0, 8)}`, "success"); | |
| 739 | }); | |
| 740 | }, | |
| 741 | }), | |
| 742 | )); | |
| 743 | }, | |
| 744 | }), | |
| 745 | ); | |
| 746 | }; | |
| 747 | ||
| 694 | 748 | /** |
| 695 | 749 | * Whether `T` takes this back: a shipped pull on a local repository, for a |
| 696 | 750 | * person who may write. The same gate `revertPull` holds; it decides only |
| @@ -866,6 +920,7 @@ export function pullScreen( | ||
| 866 | 920 | ? [binding(["v"], "see the conflict blocks")] |
| 867 | 921 | : []), |
| 868 | 922 | binding(["m"], "merge", { disabled: state() !== "ready" }), |
| 923 | ...(mayLandPast() ? [binding(["L"], "land past the gate")] : []), | |
| 869 | 924 | ...(tab === 1 && view.threadHere() !== null |
| 870 | 925 | ? [ |
| 871 | 926 | binding( |
| @@ -1064,6 +1119,10 @@ export function pullScreen( | ||
| 1064 | 1119 | ); |
| 1065 | 1120 | return true; |
| 1066 | 1121 | } |
| 1122 | if (e.key === "L" && mayLandPast()) { | |
| 1123 | landPast(); | |
| 1124 | return true; | |
| 1125 | } | |
| 1067 | 1126 | if (e.key === "R" && mayWrite()) { |
| 1068 | 1127 | review(); |
| 1069 | 1128 | return true; |
src/web/pages/pull.ts +46 −3
| @@ -54,7 +54,8 @@ import { | ||
| 54 | 54 | type Side, |
| 55 | 55 | } from "../../core/reviews.ts"; |
| 56 | 56 | import { getUsersByIds } from "../../core/users.ts"; |
| 57 | import { canWrite, getRepoById, type Repo } from "../../core/repos.ts"; | |
| 57 | import { canAdmin, canWrite, getRepoById, type Repo } from "../../core/repos.ts"; | |
| 58 | import { bypassOffered, landPastGate, type BypassError } from "../../checks/bypass.ts"; | |
| 58 | 59 | import { commitLog, readerFor } from "../../git/read.ts"; |
| 59 | 60 | import { wallOf } from "../../contract/policy.ts"; |
| 60 | 61 | import type { Ctx } from "../../http/context.ts"; |
| @@ -87,6 +88,7 @@ import { | ||
| 87 | 88 | badge, |
| 88 | 89 | btn, |
| 89 | 90 | card, |
| 91 | field as formField, | |
| 90 | 92 | icon, |
| 91 | 93 | labelCss, |
| 92 | 94 | queuedPhrase, |
| @@ -587,12 +589,25 @@ function mergeBox( | ||
| 587 | 589 | "Waiting on checks.", |
| 588 | 590 | html`${why} ${reviewsChip(status.reviews, by)} ${checks}`, |
| 589 | 591 | when(mayMerge, () => |
| 590 | btn("Merge pull request", { | |
| 592 | html`${btn("Merge pull request", { | |
| 591 | 593 | variant: "primary", |
| 592 | 594 | icon: "merge", |
| 593 | 595 | disabled: true, |
| 594 | 596 | title: "A passing deploy of this head is required first", |
| 595 | }), | |
| 597 | })}${when( | |
outdated
Gate this on the landing-stopped state, and use the named form/button components instead of hand-rolled markup (see body items 2 and 3). |
||
| 598 | bypassOffered(ctx.app, ctx.auth.user, repo), | |
| 599 | () => html`<form method="post" action="${thread}/land-past"> | |
| 600 | ${formField({ | |
| 601 | name: "reason", | |
| 602 | label: "Land past the gate", | |
| 603 | hint: "Landing has stopped. Say why this pull lands without its checks; it goes on the pull's trail. Review and conflicts still count.", | |
| 604 | required: true, | |
| 605 | minlength: 8, | |
| 606 | maxlength: 500, | |
| 607 | })} | |
| 608 | <div class="form-actions">${btn("Land past the gate", { variant: "danger", icon: "merge" })}</div> | |
| 609 | </form>`, | |
| 610 | )}`, | |
| 596 | 611 | ), |
| 597 | 612 | ); |
| 598 | 613 | } |
| @@ -1376,6 +1391,34 @@ export async function pullMergeSubmit(ctx: Ctx): Promise<Response> { | ||
| 1376 | 1391 | return flashTo(ctx, dest, { message: "Pull request merged." }); |
| 1377 | 1392 | } |
| 1378 | 1393 | |
| 1394 | // POST /:owner/:name/pulls/:number/land-past — the owner's break-glass (#7790). | |
| 1395 | export async function pullLandPastSubmit(ctx: Ctx): Promise<Response> { | |
| 1396 | const found = pullContext(ctx); | |
| 1397 | if (found instanceof Response) return found; | |
| 1398 | const user = ctx.auth.user; | |
| 1399 | if (!user || user.kind === "agent" || !canAdmin(ctx.app.db, found.repo, user)) | |
| 1400 | return new Response("forbidden", { status: 403 }); | |
| 1401 | const form = await readForm(ctx, "rw"); | |
| 1402 | if (form instanceof Response) return form; | |
| 1403 | const dest = `${repoBase(found.repo)}/pulls/${found.pull.number}`; | |
| 1404 | const landed = await landPastGate(ctx.app, user, found.repo, found.pull, field(form, "reason") ?? ""); | |
| 1405 | if (!landed.ok) return flashTo(ctx, dest, { tone: "danger", message: bypassWords(landed.error) }); | |
| 1406 | return flashTo(ctx, dest, { message: "Landed past the gate; the reason is on the trail." }); | |
| 1407 | } | |
| 1408 | ||
| 1409 | function bypassWords(e: BypassError): string { | |
| 1410 | switch (e.kind) { | |
| 1411 | case "reason": | |
| 1412 | return "Say why in 8 to 500 characters: it goes on the pull's trail."; | |
| 1413 | case "not_stopped": | |
| 1414 | return "Landing has not stopped, so there is no way past the gate: wait for the checks."; | |
| 1415 | case "merge": | |
| 1416 | return `Not landed: ${mergeFailure(e.error)}`; | |
| 1417 | default: | |
| 1418 | return "Only an owner can land a pull past the gate."; | |
| 1419 | } | |
| 1420 | } | |
| 1421 | ||
| 1379 | 1422 | /** |
| 1380 | 1423 | * GET /:owner/:name/pulls/:number/checks-line — the checks line alone, for |
| 1381 | 1424 | * the region that shows it. Authorized as the page is: a viewer who cannot |
test/gate-bypass.test.ts added +348
| @@ -0,0 +1,348 @@ | ||
| 1 | /** | |
| 2 | * The gate cannot lock itself out (#7790): three red gates of one cause say | |
| 3 | * landing has stopped, in the flow alarms (owner-only); only then, and only | |
| 4 | * on the forge's own repository, an owner lands one named pull past the | |
| 5 | * checks with a recorded reason, from the page, the MCP and the terminal. | |
| 6 | * Review and conflicts still hold; the next pull lands normally. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { landPastGate } from "../src/checks/bypass.ts"; | |
| 16 | import { landingStopped } from "../src/checks/gate_stopped.ts"; | |
| 17 | import { keepGateResult } from "../src/checks/gate_result.ts"; | |
| 18 | import { createAgentRow } from "../src/core/agents.ts"; | |
| 19 | import { claimRun, createRun, finishRun, setRunBase } from "../src/core/runs.ts"; | |
| 20 | import { getPull, type Pull } from "../src/core/pulls.ts"; | |
| 21 | import { listComments } from "../src/core/issues.ts"; | |
| 22 | import { updateRepoSettings, type Repo } from "../src/core/repos.ts"; | |
| 23 | import { createSession } from "../src/core/sessions.ts"; | |
| 24 | import { createToken } from "../src/core/tokens.ts"; | |
| 25 | import { createUser, type User } from "../src/core/users.ts"; | |
| 26 | import { firedAlarms } from "../src/flow/alarms.ts"; | |
| 27 | import { resolveOid } from "../src/git/merge.ts"; | |
| 28 | import { repoDir } from "../src/git/repos.ts"; | |
| 29 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 30 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 31 | import { pullScreen } from "../src/tui/screens/pull.ts"; | |
| 32 | import { local } from "../src/tui/screens/where.ts"; | |
| 33 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 34 | import { quietLogs } from "./helpers/globals.ts"; | |
| 35 | import { settleApp } from "./helpers/tui.ts"; | |
| 36 | ||
| 37 | quietLogs(); | |
| 38 | ||
| 39 | describe("landing past a broken gate", () => { | |
| 40 | let dataDir: string; | |
| 41 | let app: App; | |
| 42 | let servers: Servers; | |
| 43 | let owner: User; | |
| 44 | let outsider: User; | |
| 45 | let agent: User; | |
| 46 | let repo: Repo; | |
| 47 | let other: Repo; | |
| 48 | let ownerCookie: string; | |
| 49 | let n = 0; | |
| 50 | const HOUR = 3_600_000; | |
| 51 | ||
| 52 | const pullIn = async (r: Repo, branch: string, file = `${branch}.txt`, content = `${branch}\n`) => { | |
| 53 | const made = await commitToBranch(app, owner, r, { branch, message: branch, files: [{ path: file, content }] }); | |
| 54 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 55 | const opened = await openPull(app, owner, r, { title: branch, baseBranch: "main", headBranch: branch }); | |
| 56 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 57 | return opened.value; | |
| 58 | }; | |
| 59 | const pullOf = (branch: string) => pullIn(repo, branch); | |
| 60 | const gate = (at: number, state: "red" | "green", failing: string[], reason = "tests") => { | |
| 61 | n += 1; | |
| 62 | keepGateResult(app.db, `gate-${String(n).padStart(4, "0")}`, { | |
| 63 | repoId: repo.id, | |
| 64 | runId: `gate-${n}`, | |
| 65 | tipOid: "a".repeat(40), | |
| 66 | pulls: [n], | |
| 67 | state, | |
| 68 | ...(state === "red" ? { reason } : {}), | |
| 69 | passed: 1, | |
| 70 | failed: failing.length, | |
| 71 | failing: failing.map((name) => ({ name, errors: [] })), | |
| 72 | startedAt: new Date(at).toISOString(), | |
| 73 | finishedAt: new Date(at).toISOString(), | |
| 74 | }); | |
| 75 | }; | |
| 76 | /** Three red gates of one cause, in the future so no earlier landing clears them. */ | |
| 77 | const stop = () => { | |
| 78 | const t = Date.now() + HOUR; | |
| 79 | for (let i = 0; i < 3; i++) gate(t + i * 1000, "red", ["the gate > is broken", `unrelated ${i}`]); | |
| 80 | }; | |
| 81 | const green = async (pull: Pull) => { | |
| 82 | const base = (await resolveOid(repoDir(app.reposRoot, "trevato", "forge"), "refs/heads/main"))!; | |
| 83 | const run = createRun(app.db, { repoId: repo.id, kind: "check", issueId: pull.id, env: "pull", commitOid: pull.headOid }); | |
| 84 | claimRun(app.db, run.id); | |
| 85 | finishRun(app.db, run.id, { state: "ok", summary: "all green" }); | |
| 86 | setRunBase(app.db, run.id, base); | |
| 87 | }; | |
| 88 | const post = (p: Pull, cookie: string | null, reason: string, extra: Record<string, string> = {}, bearer?: string) => | |
| 89 | fetch(`${servers.url}/trevato/forge/pulls/${p.number}/land-past`, { | |
| 90 | method: "POST", | |
| 91 | redirect: "manual", | |
| 92 | headers: { | |
| 93 | "content-type": "application/x-www-form-urlencoded", | |
| 94 | ...(cookie === null ? {} : { cookie }), | |
| 95 | ...(bearer === undefined ? {} : { authorization: `Bearer ${bearer}` }), | |
| 96 | ...extra, | |
| 97 | }, | |
| 98 | body: new URLSearchParams({ reason }), | |
| 99 | }); | |
| 100 | const page = async (p: Pull, cookie: string) => | |
| 101 | (await fetch(`${servers.url}/trevato/forge/pulls/${p.number}/files`, { headers: { cookie } })).text(); | |
| 102 | const mcp = async (token: string, args: Record<string, unknown>) => { | |
| 103 | const res = await fetch(`${servers.url}/mcp`, { | |
| 104 | method: "POST", | |
| 105 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 106 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "land_past_gate", arguments: args } }), | |
| 107 | }); | |
| 108 | const body: any = await res.json(); | |
| 109 | const text: string = body.result.content[0].text; | |
| 110 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 111 | }; | |
| 112 | const isMerged = (p: Pull) => getPull(app.db, repo.id, p.number)?.mergedAt != null; | |
| 113 | ||
| 114 | beforeAll(async () => { | |
| 115 | dataDir = await mkdtemp(join(tmpdir(), "forge-bypass-")); | |
| 116 | app = createApp({ dataDir, port: 0 }); | |
| 117 | servers = await serve(app); | |
| 118 | const mk = async (username: string, first = false, isAgent = false) => { | |
| 119 | const made = await createUser(app.db, { | |
| 120 | origin: isAgent ? "agent" : "seed", | |
| 121 | trusted: true, | |
| 122 | username, | |
| 123 | email: `${username}@forge.local`, | |
| 124 | password: isAgent ? "" : "a-good-passphrase", | |
| 125 | adminIfFirst: first, | |
| 126 | ...(isAgent ? { agent: true } : {}), | |
| 127 | }); | |
| 128 | if (!made.ok) throw new Error(made.error); | |
| 129 | return made.value; | |
| 130 | }; | |
| 131 | owner = await mk("trevato", true); | |
| 132 | outsider = await mk("mallory"); | |
| 133 | agent = await mk("wright", false, true); | |
| 134 | createAgentRow(app.db, { userId: agent.id, brief: "Builds." }); | |
| 135 | ownerCookie = `forge_session=${createSession(app.db, owner.id).token}`; | |
| 136 | for (const [name, mine] of [["forge", true], ["elsewhere", false]] as const) { | |
| 137 | const made = await createRepo(app, owner, { name, visibility: "public", description: "f" }); | |
| 138 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 139 | const done = await commitToBranch(app, owner, made.value, { branch: "main", message: "seed", files: [{ path: "README.md", content: "x\n" }] }); | |
| 140 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 141 | const set = updateRepoSettings(app.db, made.value, { requireChecks: true }); | |
| 142 | if (mine) repo = set; | |
| 143 | else other = set; | |
| 144 | } | |
| 145 | }, 30_000); | |
| 146 | afterAll(async () => { | |
| 147 | servers.stop(); | |
| 148 | app.close(); | |
| 149 | await rm(dataDir, { recursive: true, force: true }); | |
| 150 | }); | |
| 151 | beforeEach(() => { | |
| 152 | app.db.query("DELETE FROM gate_result").run(); | |
| 153 | repo = updateRepoSettings(app.db, repo, { requireChecks: true, requireReview: false }); | |
| 154 | }); | |
| 155 | ||
| 156 | test("two red gates are not a stop; three of one cause are, in one sentence", () => { | |
| 157 | const t = Date.now() + HOUR; | |
| 158 | gate(t, "red", ["the gate > is broken"]); | |
| 159 | gate(t + 1, "red", ["the gate > is broken"]); | |
| 160 | expect(landingStopped(app.db, repo.id)).toBeUndefined(); | |
| 161 | gate(t + 2, "red", ["the gate > is broken", "other"]); | |
| 162 | expect(landingStopped(app.db, repo.id)?.gates).toBe(3); | |
| 163 | const alarm = firedAlarms(app.db, new Date()).find((a) => a.key === "landing_stopped"); | |
| 164 | expect(alarm?.says).toStartWith("Landing has stopped:"); | |
| 165 | expect(alarm?.says.split(". ").length).toBe(1); | |
| 166 | }); | |
| 167 | ||
| 168 | test("the alarm is the owner's: a public-only reader never sees it", () => { | |
| 169 | stop(); | |
| 170 | expect(firedAlarms(app.db, new Date()).some((a) => a.key === "landing_stopped")).toBe(true); | |
| 171 | expect(firedAlarms(app.db, new Date(), { publicOnly: true }).some((a) => a.key === "landing_stopped")).toBe(false); | |
| 172 | }); | |
| 173 | ||
| 174 | test("three red gates of different causes, or mixed with a green, are not a stop", () => { | |
| 175 | const t = Date.now() + HOUR; | |
| 176 | gate(t, "red", ["a > broke"]); | |
| 177 | gate(t + 1, "red", ["b > broke"]); | |
| 178 | gate(t + 2, "red", ["c > broke"]); | |
| 179 | expect(landingStopped(app.db, repo.id)).toBeUndefined(); | |
| 180 | gate(t + 3, "red", ["a > broke"]); | |
| 181 | gate(t + 4, "green", []); | |
| 182 | gate(t + 5, "red", ["a > broke"]); | |
| 183 | expect(landingStopped(app.db, repo.id)).toBeUndefined(); | |
| 184 | }); | |
| 185 | ||
| 186 | test("three reds followed by a landing do not alarm", async () => { | |
| 187 | const t = Date.now() - 5000; | |
| 188 | for (let i = 0; i < 3; i++) gate(t + i, "red", ["the gate > is broken"]); | |
| 189 | expect(landingStopped(app.db, repo.id)?.gates).toBe(3); | |
| 190 | const next = await pullOf("lands-after-reds"); | |
| 191 | await green(next); | |
| 192 | expect((await mergePull(app, owner, repo, next, "squash")).ok).toBe(true); | |
| 193 | expect(landingStopped(app.db, repo.id)).toBeUndefined(); | |
| 194 | expect(firedAlarms(app.db, new Date()).some((a) => a.key === "landing_stopped")).toBe(false); | |
| 195 | }); | |
| 196 | ||
| 197 | test("core: refused while landing is running, for an outsider, an agent, a short reason; then it lands and records", async () => { | |
| 198 | const fix = await pullOf("fix-the-gate"); | |
| 199 | expect(await landPastGate(app, owner, repo, fix, "a fine reason here")).toEqual({ ok: false, error: { kind: "not_stopped" } }); | |
| 200 | stop(); | |
| 201 | expect((await mergePull(app, owner, repo, fix, "squash")).ok).toBe(false); | |
| 202 | expect(await landPastGate(app, outsider, repo, fix, "a fine reason here")).toEqual({ ok: false, error: { kind: "forbidden" } }); | |
| 203 | expect(await landPastGate(app, agent, repo, fix, "a fine reason here")).toEqual({ ok: false, error: { kind: "forbidden" } }); | |
| 204 | expect(await landPastGate(app, owner, repo, fix, "no")).toEqual({ ok: false, error: { kind: "reason" } }); | |
| 205 | expect(isMerged(fix)).toBe(false); | |
| 206 | ||
| 207 | const landed = await landPastGate(app, owner, repo, fix, "the gate's own bug blocks its fix"); | |
| 208 | expect(landed.ok).toBe(true); | |
| 209 | expect(isMerged(fix)).toBe(true); | |
| 210 | const bodies = listComments(app.db, fix.id).map((c) => c.body); | |
| 211 | expect(bodies.some((b) => b.includes("past the gate") && b.includes("the gate's own bug blocks its fix"))).toBe(true); | |
| 212 | const event = app.db.query<{ payload: string }, []>("SELECT payload FROM event WHERE kind = 'pull.bypassed'").get(); | |
| 213 | expect(JSON.parse(event!.payload).reason).toBe("the gate's own bug blocks its fix"); | |
| 214 | ||
| 215 | // The next pull needs its green and lands by the usual road. | |
| 216 | app.db.query("DELETE FROM gate_result").run(); | |
| 217 | const next = await pullOf("next-change"); | |
| 218 | expect((await mergePull(app, owner, repo, next, "squash")).ok).toBe(false); | |
| 219 | await green(next); | |
| 220 | expect((await mergePull(app, owner, repo, next, "squash")).ok).toBe(true); | |
| 221 | }); | |
| 222 | ||
| 223 | test("core: only the forge's own repository has a break-glass", async () => { | |
| 224 | stop(); | |
| 225 | const p = await pullIn(other, "elsewhere-change"); | |
| 226 | expect(await landPastGate(app, owner, other, p, "a fine reason here")).toEqual({ ok: false, error: { kind: "not_stopped" } }); | |
| 227 | }); | |
| 228 | ||
| 229 | test("core: review and conflicts still hold, and the intent is on the trail even when the merge is refused", async () => { | |
| 230 | stop(); | |
| 231 | repo = updateRepoSettings(app.db, repo, { requireReview: true }); | |
| 232 | const unreviewed = await pullOf("needs-review"); | |
| 233 | const held = await landPastGate(app, owner, repo, unreviewed, "a fine reason here"); | |
| 234 | expect(held).toEqual({ ok: false, error: { kind: "merge", error: "review_required" } }); | |
| 235 | expect(isMerged(unreviewed)).toBe(false); | |
| 236 | const bodies = listComments(app.db, unreviewed.id).map((c) => c.body); | |
| 237 | expect(bodies.some((b) => b.startsWith("Landing past the gate"))).toBe(true); | |
| 238 | expect(app.db.query("SELECT 1 FROM event WHERE kind = 'pull.bypassed' AND payload LIKE ?1").get(`%"number":${unreviewed.number},%`)).toBeNull(); | |
| 239 | ||
| 240 | repo = updateRepoSettings(app.db, repo, { requireReview: false }); | |
| 241 | const clash = await pullIn(repo, "clash", "clash.txt", "theirs\n"); | |
| 242 | const moved = await commitToBranch(app, owner, repo, { branch: "main", message: "mine", files: [{ path: "clash.txt", content: "mine\n" }] }); | |
| 243 | if (!moved.ok) throw new Error(JSON.stringify(moved.error)); | |
| 244 | const conflicted = await landPastGate(app, owner, repo, getPull(app.db, repo.id, clash.number)!, "a fine reason here"); | |
| 245 | expect(conflicted.ok).toBe(false); | |
| 246 | if (!conflicted.ok) expect(conflicted.error.kind).toBe("merge"); | |
| 247 | expect(isMerged(clash)).toBe(false); | |
| 248 | }); | |
| 249 | ||
| 250 | test("web: the form shows only while landing has stopped, and works with a plain post", async () => { | |
| 251 | const p = await pullOf("web-form"); | |
| 252 | expect(await page(p, ownerCookie)).not.toContain("Land past the gate"); | |
| 253 | stop(); | |
| 254 | const html = await page(p, ownerCookie); | |
| 255 | expect(html).toContain("Land past the gate"); | |
| 256 | expect(html).toContain(`action="/trevato/forge/pulls/${p.number}/land-past"`); | |
| 257 | expect(html).toContain('data-variant="danger"'); | |
| 258 | expect(html).not.toContain("row-fields"); | |
| 259 | const guest = await fetch(`${servers.url}/trevato/forge/pulls/${p.number}/files`).then((r) => r.text()); | |
| 260 | expect(guest).not.toContain("Land past the gate"); | |
| 261 | }); | |
| 262 | ||
| 263 | test("web: 403 for a guest, a non-admin and an agent; cross-origin refused; a short reason flashes", async () => { | |
| 264 | stop(); | |
| 265 | const p = await pullOf("web-route"); | |
| 266 | const reason = "the gate's own bug blocks it"; | |
| 267 | expect((await post(p, null, reason)).status).toBe(403); | |
| 268 | const mallory = `forge_session=${createSession(app.db, outsider.id).token}`; | |
| 269 | expect((await post(p, mallory, reason)).status).toBe(403); | |
| 270 | const token = createToken(app.db, agent.id, "rw", "rw").token; | |
| 271 | expect((await post(p, null, reason, {}, token)).status).toBe(403); | |
| 272 | expect((await post(p, ownerCookie, reason, { origin: "https://evil.example" })).status).toBe(403); | |
| 273 | const short = await post(p, ownerCookie, "no"); | |
| 274 | expect(short.status).toBe(303); | |
| 275 | expect(short.headers.get("set-cookie") ?? "").toContain("8%20to%20500"); | |
| 276 | expect(isMerged(p)).toBe(false); | |
| 277 | const done = await post(p, ownerCookie, reason); | |
| 278 | expect(done.status).toBe(303); | |
| 279 | expect(isMerged(p)).toBe(true); | |
| 280 | }); | |
| 281 | ||
| 282 | test("web: refused while landing has not stopped", async () => { | |
| 283 | const p = await pullOf("web-not-stopped"); | |
| 284 | const res = await post(p, ownerCookie, "a fine reason here"); | |
| 285 | expect(res.status).toBe(303); | |
| 286 | expect(isMerged(p)).toBe(false); | |
| 287 | }); | |
| 288 | ||
| 289 | test("mcp: an agent is refused; a dry run changes nothing and checks the reason; dryRun false lands; an unseen pull is not found", async () => { | |
| 290 | const p = await pullOf("mcp-door"); | |
| 291 | const ownerRw = createToken(app.db, owner.id, "rw", "rw").token; | |
| 292 | const agentRw = createToken(app.db, agent.id, "rw", "rw").token; | |
| 293 | const args = { repo: "trevato/forge", number: p.number, reason: "the gate's own bug blocks it" }; | |
| 294 | expect((await mcp(agentRw, { ...args, dryRun: false })).error).toContain("owner"); | |
| 295 | expect((await mcp(ownerRw, args)).error).toContain("not stopped"); | |
| 296 | stop(); | |
| 297 | const plan = await mcp(ownerRw, args); | |
| 298 | expect(plan.value.dryRun).toBe(true); | |
| 299 | expect(isMerged(p)).toBe(false); | |
| 300 | expect((await mcp(ownerRw, { ...args, reason: "no" })).error).toContain("8 to 500"); | |
| 301 | expect((await mcp(ownerRw, { ...args, reason: "no", dryRun: false })).error).toContain("8 to 500"); | |
| 302 | expect((await mcp(ownerRw, { ...args, number: 9999, dryRun: false })).error).toContain("no pull"); | |
| 303 | const landed = await mcp(ownerRw, { ...args, dryRun: false }); | |
| 304 | expect(landed.value.landed).toBe(true); | |
| 305 | expect(isMerged(p)).toBe(true); | |
| 306 | const mallory = createToken(app.db, outsider.id, "rw", "rw").token; | |
| 307 | const q = await pullOf("mcp-outsider"); | |
| 308 | expect((await mcp(mallory, { ...args, number: q.number, dryRun: false })).error).toBeDefined(); | |
| 309 | expect(isMerged(q)).toBe(false); | |
| 310 | }); | |
| 311 | ||
| 312 | test("terminal: L asks a reason, then a confirm, then lands; it is not offered when landing runs", async () => { | |
| 313 | const p = await pullOf("tty-door"); | |
| 314 | const drive = async (who: User, keys: string[]) => { | |
| 315 | const terminal = headlessTerminal(160, 44); | |
| 316 | const { app: tui, session } = createForgeApp( | |
| 317 | app, | |
| 318 | { sshUsername: who.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: who }, | |
| 319 | { terminal, exitHooks: false }, | |
| 320 | ); | |
| 321 | tui.start(); | |
| 322 | await settleApp(tui); | |
| 323 | tui.push(pullScreen(session, tui.ctx, local(session, repo), p.number)); | |
| 324 | await settleApp(tui); | |
| 325 | const frames = [tui.screen.toText()]; | |
| 326 | for (const k of keys) { | |
| 327 | terminal.feed(k); | |
| 328 | await settleApp(tui); | |
| 329 | frames.push(tui.screen.toText()); | |
| 330 | } | |
| 331 | tui.stop(); | |
| 332 | return frames; | |
| 333 | }; | |
| 334 | expect((await drive(owner, ["L"])).at(-1)).not.toContain("past the gate"); | |
| 335 | stop(); | |
| 336 | const [first] = await drive(owner, []); | |
| 337 | expect(first).toContain("land past the gate"); | |
| 338 | expect((await drive(outsider, []))[0]).not.toContain("land past the gate"); | |
| 339 | const short = await drive(owner, ["L", "no", "\r"]); | |
| 340 | expect(short.at(-1)).toContain("8 to 500"); | |
| 341 | expect(isMerged(p)).toBe(false); | |
| 342 | const planned = await drive(owner, ["L", "the gate's own bug blocks it", "\r"]); | |
| 343 | expect(planned.at(-1)).toContain("Reason: the gate's own bug blocks it"); | |
| 344 | expect(isMerged(p)).toBe(false); | |
| 345 | await drive(owner, ["L", "the gate's own bug blocks it", "\r", "\x1b[C", "\r"]); | |
| 346 | expect(isMerged(p)).toBe(true); | |
| 347 | }, 60_000); | |
| 348 | }); |
test/mcp.test.ts +1
| @@ -295,6 +295,7 @@ describe("the forge over MCP", () => { | ||
| 295 | 295 | "subscriptions", |
| 296 | 296 | "usage", |
| 297 | 297 | "labels", |
| 298 | "land_past_gate", | |
| 298 | 299 | "repo_labels", |
| 299 | 300 | "wait_for", |
| 300 | 301 | "confirm_wait", |
new above
offline
Non-blocking: check that the pull is open (not merged, not closed) in
bypassRefusal. As written, a call on an already-merged pull leaves a misleading intent comment before the merge is refused.