Public: anyone can read this; the crew can change it.
A pull the queue holds as red alone says so on its page: "red alone at <time>, waiting for a new head or a new main" #6554
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6494-a-pull-the-queue-holds-as-red-alone-says → main 3 commits fork 2 threads · 2 unresolved
9 files
changed +234
−14
README.md +2
| @@ -42,6 +42,8 @@ issue — use the link on the page). Both are a forge admin's only. | ||
| 42 | 42 | |
| 43 | 43 | A hand-over card on `/asks` (an agent's pull the reviewer stopped on after three rounds) has "Give back to the crew": one press assigns the pull, and the open issue it closes if a person holds it, to the agent that opened it, writes "Given back to the crew by @who", and the card leaves; the next tick starts its run. It is for a writer of the repository, and a pull a person opened, or by an agent gone from the crew, gets no button. In the terminal, `b` on a handed-over row of the On you pane does the same (`src/actions/give_back.ts`). |
| 44 | 44 | |
| 45 | A pull the merge queue holds as red alone (red on its own merge at its current head; cleared by a new head or a moved main) says so under its checks line: "red alone at <time>, waiting for a new head or a new main", on the pull page, its live checks line and the studio change step, and in the checks row of the pull's terminal screen (`redMark` in `src/checks/queue.ts`). Nothing is drawn when it is not held. | |
| 46 | ||
| 45 | 47 | On a sealed issue, a writer gets the private repository to fix it in: `POST /api/v1/repos/:owner/:name/issues/:number/private`, the issue page's button, and `p` on the issue's terminal screen, which shows the repository and its clone URL (built from the forge's origin). All three refuse alike: not found for anyone who may not see sealed reports, `not_sealed`, and `unverified`. |
| 46 | 48 | |
| 47 | 49 | Under the agents, each team that has a pull has one pooled row (its members' |
src/checks/queue.ts +30 −2
| @@ -257,6 +257,8 @@ export interface Remembered { | ||
| 257 | 257 | readonly base: string; |
| 258 | 258 | /** The pulls its red tree held besides it: a clash when there are any. */ |
| 259 | 259 | readonly together: readonly number[]; |
| 260 | /** When the line handed it back (epoch ms). */ | |
| 261 | readonly at: number; | |
| 260 | 262 | } |
| 261 | 263 | |
| 262 | 264 | /** A pull in red batches at this head (#4999): gated alone once it is a suspect. */ |
| @@ -585,6 +587,32 @@ export function queueClashes(repoId: string): number { | ||
| 585 | 587 | return n; |
| 586 | 588 | } |
| 587 | 589 | |
| 590 | /** A pull's red mark, as the page says it: when, at which head, on which base. */ | |
| 591 | export interface RedMark { | |
| 592 | readonly at: string; | |
| 593 | readonly head: string; | |
| 594 | readonly base: string; | |
| 595 | } | |
| 596 | ||
| 597 | /** | |
| 598 | * The mark the line holds on a pull that was red alone — on its own merge, | |
| 599 | * not on top of others — at this `head`; undefined when it holds none. | |
| 600 | */ | |
| 601 | export function redMark(repoId: string, pullId: string, head: string): RedMark | undefined { | |
| 602 | const red = lines.get(repoId)?.reds.get(pullId); | |
| 603 | if (red === undefined || red.head !== head || red.together.length > 0) return undefined; | |
| 604 | return { at: new Date(red.at).toISOString(), head: red.head, base: red.base }; | |
| 605 | } | |
| 606 | ||
| 607 | /** What the line waits for on a pull it holds as red alone. */ | |
| 608 | export const RED_ALONE_WAIT = "waiting for a new head or a new main"; | |
| 609 | ||
| 610 | /** A mark's time as both doors say it: `2026-10-26 20:41 UTC`; empty when it does not parse. */ | |
| 611 | export function redAloneAt(mark: RedMark): string { | |
| 612 | const at = new Date(mark.at); | |
| 613 | return Number.isNaN(at.getTime()) ? "" : `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`; | |
| 614 | } | |
| 615 | ||
| 588 | 616 | const HOUR_MS = 60 * 60_000; |
| 589 | 617 | |
| 590 | 618 | /** The last hour's first candidates of a line, and how many of them were red (#4235). */ |
| @@ -876,7 +904,7 @@ async function solo(line: Line, gated: Gated, w: Waiting): Promise<{ readonly do | ||
| 876 | 904 | return; |
| 877 | 905 | } |
| 878 | 906 | line.suspects.delete(pull.id); |
| 879 | markRed(line, pull.id, { head: pull.headOid, base: on.baseOid, together: [] }); | |
| 907 | markRed(line, pull.id, { head: pull.headOid, base: on.baseOid, together: [], at: Date.now() }); | |
| 880 | 908 | log.info("red alone", { repo: line.repoId, pull: pull.number }); |
| 881 | 909 | w.entry.handBack(verdict, on.baseOid, []); |
| 882 | 910 | w.settle(false); |
| @@ -1193,7 +1221,7 @@ async function judged( | ||
| 1193 | 1221 | // with them, or red on its own — not gated alone on the base before |
| 1194 | 1222 | // them to tell which. Remembered with them either way (#4235). |
| 1195 | 1223 | const together = landed.map((q) => q.pull.number); |
| 1196 | markRed(search.line, culprit.pull.id, { head: culprit.pull.headOid, base: candidate.baseOid, together }); | |
| 1224 | markRed(search.line, culprit.pull.id, { head: culprit.pull.headOid, base: candidate.baseOid, together, at: Date.now() }); | |
| 1197 | 1225 | search.line.suspects.delete(culprit.pull.id); |
| 1198 | 1226 | log.info(together.length === 0 ? "red on its own merge" : "red on top of its candidate", { |
| 1199 | 1227 | repo: search.line.repoId, |
src/checks/queue_store.ts +4 −4
| @@ -167,14 +167,14 @@ export function keepRed( | ||
| 167 | 167 | if (mark === undefined) return; |
| 168 | 168 | db.query( |
| 169 | 169 | "INSERT INTO queue_red (repo_id, pull_id, head_oid, base_oid, together, at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", |
| 170 | ).run(repoId, pullId, mark.head, mark.base, JSON.stringify(mark.together), now.toISOString()); | |
| 170 | ).run(repoId, pullId, mark.head, mark.base, JSON.stringify(mark.together), new Date(mark.at ?? now.getTime()).toISOString()); | |
| 171 | 171 | } |
| 172 | 172 | |
| 173 | 173 | /** The repository's red marks, by pull id. */ |
| 174 | 174 | export function redMarks(db: Database, repoId: string): Map<string, Remembered> { |
| 175 | 175 | const rows = db |
| 176 | .query<{ pull_id: string; head_oid: string; base_oid: string; together: string }, [string]>( | |
| 177 | "SELECT pull_id, head_oid, base_oid, together FROM queue_red WHERE repo_id = ?1 ORDER BY at", | |
| 176 | .query<{ pull_id: string; head_oid: string; base_oid: string; together: string; at: string }, [string]>( | |
| 177 | "SELECT pull_id, head_oid, base_oid, together, at FROM queue_red WHERE repo_id = ?1 ORDER BY at", | |
| 178 | 178 | ) |
| 179 | 179 | .all(repoId); |
| 180 | 180 | const marks = new Map<string, Remembered>(); |
| @@ -182,7 +182,7 @@ export function redMarks(db: Database, repoId: string): Map<string, Remembered> | ||
| 182 | 182 | const together = parsed(row.together); |
| 183 | 183 | const numbers = |
| 184 | 184 | Array.isArray(together) && together.every((n) => Number.isSafeInteger(n)) ? (together as number[]) : []; |
| 185 | marks.set(row.pull_id, { head: row.head_oid, base: row.base_oid, together: numbers }); | |
| 185 | marks.set(row.pull_id, { head: row.head_oid, base: row.base_oid, together: numbers, at: Date.parse(row.at) || 0 }); | |
| 186 | 186 | } |
| 187 | 187 | return marks; |
| 188 | 188 | } |
src/tui/screens/pull.ts +12 −2
| @@ -26,6 +26,7 @@ | ||
| 26 | 26 | * `n`/`p` walk the unsettled ones while the cursor is inside a thread. |
| 27 | 27 | */ |
| 28 | 28 | |
| 29 | import { RED_ALONE_WAIT, redAloneAt, redMark } from "../../checks/queue.ts"; | |
| 29 | 30 | import { |
| 30 | 31 | addCommentThrough, |
| 31 | 32 | setIssueStateThrough, |
| @@ -253,6 +254,7 @@ export function pullScreen( | ||
| 253 | 254 | let checkSteps = ""; |
| 254 | 255 | /** `findingsWords` of the preview's website-spec findings; empty when none were recorded. */ |
| 255 | 256 | let specWords = ""; |
| 257 | let redWords = ""; | |
| 256 | 258 | /** The check's green was against a base that has since moved. */ |
| 257 | 259 | let checkOld = false; |
| 258 | 260 | /** Agent runs about this pull, newest first. */ |
| @@ -316,6 +318,11 @@ export function pullScreen( | ||
| 316 | 318 | ? null |
| 317 | 319 | : pullPreviewFindings(f.db, repo.id, previewEnv(number)); |
| 318 | 320 | specWords = spec === null ? "" : findingsWords(spec); |
| 321 | const mark = | |
| 322 | repo === undefined || row === undefined | |
| 323 | ? undefined | |
| 324 | : redMark(repo.id, row.id, row.headOid || (facts?.headOid ?? "")); | |
| 325 | redWords = mark === undefined ? "" : `red alone at ${redAloneAt(mark)}, ${RED_ALONE_WAIT}`; | |
| 319 | 326 | checkSteps = |
| 320 | 327 | settings === undefined |
| 321 | 328 | ? "" |
| @@ -833,7 +840,7 @@ export function pullScreen( | ||
| 833 | 840 | // The box is exactly as tall as what is in it: the state, then a row |
| 834 | 841 | // for the verdicts when there are any, then a row for the checks when |
| 835 | 842 | // a deploy has run or the gate demands one. |
| 836 | const checksLine = checksText(check, requireChecks, isApp, checkBase, checkOld, checkProgress, checkSteps, specWords); | |
| 843 | const checksLine = checksText(check, requireChecks, isApp, checkBase, checkOld, checkProgress, checkSteps, specWords, redWords); | |
| 837 | 844 | const boxH = |
| 838 | 845 | 3 + |
| 839 | 846 | (standingText(standing(), requireReview) === "" ? 0 : 1) + |
| @@ -1300,10 +1307,13 @@ export function checksText( | ||
| 1300 | 1307 | steps = "", |
| 1301 | 1308 | /** `findingsWords` of the preview; empty when none were recorded. */ |
| 1302 | 1309 | spec = "", |
| 1310 | /** The queue holds the pull as red alone: its words; empty when it does not. */ | |
| 1311 | red = "", | |
| 1303 | 1312 | ): string { |
| 1304 | 1313 | const line = checksLine(check, required, isApp, baseBranch, stale, progress); |
| 1305 | 1314 | const withSteps = line === "" || steps === "" ? line : `${line} · ${steps}`; |
| 1306 | return withSteps === "" || spec === "" ? withSteps : `${withSteps} · ${spec}`; | |
| 1315 | const withSpec = withSteps === "" || spec === "" ? withSteps : `${withSteps} · ${spec}`; | |
| 1316 | return withSpec === "" || red === "" ? `${withSpec}${red}` : `${withSpec} · ${red}`; | |
| 1307 | 1317 | } |
| 1308 | 1318 | |
| 1309 | 1319 | function checksLine( |
src/web/pages/pull.ts +14 −1
| @@ -25,6 +25,7 @@ import { CHECK_ASKED } from "../../actions/pull_ask_words.ts"; | ||
| 25 | 25 | import { reviewWords } from "../../actions/review_words.ts"; |
| 26 | 26 | import { readFileAt } from "../../apps/checkout.ts"; |
| 27 | 27 | import { parseCheck } from "../../apps/manifest.ts"; |
| 28 | import { RED_ALONE_WAIT, redAloneAt, redMark, type RedMark } from "../../checks/queue.ts"; | |
| 28 | 29 | import { tierLine, type TierState } from "../../checks/tiers.ts"; |
| 29 | 30 | import { getAppByRepo } from "../../core/apps.ts"; |
| 30 | 31 | import { labelsFor, listLabels } from "../../core/issues.ts"; |
| @@ -304,6 +305,17 @@ export function checksLine( | ||
| 304 | 305 | </p>`; |
| 305 | 306 | } |
| 306 | 307 | |
| 308 | /** One muted line when the merge queue holds the pull as red alone; nothing otherwise. */ | |
| 309 | export function redAloneLine(mark: RedMark | undefined): Html { | |
| 310 | if (mark === undefined) return html``; | |
| 311 | const exact = redAloneAt(mark); | |
| 312 | if (exact === "") return html``; | |
| 313 | return html`<p class="muted small"> | |
| 314 | ${icon("clock")}red alone at | |
Hand-rolled paragraph beside checksLine; prefer the existing checks-line markup or name this in docs/design/areas/. |
||
| 315 | <time datetime="${new Date(mark.at).toISOString()}">${exact}</time>, ${RED_ALONE_WAIT} | |
| 316 | </p>`; | |
| 317 | } | |
| 318 | ||
| 307 | 319 | /** Whether a head declares a fast tier: a commit's files never change. */ |
| 308 | 320 | const fastByHead = new Map<string, boolean>(); |
| 309 | 321 | export async function declaresFast(dir: string, head: string): Promise<boolean> { |
| @@ -392,7 +404,8 @@ function pullChecksLine( | ||
| 392 | 404 | `${thread}/checks-line`, |
| 393 | 405 | ); |
| 394 | 406 | const found = !isApp ? null : pullPreviewFindings(ctx.app.db, repo.id, previewEnv(pull.number)); |
| 395 | return html`${line}${found === null ? "" : specFindings(found)}`; | |
| 407 | const red = redAloneLine(redMark(repo.id, pull.id, status.headOid)); | |
| 408 | return html`${line}${red}${found === null ? "" : specFindings(found)}`; | |
| 396 | 409 | } |
| 397 | 410 | |
| 398 | 411 | /** One §5 row in a toned card; `.setting-list` wraps the sentence and drops the action under it on a phone. */ |
test/merge-queue-clash.test.ts +13 −1
| @@ -11,7 +11,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 11 | 11 | import { mkdtemp, rm } from "node:fs/promises"; |
| 12 | 12 | import { tmpdir } from "node:os"; |
| 13 | 13 | import { join } from "node:path"; |
| 14 | import { type Candidate, enqueue, type Expect, queueClashes, type Queued, redBatchRate } from "../src/checks/queue.ts"; | |
| 14 | import { type Candidate, enqueue, type Expect, queueClashes, type Queued, redBatchRate, redMark } from "../src/checks/queue.ts"; | |
| 15 | 15 | import type { Pull } from "../src/core/pulls.ts"; |
| 16 | 16 | |
| 17 | 17 | describe("the merge queue remembers clashes", () => { |
| @@ -165,6 +165,18 @@ describe("the merge queue remembers clashes", () => { | ||
| 165 | 165 | ]); |
| 166 | 166 | }); |
| 167 | 167 | |
| 168 | test("red alone is readable as a mark at its head and base, and nowhere else", async () => { | |
| 169 | await markedNow(); | |
| 170 | const head = git("rev-parse", "b"); | |
| 171 | const mark = redMark(repoId, "clash-2", head); | |
| 172 | expect(mark?.head).toBe(head); | |
| 173 | expect(mark?.base).toBe(git("rev-parse", "refs/heads/main")); | |
| 174 | expect(new Date(mark!.at).toISOString()).toBe(mark!.at); | |
| 175 | expect(redMark(repoId, "clash-2", "0".repeat(40))).toBeUndefined(); | |
| 176 | expect(redMark(repoId, "clash-99", head)).toBeUndefined(); | |
| 177 | expect(redMark("no-such-line", "clash-2", head)).toBeUndefined(); | |
| 178 | }); | |
| 179 | ||
| 168 | 180 | test("a push is a new head: B is forgotten, batched with the rest, and lands", async () => { |
| 169 | 181 | const pushed = entry(2, await headAdding("b2", "b2.txt")); |
| 170 | 182 | const g = entry(7, await headAdding("g", "g.txt")); |
test/merge-queue-restart.test.ts +4 −4
| @@ -214,11 +214,11 @@ describe("the merge queue across a restart", () => { | ||
| 214 | 214 | const head = pull.headOid; |
| 215 | 215 | const base = git(repo, "rev-parse", "main"); |
| 216 | 216 | const store = queueStore(app.db); |
| 217 | store.red(repo.id, pull.id, { head, base, together: [4, 5] }); | |
| 218 | expect(redMarks(app.db, repo.id).get(pull.id)).toEqual({ head, base, together: [4, 5] }); | |
| 217 | store.red(repo.id, pull.id, { head, base, together: [4, 5], at: 1000 }); | |
| 218 | expect(redMarks(app.db, repo.id).get(pull.id)).toEqual({ head, base, together: [4, 5], at: 1000 }); | |
| 219 | 219 | // A new mark replaces the old: the line remembers one per pull. |
| 220 | keepRed(app.db, repo.id, pull.id, { head, base: head, together: [] }); | |
| 221 | expect([...redMarks(app.db, repo.id).values()]).toEqual([{ head, base: head, together: [] }]); | |
| 220 | keepRed(app.db, repo.id, pull.id, { head, base: head, together: [], at: 2000 }); | |
| 221 | expect([...redMarks(app.db, repo.id).values()]).toEqual([{ head, base: head, together: [], at: 2000 }]); | |
| 222 | 222 | store.red(repo.id, pull.id, undefined); |
| 223 | 223 | expect(redMarks(app.db, repo.id).size).toBe(0); |
| 224 | 224 | }); |
test/pull-red-alone-line.test.ts added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { redAloneLine } from "../src/web/pages/pull.ts"; | |
| 3 | ||
| 4 | const mark = { at: "2026-10-26T20:41:30.000Z", head: "a".repeat(40), base: "b".repeat(40) }; | |
| 5 | ||
| 6 | test("a pull held as red alone says so, with a <time> element", () => { | |
| 7 | const line = redAloneLine(mark).text; | |
| 8 | expect(line).toContain("red alone at"); | |
| 9 | expect(line).toContain('<time datetime="2026-10-26T20:41:30.000Z">2026-10-26 20:41 UTC</time>'); | |
| 10 | expect(line).toContain("waiting for a new head or a new main"); | |
| 11 | }); | |
| 12 | ||
| 13 | test("an unmarked pull draws nothing, and a mark with a broken time neither", () => { | |
| 14 | expect(redAloneLine(undefined).text).toBe(""); | |
| 15 | expect(redAloneLine({ ...mark, at: "<script>" }).text).toBe(""); | |
| 16 | }); |
test/pull-red-alone-path.test.ts added +139
| @@ -0,0 +1,139 @@ | ||
| 1 | /** | |
| 2 | * A pull the merge queue hands back red alone says so on every door that | |
| 3 | * draws its checks (#6494): the page, its live checks line, the terminal. | |
| 4 | * The mark is made the real way — a red gate in the queue — and read back. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { openPull } from "../src/actions/pulls.ts"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { enqueue } from "../src/checks/queue.ts"; | |
| 14 | import { getPull, pullDir, type Pull } from "../src/core/pulls.ts"; | |
| 15 | import { createSession } from "../src/core/sessions.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 19 | import { pullScreen } from "../src/tui/screens/pull.ts"; | |
| 20 | import { local } from "../src/tui/screens/where.ts"; | |
| 21 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | import { settleApp } from "./helpers/tui.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | ||
| 27 | let dataDir: string; | |
| 28 | let app: App; | |
| 29 | let servers: Servers; | |
| 30 | let alice: User; | |
| 31 | let cookie = ""; | |
| 32 | const made: { repo: Awaited<ReturnType<typeof setup>>["repo"]; marked: Pull; plain: Pull }[] = []; | |
| 33 | const cells = process.env["FORGE_CELLS"]; | |
| 34 | ||
| 35 | async function setup() { | |
| 36 | const r = await createRepo(app, alice, { name: "lib", visibility: "public" }); | |
| 37 | if (!r.ok) throw new Error(String(r.error)); | |
| 38 | const repo = r.value; | |
| 39 | const base = await commitToBranch(app, alice, repo, { message: "base", files: [{ path: "forge.json", content: "{}" }] }); | |
| 40 | if (!base.ok) throw new Error(JSON.stringify(base.error)); | |
| 41 | const pulls: Pull[] = []; | |
| 42 | for (const branch of ["red", "fine"]) { | |
| 43 | const done = await commitToBranch(app, alice, repo, { | |
| 44 | branch, | |
| 45 | message: branch, | |
| 46 | files: [{ path: `${branch}.txt`, content: branch }], | |
| 47 | }); | |
| 48 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 49 | const opened = await openPull(app, alice, repo, { title: branch, body: "", baseBranch: "main", headBranch: branch }); | |
| 50 | if (!opened.ok) throw new Error(JSON.stringify(opened.error)); | |
| 51 | pulls.push(getPull(app.db, repo.id, opened.value.number)!); | |
| 52 | } | |
| 53 | return { repo, marked: pulls[0]!, plain: pulls[1]! }; | |
| 54 | } | |
| 55 | ||
| 56 | beforeAll(async () => { | |
| 57 | process.env["FORGE_CELLS"] = "off"; | |
| 58 | dataDir = await mkdtemp(join(tmpdir(), "forge-red-alone-")); | |
| 59 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 60 | servers = await serve(app); | |
| 61 | const u = await createUser(app.db, { | |
| 62 | origin: "seed", | |
| 63 | trusted: true, | |
| 64 | username: "alice", | |
| 65 | email: "[email protected]", | |
| 66 | password: "a-good-passphrase", | |
| 67 | adminIfFirst: true, | |
| 68 | }); | |
| 69 | if (!u.ok) throw new Error(String(u.error)); | |
| 70 | alice = u.value; | |
| 71 | cookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 72 | made.push(await setup()); | |
| 73 | const { repo, marked } = made[0]!; | |
| 74 | const dir = await pullDir(app.db, app.reposRoot, repo, marked); | |
| 75 | const verdicts = await enqueue( | |
| 76 | repo.id, | |
| 77 | [ | |
| 78 | { | |
| 79 | pull: marked, | |
| 80 | dir, | |
| 81 | land: async () => undefined, | |
| 82 | release: () => {}, | |
| 83 | handBack: () => {}, | |
| 84 | }, | |
| 85 | ], | |
| 86 | async () => ({ lands: false, reopen: true, comment: "red", learn: [] }), | |
| 87 | ); | |
| 88 | expect(verdicts).toEqual([false]); | |
| 89 | }); | |
| 90 | ||
| 91 | afterAll(async () => { | |
| 92 | await servers.stop(); | |
| 93 | app.close(); | |
| 94 | if (cells === undefined) delete process.env["FORGE_CELLS"]; | |
| 95 | else process.env["FORGE_CELLS"] = cells; | |
| 96 | await rm(dataDir, { recursive: true, force: true }); | |
| 97 | }); | |
| 98 | ||
| 99 | const text = async (path: string): Promise<string> => | |
| 100 | (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text(); | |
| 101 | ||
| 102 | test("the pull page and its live checks line carry the line for the marked pull only", async () => { | |
| 103 | const { marked, plain } = made[0]!; | |
| 104 | for (const suffix of ["", "/checks-line"]) { | |
| 105 | const on = await text(`/alice/lib/pulls/${marked.number}${suffix}`); | |
| 106 | expect(on).toContain("red alone at"); | |
| 107 | expect(on).toContain("<time datetime="); | |
| 108 | expect(on).toContain("waiting for a new head or a new main"); | |
| 109 | const off = await text(`/alice/lib/pulls/${plain.number}${suffix}`); | |
| 110 | expect(off).not.toContain("red alone"); | |
| 111 | } | |
| 112 | }); | |
| 113 | ||
| 114 | test("the terminal's checks row says it too, and not for the other pull", async () => { | |
| 115 | const { repo, marked, plain } = made[0]!; | |
| 116 | const { app: tui, session } = createForgeApp( | |
| 117 | app, | |
| 118 | { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:alice", user: alice }, | |
| 119 | { terminal: headlessTerminal(200, 44), exitHooks: false }, | |
| 120 | ); | |
| 121 | tui.start(); | |
| 122 | try { | |
| 123 | for (const [pull, says] of [[marked, true], [plain, false]] as const) { | |
| 124 | tui.ctx.push(pullScreen(session, tui.ctx, local(session, repo), pull.number)); | |
| 125 | const end = Date.now() + 10_000; | |
| 126 | while (!tui.screen.toText().includes(pull.title) && Date.now() < end) { | |
| 127 | await settleApp(tui); | |
| 128 | await Bun.sleep(20); | |
| 129 | } | |
| 130 | await settleApp(tui); | |
| 131 | const screen = tui.screen.toText(); | |
| 132 | expect(screen.includes("red alone at")).toBe(says); | |
| 133 | if (says) expect(screen).toContain("waiting for a new head or a new main"); | |
| 134 | tui.ctx.pop(); | |
| 135 | } | |
| 136 | } finally { | |
| 137 | tui.stop(); | |
| 138 | } | |
| 139 | }, 30_000); |
new above
offline
A bad stored time becomes at: 0, which redAloneAt renders as 1970-01-01. Skip the mark or omit the time instead.