Public: anyone can read this; the crew can change it.

Ask-to-live reads the deploy record, runs no git on a page view, and counts an issue once #1783

Merged opened this

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

Merged

scout/forge:scout/1601-ask-to-live-reads-the-deploy-record-runs → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/1601-ask-to-live-reads-the-deploy-record-runs was merged into main as 10332e9.

Review

Sign in to review this change.
14 files changed +174 −150
README.md +4
@@ -506,6 +506,10 @@ Where the whole forge waits is a forge admin's: `/flow` shows each stage from re
506506 what "ready now" means to a person and to the crew. The map also shows each
507507 pillar's ask-to-live: the median hours from an issue opening to its change live
508508 in production, over the last 7 days (also in the map and milestones API and MCP).
509 It reads the `deploy_pull` record a production deploy writes when it finishes,
510 so a page view runs no git; an issue closed by several pulls counts once, at
511 its first time live. On boot the forge records what the last 7 days of earlier
512 production deploys carried, so work that went live before the record is counted.
509513
510514 `docs/STUDIO.md` is the front door from the top of this page in full: the five
511515 words and where each is decided, the box, the three lists, a tell, a run
scripts/gate.ts −1
@@ -244,7 +244,6 @@ export const GATE: readonly string[] = [
244244 "test/pillars.test.ts",
245245 "test/carried.test.ts",
246246 "test/carried-deploy.test.ts",
247 "test/reaches.test.ts",
248247 "test/milestones.test.ts",
249248 "test/mcp.test.ts",
250249 "test/name-marks.test.ts",
src/core/carried.ts +41
@@ -11,6 +11,9 @@
1111
1212 import type { Database } from "bun:sqlite";
1313 import { runLocal } from "../git/local.ts";
14 import { repoDir } from "../git/repos.ts";
15 import { PILLAR_DAYS } from "./pillars.ts";
16 import { getRepoById } from "./repos.ts";
1417
1518 const FULL_OID = /^[0-9a-f]{40,64}$/;
1619 /** Most commits one walk reads; past this a deploy records what it saw. */
@@ -68,6 +71,44 @@ export async function recordCarried(
6871 return carried.map((p) => p.number);
6972 }
7073
74 /**
75 * Record what earlier production deploys carried, for the deploys of the
76 * ask-to-live window that have no record yet (they finished before deploys
77 * wrote one). A boot step, never a page view; a deploy that carries nothing
78 * is walked again on each boot, bounded by the window. Returns how many
79 * deploys it recorded something for.
80 */
81 export async function backfillCarried(
82 db: Database,
83 reposRoot: string,
84 now = new Date(),
85 ): Promise<number> {
86 const since = new Date(now.getTime() - PILLAR_DAYS * 86_400_000).toISOString();
87 const runs = db
88 .query<{ id: string; repoId: string; oid: string }, [string]>(
89 `SELECT id, repo_id AS repoId, commit_oid AS oid FROM run
90 WHERE kind = 'deploy' AND env = 'production' AND state = 'ok' AND commit_oid != ''
91 AND COALESCE(serving_at, finished_at) >= ?1
92 AND NOT EXISTS (SELECT 1 FROM deploy_pull WHERE run_id = run.id)
93 ORDER BY COALESCE(serving_at, finished_at)`,
94 )
95 .all(since);
96 let recorded = 0;
97 for (const run of runs) {
98 const repo = getRepoById(db, run.repoId);
99 if (repo === undefined) continue;
100 const dir = repoDir(reposRoot, repo.ownerName, repo.name);
101 const carried = await recordCarried(db, {
102 runId: run.id,
103 repoId: run.repoId,
104 commitOid: run.oid,
105 dir,
106 });
107 if (carried.length > 0) recorded++;
108 }
109 return recorded;
110 }
111
71112 /** The pull numbers a deploy run carries, ascending. */
72113 export function carriedBy(db: Database, runId: string): number[] {
73114 return db
src/core/pillars.ts +40 −42
@@ -259,6 +259,7 @@ export function mergedByPillar(
259259 }
260260
261261 interface MergedPull {
262 readonly id: string;
262263 readonly closes: readonly number[];
263264 readonly mergedAt: string;
264265 readonly mergedOid: string;
@@ -291,8 +292,8 @@ function mergedPulls(
291292 scope: SealedScope = {},
292293 ): { pulls: MergedPull[]; issues: Map<number, ClosedIssue> } {
293294 const rows = db
294 .query<{ body: string; at: string; oid: string | null }, string[]>(
295 `SELECT i.body, p.merged_at AS at, p.merged_oid AS oid
295 .query<{ id: string; body: string; at: string; oid: string | null }, string[]>(
296 `SELECT i.id, i.body, p.merged_at AS at, p.merged_oid AS oid
296297 FROM pull p JOIN issue i ON i.id = p.issue_id
297298 WHERE i.repo_id = ?1 AND p.merged_at IS NOT NULL AND p.merged_at >= ?2
298299 AND ${sealedClause("i", scope, 3)}
@@ -300,6 +301,7 @@ function mergedPulls(
300301 )
301302 .all(repoId, since, ...sealedParams(scope));
302303 const pulls = rows.map((row) => ({
304 id: row.id,
303305 closes: closesFromBody(row.body),
304306 mergedAt: row.at,
305307 mergedOid: row.oid ?? "",
@@ -342,9 +344,6 @@ function pillarOf(
342344
343345 // ---- ask-to-live: an issue opening to its change in production (#1185) ----
344346
345 /** Whether `ancestor` is reachable from `descendant`; false when unsure. The git lives in src/git, handed in. */
346 export type Reaches = (ancestor: string, descendant: string) => Promise<boolean>;
347
348347 /** How long the asking took, for one pillar's merged work. */
349348 export interface AskToLive {
350349 /** Median hours from an issue opening to its change live in production. */
@@ -359,54 +358,55 @@ export interface AskToLiveCount {
359358 readonly askToLive: AskToLive | undefined;
360359 }
361360
362 /** Most ancestry questions one request may put to git; a pull left unanswered counts for nothing. */
363 const ASK_GIT_CALLS = 120;
364
365361 /**
366362 * Per pillar, over the pulls merged since an instant: the median and the
367363 * slowest ask-to-live, from the creation of the issue that pull closed to
368 * the first production deploy that went live on a commit containing the
369 * pull's merge. A pull not yet live counts for nothing. Every name has a
370 * row, in order; one read of deploys however many pulls merged.
364 * the first production deploy that went live carrying the pull (the
365 * `deploy_pull` record, so no git runs here). A pull not yet live counts
366 * for nothing; an issue closed by several pulls counts once, at its first
367 * time live. Every name has a row, in order; two reads however many pulls.
371368 */
372 export async function askToLiveByPillar(
369 export function askToLiveByPillar(
373370 db: Database,
374371 repoId: string,
375372 set: Pillars,
376373 served: ReadonlyMap<string, string>,
377374 since: string,
378 reaches: Reaches,
379375 scope: SealedScope = {},
380 ): Promise<AskToLiveCount[]> {
376 ): AskToLiveCount[] {
381377 const { pulls, issues } = mergedPulls(db, repoId, since, scope);
382 const deploys = db
383 .query<{ oid: string; at: string }, [string, string]>(
384 `SELECT commit_oid AS oid, COALESCE(serving_at, finished_at) AS at FROM run
385 WHERE repo_id = ?1 AND kind = 'deploy' AND env = 'production' AND state = 'ok'
386 AND commit_oid != '' AND COALESCE(serving_at, finished_at) >= ?2
387 ORDER BY COALESCE(serving_at, finished_at), id`,
378 const live = new Map<string, string[]>();
379 for (const row of db
380 .query<{ id: string; at: string }, [string, string]>(
381 `SELECT dp.issue_id AS id, COALESCE(r.serving_at, r.finished_at) AS at
382 FROM deploy_pull dp JOIN run r ON r.id = dp.run_id
383 WHERE r.repo_id = ?1 AND r.kind = 'deploy' AND r.env = 'production' AND r.state = 'ok'
384 AND COALESCE(r.serving_at, r.finished_at) >= ?2
385 ORDER BY COALESCE(r.serving_at, r.finished_at), r.id`,
388386 )
389 .all(repoId, since);
390 const asked = new Map<string, { number: number; hours: number }[]>();
391 let budget = ASK_GIT_CALLS;
387 .all(repoId, since)) {
388 const list = live.get(row.id) ?? [];
389 list.push(row.at);
390 live.set(row.id, list);
391 }
392 // One entry per asked issue: its first time live over every pull that closed it.
393 const first = new Map<number, { pillar: string; createdAt: string; at: string }>();
392394 for (const pull of pulls) {
393395 const ask = pillarOf(pull.closes, issues, served);
394 if (ask === undefined || pull.mergedOid === "") continue;
395 let live: { oid: string; at: string } | undefined;
396 for (const d of deploys) {
397 if (d.at < pull.mergedAt) continue;
398 if (budget-- <= 0) break;
399 if (await reaches(pull.mergedOid, d.oid)) {
400 live = d;
401 break;
402 }
403 }
404 if (live === undefined) continue;
405 const hours = (Date.parse(live.at) - Date.parse(ask.createdAt)) / 3_600_000;
396 if (ask === undefined) continue;
397 const at = live.get(pull.id)?.find((t) => t >= pull.mergedAt);
398 if (at === undefined) continue;
399 const seen = first.get(ask.number);
400 if (seen === undefined || at < seen.at)
401 first.set(ask.number, { pillar: ask.pillar, createdAt: ask.createdAt, at });
402 }
403 const asked = new Map<string, { number: number; hours: number }[]>();
404 for (const [number, f] of first) {
405 const hours = (Date.parse(f.at) - Date.parse(f.createdAt)) / 3_600_000;
406406 if (!Number.isFinite(hours)) continue;
407 const list = asked.get(ask.pillar) ?? [];
408 list.push({ number: ask.number, hours: Math.max(0, hours) });
409 asked.set(ask.pillar, list);
407 const list = asked.get(f.pillar) ?? [];
408 list.push({ number, hours: Math.max(0, hours) });
409 asked.set(f.pillar, list);
410410 }
411411 return pillarNames(set).map((pillar) => {
412412 const list = (asked.get(pillar) ?? []).sort((a, b) => a.hours - b.hours);
@@ -488,22 +488,20 @@ export function pillarView(
488488 };
489489 }
490490
491 /** A view's ask-to-live over the last `PILLAR_DAYS` days; opt-in, for it reads git. */
491 /** A view's ask-to-live over the last `PILLAR_DAYS` days; opt-in, for it reads the deploy record. */
492492 export function askToLiveOf(
493493 db: Database,
494494 repo: Repo,
495495 view: PillarView,
496 reaches: Reaches,
497496 scope: SealedScope = {},
498497 now = new Date(),
499 ): Promise<AskToLiveCount[]> {
498 ): AskToLiveCount[] {
500499 return askToLiveByPillar(
501500 db,
502501 repo.id,
503502 view.set,
504503 view.of,
505504 pillarWindowStart(now),
506 reaches,
507505 scope,
508506 );
509507 }
src/git/local.ts −50
@@ -861,56 +861,6 @@ export async function isAncestor(
861861 return out.ok;
862862 }
863863
864 const FULL_OID = /^[0-9a-f]{40,64}$/;
865 const KNOWN_LIMIT = 1000;
866 const KNOWN_DIRS = 200;
867
868 /**
869 * Whether one commit contains another, for a repository's directory.
870 *
871 * Only a real answer is remembered — an ancestry between two commits never
872 * changes — and the oldest answer goes first when the memory is full. A
873 * missing object or directory is "no" now and asked again later, because it
874 * may be mended. The promise never rejects.
875 */
876 export function reachesIn(
877 dir: string,
878 ): (ancestor: string, descendant: string) => Promise<boolean> {
879 return async (ancestor, descendant) => {
880 if (!FULL_OID.test(ancestor) || !FULL_OID.test(descendant)) return false;
881 if (ancestor === descendant) return true;
882 const key = `${ancestor}\0${descendant}`;
883 const known = reachKnown.get(dir)?.get(key);
884 if (known !== undefined) return known;
885 try {
886 const out = await runLocal(
887 ["merge-base", "--is-ancestor", ancestor, descendant],
888 { cwd: dir },
889 );
890 if (out.ok) return remember(dir, key, true);
891 return out.error.code === 1 ? remember(dir, key, false) : false;
892 } catch {
893 return false;
894 }
895 };
896 }
897
898 /** Answers per repository directory, so one busy repository cannot evict another's. */
899 const reachKnown = new Map<string, Map<string, boolean>>();
900
901 function remember(dir: string, key: string, value: boolean): boolean {
902 let known = reachKnown.get(dir);
903 if (known === undefined) {
904 if (reachKnown.size >= KNOWN_DIRS)
905 reachKnown.delete(reachKnown.keys().next().value as string);
906 known = new Map();
907 reachKnown.set(dir, known);
908 }
909 if (known.size >= KNOWN_LIMIT) known.delete(known.keys().next().value as string);
910 known.set(key, value);
911 return value;
912 }
913
914864 /** `rev-list --left-right --count a...b` — what each side has alone. */
915865 export async function aheadBehind(
916866 dir: string,
src/http/api.ts +1 −1
@@ -2712,7 +2712,7 @@ export function apiRoutes(router: Router<Handler>): void {
27122712 listActiveRuns(ctx.app.db, [repo.id]),
27132713 ),
27142714 pillars,
2715 await askToLiveFor(ctx.app, repo, pillars, ctx.auth.user),
2715 askToLiveFor(ctx.app, repo, pillars, ctx.auth.user),
27162716 ),
27172717 );
27182718 });
src/http/mcp.ts +1 −1
@@ -1415,7 +1415,7 @@ const TOOLS: readonly Tool[] = [
14151415 listActiveRuns(ctx.app.db, [repo.id]),
14161416 ),
14171417 pillars,
1418 await askToLiveFor(ctx.app, repo, pillars, user),
1418 askToLiveFor(ctx.app, repo, pillars, user),
14191419 ),
14201420 );
14211421 },
src/main.ts +5
@@ -9,6 +9,7 @@ import { handover } from "./release/swap.ts";
99 import { parentUrlProblem } from "./core/parent_releases.ts";
1010 import { releaseEnv } from "./release/env.ts";
1111 import { createApp } from "./app.ts";
12 import { backfillCarried } from "./core/carried.ts";
1213 import { reconcile } from "./core/reconcile.ts";
1314 import { syncAllPillars } from "./actions/pillars.ts";
1415 import { firstBoot } from "./spawn/first-boot.ts";
@@ -265,6 +266,10 @@ const apps = appDomain?.ok
265266 })
266267 : undefined;
267268 await apps?.start();
269 // What deploys before the record carried, so ask-to-live is not blank for them.
270 void backfillCarried(app.db, app.reposRoot).catch((error) =>
271 app.log.error("could not backfill what deploys carried", { error: String(error) }),
272 );
268273 // A repository's own check, on every push a pull or the default branch takes.
269274 const checks =
270275 process.env.FORGE_CHECKS === "0"
src/web/ask_to_live.ts +5 −7
@@ -1,7 +1,8 @@
11 /**
22 * A repository's pillars' ask-to-live, for the surfaces that show it (the map,
3 * the milestones, their data). Opt-in and async: it asks git which deploys
4 * carry which merges, so only the callers that print it pay for it.
3 * the milestones, their data). Opt-in, so only the callers that print it pay
outdated

Tidy the wrapped comment so it reads as one sentence; the old 'async' remark is gone but the line break is awkward.

4 * for it; it reads which deploys carry which merges from `deploy_pull` and
5 * runs no git.
56 */
67
78 import type { App } from "../app.ts";
@@ -13,21 +14,18 @@ import {
1314 import type { Repo } from "../core/repos.ts";
1415 import { sealedScope } from "../core/sealed.ts";
1516 import type { User } from "../core/users.ts";
16 import { reachesIn } from "../git/local.ts";
17 import { repoDir } from "../git/repos.ts";
1817
1918 export function askToLiveFor(
2019 app: App,
2120 repo: Repo,
2221 view: PillarView | undefined,
2322 user: User | undefined,
24 ): Promise<AskToLiveCount[]> {
25 if (view === undefined) return Promise.resolve([]);
23 ): AskToLiveCount[] {
24 if (view === undefined) return [];
2625 return askToLiveOf(
2726 app.db,
2827 repo,
2928 view,
30 reachesIn(repoDir(app.reposRoot, repo.ownerName, repo.name)),
3129 sealedScope(app.db, repo, user),
3230 );
3331 }
src/web/pages/map.ts +1 −1
@@ -1419,7 +1419,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
14191419 graph.issues.map((i) => wordFor(i, readySet, marks)),
14201420 );
14211421 const pillars = pillarView(db, repo);
1422 const ask = await askToLiveFor(ctx.app, repo, pillars, ctx.auth.user);
1422 const ask = askToLiveFor(ctx.app, repo, pillars, ctx.auth.user);
14231423 const body = html`${repoHeader(ctx, repo, "map")}
14241424 <div class="page-head mt">
14251425 <h1>Map</h1>
src/web/pages/milestones.ts +6 −6
@@ -519,7 +519,7 @@ export async function milestonesJson(
519519 const plan = readPlan(ctx.app.db, repo, {
520520 spend: canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user),
521521 });
522 const ask = await askByUmbrella(ctx, repo);
522 const ask = askByUmbrella(ctx, repo);
523523 return {
524524 repo: { owner: repo.ownerName, name: repo.name },
525525 limbs: plan.limbs.map((limb) => ({
@@ -530,18 +530,18 @@ export async function milestonesJson(
530530 }
531531
532532 /** Each pillar's ask-to-live, by its umbrella's number: what its limb shows too. */
533 async function askByUmbrella(
533 function askByUmbrella(
534534 ctx: Ctx,
535535 repo: Repo,
536536 only?: number,
537 ): Promise<Map<number, AskToLive | null>> {
537 ): Map<number, AskToLive | null> {
538538 const out = new Map<number, AskToLive | null>();
539539 const view = pillarView(ctx.app.db, repo);
540540 if (view === undefined) return out;
541 // A milestone that is no pillar's umbrella has none: ask git for nothing.
541 // A milestone that is no pillar's umbrella has none: read nothing.
542542 if (only !== undefined && !view.set.pillars.some((p) => p.umbrella.number === only))
543543 return out;
544 const ask = await askToLiveFor(ctx.app, repo, view, ctx.auth.user);
544 const ask = askToLiveFor(ctx.app, repo, view, ctx.auth.user);
545545 for (const pillar of view.set.pillars)
546546 out.set(
547547 pillar.umbrella.number,
@@ -569,7 +569,7 @@ export async function milestoneData(
569569 : {
570570 repo: { owner: repo.ownerName, name: repo.name },
571571 ...milestoneJson(milestone, owner),
572 ...askOf(await askByUmbrella(ctx, repo, number), number),
572 ...askOf(askByUmbrella(ctx, repo, number), number),
573573 // Whether the caller watches it; a caller who cannot has no answer.
574574 ...(viewer?.kind === "user"
575575 ? {
test/carried.test.ts +14 −1
@@ -10,7 +10,7 @@ import { join } from "node:path";
1010 import { createApp, type App } from "../src/app.ts";
1111 import { mergePull, openPull } from "../src/actions/pulls.ts";
1212 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { carriedBy, recordCarried } from "../src/core/carried.ts";
13 import { backfillCarried, carriedBy, recordCarried } from "../src/core/carried.ts";
1414 import { getPull } from "../src/core/pulls.ts";
1515 import { repoDir } from "../src/git/repos.ts";
1616 import type { Repo } from "../src/core/repos.ts";
@@ -110,4 +110,17 @@ describe("carried", () => {
110110 }),
111111 ).toEqual([]);
112112 });
113
114 test("backfill records what a recent production deploy carried before the record existed, once", async () => {
115 app.db
116 .query(
117 `INSERT INTO run (id, repo_id, kind, env, commit_oid, state, serving_at, finished_at)
118 VALUES ('old-prod', ?1, 'deploy', 'production', ?2, 'ok', ?3, ?3)`,
119 )
120 .run(repo.id, merged[0]!.oid, new Date().toISOString());
121 expect(await backfillCarried(app.db, app.reposRoot)).toBe(1);
122 expect(carriedBy(app.db, "old-prod")).toEqual([merged[0]!.number]);
123 // Already recorded: not walked again.
124 expect(await backfillCarried(app.db, app.reposRoot)).toBe(0);
125 });
113126 });
test/pillars.test.ts +56 −9
@@ -477,25 +477,45 @@ describe("pillars", () => {
477477 setCreated(parts[0]!, 10);
478478 setMerged(queuePull.id, 8);
479479 setMerged(composerPull.id, 6);
480 const deploy = (env: string, oid: string, state: string, hoursAgo: number) =>
480 // A deploy and the merged pulls it carries (the `deploy_pull` record).
481 const deploy = (
482 env: string,
483 oid: string,
484 state: string,
485 hoursAgo: number,
486 carries: { id: string; oid: string }[],
487 ) => {
488 const id = `ask-${nextId++}`;
481489 app.db
482490 .query(
483491 `INSERT INTO run (id, repo_id, kind, env, commit_oid, state, serving_at, finished_at)
484492 VALUES (?1, ?2, 'deploy', ?3, ?4, ?5, ?6, ?6)`,
485493 )
486 .run(`ask-${nextId++}`, repo.id, env, oid, state, at(hoursAgo));
494 .run(id, repo.id, env, oid, state, at(hoursAgo));
495 for (const p of carries)
496 app.db
497 .query(
498 `INSERT INTO deploy_pull (run_id, issue_id, pull_number, merged_oid)
499 VALUES (?1, ?2, 0, ?3)`,
500 )
501 .run(id, p.id, p.oid);
502 };
487503 // A preview and a failed deploy are not production; they count for nothing.
488 deploy("pull-9", queuePull.oid, "ok", 19);
489 deploy("production", composerPull.oid, "failed", 5.5);
504 deploy("pull-9", queuePull.oid, "ok", 19, [sandboxPull, queuePull]);
505 deploy("production", composerPull.oid, "failed", 5.5, [composerPull]);
490506 // Before the merge: it cannot hold it.
491 deploy("production", sandboxPull.oid, "ok", 25);
507 deploy("production", sandboxPull.oid, "ok", 25, [sandboxPull]);
492508 // The first live deploy holding sandbox's merge: 30h asked, live 12h later.

This comment is stale: nothing asks git ancestry any more; the deploy_pull rows say what a deploy holds. Reword it.

493 // It runs a later commit on main, so only git ancestry says it holds it.
494 deploy("production", queuePull.oid, "ok", 18);
509 // The record, not the commit it ran, says which merges it holds.
510 deploy("production", queuePull.oid, "ok", 18, [sandboxPull]);
495511 // Holds queue's merge, and sandbox's before it: queue was asked 10h ago, live 5h.
496 deploy("production", queuePull.oid, "ok", 5);
512 deploy("production", queuePull.oid, "ok", 5, [sandboxPull, queuePull]);
497513 const view = pillarView(app.db, fresh(), new Date(T))!;
498 const asked = await askToLiveFor(app, fresh(), view, alice);
514 // The record answers: with no repository on disk, no git can have run.
515 const realRoot = app.reposRoot;
516 (app as { reposRoot: string }).reposRoot = "/nonexistent";
517 const asked = askToLiveFor(app, fresh(), view, alice);
518 (app as { reposRoot: string }).reposRoot = realRoot;
499519 const ask = (name: string) =>
500520 asked.find((a) => a.pillar === name)!.askToLive;
501521 // sandbox: 30h -> deploy at 18h ago = 12h; queue: 10h -> 5h ago = 5h.
@@ -587,6 +607,33 @@ describe("pillars", () => {
587607 }
588608 });
589609
610 test("ask-to-live counts an issue once when two merged pulls close it, at its first time live", async () => {
611 // Continues the tests above: the queue pull now also closes sandbox.
612 const queueIssue = app.db
613 .query<{ id: string; body: string }, []>(
614 "SELECT id, body FROM issue WHERE title = 'Land queue'",
615 )
616 .get()!;
617 app.db
618 .query("UPDATE issue SET body = ?1 WHERE id = ?2")
619 .run(`Closes #${sandbox.number}`, queueIssue.id);
620 try {
621 const view = pillarView(app.db, fresh(), new Date())!;
622 const trustAsk = askToLiveFor(app, fresh(), view, alice).find(
623 (a) => a.pillar === "P0 Trust",
624 )!.askToLive;
625 // sandbox once, live 12h after it was asked (the first of its two pulls to go live).
626 expect(trustAsk).toEqual({
627 medianHours: 12,
628 slowest: { number: sandbox.number, hours: 12 },
629 });
630 } finally {
631 app.db
632 .query("UPDATE issue SET body = ?1 WHERE id = ?2")
633 .run(queueIssue.body, queueIssue.id);
634 }
635 });
636
590637 test("a repository with no pillars shows none of it", async () => {
591638 const made = await createRepo(app, alice, {
592639 name: "plain",
test/reaches.test.ts deleted −31
@@ -1,31 +0,0 @@
1 import { afterAll, expect, test } from "bun:test";
2 import { mkdtempSync, rmSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { reachesIn } from "../src/git/local.ts";
6
7 const dir = mkdtempSync(join(tmpdir(), "forge-reaches-"));
8 afterAll(() => rmSync(dir, { recursive: true, force: true }));
9
10 const git = (...args: string[]) => {
11 const out = Bun.spawnSync(
12 ["git", "-c", "user.name=t", "-c", "user.email=t@t", ...args],
13 { cwd: dir },
14 );
15 return out.stdout.toString().trim();
16 };
17
18 test("reachesIn: a later commit holds an earlier one, not the other way; a broken repository is no, not a throw", async () => {
19 git("init", "-q");
20 git("commit", "-q", "--allow-empty", "-m", "one");
21 const first = git("rev-parse", "HEAD");
22 git("commit", "-q", "--allow-empty", "-m", "two");
23 const second = git("rev-parse", "HEAD");
24 const reaches = reachesIn(dir);
25 expect(await reaches(first, second)).toBe(true);
26 expect(await reaches(second, first)).toBe(false);
27 expect(await reaches(first, first)).toBe(true);
28 expect(await reaches("f".repeat(40), second)).toBe(false);
29 expect(await reaches("--all", second)).toBe(false);
30 expect(await reachesIn(join(dir, "missing"))(first, second)).toBe(false);
31 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs