Public: anyone can read this; the crew can change it.
/milestones and /milestones/:number answer under 50 ms on trevato/forge, each naming its Server-Timing phases #6152
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6122-milestones-and-milestones-number-answer → main 1 commit fork 1 thread · 1 unresolved
8 files
changed +249
−41
src/core/dependencies.ts +41 −14
| @@ -10,11 +10,11 @@ | ||
| 10 | 10 | |
| 11 | 11 | import type { Database } from "bun:sqlite"; |
| 12 | 12 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 13 | import { getIssuesByIds, type CloseReason, type Issue } from "./issues.ts"; | |
| 14 | import { pillarsOf, structuralIds } from "./pillars.ts"; | |
| 13 | import { getIssuesByIds, getIssuesOfRepo, type CloseReason, type Issue } from "./issues.ts"; | |
| 14 | import { pillarsOf, structuralIds, type Pillars } from "./pillars.ts"; | |
| 15 | 15 | import { getRepoById } from "./repos.ts"; |
| 16 | 16 | import { unadoptedAmong } from "../crew/direct.ts"; |
| 17 | import { raisedAt } from "./thought_links.ts"; | |
| 17 | import { raisedAt, raisedInRepo } from "./thought_links.ts"; | |
| 18 | 18 | import { limbRanks, limbTree, readPlaces } from "./limb_order.ts"; |
| 19 | 19 | |
| 20 | 20 | export type DependencyError = "self" | "cross_repo" | "cycle" | "not_an_issue"; |
| @@ -282,7 +282,12 @@ export function repoGraph( | ||
| 282 | 282 | db: Database, |
| 283 | 283 | repoId: string, |
| 284 | 284 | /** Include sealed issues (`sealed.ts`): for a viewer who may see them, and the crew. */ |
| 285 | options: { readonly sealed?: boolean; readonly sealedBy?: string } = {}, | |
| 285 | options: { | |
| 286 | readonly sealed?: boolean; | |
| 287 | readonly sealedBy?: string; | |
| 288 | /** The repository's pillars, when the caller has read them already. */ | |
| 289 | readonly pillars?: Pillars; | |
| 290 | } = {}, | |
| 286 | 291 | ): Graph { |
| 287 | 292 | const edges = db |
| 288 | 293 | .query<{ issue_id: string; blocked_by_id: string }, [string]>( |
| @@ -305,19 +310,37 @@ export function repoGraph( | ||
| 305 | 310 | ORDER BY b.number`, |
| 306 | 311 | ) |
| 307 | 312 | .all(repoId); |
| 308 | const open = db | |
| 309 | .query<{ id: string }, [string, number]>( | |
| 310 | `SELECT id FROM issue WHERE repo_id = ?1 AND kind = 'issue' AND state = 'open' | |
| 311 | ORDER BY number LIMIT ?2`, | |
| 312 | ) | |
| 313 | .all(repoId, GRAPH_MAX) | |
| 314 | .map((r) => r.id); | |
| 313 | // A big graph wants most of its repository: one read of it beats chunked | |
| 314 | // lookups, and the open issues are found in it. | |
| 315 | const whole = edges.length > GRAPH_MAX ? getIssuesOfRepo(db, repoId) : undefined; | |
| 316 | const open = | |
| 317 | whole === undefined | |
| 318 | ? db | |
| 319 | .query<{ id: string }, [string, number]>( | |
| 320 | `SELECT id FROM issue WHERE repo_id = ?1 AND kind = 'issue' AND state = 'open' | |
| 321 | ORDER BY number LIMIT ?2`, | |
| 322 | ) | |
| 323 | .all(repoId, GRAPH_MAX) | |
| 324 | .map((r) => r.id) | |
| 325 | : [...whole.values()] | |
| 326 | .filter((i) => i.kind === "issue" && i.state === "open") | |
| 327 | .sort((a, b) => a.number - b.number) | |
| 328 | .slice(0, GRAPH_MAX) | |
| 329 | .map((i) => i.id); | |
| 315 | 330 | const ids = new Set<string>(open); |
| 316 | 331 | for (const e of edges) { |
| 317 | 332 | ids.add(e.issueId); |
| 318 | 333 | ids.add(e.blockedById); |
| 319 | 334 | } |
| 320 | const issues = [...getIssuesByIds(db, [...ids]).values()] | |
| 335 | const found = new Map<string, Issue>(); | |
| 336 | const strays: string[] = []; | |
| 337 | for (const id of ids) { | |
| 338 | const issue = whole?.get(id); | |
| 339 | if (issue === undefined) strays.push(id); | |
| 340 | else found.set(id, issue); | |
| 341 | } | |
| 342 | for (const [id, issue] of getIssuesByIds(db, strays)) found.set(id, issue); | |
| 343 | const issues = [...found.values()] | |
| 321 | 344 | .filter( |
| 322 | 345 | (i) => |
| 323 | 346 | options.sealed === true || |
| @@ -330,7 +353,8 @@ export function repoGraph( | ||
| 330 | 353 | (e) => present.has(e.issueId) && present.has(e.blockedById), |
| 331 | 354 | ); |
| 332 | 355 | const repo = getRepoById(db, repoId); |
| 333 | const set = repo === undefined ? undefined : pillarsOf(db, repo); | |
| 356 | const set = | |
| 357 | repo === undefined ? undefined : (options.pillars ?? pillarsOf(db, repo)); | |
| 334 | 358 | const places = readPlaces(db, repoId); |
| 335 | 359 | return { |
| 336 | 360 | ...(set === undefined || places.size === 0 |
| @@ -341,7 +365,10 @@ export function repoGraph( | ||
| 341 | 365 | places, |
| 342 | 366 | ), |
| 343 | 367 | }), |
| 344 | raised: raisedAt(db, issues.map((i) => i.id)), | |
| 368 | raised: | |
| 369 | whole === undefined | |
| 370 | ? raisedAt(db, issues.map((i) => i.id)) | |
| 371 | : raisedInRepo(db, repoId), | |
| 345 | 372 | issues, |
| 346 | 373 | unadopted: |
| 347 | 374 | repo === undefined ? new Set() : unadoptedAmong(db, repo, issues), |
src/core/issues.ts +13
| @@ -165,6 +165,19 @@ export function getIssuesByIds( | ||
| 165 | 165 | return out; |
| 166 | 166 | } |
| 167 | 167 | |
| 168 | /** | |
| 169 | * Every conversation of one repository in one read, by id: cheaper than | |
| 170 | * `getIssuesByIds` once most of a repository is wanted. | |
| 171 | */ | |
| 172 | export function getIssuesOfRepo(db: Database, repoId: string): Map<string, Issue> { | |
| 173 | const out = new Map<string, Issue>(); | |
| 174 | for (const row of db | |
| 175 | .query<IssueRow, [string]>(`${SELECT} WHERE issue.repo_id = ?1`) | |
| 176 | .all(repoId)) | |
| 177 | out.set(row.id, toIssue(row)); | |
| 178 | return out; | |
| 179 | } | |
| 180 | ||
| 168 | 181 | /** The key `getIssuesByNumbers` files a conversation under. */ |
| 169 | 182 | export const numberKey = (repoId: string, number: number): string => |
| 170 | 183 | `${repoId}#${number}`; |
src/core/limb_order.ts +6 −8
| @@ -50,14 +50,12 @@ export function limbTree( | ||
| 50 | 50 | for (const edge of graph.edges) { |
| 51 | 51 | waits.add(edge.issueId); |
| 52 | 52 | if (isStructural(set, edge.blockedById)) continue; |
| 53 | waitsFor.set(edge.issueId, [ | |
| 54 | ...(waitsFor.get(edge.issueId) ?? []), | |
| 55 | edge.blockedById, | |
| 56 | ]); | |
| 57 | waitedBy.set(edge.blockedById, [ | |
| 58 | ...(waitedBy.get(edge.blockedById) ?? []), | |
| 59 | edge.issueId, | |
| 60 | ]); | |
| 53 | const waiting = waitsFor.get(edge.issueId); | |
| 54 | if (waiting === undefined) waitsFor.set(edge.issueId, [edge.blockedById]); | |
| 55 | else waiting.push(edge.blockedById); | |
| 56 | const waiters = waitedBy.get(edge.blockedById); | |
| 57 | if (waiters === undefined) waitedBy.set(edge.blockedById, [edge.issueId]); | |
| 58 | else waiters.push(edge.issueId); | |
| 61 | 59 | } |
| 62 | 60 | // Work behind the gate is Later's whether or not the Later umbrella waits |
| 63 | 61 | // for it: its milestone counts what the map's Later row counts. |
src/core/milestones.ts +21 −1
| @@ -173,6 +173,26 @@ const counted = (issue: Issue): boolean => | ||
| 173 | 173 | issue.closeReason === "done" || |
| 174 | 174 | issue.closeReason === null; |
| 175 | 175 | |
| 176 | /** | |
| 177 | * What `readPlan` reads of one repository, as a string that moves when any | |
| 178 | * of it does: its issues and pulls, their dependencies, the runs on it (a | |
| 179 | * start, a finish, a cost) and where limbs are placed. Equal strings mean | |
| 180 | * the same plan. Another repository's writes do not move it. Undefined | |
| 181 | * inside a transaction: nothing is kept that a rollback could outlive. | |
| 182 | */ | |
| 183 | export function planStamp(db: Database, repo: Repo): string | undefined { | |
| 184 | if (db.inTransaction) return undefined; | |
| 185 | const one = (sql: string): string => | |
| 186 | JSON.stringify(Object.values(db.query<Record<string, unknown>, [string]>(sql).get(repo.id) ?? {})); | |
| 187 | return [ | |
| 188 | one(`SELECT COUNT(*), SUM(state = 'open'), MAX(updated_at), MAX(closed_at), SUM(length(title)), SUM(sealed), SUM(parked), COUNT(close_reason), COUNT(assignee_id) FROM issue WHERE repo_id = ?1`), | |
| 189 | one(`SELECT COUNT(*), MAX(d.created_at), SUM(d.confirmed) FROM issue_dependency d JOIN issue i ON i.id = d.issue_id WHERE i.repo_id = ?1`), | |
| 190 | one(`SELECT COUNT(*), MAX(p.merged_at) FROM pull p JOIN issue i ON i.id = p.issue_id WHERE i.repo_id = ?1`), | |
| 191 | one(`SELECT COUNT(*), SUM(state IN ('queued','running')), SUM(cost_usd), MAX(started_at), MAX(finished_at) FROM run WHERE repo_id = ?1 AND kind = 'agent'`), | |
| 192 | one(`SELECT COUNT(*), SUM(place), SUM(rowid) FROM limb_place WHERE repo_id = ?1`), | |
| 193 | ].join("|"); | |
| 194 | } | |
| 195 | ||
| 176 | 196 | /** |
| 177 | 197 | * Read one repository's plan as milestones. `spend` adds what each limb |
| 178 | 198 | * cost, for a viewer who may see owner facts — the caller decides. |
| @@ -184,8 +204,8 @@ export function readPlan( | ||
| 184 | 204 | ): Plan { |
| 185 | 205 | const now = options.now ?? new Date(); |
| 186 | 206 | const since = pillarWindowStart(now); |
| 187 | const graph: Graph = repoGraph(db, repo.id); | |
| 188 | 207 | const set: Pillars = pillarsOf(db, repo); |
| 208 | const graph: Graph = repoGraph(db, repo.id, { pillars: set }); | |
| 189 | 209 | const byId = new Map(graph.issues.map((i) => [i.id, i])); |
| 190 | 210 | const byNumber = new Map(graph.issues.map((i) => [i.number, i])); |
| 191 | 211 |
src/core/thought_links.ts +13
| @@ -45,6 +45,19 @@ export function raiseIssue(db: Database, issueId: string, now = new Date()): voi | ||
| 45 | 45 | ).run(issueId, now.toISOString()); |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | /** When each issue of one repository was raised, in one read: for a graph that wants them all. */ | |
| 49 | export function raisedInRepo(db: Database, repoId: string): Map<string, string> { | |
| 50 | const out = new Map<string, string>(); | |
| 51 | for (const r of db | |
| 52 | .query<{ issue_id: string; raised_at: string }, [string]>( | |
| 53 | `SELECT r.issue_id, r.raised_at FROM issue_raise r | |
| 54 | JOIN issue i ON i.id = r.issue_id WHERE i.repo_id = ?1`, | |
| 55 | ) | |
| 56 | .all(repoId)) | |
| 57 | out.set(r.issue_id, r.raised_at); | |
| 58 | return out; | |
| 59 | } | |
| 60 | ||
| 48 | 61 | /** |
| 49 | 62 | * When each of these issues was last raised: one query per chunk, never one |
| 50 | 63 | * per issue — the map asks this for every open issue it draws (#2675). |
src/web/map_cache.ts +4 −3
| @@ -76,9 +76,10 @@ const plans = new ViewCache({ maxEntries: 64 }); | ||
| 76 | 76 | let planBuilds = 0; |
| 77 | 77 | |
| 78 | 78 | /** |
| 79 | * The milestones page's plan: `build()` once per write stamp (the forge's | |
| 80 | * write clock, which also moves for runs, their cost and limb places, none | |
| 81 | * of which write an event), spend visibility and UTC day, for at most a lane's lifetime. The plan holds no | |
| 79 | * The milestones page's plan: `build()` once per plan stamp (`planStamp`: | |
| 80 | * what the plan reads of this repository, so a write elsewhere on the forge | |
| 81 | * leaves it warm), spend visibility and UTC day, for at most a lane's | |
| 82 | * lifetime. The plan holds no | |
| 82 | 83 | * viewer: who may read the repository and the seats are the caller's, every |
| 83 | 84 | * request. |
| 84 | 85 | */ |
src/web/pages/milestones.ts +21 −15
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | import { |
| 14 | 14 | limbJson, |
| 15 | 15 | milestoneJson, |
| 16 | planStamp, | |
| 16 | 17 | readPlan, |
| 17 | 18 | type LeafState, |
| 18 | 19 | type Limb, |
| @@ -38,7 +39,6 @@ import { replanResult } from "../../actions/limb_replan.ts"; | ||
| 38 | 39 | import { given, pendingFor } from "../../core/limb_replan.ts"; |
| 39 | 40 | import type { Ctx } from "../../http/context.ts"; |
| 40 | 41 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 41 | import { writeStamp } from "../../core/write_clock.ts"; | |
| 42 | 42 | import { cachedPlan } from "../map_cache.ts"; |
| 43 | 43 | import { askToLiveFor } from "../ask_to_live.ts"; |
| 44 | 44 | import { |
| @@ -197,23 +197,25 @@ function limbLine(base: string, limb: Limb, move: Html | null = null): Html { | ||
| 197 | 197 | }); |
| 198 | 198 | } |
| 199 | 199 | |
| 200 | /** The repository's plan, kept while nothing it reads changes. */ | |
| 201 | const planFor = (ctx: Ctx, repo: Repo, spend: boolean): Plan => | |
| 202 | cachedPlan( | |
| 203 | { | |
| 204 | repoId: repo.id, | |
| 205 | stamp: planStamp(ctx.app.db, repo), | |
| 206 | spend, | |
| 207 | now: new Date(), | |
| 208 | }, | |
| 209 | () => readPlan(ctx.app.db, repo, { spend }), | |
| 210 | ); | |
| 211 | ||
| 200 | 212 | /** GET /:owner/:name/milestones — the plan, collapsed to its limbs. */ |
| 201 | 213 | export function milestonesPage(ctx: Ctx): Response { |
| 202 | 214 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 203 | 215 | const repo = timePhase(ctx.request, "repo", () => loadRepo(ctx)); |
| 204 | 216 | if (!repo) return notFoundPage(ctx); |
| 205 | 217 | const spend = canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user); |
| 206 | const plan = timePhase(ctx.request, "plan", () => | |
| 207 | cachedPlan( | |
| 208 | { | |
| 209 | repoId: repo.id, | |
| 210 | stamp: writeStamp(ctx.app.db), | |
| 211 | spend, | |
| 212 | now: new Date(), | |
| 213 | }, | |
| 214 | () => readPlan(ctx.app.db, repo, { spend }), | |
| 215 | ), | |
| 216 | ); | |
| 218 | const plan = timePhase(ctx.request, "plan", () => planFor(ctx, repo, spend)); | |
| 217 | 219 | const base = repoBase(repo); |
| 218 | 220 | const move = timePhase(ctx.request, "seats", () => |
| 219 | 221 | moverFor(ctx.app.db, repo, ctx.auth.user, plan.read), |
| @@ -530,15 +532,17 @@ function milestoneBody( | ||
| 530 | 532 | |
| 531 | 533 | /** GET /:owner/:name/milestones/:number — one milestone, opened. */ |
| 532 | 534 | export function milestonePage(ctx: Ctx): Response { |
| 533 | const repo = loadRepo(ctx); | |
| 535 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 536 | const repo = timePhase(ctx.request, "repo", () => loadRepo(ctx)); | |
| 534 | 537 | if (!repo) return notFoundPage(ctx); |
| 535 | 538 | const number = Number(ctx.params.number); |
| 536 | 539 | const owner = canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user); |
| 537 | 540 | const plan = Number.isInteger(number) |
| 538 | ? readPlan(ctx.app.db, repo, { spend: owner }) | |
| 541 | ? timePhase(ctx.request, "plan", () => planFor(ctx, repo, owner)) | |
| 539 | 542 | : undefined; |
| 540 | 543 | const milestone = plan?.milestone(number); |
| 541 | 544 | if (plan === undefined || milestone === undefined) return notFoundPage(ctx); |
| 545 | const rendering = performance.now(); | |
| 542 | 546 | const viewer = ctx.auth.user; |
| 543 | 547 | // Watch is each person's own; acting on the whole limb is for whoever |
| 544 | 548 | // administers the repository. One row, Watch first. |
| @@ -567,7 +571,7 @@ export function milestonePage(ctx: Ctx): Response { | ||
| 567 | 571 | : watch === null && replan === null |
| 568 | 572 | ? null |
| 569 | 573 | : html`<p class="bar mt">${watch}${replan}</p>`; |
| 570 | return page({ | |
| 574 | const response = page({ | |
| 571 | 575 | ctx, |
| 572 | 576 | title: `${milestone.name} · ${repo.name}`, |
| 573 | 577 | crumbs: crumbsFor(repo), |
| @@ -578,6 +582,8 @@ export function milestonePage(ctx: Ctx): Response { | ||
| 578 | 582 | ${liveMount({ repo: repoBase(repo) })}`, |
| 579 | 583 | scriptJs: liveClientJs, |
| 580 | 584 | }); |
| 585 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 586 | return response; | |
| 581 | 587 | } |
| 582 | 588 | |
| 583 | 589 | /** POST /:owner/:name/milestones/:number/watch — watch, or stop, and back to it. */ |
test/milestones-speed.test.ts added +130
| @@ -0,0 +1,130 @@ | ||
| 1 | /** The plan reads in milliseconds and is kept by what it reads, not by any write on the forge (#6122). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { openIssue } from "../src/actions/issues.ts"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { planStamp, readPlan } from "../src/core/milestones.ts"; | |
| 10 | import { getRepo } from "../src/core/repos.ts"; | |
| 11 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { clearMapCache, planBuildCount } from "../src/web/map_cache.ts"; | |
| 14 | import { baselineMs, expectBudget } from "./helpers/budget.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | import { proveAll } from "./helpers/verified.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | const PILLARS = 10; | |
| 21 | const CLOSED = 2000; | |
| 22 | const OPEN = 300; | |
| 23 | ||
| 24 | let root = ""; | |
| 25 | let app: App; | |
| 26 | let servers: Servers; | |
| 27 | let cookie = ""; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | root = mkdtempSync(join(tmpdir(), "forge-milestones-speed-")); | |
| 31 | app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" }); | |
| 32 | const u = await createUser(app.db, { | |
| 33 | origin: "seed", | |
| 34 | trusted: true, | |
| 35 | username: "alice", | |
| 36 | email: "[email protected]", | |
| 37 | password: "a-good-passphrase", | |
| 38 | adminIfFirst: true, | |
| 39 | }); | |
| 40 | if (!u.ok) throw new Error(u.error); | |
| 41 | proveAll(app); | |
| 42 | for (const name of ["forge", "other"]) { | |
| 43 | const made = await createRepo(app, { id: u.value.id, username: "alice" } as never, { name }); | |
| 44 | if (!made.ok) throw new Error(made.error); | |
| 45 | } | |
| 46 | const repoId = getRepo(app.db, "alice", "forge")!.id; | |
| 47 | const issue = app.db.prepare( | |
| 48 | `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at, updated_at, closed_at) | |
| 49 | VALUES (?1, ?2, ?3, 'issue', ?4, '', ?5, ?6, ?7, ?7, ?8)`, | |
| 50 | ); | |
| 51 | const edge = app.db.prepare( | |
| 52 | "INSERT INTO issue_dependency (issue_id, blocked_by_id) VALUES (?1, ?2)", | |
| 53 | ); | |
| 54 | const at = new Date(Date.now() - 3_600_000).toISOString(); | |
| 55 | app.db.transaction(() => { | |
| 56 | let n = 0; | |
| 57 | const total = CLOSED + OPEN; | |
| 58 | for (let p = 0; p < PILLARS; p++) | |
| 59 | issue.run(`u-${p}`, repoId, ++n, `P${p + 1} Pillar ${p + 1}: ask`, u.value.id, "open", at, null); | |
| 60 | for (let k = 0; k < total; k++) { | |
| 61 | const closed = k < CLOSED; | |
| 62 | issue.run(`c-${k}`, repoId, ++n, `Step ${k}`, u.value.id, closed ? "closed" : "open", at, closed ? at : null); | |
| 63 | edge.run(`u-${k % PILLARS}`, `c-${k}`); | |
| 64 | } | |
| 65 | app.db.run("UPDATE repo SET issue_seq = ?1 WHERE id = ?2", [n, repoId]); | |
| 66 | })(); | |
| 67 | servers = await serve(app); | |
| 68 | const signin = await fetch(`${servers.url}/signin`, { | |
| 69 | method: "POST", | |
| 70 | headers: { origin: servers.url }, | |
| 71 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 72 | redirect: "manual", | |
| 73 | }); | |
| 74 | cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 75 | }); | |
| 76 | afterAll(() => { | |
| 77 | servers.stop(); | |
| 78 | app.close(); | |
| 79 | rmSync(root, { recursive: true, force: true }); | |
| 80 | }); | |
| 81 | ||
| 82 | const list = async () => { | |
| 83 | const res = await fetch(`${servers.url}/alice/forge/milestones`, { headers: { cookie } }); | |
| 84 | await res.text(); | |
| 85 | return res.status; | |
| 86 | }; | |
| 87 | ||
| 88 | test("readPlan of 2,000 closed and 300 open issues under 10 pillars fits a 30 ms budget", async () => { | |
| 89 | const against = await baselineMs(servers.url); | |
| 90 | const repo = getRepo(app.db, "alice", "forge")!; | |
| 91 | readPlan(app.db, repo, { spend: true }); | |
| 92 | const times: number[] = []; | |
| 93 | for (let n = 0; n < 7; n++) { | |
| 94 | const start = performance.now(); | |
| 95 | const plan = readPlan(app.db, repo, { spend: true }); | |
| 96 | times.push(performance.now() - start); | |
| 97 | expect(plan.limbs).toHaveLength(PILLARS); | |
| 98 | } | |
| 99 | expectBudget(Math.min(...times), { ms: 30, against }); | |
| 100 | }); | |
| 101 | ||
| 102 | test("a write to another repository keeps the plan warm; a new issue here refreshes it", async () => { | |
| 103 | clearMapCache(); | |
| 104 | const owner = getUserByName(app.db, "alice")!; | |
| 105 | await list(); | |
| 106 | const warm = planBuildCount(); | |
| 107 | await list(); | |
| 108 | expect(planBuildCount()).toBe(warm); | |
| 109 | ||
| 110 | const other = getRepo(app.db, "alice", "other")!; | |
| 111 | expect(openIssue(app, owner, other, { title: "Elsewhere" }).ok).toBe(true); | |
| 112 | await list(); | |
| 113 | expect(planBuildCount()).toBe(warm); | |
| 114 | ||
| 115 | const repo = getRepo(app.db, "alice", "forge")!; | |
| 116 | const before = planStamp(app.db, repo); | |
| 117 | expect(openIssue(app, owner, repo, { title: "Here" }).ok).toBe(true); | |
| 118 | expect(planStamp(app.db, repo)).not.toBe(before); | |
| 119 | await list(); | |
| 120 | expect(planBuildCount()).toBe(warm + 1); | |
| 121 | }); | |
| 122 | ||
| 123 | test("a milestone page names session, repo, plan and render", async () => { | |
| 124 | const res = await fetch(`${servers.url}/alice/forge/milestones/1`, { headers: { cookie } }); | |
| 125 | expect(res.status).toBe(200); | |
| 126 | await res.text(); | |
| 127 | const parts = (res.headers.get("server-timing") ?? "").split(", "); | |
| 128 | for (const name of ["session", "repo", "plan", "render"]) | |
| 129 | expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/); | |
| 130 | }); |
new above
offline
The pull stamp only reads COUNT and MAX(merged_at), so closing or reopening an unmerged pull does not move it. Add SUM over pull state, or whatever the plan reads of pulls.