Public: anyone can read this; the crew can change it.
A hot-file hold names the open pulls on that file, oldest first, on the map and in the Flow panel #7491
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7423-a-hot-file-hold-names-the-open-pulls-on → main 6 commits fork 3 threads · 3 unresolved
10 files
changed +186
−26
README.md +1
| @@ -411,6 +411,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 411 | 411 | - Events at the terminal: `v` on the About card (`I`) lists the repository's events newest first, 50 at a time; `n` loads the next page, `esc` goes back; it reads once on opening and does not refresh while open. The audience is `GET …/events`'s and MCP `events`' (a webhook's URL and an app's variable names are the admins'). |
| 412 | 412 | - Why it is not moving: MCP `flow` with `{ repo }`, `{ repo, issue }` or `{ repo, pull }` (a writer's) answers the first gate that holds it (`flow_only`, `ceiling`, `hot_file`, `blocked_by`, `later_gate`, `no_builder`, `no_headroom`, `red_check`, `waiting_review`) with the action that clears it, or `state: closed`/`merged` when nothing is left to move; a pull is read against the head git has now. The terminal's About card (`I`) carries the repository's answer in a **Moving** row for a writer (`src/flow/why.ts`). |
| 413 | 413 | - [](docs/FLOW.md) — where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h. |
| 414 | - A ready issue held on a hot file names the open pulls on that file, oldest first, at most 8: `held.pulls` in `GET …/map` and the MCP `map` tool, links after the sentence in the flow panel's "Ready, and why it waits" card, and `#N` lines in the terminal Flow dialog. Read from the pull files the picker caches (`src/crew/hot_files.ts`), per reader: a sealed pull is named and counted for the owner only. | |
| 414 | 415 | - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [](docs/FLOW.md). |
| 415 | 416 | - Under it, the panel says the crew's build yield over 24 h (runs finished, pushed, landed; `src/flow/build_yield.ts`) and the median hours to first review (`src/flow/review_latency.ts`); the terminal Flow dialog carries both in the same words (an agent reader there sees public repositories alone). `/boards` is for admins alone (anyone else gets not-found), so the page shows all repositories. |
| 416 | 417 | - The flow panel on `/boards` also says the time to land (median and slowest of the last 24 h, `src/flow/lead_time.ts`) and who opened the last hour's pulls (names linked, `src/flow/openers.ts`); non-admins read public repositories alone. The terminal Flow dialog carries the same two facts in its own words. |
docs/design/areas/boards.md +6
| @@ -9,6 +9,12 @@ repositories the viewer administers and shows when there are none, because it | ||
| 9 | 9 | holds the form. A person who administers a repository but is not a forge |
| 10 | 10 | admin sees `/boards` as this card alone. |
| 11 | 11 | |
| 12 | ## Pulls on a held issue (`.held-pulls`) | |
| 13 | ||
| 14 | In the held card, a hot-file hold lists the open pulls on that file after its | |
| 15 | sentence. `.held-pulls` is a wrapping flex line; each `<a>` is a 44px-high tap | |
| 16 | target with 8px of padding. Defined in `src/web/style/components.ts`. | |
| 17 | ||
| 12 | 18 | ## Verified after landing (#7786) |
| 13 | 19 | |
| 14 | 20 | The card under the Pipeline table (`id="verified"`) adds no class: it is a `card()` with an `<h2>`, one `<p>` per repository in the default text colour, the repository and its run as plain `<a>` links, `small` for the run link. It is server-rendered, needs no JavaScript, and has no accent of its own. |
src/crew/held.ts +9 −4
| @@ -47,7 +47,7 @@ import { flowOnlyHolds } from "./flow_only.ts"; | ||
| 47 | 47 | import { interruptionOf } from "./interrupted.ts"; |
| 48 | 48 | import { PARK_STRIKES, parkedCount } from "./parks.ts"; |
| 49 | 49 | import { heldFiles, type FileHold } from "./file_holds.ts"; |
| 50 | import { expectedFiles, hotFiles, hotOverlap, relievedFiles } from "./hot_files.ts"; | |
| 50 | import { expectedFiles, hotFiles, hotOverlap, pullsTouching, relievedFiles } from "./hot_files.ts"; | |
| 51 | 51 | import { coveredByRedFix } from "./red_fix.ts"; |
| 52 | 52 | |
| 53 | 53 | export const HOLDS = [ |
| @@ -257,6 +257,8 @@ export function stuckOf(held: Held): Stuck | undefined { | ||
| 257 | 257 | export interface MapHold { |
| 258 | 258 | readonly by: HoldName; |
| 259 | 259 | readonly detail: string; |
| 260 | /** For a hot file: the open pulls that touch it, oldest first, at most 8. */ | |
| 261 | readonly pulls?: readonly number[]; | |
| 260 | 262 | } |
| 261 | 263 | |
| 262 | 264 | const HOLD_WORDS: Readonly<Record<HoldName, string>> = { |
| @@ -289,6 +291,7 @@ export function mapHolds( | ||
| 289 | 291 | for (const name of HOLDS) { |
| 290 | 292 | for (const issue of held.by[name]) { |
| 291 | 293 | let detail = HOLD_WORDS[name]; |
| 294 | let pulls: number[] | undefined; | |
| 292 | 295 | if (name === "hot_file") { |
| 293 | 296 | const pin = held.pinned.get(issue.number); |
| 294 | 297 | const top = hotOverlap(issue, hot)[0]; |
| @@ -296,17 +299,19 @@ export function mapHolds( | ||
| 296 | 299 | if (pin !== undefined) detail = `${pin.path} is held by an admin`; |
| 297 | 300 | else if (top === undefined && rel !== undefined) |
| 298 | 301 | detail = `waits: ${rel.path} is already being relieved by #${rel.by}`; |
| 299 | else if (top !== undefined) | |
| 302 | else if (top !== undefined) { | |
| 300 | 303 | detail = `${top.path} is in ${top.pulls} open ${top.pulls === 1 ? "pull" : "pulls"}`; |
| 304 | pulls = pullsTouching(repo.id, top.path, admit); | |
| 305 | } | |
| 301 | 306 | } |
| 302 | holds.set(issue.number, { by: name, detail }); | |
| 307 | holds.set(issue.number, { by: name, detail, ...(pulls === undefined ? {} : { pulls }) }); | |
| 303 | 308 | } |
| 304 | 309 | } |
| 305 | 310 | return { holds, startable: held.startable.map((r) => r.issue.number) }; |
| 306 | 311 | } |
| 307 | 312 | |
| 308 | 313 | /** The pulls a viewer of `scope` may see: all with the sealed scope, else the unsealed and their own. */ |
| 309 | function admits(app: App, repo: Repo, scope: { sealed?: true; sealedBy?: string }) { | |
| 314 | export function admits(app: App, repo: Repo, scope: { sealed?: true; sealedBy?: string }) { | |
| 310 | 315 | if (scope.sealed === true) return () => true; |
| 311 | 316 | return (n: number): boolean => { |
| 312 | 317 | const row = sealedRowByNumber(app.db, repo.id, n); |
src/crew/hot_files.ts +15
| @@ -139,6 +139,21 @@ export function pullCounts( | ||
| 139 | 139 | return counts; |
| 140 | 140 | } |
| 141 | 141 | |
| 142 | /** The most pulls a hold names. */ | |
| 143 | export const PULLS_NAMED = 8; | |
| 144 | ||
| 145 | /** The numbers of the open pulls that touch `path`, oldest first, at most `PULLS_NAMED`. Memory only. */ | |
| 146 | export function pullsTouching( | |
| 147 | repoId: string, | |
| 148 | path: string, | |
| 149 | admit: (pullNumber: number) => boolean = () => true, | |
| 150 | ): number[] { | |
| 151 | const out: number[] = []; | |
| 152 | for (const { files, number } of touched.get(repoId)?.values() ?? []) | |
| 153 | if (files.length <= WIDE_PULL && admit(number) && files.includes(path)) out.push(number); | |
| 154 | return out.sort((a, b) => a - b).slice(0, PULLS_NAMED); | |
| 155 | } | |
| 156 | ||
| 142 | 157 | /** Every file of `repo` that `HOT_PULLS` or more open pulls touch, with how many. */ |
| 143 | 158 | export function hotFiles( |
| 144 | 159 | repoId: string, |
src/flow/held.ts +31 −18
| @@ -9,9 +9,9 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import type { App } from "../app.ts"; |
| 12 | import { listAutonomousRepos } from "../core/repos.ts"; | |
| 12 | import { listAutonomousRepos, type Repo } from "../core/repos.ts"; | |
| 13 | 13 | import { smallShareWords } from "../crew/autonomy.ts"; |
| 14 | import { heldCounts, heldNow, type HoldName } from "../crew/held.ts"; | |
| 14 | import { admits, heldCounts, heldNow, mapHolds, type Held, type HoldName } from "../crew/held.ts"; | |
| 15 | 15 | |
| 16 | 16 | export interface HeldRow { |
| 17 | 17 | /** `owner/name`. */ |
| @@ -19,33 +19,46 @@ export interface HeldRow { | ||
| 19 | 19 | readonly public: boolean; |
| 20 | 20 | readonly startable: number; |
| 21 | 21 | readonly counts: Readonly<Record<HoldName, number>>; |
| 22 | /** Issues a hot file holds, with the open pulls on that file, oldest first. */ | |
| 23 | readonly hot: readonly { readonly number: number; readonly detail: string; readonly pulls: readonly number[] }[]; | |
| 22 | 24 | /** `1/2`: small issues in flight of the builder slots they may hold (#7571). */ |
| 23 | 25 | readonly small: string; |
| 24 | 26 | } |
| 25 | 27 | |
| 28 | /** The most hot-file holds a row lists. */ | |
| 29 | const HOT_ROWS = 5; | |
| 30 | ||
| 26 | 31 | /** How long the rows are kept. */ |
| 27 | 32 | export const HELD_TTL_MS = 5_000; |
| 28 | 33 | |
| 29 | const kept = new WeakMap<App, { at: number; rows: readonly HeldRow[] }>(); | |
| 34 | const kept = new WeakMap<App, { at: number; repos: readonly { repo: Repo; held: Held }[] }>(); | |
| 30 | 35 | |
| 31 | /** Each autonomous repository's ready list, sorted by what holds it; private ones for the owner only. */ | |
| 36 | /** | |
| 37 | * Each autonomous repository's ready list, sorted by what holds it; private ones for the owner only. | |
| 38 | * The counts are the same for every reader. The pulls a hot file holds are resolved per reader on | |
| 39 | * every call, from memory: a sealed pull is named, and counted, for the owner only. | |
| 40 | */ | |
| 32 | 41 | export function readHeld(app: App, owner: boolean, now = Date.now()): HeldRow[] { |
| 33 | 42 | let entry = kept.get(app); |
| 34 | 43 | if (entry === undefined || now - entry.at >= HELD_TTL_MS || now < entry.at) { |
| 35 | entry = { | |
| 36 | at: now, | |
| 37 | rows: listAutonomousRepos(app.db).map((repo) => { | |
| 38 | const held = heldNow(app, repo); | |
| 39 | return { | |
| 40 | repo: `${repo.ownerName}/${repo.name}`, | |
| 41 | public: repo.visibility === "public", | |
| 42 | startable: held.startable.length, | |
| 43 | counts: heldCounts(held), | |
| 44 | small: smallShareWords(app, repo), | |
| 45 | }; | |
| 46 | }), | |
| 47 | }; | |
| 44 | entry = { at: now, repos: listAutonomousRepos(app.db).map((repo) => ({ repo, held: heldNow(app, repo) })) }; | |
| 48 | 45 | kept.set(app, entry); |
| 49 | 46 | } |
| 50 | return entry.rows.filter((r) => owner || r.public); | |
| 47 | return entry.repos | |
| 48 | .filter(({ repo }) => owner || repo.visibility === "public") | |
| 49 | .map(({ repo, held }) => { | |
| 50 | const { holds } = mapHolds(repo, held, admits(app, repo, owner ? { sealed: true } : {})); | |
| 51 | return { | |
| 52 | repo: `${repo.ownerName}/${repo.name}`, | |
| 53 | public: repo.visibility === "public", | |
| 54 | startable: held.startable.length, | |
| 55 | counts: heldCounts(held), | |
| 56 | small: smallShareWords(app, repo), | |
| 57 | hot: [...holds].flatMap(([number, h]) => | |
| 58 | h.by === "hot_file" && h.pulls !== undefined && h.pulls.length > 0 | |
| 59 | ? [{ number, detail: h.detail, pulls: h.pulls }] | |
| 60 | : [], | |
| 61 | ).slice(0, HOT_ROWS), | |
| 62 | }; | |
| 63 | }); | |
| 51 | 64 | } |
src/tui/screens/flow.ts +6 −3
| @@ -209,8 +209,8 @@ export function heldLines(rows: readonly HeldRow[], inner: number): string[] { | ||
| 209 | 209 | if (rows.length === 0) return ["Ready, and why it waits: no repository runs on its own."]; |
| 210 | 210 | return [ |
| 211 | 211 | "Ready, and why it waits:", |
| 212 | ...rows.flatMap((r) => | |
| 213 | wrap( | |
| 212 | ...rows.flatMap((r) => [ | |
| 213 | ...wrap( | |
| 214 | 214 | [ |
| 215 | 215 | r.repo, |
| 216 | 216 | `startable=${r.startable}`, |
| @@ -219,7 +219,10 @@ export function heldLines(rows: readonly HeldRow[], inner: number): string[] { | ||
| 219 | 219 | ].join(" "), |
| 220 | 220 | inner, |
| 221 | 221 | ), |
| 222 | ), | |
| 222 | ...r.hot.flatMap((h) => | |
| 223 | wrap(` #${h.number} waits: ${h.detail}: ${h.pulls.map((n) => `#${n}`).join(" ")}`, inner), | |
| 224 | ), | |
| 225 | ]), | |
| 223 | 226 | ]; |
| 224 | 227 | } |
| 225 | 228 |
src/web/pages/boards-held.ts +11
| @@ -55,6 +55,17 @@ export function heldCard(rows: readonly HeldRow[]): Html { | ||
| 55 | 55 | <td>${heldCount("startable", r.startable)} ${startableMeter(r)}</td> |
| 56 | 56 | <td> |
| 57 | 57 | ${HOLDS.filter((h) => r.counts[h] > 0).map((h) => html`${heldCount(h, r.counts[h])} `)} |
| 58 | ${r.hot.length === 0 | |
| 59 | ? null | |
| 60 | : html`<ul class="small" data-held-hot> | |
| 61 | ${r.hot.map( | |
| 62 | (h) => | |
| 63 | html`<li data-held-hot-issue="${String(h.number)}"> | |
| 64 | #${String(h.number)} waits: ${h.detail} | |
| 65 | <span class="held-pulls">${h.pulls.map((n) => html`<a class="mono" href="/${r.repo}/pulls/${String(n)}">#${String(n)}</a>`)}</span> | |
outdated
Links are bare inline anchors separated by spaces; they are hard to hit with a thumb at 360px. Give them a gap/padding via a named layout class or token. |
||
| 66 | </li>`, | |
| 67 | )} | |
| 68 | </ul>`} | |
| 58 | 69 | </td> |
| 59 | 70 | </tr>`, |
| 60 | 71 | )} |
src/web/style/components.ts +3
| @@ -338,6 +338,9 @@ a.sha:hover { color: var(--accent); } | ||
| 338 | 338 | /* Cards stacked in normal flow space themselves. Inside a container that |
| 339 | 339 | already manages its own spacing with gap, that margin would double it. */ |
| 340 | 340 | .card + .card { margin-top: 12px; } |
| 341 | /* The open pulls a held issue waits on: a wrapping line of touch-sized links. */ | |
| 342 | .held-pulls { display: flex; flex-wrap: wrap; gap: 0 4px; align-items: center; } | |
| 343 | .held-pulls a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 8px; } | |
| 341 | 344 | :is(.stack, .split, .timeline) > .card + .card { margin-top: 0; } |
| 342 | 345 | .card-head { |
| 343 | 346 | display: flex; align-items: center; gap: 8px; flex-wrap: wrap; |
test/held-hot-pulls.test.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** A hot-file hold names the open pulls on that file, oldest first, in the map answer and the held card (#7491). */ | |
outdated
Header cites #7423, but this is #7491. Add a guest/sealed-pull test and a terminal test. |
||
| 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 { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createIssue } from "../src/core/issues.ts"; | |
| 9 | import { PULLS_NAMED, pullsTouching, rememberPullFiles } from "../src/crew/hot_files.ts"; | |
| 10 | import { readHeld } from "../src/flow/held.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { heldLines } from "../src/tui/screens/flow.ts"; | |
| 13 | import { heldCard } from "../src/web/pages/boards-held.ts"; | |
| 14 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 15 | ||
| 16 | const SEED = [ | |
| 17 | { number: 31, files: ["src/hot.ts"] }, | |
| 18 | { number: 12, files: ["src/hot.ts", "a.ts"] }, | |
| 19 | { number: 20, files: ["src/hot.ts"] }, | |
| 20 | { number: 25, files: ["src/hot.ts"] }, | |
| 21 | { number: 26, files: ["src/hot.ts"] }, | |
| 22 | { number: 40, files: ["other.ts"] }, | |
| 23 | ]; | |
| 24 | ||
| 25 | let dataDir = ""; | |
| 26 | let app: App, servers: Servers, repoId = "", issueNo = 0; | |
| 27 | ||
| 28 | beforeAll(async () => { | |
| 29 | dataDir = await mkdtemp(join(tmpdir(), "forge-heldpulls-")); | |
| 30 | app = createApp({ dataDir, port: 0 }); | |
| 31 | servers = await serve(app); | |
| 32 | const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 33 | if (!admin.ok) throw new Error(admin.error); | |
| 34 | const made = await createRepo(app, admin.value, { name: "notes", visibility: "public", description: "" }); | |
| 35 | if (!made.ok) throw new Error(String(made.error)); | |
| 36 | repoId = made.value.id; | |
| 37 | app.db.run("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2 WHERE id = ?1", [repoId, admin.value.id]); | |
| 38 | rememberPullFiles(repoId, SEED); | |
| 39 | const r = createIssue(app.db, { repoId, authorId: admin.value.id, title: "Add a flag to src/hot.ts", body: "", sealed: false }); | |
| 40 | if (!r.ok) throw new Error(r.error); | |
| 41 | issueNo = r.value.number; | |
| 42 | }); | |
| 43 | ||
| 44 | afterAll(async () => { | |
| 45 | rememberPullFiles(repoId, []); | |
| 46 | servers.stop?.(); | |
| 47 | app.close(); | |
| 48 | await rm(dataDir, { recursive: true, force: true }); | |
| 49 | }); | |
| 50 | ||
| 51 | test("pullsTouching lists pull numbers oldest first and stops at eight", () => { | |
| 52 | expect(pullsTouching(repoId, "src/hot.ts")).toEqual([12, 20, 25, 26, 31]); | |
| 53 | rememberPullFiles(repoId, Array.from({ length: 12 }, (_, i) => ({ number: 50 - i, files: ["x.ts"] }))); | |
| 54 | const many = pullsTouching(repoId, "x.ts"); | |
| 55 | expect(many).toHaveLength(PULLS_NAMED); | |
| 56 | expect(many[0]).toBe(39); | |
| 57 | rememberPullFiles(repoId, SEED); | |
| 58 | }); | |
| 59 | ||
| 60 | test("the map answer carries the pulls on a held issue, oldest first", async () => { | |
| 61 | const body = (await (await fetch(`${servers.url}/api/v1/repos/alice/notes/map`)).json()) as { | |
| 62 | ready: { number: number; held: { by: string; detail: string; pulls: number[] } | null }[]; | |
| 63 | }; | |
| 64 | const held = body.ready.find((r) => r.number === issueNo)!.held!; | |
| 65 | expect(held.by).toBe("hot_file"); | |
| 66 | expect(held.detail).toBe("src/hot.ts is in 5 open pulls"); | |
| 67 | expect(held.pulls).toEqual([12, 20, 25, 26, 31]); | |
| 68 | }); | |
| 69 | ||
| 70 | test("the held card links them after the sentence, in order", () => { | |
| 71 | const page = String(heldCard(readHeld(app, true, Date.now() + 60_000))); | |
| 72 | expect(page).toContain(`data-held-hot-issue="${issueNo}"`); | |
| 73 | const at = (n: number) => page.indexOf(`href="/alice/notes/pulls/${n}"`); | |
| 74 | expect(page.indexOf("src/hot.ts is in 5 open pulls")).toBeGreaterThan(-1); | |
| 75 | expect(at(12)).toBeGreaterThan(page.indexOf("src/hot.ts is in 5 open pulls")); | |
| 76 | expect([12, 20, 25, 26, 31].map(at)).toEqual([...[12, 20, 25, 26, 31].map(at)].sort((a, b) => a - b)); | |
| 77 | expect(at(40)).toBe(-1); | |
| 78 | }); | |
| 79 | ||
| 80 | test("the terminal's Flow lines name the pulls too", () => { | |
| 81 | const lines = heldLines(readHeld(app, true, Date.now() + 120_000), 200).join("\n"); | |
| 82 | expect(lines).toContain(`#${issueNo} waits: src/hot.ts is in 5 open pulls: #12 #20 #25 #26 #31`); | |
| 83 | }); | |
| 84 | ||
| 85 | test("a guest is told neither the number of a sealed pull nor a count that includes it", () => { | |
| 86 | app.db.run( | |
| 87 | "INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, sealed) VALUES ('sp', ?1, 26, 'pull', 's', '', (SELECT id FROM user LIMIT 1), 'open', 1)", | |
| 88 | [repoId], | |
| 89 | ); | |
| 90 | rememberPullFiles(repoId, [...SEED, { number: 27, files: ["src/hot.ts"] }]); | |
| 91 | const now = Date.now() + 300_000; | |
| 92 | const guest = String(heldCard(readHeld(app, false, now))); | |
| 93 | expect(guest).toContain("src/hot.ts is in 5 open pulls"); | |
| 94 | expect(guest).not.toContain("/pulls/26"); | |
| 95 | expect(guest).not.toContain("#26"); | |
| 96 | expect(heldLines(readHeld(app, false, now), 200).join("\n")).not.toContain("#26"); | |
| 97 | const owner = String(heldCard(readHeld(app, true, now))); | |
| 98 | expect(owner).toContain("src/hot.ts is in 6 open pulls"); | |
| 99 | expect(owner).toContain("/pulls/26"); | |
| 100 | rememberPullFiles(repoId, SEED); | |
| 101 | app.db.run("DELETE FROM issue WHERE id = 'sp'"); | |
| 102 | }); |
test/held-relieves-map.test.ts +2 −1
| @@ -109,7 +109,8 @@ test("the three planned bodies are held by nothing on their own hot files; a pla | ||
| 109 | 109 | expect(heldOf(map, tui.number)).toBeNull(); |
| 110 | 110 | expect(heldOf(map, cli.number)).toBeNull(); |
| 111 | 111 | expect(heldOf(map, mcp.number)).toBeNull(); |
| 112 | expect(heldOf(map, plain.number)).toEqual({ by: "hot_file", detail: `src/tui/forge.ts is in ${HOT_PULLS + 1} open pulls` }); | |
| 112 | expect(heldOf(map, plain.number)).toMatchObject({ by: "hot_file", detail: `src/tui/forge.ts is in ${HOT_PULLS + 1} open pulls` }); | |
| 113 | expect((heldOf(map, plain.number) as { pulls?: number[] } | undefined)?.pulls).toHaveLength(HOT_PULLS + 1); | |
| 113 | 114 | expect(map.startable.sort((a, b) => a - b)).toEqual([tui.number, cli.number, mcp.number]); |
| 114 | 115 | } |
| 115 | 116 | }); |
new above
offline
mapHolds(repo, held)has noadmit, and these rows are cached and shared across viewers. Sealed pull numbers leak to guests on public repos. Resolve the pulls per viewer. Also drop theh.pulls!below.