Public: anyone can read this; the crew can change it.
An approved pull says when it will land #10879
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10860-an-approved-pull-says-when-it-will-land → main 4 commits fork 2 threads · 2 unresolved
9 files
changed +318
−5
docs/features/landing-line.md added +18
| @@ -0,0 +1,18 @@ | ||
| 1 | # The landing line: when an approved pull will land | |
| 2 | ||
| 3 | This page is for anyone waiting on a pull. Once a pull is open and approved, it says where it stands in the landing queue and about when it will land. | |
| 4 | ||
| 5 | ## Where to read it | |
| 6 | ||
| 7 | - **The page:** a muted line under the pull's path, for example `first in the queue: lands in about 15 min`. | |
| 8 | - **The terminal:** the same words in the pull screen's header line, after the step. | |
| 9 | - **The `pull` tool:** `eta: { minutes, says }`. `says` is the same text; `minutes` is the number, or null when there is no pace to read. `eta` is null unless the pull is open, approved and not landed. | |
| 10 | ||
| 11 | All three call `landingLine` in `src/flow/landing_line.ts`, so they never disagree. | |
| 12 | ||
| 13 | ## How place and pace are read | |
| 14 | ||
| 15 | - **Place:** the repository's open, unlanded pulls whose latest review is an approval (no changes requested after it), ordered by when they were approved. The pull's index is the number ahead of it: `first`, then `number 2`, and so on. | |
| 16 | - **Pace:** the pulls the repository landed into its default branch in the last hour, and the median length of its finished merge-queue gates in the last 6 hours. `src/flow/landing_eta.ts` turns place and pace into minutes and words; with no pace at all it says so instead of guessing. | |
| 17 | ||
| 18 | The line is an estimate from recent history, not a promise: a red check, a conflict or a hold moves it. |
scripts/unreached-allow.json −1
| @@ -10,7 +10,6 @@ | ||
| 10 | 10 | "src/flow/conflict_pairs.ts", |
| 11 | 11 | "src/flow/declines.ts", |
| 12 | 12 | "src/flow/handovers.ts", |
| 13 | "src/flow/landing_eta.ts", | |
| 14 | 13 | "src/flow/rebase_first.ts", |
| 15 | 14 | "src/flow/review_rounds.ts", |
| 16 | 15 | "src/flow/shared_red.ts", |
src/flow/landing_line.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** | |
| 2 | * Where an approved pull stands in the landing queue, read from the rows | |
| 3 | * (#10860): its place among the approved open pulls of its repository (those | |
| 4 | * approved earlier are ahead), the pulls the repository landed in the last | |
| 5 | * hour, and the median of its finished merge-queue gates in the last 6 h. | |
| 6 | * `landing_eta.ts` turns those into minutes and words; the pull page and the | |
| 7 | * MCP `pull` tool both call this, so they say the same. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import { etaSays, landingEta } from "./landing_eta.ts"; | |
| 12 | import { median } from "./pipeline.ts"; | |
| 13 | ||
| 14 | const HOUR_MS = 3_600_000; | |
| 15 | ||
| 16 | export interface LandingLine { | |
| 17 | /** Minutes until it lands, or null when there is no pace to read. */ | |
| 18 | readonly minutes: number | null; | |
| 19 | readonly says: string; | |
| 20 | } | |
| 21 | ||
| 22 | /** The pull's estimate, or null when it is not an open, approved, unlanded pull. */ | |
| 23 | export function landingLine( | |
| 24 | db: Database, | |
| 25 | pull: { readonly id: string; readonly number: number; readonly state: string; readonly mergedAt: string | null }, | |
| 26 | repoId: string, | |
| 27 | now: Date = new Date(), | |
| 28 | ): LandingLine | null { | |
| 29 | if (pull.state !== "open" || pull.mergedAt !== null) return null; | |
| 30 | // Open approved pulls, each by its latest approval with no changes-requested after it. | |
| 31 | const approved = db | |
| 32 | .query<{ id: string; number: number; at: string }, [string]>( | |
| 33 | `SELECT i.id AS id, i.number AS number, a.at AS at | |
| 34 | FROM pull p JOIN issue i ON i.id = p.issue_id | |
| 35 | JOIN (SELECT issue_id, MAX(created_at) AS at FROM review WHERE state = 'approved' GROUP BY issue_id) a | |
| 36 | ON a.issue_id = i.id | |
| 37 | WHERE i.repo_id = ?1 AND i.state = 'open' AND p.merged_at IS NULL | |
| 38 | AND NOT EXISTS (SELECT 1 FROM review c WHERE c.issue_id = i.id AND c.state = 'changes_requested' AND c.created_at > a.at) | |
| 39 | ORDER BY a.at ASC, i.number ASC`, | |
| 40 | ) | |
| 41 | .all(repoId); | |
| 42 | const place = approved.findIndex((a) => a.id === pull.id); | |
| 43 | if (place < 0) return null; | |
| 44 | const since = new Date(now.getTime() - HOUR_MS).toISOString(); | |
| 45 | const landed = | |
| 46 | db | |
| 47 | .query<{ n: number }, [string, string, string]>( | |
| 48 | `SELECT COUNT(*) AS n FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id | |
| 49 | WHERE r.id = ?1 AND p.base_branch = r.default_branch AND p.merged_at >= ?2 AND p.merged_at <= ?3`, | |
| 50 | ) | |
| 51 | .get(repoId, since, now.toISOString())?.n ?? 0; | |
| 52 | const gates = db | |
| 53 | .query<{ ms: number }, [string, string, string]>( | |
| 54 | `SELECT (julianday(finished_at) - julianday(started_at)) * 86400000 AS ms FROM check_gate | |
| 55 | WHERE repo_id = ?1 AND finished_at IS NOT NULL AND finished_at >= ?2 AND finished_at <= ?3`, | |
| 56 | ) | |
| 57 | .all(repoId, new Date(now.getTime() - 6 * HOUR_MS).toISOString(), now.toISOString()) | |
| 58 | .map((g) => g.ms) | |
| 59 | .filter((ms) => ms > 0); | |
| 60 | const gate = median(gates); | |
| 61 | const raw = landingEta({ | |
| 62 | ahead: place, | |
| 63 | landedLastHour: landed, | |
| 64 | medianGateMinutes: gate === null ? null : gate / 60_000, | |
| 65 | }); | |
| 66 | const minutes = raw === null ? null : Math.round(raw * 10) / 10; | |
| 67 | return { minutes, says: `${place === 0 ? "first" : `number ${place + 1}`} in the queue: ${etaSays(minutes)}` }; | |
| 68 | } |
src/http/mcp/tools/pull.ts +3 −1
| @@ -9,6 +9,7 @@ import { nextOfPull } from "../../../flow/next_actor.ts"; | ||
| 9 | 9 | import { stepOfPull } from "../../../flow/path.ts"; |
| 10 | 10 | import { appView } from "../../../actions/apps.ts"; |
| 11 | 11 | import { countThread, EMBEDDED_COMMENTS, listComments } from "../../../core/issues.ts"; |
| 12 | import { landingLine } from "../../../flow/landing_line.ts"; | |
| 12 | 13 | import { pullLooks } from "../../../looks/readable.ts"; |
| 13 | 14 | import { isCrew } from "../../../core/agents.ts"; |
| 14 | 15 | import { markReaders } from "../../../crew/direct.ts"; |
| @@ -16,7 +17,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 16 | 17 | |
| 17 | 18 | export const tool: Tool = { |
| 18 | 19 | name: "pull", |
| 19 | doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), `step: { step, why, done }` (check, review, land or live; null once closed unlanded), and `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), and `looks` (newest first: `{ id, width, says, by, commit, url, at }`, the same as `GET …/pulls/:number/looks`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment", | |
| 20 | doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), `step: { step, why, done }` (check, review, land or live; null once closed unlanded), and `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), and `looks` (newest first: `{ id, width, says, by, commit, url, at }`, the same as `GET …/pulls/:number/looks`), and `eta: { minutes, says }` (approved open pull only, else null), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment", | |
| 20 | 21 | description: |
| 21 | 22 | "A pull: title, state, head, base; open: `mergeable`, `conflicts`, `checkStale`, `check`.", |
| 22 | 23 | inputSchema: { |
| @@ -66,6 +67,7 @@ export const tool: Tool = { | ||
| 66 | 67 | state: pull.mergedAt !== null ? "merged" : pull.state, |
| 67 | 68 | base: pull.baseBranch, |
| 68 | 69 | head: pull.headBranch, |
| 70 | eta: landingLine(ctx.app.db, pull, repo.id), | |
| 69 | 71 | proposal: pullProposalOf(ctx.app.db, pull.id) ?? null, |
| 70 | 72 | check: { state: check.state, baseOid: check.run?.baseOid ?? "" }, |
| 71 | 73 | checkStale: stale, |
src/tui/screens/pull.ts +6 −1
| @@ -72,6 +72,7 @@ import { checkWords } from "../../checks/summary.ts"; | ||
| 72 | 72 | import { oneLine } from "../../checks/words.ts"; |
| 73 | 73 | import { resolveOid } from "../../git/merge.ts"; |
| 74 | 74 | import { nextOfPull, type Next } from "../../flow/next_actor.ts"; |
| 75 | import { landingLine } from "../../flow/landing_line.ts"; | |
| 75 | 76 | import { stepLine, stepOfPull, type PathStep } from "../../flow/path.ts"; |
| 76 | 77 | import { fromKeptFork } from "../../core/landing_kind.ts"; |
| 77 | 78 | import { getPull, pullDir, variantSiblings, type MergePullError, type Pull } from "../../core/pulls.ts"; |
| @@ -258,6 +259,8 @@ export function pullScreen( | ||
| 258 | 259 | let next: Next | null = null; |
| 259 | 260 | /** Which of check, review, land, live the pull is at; the page's step line. */ |
| 260 | 261 | let step: PathStep | null = null; |
| 262 | /** Where an approved open pull stands in the landing queue; the page's line under its path (#10860). */ | |
| 263 | let landing: string | null = null; | |
| 261 | 264 | /** Where each team the issue names stands; empty when it names none. */ |
| 262 | 265 | let teams: TeamVerdict[] = []; |
| 263 | 266 | /** The named teams `mergePull` would refuse for (`team_review_required`), by name. */ |
| @@ -410,6 +413,8 @@ export function pullScreen( | ||
| 410 | 413 | }, |
| 411 | 414 | next, |
| 412 | 415 | ); |
| 416 | landing = | |
| 417 | row === undefined || repo === undefined || repo.origin != null ? null : (landingLine(f.db, row, repo.id)?.says ?? null); | |
| 413 | 418 | checkProgress = runningProgress(f.app.config.dataDir, check); |
| 414 | 419 | const spec = |
| 415 | 420 | repo === undefined || !isApp |
| @@ -1237,7 +1242,7 @@ export function pullScreen( | ||
| 1237 | 1242 | x1, |
| 1238 | 1243 | head!.y + 1, |
| 1239 | 1244 | truncate( |
| 1240 | ` ${facts?.mergedAt != null ? "merged" : issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)}${guestWaiting() ? ` · ${repo !== undefined && crewFilters(repo) ? GUEST_NOTE_FILTERED : GUEST_NOTE}` : ""}${assignedText(issueRow, ctx.icons)}${facts?.proposal ? ` · ${proposedByText(facts.proposal)}` : ""}${hold === undefined ? "" : ` · held by ${hold.heldByName}: ${hold.why}`}${step === null ? "" : ` · step: ${stepLine(step)}`}${next === null ? "" : ` · next: ${next.who}, ${next.why}`}`, | |
| 1245 | ` ${facts?.mergedAt != null ? "merged" : issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)}${guestWaiting() ? ` · ${repo !== undefined && crewFilters(repo) ? GUEST_NOTE_FILTERED : GUEST_NOTE}` : ""}${assignedText(issueRow, ctx.icons)}${facts?.proposal ? ` · ${proposedByText(facts.proposal)}` : ""}${hold === undefined ? "" : ` · held by ${hold.heldByName}: ${hold.why}`}${step === null ? "" : ` · step: ${stepLine(step)}`}${landing === null ? "" : ` · ${landing}`}${next === null ? "" : ` · next: ${next.who}, ${next.why}`}`, | |
| 1241 | 1246 | Math.max(0, edge - x1), |
| 1242 | 1247 | ), |
| 1243 | 1248 | style(c.mutedFg, c.bg), |
src/web/studio/change.ts +5 −1
| @@ -88,6 +88,7 @@ import { previewVision } from "../../crew/vision.ts"; | ||
| 88 | 88 | import { visionPreviewCard } from "./direction.ts"; |
| 89 | 89 | import { holdCard } from "./hold.ts"; |
| 90 | 90 | import { pullMayRun } from "../../core/pull_trust.ts"; |
| 91 | import { landingLine } from "../../flow/landing_line.ts"; | |
| 91 | 92 | import { nextOfPull } from "../../flow/next_actor.ts"; |
| 92 | 93 | import { STEP_MEANS, STEPS, stepOfPull, stepWord, type PathStep } from "../../flow/path.ts"; |
| 93 | 94 | import { changeHref } from "./nouns.ts"; |
| @@ -267,7 +268,10 @@ export function changeHead( | ||
| 267 | 268 | >`, |
| 268 | 269 | )} |
| 269 | 270 | </div> |
| 270 | ${when(step, (st) => pathLine(st, next === null ? undefined : `${next.who}, ${next.why}`))} | |
| 271 | ${when(step, (st) => pathLine(st, next === null ? undefined : `${next.who}, ${next.why}`))}${when( | |
The page shows the line but the terminal pull screen in src/tui/ does not: both doors must. Call landingLine there too and test it. |
||
| 272 | live === null ? null : landingLine(ctx.app.db, pull, repo.id), | |
| 273 | (eta) => html`<p class="muted small" data-landing-eta>${eta.says}</p>`, | |
| 274 | )} | |
| 271 | 275 | </div>`; |
| 272 | 276 | } |
| 273 | 277 |
test/landing-line.test.ts added +126
| @@ -0,0 +1,126 @@ | ||
| 1 | /** An approved pull says when it will land, on its page and in the `pull` tool (#10860): a fixed queue and pace. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 9 | import { openPull } from "../src/actions/pulls.ts"; | |
| 10 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { submitReview } from "../src/core/reviews.ts"; | |
| 12 | import { createToken } from "../src/core/tokens.ts"; | |
| 13 | import type { Pull } from "../src/core/pulls.ts"; | |
| 14 | import type { Repo } from "../src/core/repos.ts"; | |
| 15 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | const PASSWORD = "a-long-step-passphrase"; | |
| 22 | ||
| 23 | let dataDir = ""; | |
| 24 | let app: App; | |
| 25 | let servers: Servers; | |
| 26 | let alice: User; | |
| 27 | let cookie = ""; | |
| 28 | let repo: Repo; | |
| 29 | let pull: Pull; | |
| 30 | ||
| 31 | beforeAll(async () => { | |
| 32 | dataDir = await mkdtemp(join(tmpdir(), "forge-landing-line-")); | |
| 33 | app = createApp({ dataDir, port: 0 }); | |
| 34 | servers = await serve(app); | |
| 35 | const a = await createUser(app.db, { | |
| 36 | origin: "seed", | |
| 37 | trusted: true, | |
| 38 | username: "alice", | |
| 39 | email: "[email protected]", | |
| 40 | password: PASSWORD, | |
| 41 | adminIfFirst: true, | |
| 42 | }); | |
| 43 | if (!a.ok) throw new Error(a.error); | |
| 44 | alice = a.value; | |
| 45 | const res = await fetch(`${servers.url}/signin`, { | |
| 46 | method: "POST", | |
| 47 | body: new URLSearchParams({ username: "alice", password: PASSWORD }), | |
| 48 | redirect: "manual", | |
| 49 | }); | |
| 50 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 51 | await createAgent(app, alice, { username: "bot" }); | |
| 52 | const made = await createRepo(app, alice, { name: "site", visibility: "public" }); | |
| 53 | if (!made.ok) throw new Error(String(made.error)); | |
| 54 | repo = made.value; | |
| 55 | await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# site\n" }] }); | |
| 56 | addToCrew(app, alice, repo, "bot"); | |
| 57 | await commitToBranch(app, alice, repo, { | |
| 58 | branch: "bot/1", | |
| 59 | base: "main", | |
| 60 | message: "Tidy", | |
| 61 | files: [{ path: "F.md", content: "1\n" }], | |
| 62 | }); | |
| 63 | const opened = await openPull(app, getUserByName(app.db, "bot")!, repo, { | |
| 64 | title: "Tidy the footer", | |
| 65 | body: "", | |
| 66 | baseBranch: "main", | |
| 67 | headBranch: "bot/1", | |
| 68 | }); | |
| 69 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 70 | pull = opened.value; | |
| 71 | }, 30_000); | |
| 72 | ||
| 73 | afterAll(async () => { | |
| 74 | await servers.stop(); | |
| 75 | app.close(); | |
| 76 | await rm(dataDir, { recursive: true, force: true }); | |
| 77 | }); | |
| 78 | ||
| 79 | const LINE = "first in the queue: lands in about 15 min"; | |
| 80 | ||
| 81 | async function pageLine(): Promise<string> { | |
| 82 | const res = await fetch(`${servers.url}/alice/site/pulls/${pull.number}`, { headers: { cookie } }); | |
| 83 | const m = /data-landing-eta>([^<]*)</.exec(await res.text()); | |
| 84 | return m === null ? "" : m[1]!.trim(); | |
| 85 | } | |
| 86 | ||
| 87 | async function tool(): Promise<{ eta: { minutes: number | null; says: string } | null }> { | |
| 88 | const res = await fetch(`${servers.url}/mcp`, { | |
| 89 | method: "POST", | |
| 90 | headers: { | |
| 91 | "content-type": "application/json", | |
| 92 | accept: "application/json, text/event-stream", | |
| 93 | authorization: `Bearer ${createToken(app.db, alice.id, "t", "ro").token}`, | |
| 94 | }, | |
| 95 | body: JSON.stringify({ | |
| 96 | jsonrpc: "2.0", | |
| 97 | id: 1, | |
| 98 | method: "tools/call", | |
| 99 | params: { name: "pull", arguments: { repo: "alice/site", number: pull.number } }, | |
| 100 | }), | |
| 101 | }); | |
| 102 | return JSON.parse(((await res.json()) as any).result.content[0].text); | |
| 103 | } | |
| 104 | ||
| 105 | test("an unapproved pull says nothing about landing", async () => { | |
| 106 | expect(await pageLine()).toBe(""); | |
| 107 | expect((await tool()).eta).toBeNull(); | |
| 108 | }); | |
| 109 | ||
| 110 | test("an approved pull says its place and pace on the page and in the tool", async () => { | |
| 111 | // A fixed pace: nothing landed in the last hour, one gate of 14 min. | |
| 112 | const end = new Date(Date.now() - 10 * 60_000); | |
| 113 | app.db | |
| 114 | .query("INSERT INTO check_gate (id, repo_id, started_at, finished_at) VALUES ('g1', ?1, ?2, ?3)") | |
| 115 | .run(repo.id, new Date(end.getTime() - 14 * 60_000).toISOString(), end.toISOString()); | |
| 116 | const r = submitReview(app.db, { | |
| 117 | issueId: pull.id, | |
| 118 | authorId: alice.id, | |
| 119 | state: "approved", | |
| 120 | commitOid: pull.headOid, | |
| 121 | } as Parameters<typeof submitReview>[1]); | |
| 122 | expect(r.ok).toBe(true); | |
| 123 | expect(await pageLine()).toBe(LINE); | |
| 124 | const eta = (await tool()).eta; | |
| 125 | expect(eta).toEqual({ minutes: 14, says: LINE }); | |
| 126 | }); |
test/pages-budget.test.ts +1 −1
| @@ -34,7 +34,7 @@ const DEFAULT = { statements: 60, git: 8 }; | ||
| 34 | 34 | const HEAVIER: Record<string, { statements?: number; git?: number }> = { |
| 35 | 35 | "/:owner/:name/issues/:number": { statements: 70 }, |
| 36 | 36 | "/:owner/:name/next": { statements: 64 }, |
| 37 | "/:owner/:name/pulls/:number": { statements: 109 }, // +1: the header Talk count (#10327) | |
| 37 | "/:owner/:name/pulls/:number": { statements: 110 }, // +1: the header Talk count (#10327); +1: the landing line (#10860) | |
| 38 | 38 | "/board": { statements: 64 }, |
| 39 | 39 | "/settings/capacity": { statements: 71 }, |
| 40 | 40 | }; |
test/tui-pull-landing.test.ts added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | /** The terminal's pull screen says when an approved pull will land, as the page does (#10860). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 7 | import { openPull } from "../src/actions/pulls.ts"; | |
| 8 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 10 | import { submitReview } from "../src/core/reviews.ts"; | |
| 11 | import type { Pull } from "../src/core/pulls.ts"; | |
| 12 | import type { Repo } from "../src/core/repos.ts"; | |
| 13 | import { getUserByName, type User } from "../src/core/users.ts"; | |
| 14 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 15 | import { pullScreen } from "../src/tui/screens/pull.ts"; | |
| 16 | import { local } from "../src/tui/screens/where.ts"; | |
| 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | import { settleApp } from "./helpers/tui.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | let dataDir: string; | |
| 25 | let forge: Forge; | |
| 26 | let alice: User; | |
| 27 | let repo: Repo; | |
| 28 | let pull: Pull; | |
| 29 | ||
| 30 | beforeAll(async () => { | |
| 31 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-landing-")); | |
| 32 | forge = createApp({ dataDir, port: 0 }); | |
| 33 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 34 | if (!a.ok) throw new Error("signup failed"); | |
| 35 | alice = a.value; | |
| 36 | await createAgent(forge, alice, { username: "bot" }); | |
| 37 | const r = await createRepo(forge, alice, { name: "site", visibility: "public" }); | |
| 38 | if (!r.ok) throw new Error("repo creation failed"); | |
| 39 | repo = r.value; | |
| 40 | await commitToBranch(forge, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# s\n" }] }); | |
| 41 | addToCrew(forge, alice, repo, "bot"); | |
| 42 | await commitToBranch(forge, alice, repo, { | |
| 43 | branch: "bot/1", | |
| 44 | base: "main", | |
| 45 | message: "Tidy", | |
| 46 | files: [{ path: "F.md", content: "1\n" }], | |
| 47 | }); | |
| 48 | const opened = await openPull(forge, getUserByName(forge.db, "bot")!, repo, { | |
| 49 | title: "Tidy", | |
| 50 | body: "", | |
| 51 | baseBranch: "main", | |
| 52 | headBranch: "bot/1", | |
| 53 | }); | |
| 54 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 55 | pull = opened.value; | |
| 56 | }, 30_000); | |
| 57 | ||
| 58 | afterAll(async () => { | |
| 59 | forge.close(); | |
| 60 | await rm(dataDir, { recursive: true, force: true }); | |
| 61 | }); | |
| 62 | ||
| 63 | async function screenText(): Promise<string> { | |
| 64 | const { app, session } = createForgeApp( | |
| 65 | forge, | |
| 66 | { sshUsername: "alice", remoteAddress: "127.0.0.1", user: alice }, | |
| 67 | { terminal: headlessTerminal(200, 44), exitHooks: false }, | |
| 68 | ); | |
| 69 | app.start(); | |
| 70 | app.ctx.push(pullScreen(session, app.ctx, local(session, repo), pull.number)); | |
| 71 | await settleApp(app); | |
| 72 | const text = app.screen.toText(); | |
| 73 | app.stop(); | |
| 74 | return text; | |
| 75 | } | |
| 76 | ||
| 77 | test("the pull screen says its place in the queue once approved, and not before", async () => { | |
| 78 | expect(await screenText()).not.toContain("in the queue"); | |
| 79 | const end = new Date(Date.now() - 10 * 60_000); | |
| 80 | forge.db | |
| 81 | .query("INSERT INTO check_gate (id, repo_id, started_at, finished_at) VALUES ('g1', ?1, ?2, ?3)") | |
| 82 | .run(repo.id, new Date(end.getTime() - 14 * 60_000).toISOString(), end.toISOString()); | |
| 83 | const r = submitReview(forge.db, { | |
| 84 | issueId: pull.id, | |
| 85 | authorId: alice.id, | |
| 86 | state: "approved", | |
| 87 | commitOid: pull.headOid, | |
| 88 | } as Parameters<typeof submitReview>[1]); | |
| 89 | expect(r.ok).toBe(true); | |
| 90 | expect(await screenText()).toContain("first in the queue: lands in about 15 min"); | |
| 91 | }); |
No docs/features/landing-line.md for this feature; add the how-to there (not in README).