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

Every pillar's ask-to-live time on the map: median hours from an issue opening to its change live in production, last 7 days #1362

Merged opened this

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

Merged

scout/forge:scout/1185-every-pillar-s-ask-to-live-time-on-the-m → main 5 commits fork 10 threads · 10 unresolved

  • Merged. scout/1185-every-pillar-s-ask-to-live-time-on-the-m was merged into main as c5d1517.

Review

Sign in to review this change.
12 files changed +572 −56
README.md +3 −1
@@ -342,7 +342,9 @@ from before this record say nothing).
342342 mechanical rather than advisory.
343343
344344 `docs/PLANNING.md` is how issues depend on issues: the edge, the map, and
345 what "ready now" means to a person and to the crew.
345 what "ready now" means to a person and to the crew. The map also shows each
346 pillar's ask-to-live: the median hours from an issue opening to its change live
347 in production, over the last 7 days (also in the map and milestones API and MCP).
346348
347349 `docs/STUDIO.md` is the front door from the top of this page in full: the five
348350 words and where each is decided, the box, the three lists, a tell, a run
docs/PLANNING.md +6 −1
@@ -234,7 +234,12 @@ this; three surfaces show what it reads.
234234 every issue carries `pillar` (null for none), `pillars` is the same
235235 rows, and `mergedByPillar` is `{ days: 7, counts: [{ pillar, merged }] }`:
236236 the pulls merged in the last 7 days, each counted under the pillar of the
237 first issue it closes that serves one.
237 first issue it closes that serves one. Each pillar's count also carries
238 `askToLive: {medianHours, slowest: {number, hours}}` (null, a dash on the
239 page, when none merged has gone live): hours from that issue opening to the
240 first production deploy whose commit contains the pull's merge, by
241 `git merge-base --is-ancestor`, cached. The MCP's `map` and `milestones`
242 answers carry it too, on each pillar's limb.
238243 - **The board by pillar.** _Landed_ leads with the same seven days for
239244 each app that has pillars: every pillar and its count, zero or not, each
240245 a link to its issues.
scripts/gate.ts +1
@@ -187,6 +187,7 @@ export const GATE: readonly string[] = [
187187 "test/make-it-yours.test.ts",
188188 "test/map.test.ts",
189189 "test/pillars.test.ts",
190 "test/reaches.test.ts",
190191 "test/milestones.test.ts",
191192 "test/mcp.test.ts",
192193 "test/name-marks.test.ts",
src/core/pillars.ts +196 −32
@@ -244,39 +244,11 @@ export function mergedByPillar(
244244 served: ReadonlyMap<string, string>,
245245 since: string,
246246 ): MergedCount[] {
247 const bodies = db
248 .query<{ body: string }, [string, string]>(
249 `SELECT i.body FROM pull p JOIN issue i ON i.id = p.issue_id
250 WHERE i.repo_id = ?1 AND p.merged_at IS NOT NULL AND p.merged_at >= ?2
251 ORDER BY p.merged_at DESC LIMIT 1000`,
252 )
253 .all(repoId, since);
254 const closes = bodies.map((row) => closesFromBody(row.body));
255 const numbers = [...new Set(closes.flat())];
256 const idOf = new Map<number, string>();
257 const CHUNK = 400;
258 for (let i = 0; i < numbers.length; i += CHUNK) {
259 const chunk = numbers.slice(i, i + CHUNK);
260 const rows = db
261 .query<{ id: string; number: number }, (string | number)[]>(
262 `SELECT id, number FROM issue WHERE repo_id = ?1 AND kind = 'issue'
263 AND number IN (${chunk.map((_, n) => `?${n + 2}`).join(",")})`,
264 )
265 .all(repoId, ...chunk);
266 for (const row of rows) idOf.set(row.number, row.id);
267 }
247 const { pulls, issues } = mergedPulls(db, repoId, since);
268248 const names = pillarNames(set);
269249 const counts = new Map<string | null, number>(names.map((n) => [n, 0]));
270 for (const closed of closes) {
271 let pillar: string | null = null;
272 for (const number of closed) {
273 const id = idOf.get(number);
274 const found = id === undefined ? undefined : served.get(id);
275 if (found !== undefined) {
276 pillar = found;
277 break;
278 }
279 }
250 for (const pull of pulls) {
251 const pillar = pillarOf(pull.closes, issues, served)?.pillar ?? null;
280252 counts.set(pillar, (counts.get(pillar) ?? 0) + 1);
281253 }
282254 const none = counts.get(null);
@@ -286,6 +258,177 @@ export function mergedByPillar(
286258 ];
287259 }
288260
261 interface MergedPull {
262 readonly closes: readonly number[];
263 readonly mergedAt: string;
264 readonly mergedOid: string;
265 }
266
267 interface ClosedIssue {
268 readonly id: string;
269 readonly createdAt: string;
270 }
271
272 type SealedScope = { sealed?: true; sealedBy?: string };
273
274 /** The rows of table `t` a viewer may see (`sealed.ts`); `?n` is the reporter's id, bound only when `by` is true. */
275 function sealedClause(t: string, scope: SealedScope, n: number): string {
276 if (scope.sealed === true) return "1";
277 return scope.sealedBy === undefined
278 ? `${t}.sealed = 0`
279 : `(${t}.sealed = 0 OR ${t}.author_id = ?${n})`;
280 }
281
282 /** The parameters `sealedClause` binds: the reporter's id, when it asks for one. */
283 const sealedParams = (scope: SealedScope): string[] =>
284 scope.sealed !== true && scope.sealedBy !== undefined ? [scope.sealedBy] : [];
285
286 /** The pulls merged since an instant, and the issues they close, by number. Two reads. */
287 function mergedPulls(
288 db: Database,
289 repoId: string,
290 since: string,
291 scope: SealedScope = {},
292 ): { pulls: MergedPull[]; issues: Map<number, ClosedIssue> } {
293 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
296 FROM pull p JOIN issue i ON i.id = p.issue_id
297 WHERE i.repo_id = ?1 AND p.merged_at IS NOT NULL AND p.merged_at >= ?2
298 AND ${sealedClause("i", scope, 3)}
299 ORDER BY p.merged_at DESC LIMIT 1000`,
300 )
301 .all(repoId, since, ...sealedParams(scope));
outdated

High: these issues and the pulls above are read with no sealed scope, so a sealed issue's number becomes slowest.number for any reader. Take the caller's sealedScope and leave out the rows that caller cannot view.

302 const pulls = rows.map((row) => ({
303 closes: closesFromBody(row.body),
304 mergedAt: row.at,
305 mergedOid: row.oid ?? "",
306 }));
307 const numbers = [...new Set(pulls.flatMap((pull) => pull.closes))];
308 const issues = new Map<number, ClosedIssue>();
309 const CHUNK = 400;
310 for (let i = 0; i < numbers.length; i += CHUNK) {
311 const chunk = numbers.slice(i, i + CHUNK);
312 const found = db
313 .query<
314 { id: string; number: number; created_at: string },
315 (string | number)[]
316 >(
317 `SELECT id, number, created_at FROM issue WHERE repo_id = ?1 AND kind = 'issue'
318 AND ${sealedClause("issue", scope, 2)}
319 AND number IN (${chunk.map((_, n) => `?${n + 2 + sealedParams(scope).length}`).join(",")})`,
320 )
321 .all(repoId, ...sealedParams(scope), ...chunk);
322 for (const row of found)
323 issues.set(row.number, { id: row.id, createdAt: row.created_at });
324 }
325 return { pulls, issues };
326 }
327
328 /** The first issue a pull closes that serves a pillar, and that pillar. */
329 function pillarOf(
330 closes: readonly number[],
331 issues: ReadonlyMap<number, ClosedIssue>,
332 served: ReadonlyMap<string, string>,
333 ): { pillar: string; number: number; createdAt: string } | undefined {
334 for (const number of closes) {
335 const issue = issues.get(number);
336 const pillar = issue === undefined ? undefined : served.get(issue.id);
337 if (issue !== undefined && pillar !== undefined)
338 return { pillar, number, createdAt: issue.createdAt };
339 }
340 return undefined;
341 }
342
343 // ---- ask-to-live: an issue opening to its change in production (#1185) ----
344
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
348 /** How long the asking took, for one pillar's merged work. */
349 export interface AskToLive {
350 /** Median hours from an issue opening to its change live in production. */
outdated

Git ancestry belongs in src/git next to isAncestor, not in core. Also, wiping the cache whole at 5000 entries means one busy repository makes every map view cold again.

351 readonly medianHours: number;
352 /** The one that took longest. */
353 readonly slowest: { readonly number: number; readonly hours: number };
354 }
355
356 /** A pillar's ask-to-live; `askToLive` is undefined where nothing merged has gone live. */
357 export interface AskToLiveCount {
outdated

High: a synchronous process spawn inside a guest-reachable GET. It blocks the server's one event loop, and it throws (500) when the repository directory is missing. Use the async isAncestor in src/git/local.ts, or precompute at deploy time.

358 readonly pillar: string;
359 readonly askToLive: AskToLive | undefined;
360 }
361
362 /** Most ancestry questions one request may put to git; a pull left unanswered counts for nothing. */
363 const ASK_GIT_CALLS = 120;
364
365 /**
366 * Per pillar, over the pulls merged since an instant: the median and the
367 * 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.
371 */
372 export async function askToLiveByPillar(
373 db: Database,
374 repoId: string,
375 set: Pillars,
376 served: ReadonlyMap<string, string>,
outdated

Medium: up to pulls × deploys sequential git spawns on a guest's GET, uncached on the first view. Bound it per request.

377 since: string,
378 reaches: Reaches,
379 scope: SealedScope = {},
380 ): Promise<AskToLiveCount[]> {
381 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`,
388 )
389 .all(repoId, since);
390 const asked = new Map<string, { number: number; hours: number }[]>();
391 let budget = ASK_GIT_CALLS;
392 for (const pull of pulls) {
393 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;
406 if (!Number.isFinite(hours)) continue;
407 const list = asked.get(ask.pillar) ?? [];
408 list.push({ number: ask.number, hours: Math.max(0, hours) });

Low: two merged pulls that close the same issue (a fix and a follow-up) push that issue into the list twice. That skews the median toward it. Consider keeping one entry per issue number: its first time live.

409 asked.set(ask.pillar, list);
410 }
411 return pillarNames(set).map((pillar) => {
412 const list = (asked.get(pillar) ?? []).sort((a, b) => a.hours - b.hours);
413 if (list.length === 0) return { pillar, askToLive: undefined };
414 const mid = list.length >> 1;
415 const median =
416 list.length % 2 === 1
417 ? list[mid]!.hours
418 : (list[mid - 1]!.hours + list[mid]!.hours) / 2;
419 const slowest = list[list.length - 1]!;
420 return {
421 pillar,
422 askToLive: {
423 medianHours: round1(median),
424 slowest: { number: slowest.number, hours: round1(slowest.hours) },
425 },
426 };
427 });
428 }
429
430 const round1 = (n: number): number => Math.round(n * 10) / 10;
431
289432 /** Of these repositories, the ones with an open issue titled like an umbrella: one read. */
290433 export function reposWithUmbrellas(
291434 db: Database,
@@ -336,10 +479,31 @@ export function pillarView(
336479 const set = pillarsOf(db, repo);
337480 if (set.pillars.length === 0) return undefined;
338481 const of = servedBy(db, set);
482 const since = pillarWindowStart(now);
339483 return {
340484 set,
341485 names: pillarNames(set),
342486 of,
343 merged: mergedByPillar(db, repo.id, set, of, pillarWindowStart(now)),
487 merged: mergedByPillar(db, repo.id, set, of, since),
344488 };
345489 }
490
491 /** A view's ask-to-live over the last `PILLAR_DAYS` days; opt-in, for it reads git. */
492 export function askToLiveOf(
493 db: Database,
494 repo: Repo,
495 view: PillarView,
496 reaches: Reaches,
497 scope: SealedScope = {},
498 now = new Date(),
499 ): Promise<AskToLiveCount[]> {
500 return askToLiveByPillar(
501 db,
502 repo.id,
503 view.set,
504 view.of,
505 pillarWindowStart(now),
506 reaches,
507 scope,
508 );
509 }
src/git/local.ts +50
@@ -861,6 +861,56 @@ export async function isAncestor(
861861 return out.ok;
862862 }
863863
864 const FULL_OID = /^[0-9a-f]{40,64}$/;
865 const KNOWN_LIMIT = 1000;
outdated

One 5000-entry cache shared across every repository: one busy repository evicts all the others, so any reader can keep it cold.

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;

Low: a missing object (exit > 1) is never cached. A production deploy of a commit the repository lacks makes every view spend git calls on it, up to the 120 cap, even for guests. Recording at deploy time which merges a deploy carries, as already noted in the pull, would retire this. Worth an issue.

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
864914 /** `rev-list --left-right --count a...b` — what each side has alone. */
865915 export async function aheadBehind(
866916 dir: string,
src/http/api.ts +11 −6
@@ -258,6 +258,7 @@ import {
258258 import { openPlan, parsePlan } from "../actions/plan.ts";
259259 import { dependenciesOf, repoGraph } from "../core/dependencies.ts";
260260 import { pillarView } from "../core/pillars.ts";
261 import { askToLiveFor } from "../web/ask_to_live.ts";
261262 import { decisionStats, listDecisions } from "../core/decisions.ts";
262263 import { globalMapJson, mapJson, marksFor } from "../web/pages/map.ts";
263264 import {
@@ -2465,20 +2466,22 @@ export function apiRoutes(router: Router<Handler>): void {
24652466 );
24662467 // Milestones as data: the plan's limbs with their weights, and one
24672468 // milestone opened. The same numbers as the pages.
2468 router.add("GET", "/api/v1/repos/:owner/:name/milestones", (ctx) => {
2469 router.add("GET", "/api/v1/repos/:owner/:name/milestones", async (ctx) => {
24692470 const repo = loadRepo(ctx);
24702471 if (!repo) return notFound();
2471 return json(milestonesJson(ctx, repo));
2472 return json(await milestonesJson(ctx, repo));
24722473 });
24732474 router.add(
24742475 "GET",
24752476 "/api/v1/repos/:owner/:name/milestones/:number",
2476 (ctx) => {
2477 async (ctx) => {
24772478 const repo = loadRepo(ctx);
24782479 if (!repo) return notFound();
24792480 const number = positiveInt(ctx.params.number);
24802481 const data =
2481 number === undefined ? undefined : milestoneData(ctx, repo, number);
2482 number === undefined
2483 ? undefined
2484 : await milestoneData(ctx, repo, number);
24822485 return data === undefined ? notFound() : json(data);
24832486 },
24842487 );
@@ -2543,12 +2546,13 @@ export function apiRoutes(router: Router<Handler>): void {
25432546 );
25442547 // The map as data: ready now, blocked on, the critical path, every edge,
25452548 // and where the crew is.
2546 router.add("GET", "/api/v1/repos/:owner/:name/map", (ctx) => {
2549 router.add("GET", "/api/v1/repos/:owner/:name/map", async (ctx) => {
25472550 const repo = loadRepo(ctx);
25482551 if (!repo) return notFound();
25492552 const graph = repoGraph(ctx.app.db, repo.id, {
25502553 ...sealedScope(ctx.app.db, repo, ctx.auth.user),
25512554 });
2555 const pillars = pillarView(ctx.app.db, repo);
25522556 return json(
25532557 mapJson(
25542558 repo,
@@ -2559,7 +2563,8 @@ export function apiRoutes(router: Router<Handler>): void {
25592563 graph,
25602564 listActiveRuns(ctx.app.db, [repo.id]),
25612565 ),
2562 pillarView(ctx.app.db, repo),
2566 pillars,
2567 await askToLiveFor(ctx.app, repo, pillars, ctx.auth.user),
25632568 ),
25642569 );
25652570 });
src/http/mcp.ts +9 −5
@@ -62,6 +62,7 @@ import { lookUrl } from "../core/looks.ts";
6262 import { getPull } from "../core/pulls.ts";
6363 import { repoGraph } from "../core/dependencies.ts";
6464 import { pillarView } from "../core/pillars.ts";
65 import { askToLiveFor } from "../web/ask_to_live.ts";
6566 import { getAppByRepo } from "../core/apps.ts";
6667 import {
6768 canRead,
@@ -1152,12 +1153,13 @@ const TOOLS: readonly Tool[] = [
11521153 required: ["app"],
11531154 },
11541155 writes: false,
1155 run(ctx, user, args) {
1156 async run(ctx, user, args) {
11561157 const repo = planRepo(ctx, user, args);
11571158 if (isAnswer(repo)) return repo;
11581159 const graph = repoGraph(ctx.app.db, repo.id, {
11591160 ...sealedScope(ctx.app.db, repo, user),
11601161 });
1162 const pillars = pillarView(ctx.app.db, repo);
11611163 return good(
11621164 mapJson(
11631165 repo,
@@ -1168,7 +1170,8 @@ const TOOLS: readonly Tool[] = [
11681170 graph,
11691171 listActiveRuns(ctx.app.db, [repo.id]),
11701172 ),
1171 pillarView(ctx.app.db, repo),
1173 pillars,
1174 await askToLiveFor(ctx.app, repo, pillars, user),
11721175 ),
11731176 );
11741177 },
@@ -1191,14 +1194,15 @@ const TOOLS: readonly Tool[] = [
11911194 required: ["app"],
11921195 },
11931196 writes: false,
1194 run(ctx, user, args) {
1197 async run(ctx, user, args) {
11951198 const repo = planRepo(ctx, user, args);
11961199 if (isAnswer(repo)) return repo;
1197 if (args["issue"] === undefined) return good(milestonesJson(ctx, repo));
1200 if (args["issue"] === undefined)
1201 return good(await milestonesJson(ctx, repo));
11981202 const number = args["issue"];
11991203 const data =
12001204 typeof number === "number" && Number.isInteger(number) && number > 0
1201 ? milestoneData(ctx, repo, number)
1205 ? await milestoneData(ctx, repo, number)
12021206 : undefined;
12031207 return data === undefined
12041208 ? bad(
src/web/ask_to_live.ts added +33
@@ -0,0 +1,33 @@
1 /**
2 * 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.
5 */
6
7 import type { App } from "../app.ts";
8 import {
9 askToLiveOf,
10 type AskToLiveCount,
11 type PillarView,
12 } from "../core/pillars.ts";
13 import type { Repo } from "../core/repos.ts";
14 import { sealedScope } from "../core/sealed.ts";
15 import type { User } from "../core/users.ts";
16 import { reachesIn } from "../git/local.ts";
17 import { repoDir } from "../git/repos.ts";
18
19 export function askToLiveFor(
20 app: App,
21 repo: Repo,
22 view: PillarView | undefined,
23 user: User | undefined,
24 ): Promise<AskToLiveCount[]> {
25 if (view === undefined) return Promise.resolve([]);
26 return askToLiveOf(
27 app.db,
28 repo,
29 view,
30 reachesIn(repoDir(app.reposRoot, repo.ownerName, repo.name)),
31 sealedScope(app.db, repo, user),
32 );
33 }
src/web/pages/map.ts +42 −4
@@ -73,6 +73,8 @@ import {
7373 import { studioRecord, tellHref } from "../studio/nouns.ts";
7474 import { milestoneHref } from "./milestones.ts";
7575 import {
76 type AskToLive,
77 type AskToLiveCount,
7678 isStructural,
7779 LATER,
7880 NO_PILLAR,
@@ -81,6 +83,7 @@ import {
8183 pillarView,
8284 type PillarView,
8385 } from "../../core/pillars.ts";
86 import { askToLiveFor } from "../ask_to_live.ts";
8487
8588 /** Where a card's own link goes: dev's issue page, by default. */
8689 const devIssueHref = (base: string, number: number): string =>
@@ -921,6 +924,9 @@ export interface PillarRow {
921924 readonly open: number;
922925 readonly ready: number;
923926 readonly blocked: number;
927 /** For a pillar: the week's merged pulls, and how long asking took. */
928 readonly merged?: number;
929 readonly askToLive?: AskToLive | undefined;
924930 /** For no pillar: the issues themselves, to be filed. */
925931 readonly issues?: readonly Issue[];
926932 }
@@ -936,6 +942,7 @@ export function pillarRows(
936942 ready: Set<string>,
937943 marks: Marks,
938944 view: PillarView,
945 ask: readonly AskToLiveCount[] = [],
939946 ): PillarRow[] {
940947 const tally = new Map<
941948 string | null,
@@ -963,6 +970,8 @@ export function pillarRows(
963970 name,
964971 ...(umbrella.has(name) ? { umbrella: umbrella.get(name)! } : {}),
965972 ...tally.get(name)!,
973 merged: view.merged.find((m) => m.pillar === name)?.merged ?? 0,
974 askToLive: ask.find((a) => a.pillar === name)?.askToLive,
966975 })),
967976 ...(loose.length === 0
968977 ? []
@@ -970,6 +979,11 @@ export function pillarRows(
970979 ];
971980 }
972981
982 /** Hours as a person reads them: `3.5h`, or `2.1d` past two days. */
983 export function hours(h: number): string {
984 return h >= 48 ? `${(h / 24).toFixed(1)}d` : `${h}h`;
985 }
986
973987 /** Where a pillar's issues are: the list, narrowed to its label. */
974988 export const pillarHref = (base: string, name: string): string =>
975989 `${base}/issues?label=${encodeURIComponent(name)}`;
@@ -1011,6 +1025,16 @@ function pillarList(repo: Repo, rows: readonly PillarRow[]): Html {
10111025 >${r.name}</a
10121026 >
10131027 <span class="muted small">${counts(r)}</span>
1028 <span class="muted small map-ask" title="Median and slowest hours from an issue opening to its change live in production, over ${PILLAR_DAYS} days"
1029 >${r.merged ?? 0} merged · ask to live
1030 ${r.askToLive === undefined
1031 ? "—"
1032 : html`${hours(r.askToLive.medianHours)} median, slowest
1033 <a href="${base}/issues/${r.askToLive.slowest.number}"
1034 >#${r.askToLive.slowest.number}</a

This links a sealed issue's number on a public map. Once the scope fix is in, test that a guest never sees it here.

1035 >
1036 ${hours(r.askToLive.slowest.hours)}`}</span
1037 >
10141038 </li>`,
10151039 )}
10161040 </ul>
@@ -1152,6 +1176,7 @@ export const mapCss = /* css */ `
11521176 .map-list ol li::marker { color: var(--faint); font-variant-numeric: tabular-nums; }
11531177 .map-list li span { display: block; }
11541178 .map-list + .map-list { margin-top: 18px; }
1179 .map-ask { display: block; }
11551180 .map-pillars ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; font-size: 13.5px; }
11561181 .map-chart { width: 100%; height: auto; display: block; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); }
11571182 .map-level { fill: none; stroke: var(--accent); stroke-width: 1.5; }
@@ -1373,7 +1398,7 @@ const studioEmpty = (base: string): Html =>
13731398 html`<p>No tells to map yet. <a href="${base}#tell">Say one</a>, then link what it waits for.</p>`;
13741399
13751400 /** GET /:owner/:name/map */
1376 export function mapPage(ctx: Ctx): Response {
1401 export async function mapPage(ctx: Ctx): Promise<Response> {
13771402 const repo = loadRepo(ctx);
13781403 if (!repo) return notFoundPage(ctx);
13791404 const db = ctx.app.db;
@@ -1394,6 +1419,7 @@ export function mapPage(ctx: Ctx): Response {
13941419 graph.issues.map((i) => wordFor(i, readySet, marks)),
13951420 );
13961421 const pillars = pillarView(db, repo);
1422 const ask = await askToLiveFor(ctx.app, repo, pillars, ctx.auth.user);
13971423 const body = html`${repoHeader(ctx, repo, "map")}
13981424 <div class="page-head mt">
13991425 <h1>Map</h1>
@@ -1422,7 +1448,10 @@ export function mapPage(ctx: Ctx): Response {
14221448 <aside>
14231449 ${pillars === undefined
14241450 ? null
1425 : pillarList(repo, pillarRows(repo, graph, readySet, marks, pillars))}
1451 : pillarList(
1452 repo,
1453 pillarRows(repo, graph, readySet, marks, pillars, ask),
1454 )}
14261455 ${graph.issues.length === 0
14271456 ? null
14281457 : rankedList(
@@ -1483,6 +1512,7 @@ export function mapJson(
14831512 graph: Graph,
14841513 marks: Marks = NO_MARKS,
14851514 pillars?: PillarView,
1515 ask: readonly AskToLiveCount[] = [],
14861516 ): Record<string, unknown> {
14871517 const ready = readyNow(graph);
14881518 const readySet = new Set(ready.map((r) => r.issue.id));
@@ -1537,7 +1567,7 @@ export function mapJson(
15371567 ...(pillars === undefined
15381568 ? {}
15391569 : {
1540 pillars: pillarRows(repo, graph, readySet, marks, pillars).map(
1570 pillars: pillarRows(repo, graph, readySet, marks, pillars, ask).map(
15411571 (r) => ({
15421572 pillar: r.name,
15431573 ...(r.umbrella === undefined ? {} : { umbrella: r.umbrella }),
@@ -1546,7 +1576,15 @@ export function mapJson(
15461576 blocked: r.blocked,
15471577 }),
15481578 ),
1549 mergedByPillar: { days: PILLAR_DAYS, counts: pillars.merged },
1579 mergedByPillar: {
1580 days: PILLAR_DAYS,
1581 counts: pillars.merged.map((count) => {
1582 const asked = ask.find((a) => a.pillar === count.pillar);
1583 return asked === undefined
1584 ? count
1585 : { ...count, askToLive: asked.askToLive ?? null };
1586 }),
1587 },
15501588 }),
15511589 };
15521590 }
src/web/pages/milestones.ts +42 −6
@@ -19,11 +19,18 @@ import {
1919 type Milestone,
2020 } from "../../core/milestones.ts";
2121 import type { LimbAction } from "../../actions/limbs.ts";
22 import { isStructural, PILLAR_DAYS, pillarsOf } from "../../core/pillars.ts";
22 import {
23 type AskToLive,
24 isStructural,
25 PILLAR_DAYS,
26 pillarsOf,
27 pillarView,
28 } from "../../core/pillars.ts";
2329 import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
2430 import { isWatching } from "../../core/watches.ts";
2531 import { watchMilestone } from "../../actions/watches.ts";
2632 import type { Ctx } from "../../http/context.ts";
33 import { askToLiveFor } from "../ask_to_live.ts";
2734 import {
2835 crumbsFor,
2936 loadRepo,
@@ -437,25 +444,53 @@ export async function milestoneWatch(ctx: Ctx): Promise<Response> {
437444 }
438445
439446 /** The plan's limbs as data: `GET /api/v1/repos/:owner/:name/milestones`, and the MCP's `milestones`. */
440 export function milestonesJson(
447 export async function milestonesJson(
441448 ctx: Ctx,
442449 repo: Repo,
443 ): Record<string, unknown> {
450 ): Promise<Record<string, unknown>> {
444451 const plan = readPlan(ctx.app.db, repo, {
445452 spend: canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user),
446453 });
454 const ask = await askByUmbrella(ctx, repo);
447455 return {
448456 repo: { owner: repo.ownerName, name: repo.name },
449 limbs: plan.limbs.map(limbJson),
457 limbs: plan.limbs.map((limb) => ({
458 ...limbJson(limb),
459 ...askOf(ask, limb.issue.number),
460 })),
450461 };
451462 }
452463
464 /** Each pillar's ask-to-live, by its umbrella's number: what its limb shows too. */
465 async function askByUmbrella(
466 ctx: Ctx,
467 repo: Repo,
468 only?: number,
469 ): Promise<Map<number, AskToLive | null>> {
470 const out = new Map<number, AskToLive | null>();
471 const view = pillarView(ctx.app.db, repo);
472 if (view === undefined) return out;
473 // A milestone that is no pillar's umbrella has none: ask git for nothing.
474 if (only !== undefined && !view.set.pillars.some((p) => p.umbrella.number === only))
475 return out;
476 const ask = await askToLiveFor(ctx.app, repo, view, ctx.auth.user);
477 for (const pillar of view.set.pillars)
478 out.set(
479 pillar.umbrella.number,
480 ask.find((a) => a.pillar === pillar.name)?.askToLive ?? null,
481 );
482 return out;
483 }
484
485 const askOf = (ask: Map<number, AskToLive | null>, number: number) =>
486 ask.has(number) ? { askToLive: ask.get(number) } : {};
487
453488 /** One milestone as data, or nothing for an issue that waits for nothing. */
454 export function milestoneData(
489 export async function milestoneData(
455490 ctx: Ctx,
456491 repo: Repo,
457492 number: number,
458 ): Record<string, unknown> | undefined {
493 ): Promise<Record<string, unknown> | undefined> {
459494 const owner = canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user);
outdated

Builds the whole pillar view, git included, for every milestone, even one that is not a pillar's umbrella. Check pillarsOf for this number first.

460495 const milestone = readPlan(ctx.app.db, repo, { spend: owner }).milestone(
461496 number,
@@ -466,6 +501,7 @@ export function milestoneData(
466501 : {
467502 repo: { owner: repo.ownerName, name: repo.name },
468503 ...milestoneJson(milestone, owner),
504 ...askOf(await askByUmbrella(ctx, repo, number), number),
469505 // Whether the caller watches it; a caller who cannot has no answer.
470506 ...(viewer?.kind === "user"
471507 ? {
test/pillars.test.ts +148 −1
@@ -39,6 +39,7 @@ import {
3939 type Issue,
4040 } from "../src/core/issues.ts";
4141 import { pillarsOf, pillarView } from "../src/core/pillars.ts";
42 import { askToLiveFor } from "../src/web/ask_to_live.ts";
4243 import { getRepoById, type Repo } from "../src/core/repos.ts";
4344 import { createToken } from "../src/core/tokens.ts";
4445 import { createUser, getUserByName, type User } from "../src/core/users.ts";
@@ -424,7 +425,13 @@ describe("pillars", () => {
424425 ];
425426 expect(pillarView(app.db, fresh())!.merged).toEqual(want);
426427 const data = await api("/api/v1/repos/alice/engine/map");
427 expect(data.mergedByPillar).toEqual({ days: 7, counts: want });
428 // Nothing has gone live yet, so each pillar's ask-to-live is null: a dash.
429 expect(data.mergedByPillar).toEqual({
430 days: 7,
431 counts: want.map((w) =>
432 w.pillar === null ? w : { ...w, askToLive: null },
433 ),
434 });
428435
429436 const board = await get("/board");
430437 expect(board).toMatch(/merged in\s+7 days/);
@@ -440,6 +447,146 @@ describe("pillars", () => {
440447 );
441448 });
442449
450 test("ask-to-live: per pillar, an issue opening to the first production deploy holding the merge of the pull that closed it", async () => {
451 // Reads the pulls the test above landed: sandbox and queue for P0 Trust,
452 // composer for P1 Feed. Hours are counted back from one instant.
453 const T = Date.now();
454 const at = (hoursAgo: number) =>
455 new Date(T - hoursAgo * 3_600_000).toISOString();
456 const pullOf = (title: string) =>
457 app.db
458 .query<{ id: string; oid: string }, [string]>(
459 `SELECT p.issue_id AS id, p.merged_oid AS oid FROM pull p
460 JOIN issue i ON i.id = p.issue_id WHERE i.title = ?1`,
461 )
462 .get(`Land ${title}`)!;
463 const sandboxPull = pullOf("sandbox");
464 const queuePull = pullOf("queue");
465 const composerPull = pullOf("composer");
466 expect(queuePull.oid).toMatch(/^[0-9a-f]{40}$/);
467 const setCreated = (issue: Issue, hoursAgo: number) =>
468 app.db
469 .query("UPDATE issue SET created_at = ?1 WHERE id = ?2")
470 .run(at(hoursAgo), issue.id);
471 const setMerged = (id: string, hoursAgo: number) =>
472 app.db
473 .query("UPDATE pull SET merged_at = ?1 WHERE issue_id = ?2")
474 .run(at(hoursAgo), id);
475 setCreated(sandbox, 30);
476 setMerged(sandboxPull.id, 20);
477 setCreated(parts[0]!, 10);
478 setMerged(queuePull.id, 8);
479 setMerged(composerPull.id, 6);
480 const deploy = (env: string, oid: string, state: string, hoursAgo: number) =>
481 app.db
482 .query(
483 `INSERT INTO run (id, repo_id, kind, env, commit_oid, state, serving_at, finished_at)
484 VALUES (?1, ?2, 'deploy', ?3, ?4, ?5, ?6, ?6)`,
485 )
486 .run(`ask-${nextId++}`, repo.id, env, oid, state, at(hoursAgo));
487 // 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);
490 // Before the merge: it cannot hold it.
491 deploy("production", sandboxPull.oid, "ok", 25);
492 // The first live deploy holding sandbox's merge: 30h asked, live 12h later.
493 // It runs a later commit on main, so only git ancestry says it holds it.
494 deploy("production", queuePull.oid, "ok", 18);
outdated

Both live deploys use the merge's own oid, so the git ancestry is never exercised. Add a later commit on main, deploy that commit to production, and assert that the earlier merge counts as live there.

495 // Holds queue's merge, and sandbox's before it: queue was asked 10h ago, live 5h.
496 deploy("production", queuePull.oid, "ok", 5);
497 const view = pillarView(app.db, fresh(), new Date(T))!;
498 const asked = await askToLiveFor(app, fresh(), view, alice);
499 const ask = (name: string) =>
500 asked.find((a) => a.pillar === name)!.askToLive;
501 // sandbox: 30h -> deploy at 18h ago = 12h; queue: 10h -> 5h ago = 5h.
502 expect(ask("P0 Trust")).toEqual({
503 medianHours: 8.5,
504 slowest: { number: sandbox.number, hours: 12 },
505 });
506 // composer merged but never live in production.
507 expect(ask("P1 Feed")).toBeUndefined();
508 expect(ask("Later")).toBeUndefined();
509
510 const data = await api("/api/v1/repos/alice/engine/map");
511 const counts = data.mergedByPillar.counts;
512 expect(counts[0]).toEqual({
513 pillar: "P0 Trust",
514 merged: 2,
515 askToLive: {
516 medianHours: 8.5,
517 slowest: { number: sandbox.number, hours: 12 },
518 },
519 });
520 expect(counts[1].askToLive).toBeNull();
521 const mcp = await tool("map", { app: "alice/engine" });
522 expect(mcp.value.mergedByPillar).toEqual(data.mergedByPillar);
523 const limbs = await tool("milestones", { app: "alice/engine" });
524 const limb = limbs.value.limbs.find((l: any) => l.number === trust.number);
525 expect(limb.askToLive).toEqual(counts[0].askToLive);
526 expect(
527 limbs.value.limbs.find((l: any) => l.number === feed.number).askToLive,
528 ).toBeNull();
529
530 const page = await get("/alice/engine/map");
531 expect(page).toContain("8.5h median");
532 expect(page).toContain(`#${sandbox.number}`);
533 expect(page).toMatch(/P1 Feed[\s\S]*?ask to live\s+—/);
534 });
535
536 test("ask-to-live leaves out sealed issues for a reader who may not see them", async () => {
537 // Continues the test above: sandbox was the slowest at 12h, queue 5h.
538 const seal = (sealed: number) =>
539 app.db.query("UPDATE issue SET sealed = ?1 WHERE id = ?2").run(sealed, sandbox.id);
540 const bobToken = createToken(app.db, bob.id, "ro", "ro").token;
541 const asBob = async (path: string) =>
542 (await fetch(`${base}${path}`, { headers: { authorization: `Bearer ${bobToken}` } })).json() as Promise<any>;
543 const bobTool = async (name: string) => {
544 const res = await fetch(`${base}/mcp`, {
545 method: "POST",
546 headers: {
547 "content-type": "application/json",
548 accept: "application/json, text/event-stream",
549 authorization: `Bearer ${bobToken}`,
550 },
551 body: JSON.stringify({
552 jsonrpc: "2.0",
553 id: nextId++,
554 method: "tools/call",
555 params: { name, arguments: { app: "alice/engine" } },
556 }),
557 });
558 const body = (await res.json()) as any;
559 return JSON.parse(body.result.content[0].text);
560 };
561 seal(1);
562 try {
563 const sealedLink = `/issues/${sandbox.number}"`;
564 const guest = await (await fetch(`${base}/alice/engine/map`)).text();
565 expect(guest).toContain("5h median");
566 expect(guest).not.toContain(sealedLink);
567 const data = await asBob("/api/v1/repos/alice/engine/map");
568 expect(data.mergedByPillar.counts[0].askToLive).toEqual({
569 medianHours: 5,
570 slowest: { number: parts[0]!.number, hours: 5 },
571 });
572 expect(JSON.stringify(data)).not.toContain(`"number":${sandbox.number},"hours"`);
573 const viaMcp = await bobTool("map");
574 expect(viaMcp.mergedByPillar).toEqual(data.mergedByPillar);
575 const limbs = await bobTool("milestones");
576 expect(
577 limbs.limbs.find((l: any) => l.number === trust.number).askToLive.slowest.number,
578 ).toBe(parts[0]!.number);
579 // A writer still sees it.
580 expect(await get("/alice/engine/map")).toContain(sealedLink);
581 expect(
582 (await api("/api/v1/repos/alice/engine/map")).mergedByPillar.counts[0].askToLive
583 .slowest.number,
584 ).toBe(sandbox.number);
585 } finally {
586 seal(0);
587 }
588 });
589
443590 test("a repository with no pillars shows none of it", async () => {
444591 const made = await createRepo(app, alice, {
445592 name: "plain",
test/reaches.test.ts added +31
@@ -0,0 +1,31 @@
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