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

Merged opened this in answer to

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

Merged

scout/forge:scout/7790-the-gate-cannot-lock-itself-out-a-known → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/7790-the-gate-cannot-lock-itself-out-a-known was merged into main as 0ffb350.

Review

Sign in to review this change.
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
8787
8888 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`).
8989
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
9092 **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.
9193
9294 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.
101101 | `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 | |
102102 | `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 |
103103 | `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 |
104105 | `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 |
105106 | `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 |
106107 | `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
44
55 - `.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`.
66 - `.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(
363363 strategy: MergeStrategy,
364364 /** The merge queue's guard: land only onto this base, as this tree (#2036). */
365365 expect?: LandingExpect,
366 /** `pastGate`: the owner's break-glass (`checks/bypass.ts`) skips the checks requirement. */
367 opts: { readonly pastGate?: boolean } = {},
366368 ): Promise<
367369 Result<{ mergeOid: string; carried: boolean }, AccessError | MergePullError>
368370 > {
@@ -409,7 +411,7 @@ export async function mergePull(
409411 gate: {
410412 db: app.db,
411413 requireReview: repo.requireReview,
412 requireChecks: repo.requireChecks,
414 requireChecks: repo.requireChecks && opts.pastGate !== true,
413415 },
414416 strategy,
415417 ...(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) {

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.

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([
7070 "pull.reopened",
7171 "pull.opened",
7272 "pull.merged",
73 "pull.bypassed",
7374 "issue.opened",
7475 "spend.reported",
7576 "forge.proposal",
src/flow/alarms.ts +6 −1
@@ -7,6 +7,7 @@
77 */
88
99 import type { Database } from "bun:sqlite";
10 import { landingStopped, stoppedSentence } from "../checks/gate_stopped.ts";
1011 import { redHref, redMain } from "../checks/red_main.ts";
1112 import { getRepo } from "../core/repos.ts";
1213 import { ownRepoPath } from "../kernel/own_repo.ts";
@@ -16,7 +17,7 @@ import { redMainAlarm } from "./alarm_red_main.ts";
1617 import { alarmStuck } from "./alarm_stuck.ts";
1718
1819 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";
2021 readonly says: string;
2122 readonly since: string;
2223 /** A same-origin path to the item the alarm names. */
@@ -36,6 +37,10 @@ export function firedAlarms(
3637 opts: { readonly publicOnly?: boolean; readonly ceilingUsd?: number | null } = {},
3738 ): FiredAlarm[] {
3839 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" });
3944 const red = redMainAlarm(db, now);
4045 if (red.fired) out.push({ key: "red_main", says: red.says, since: red.since!, href: redMainHref(db) });
4146 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";
7171 export { tool as issue } from "./issue.ts";
7272 export { tool as issue_teams } from "./issue_teams.ts";
7373 export { tool as labels } from "./labels.ts";
74 export { tool as land_past_gate } from "./land_past_gate.ts";
7475 export { tool as limb } from "./limb.ts";
7576 export { tool as limits } from "./limits.ts";
7677 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 {
357357 pullFilesPage,
358358 pullChecksFragment,
359359 pullMergeSubmit,
360 pullLandPastSubmit,
360361 pullChecksSubmit,
361362 pullReviewStateSubmit,
362363 pullApproveHereSubmit,
@@ -1259,6 +1260,7 @@ export function buildRouter(): Router<Handler> {
12591260 threadResolveSubmit(false),
12601261 );
12611262 router.add("POST", "/:owner/:name/pulls/:number/merge", pullMergeSubmit);
1263 router.add("POST", "/:owner/:name/pulls/:number/land-past", pullLandPastSubmit);
12621264 router.add("POST", "/:owner/:name/pulls/:number/state", (ctx) =>
12631265 stateSubmit(ctx, "pull"),
12641266 );
src/tui/screens/pull.ts +59
@@ -41,6 +41,7 @@ import {
4141 } from "../../actions/pulls.ts";
4242 import { holdPull, releasePull } from "../../actions/holds.ts";
4343 import { getHold, type Hold } from "../../core/holds.ts";
44 import { bypassOffered, cleanReason, landPastGate } from "../../checks/bypass.ts";
4445 import { revertPull } from "../../actions/revert.ts";
4546 import { takeBackWords } from "../../actions/revert_words.ts";
4647 import { openEnvUrl } from "../../actions/apps.ts";
@@ -691,6 +692,59 @@ export function pullScreen(
691692 });
692693 };
693694
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
694748 /**
695749 * Whether `T` takes this back: a shipped pull on a local repository, for a
696750 * person who may write. The same gate `revertPull` holds; it decides only
@@ -866,6 +920,7 @@ export function pullScreen(
866920 ? [binding(["v"], "see the conflict blocks")]
867921 : []),
868922 binding(["m"], "merge", { disabled: state() !== "ready" }),
923 ...(mayLandPast() ? [binding(["L"], "land past the gate")] : []),
869924 ...(tab === 1 && view.threadHere() !== null
870925 ? [
871926 binding(
@@ -1064,6 +1119,10 @@ export function pullScreen(
10641119 );
10651120 return true;
10661121 }
1122 if (e.key === "L" && mayLandPast()) {
1123 landPast();
1124 return true;
1125 }
10671126 if (e.key === "R" && mayWrite()) {
10681127 review();
10691128 return true;
src/web/pages/pull.ts +46 −3
@@ -54,7 +54,8 @@ import {
5454 type Side,
5555 } from "../../core/reviews.ts";
5656 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";
5859 import { commitLog, readerFor } from "../../git/read.ts";
5960 import { wallOf } from "../../contract/policy.ts";
6061 import type { Ctx } from "../../http/context.ts";
@@ -87,6 +88,7 @@ import {
8788 badge,
8889 btn,
8990 card,
91 field as formField,
9092 icon,
9193 labelCss,
9294 queuedPhrase,
@@ -587,12 +589,25 @@ function mergeBox(
587589 "Waiting on checks.",
588590 html`${why} ${reviewsChip(status.reviews, by)} ${checks}`,
589591 when(mayMerge, () =>
590 btn("Merge pull request", {
592 html`${btn("Merge pull request", {
591593 variant: "primary",
592594 icon: "merge",
593595 disabled: true,
594596 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 )}`,
596611 ),
597612 );
598613 }
@@ -1376,6 +1391,34 @@ export async function pullMergeSubmit(ctx: Ctx): Promise<Response> {
13761391 return flashTo(ctx, dest, { message: "Pull request merged." });
13771392 }
13781393
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
13791422 /**
13801423 * GET /:owner/:name/pulls/:number/checks-line — the checks line alone, for
13811424 * 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", () => {
295295 "subscriptions",
296296 "usage",
297297 "labels",
298 "land_past_gate",
298299 "repo_labels",
299300 "wait_for",
300301 "confirm_wait",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs