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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1185-every-pillar-s-ask-to-live-time-on-the-m → main 5 commits fork 10 threads · 10 unresolved
README.md +3 −1
| @@ -342,7 +342,9 @@ from before this record say nothing). | ||
| 342 | 342 | mechanical rather than advisory. |
| 343 | 343 | |
| 344 | 344 | `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). | |
| 346 | 348 | |
| 347 | 349 | `docs/STUDIO.md` is the front door from the top of this page in full: the five |
| 348 | 350 | 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. | ||
| 234 | 234 | every issue carries `pillar` (null for none), `pillars` is the same |
| 235 | 235 | rows, and `mergedByPillar` is `{ days: 7, counts: [{ pillar, merged }] }`: |
| 236 | 236 | 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. | |
| 238 | 243 | - **The board by pillar.** _Landed_ leads with the same seven days for |
| 239 | 244 | each app that has pillars: every pillar and its count, zero or not, each |
| 240 | 245 | a link to its issues. |
scripts/gate.ts +1
| @@ -187,6 +187,7 @@ export const GATE: readonly string[] = [ | ||
| 187 | 187 | "test/make-it-yours.test.ts", |
| 188 | 188 | "test/map.test.ts", |
| 189 | 189 | "test/pillars.test.ts", |
| 190 | "test/reaches.test.ts", | |
| 190 | 191 | "test/milestones.test.ts", |
| 191 | 192 | "test/mcp.test.ts", |
| 192 | 193 | "test/name-marks.test.ts", |
src/core/pillars.ts +196 −32
| @@ -244,39 +244,11 @@ export function mergedByPillar( | ||
| 244 | 244 | served: ReadonlyMap<string, string>, |
| 245 | 245 | since: string, |
| 246 | 246 | ): 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); | |
| 268 | 248 | const names = pillarNames(set); |
| 269 | 249 | 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; | |
| 280 | 252 | counts.set(pillar, (counts.get(pillar) ?? 0) + 1); |
| 281 | 253 | } |
| 282 | 254 | const none = counts.get(null); |
| @@ -286,6 +258,177 @@ export function mergedByPillar( | ||
| 286 | 258 | ]; |
| 287 | 259 | } |
| 288 | 260 | |
| 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)); | |
| 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 |
||
| 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 |
||
| 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 | ||
| 289 | 432 | /** Of these repositories, the ones with an open issue titled like an umbrella: one read. */ |
| 290 | 433 | export function reposWithUmbrellas( |
| 291 | 434 | db: Database, |
| @@ -336,10 +479,31 @@ export function pillarView( | ||
| 336 | 479 | const set = pillarsOf(db, repo); |
| 337 | 480 | if (set.pillars.length === 0) return undefined; |
| 338 | 481 | const of = servedBy(db, set); |
| 482 | const since = pillarWindowStart(now); | |
| 339 | 483 | return { |
| 340 | 484 | set, |
| 341 | 485 | names: pillarNames(set), |
| 342 | 486 | of, |
| 343 | merged: mergedByPillar(db, repo.id, set, of, pillarWindowStart(now)), | |
| 487 | merged: mergedByPillar(db, repo.id, set, of, since), | |
| 344 | 488 | }; |
| 345 | 489 | } |
| 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( | ||
| 861 | 861 | return out.ok; |
| 862 | 862 | } |
| 863 | 863 | |
| 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 | ||
| 864 | 914 | /** `rev-list --left-right --count a...b` — what each side has alone. */ |
| 865 | 915 | export async function aheadBehind( |
| 866 | 916 | dir: string, |
src/http/api.ts +11 −6
| @@ -258,6 +258,7 @@ import { | ||
| 258 | 258 | import { openPlan, parsePlan } from "../actions/plan.ts"; |
| 259 | 259 | import { dependenciesOf, repoGraph } from "../core/dependencies.ts"; |
| 260 | 260 | import { pillarView } from "../core/pillars.ts"; |
| 261 | import { askToLiveFor } from "../web/ask_to_live.ts"; | |
| 261 | 262 | import { decisionStats, listDecisions } from "../core/decisions.ts"; |
| 262 | 263 | import { globalMapJson, mapJson, marksFor } from "../web/pages/map.ts"; |
| 263 | 264 | import { |
| @@ -2465,20 +2466,22 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2465 | 2466 | ); |
| 2466 | 2467 | // Milestones as data: the plan's limbs with their weights, and one |
| 2467 | 2468 | // 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) => { | |
| 2469 | 2470 | const repo = loadRepo(ctx); |
| 2470 | 2471 | if (!repo) return notFound(); |
| 2471 | return json(milestonesJson(ctx, repo)); | |
| 2472 | return json(await milestonesJson(ctx, repo)); | |
| 2472 | 2473 | }); |
| 2473 | 2474 | router.add( |
| 2474 | 2475 | "GET", |
| 2475 | 2476 | "/api/v1/repos/:owner/:name/milestones/:number", |
| 2476 | (ctx) => { | |
| 2477 | async (ctx) => { | |
| 2477 | 2478 | const repo = loadRepo(ctx); |
| 2478 | 2479 | if (!repo) return notFound(); |
| 2479 | 2480 | const number = positiveInt(ctx.params.number); |
| 2480 | 2481 | const data = |
| 2481 | number === undefined ? undefined : milestoneData(ctx, repo, number); | |
| 2482 | number === undefined | |
| 2483 | ? undefined | |
| 2484 | : await milestoneData(ctx, repo, number); | |
| 2482 | 2485 | return data === undefined ? notFound() : json(data); |
| 2483 | 2486 | }, |
| 2484 | 2487 | ); |
| @@ -2543,12 +2546,13 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2543 | 2546 | ); |
| 2544 | 2547 | // The map as data: ready now, blocked on, the critical path, every edge, |
| 2545 | 2548 | // 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) => { | |
| 2547 | 2550 | const repo = loadRepo(ctx); |
| 2548 | 2551 | if (!repo) return notFound(); |
| 2549 | 2552 | const graph = repoGraph(ctx.app.db, repo.id, { |
| 2550 | 2553 | ...sealedScope(ctx.app.db, repo, ctx.auth.user), |
| 2551 | 2554 | }); |
| 2555 | const pillars = pillarView(ctx.app.db, repo); | |
| 2552 | 2556 | return json( |
| 2553 | 2557 | mapJson( |
| 2554 | 2558 | repo, |
| @@ -2559,7 +2563,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2559 | 2563 | graph, |
| 2560 | 2564 | listActiveRuns(ctx.app.db, [repo.id]), |
| 2561 | 2565 | ), |
| 2562 | pillarView(ctx.app.db, repo), | |
| 2566 | pillars, | |
| 2567 | await askToLiveFor(ctx.app, repo, pillars, ctx.auth.user), | |
| 2563 | 2568 | ), |
| 2564 | 2569 | ); |
| 2565 | 2570 | }); |
src/http/mcp.ts +9 −5
| @@ -62,6 +62,7 @@ import { lookUrl } from "../core/looks.ts"; | ||
| 62 | 62 | import { getPull } from "../core/pulls.ts"; |
| 63 | 63 | import { repoGraph } from "../core/dependencies.ts"; |
| 64 | 64 | import { pillarView } from "../core/pillars.ts"; |
| 65 | import { askToLiveFor } from "../web/ask_to_live.ts"; | |
| 65 | 66 | import { getAppByRepo } from "../core/apps.ts"; |
| 66 | 67 | import { |
| 67 | 68 | canRead, |
| @@ -1152,12 +1153,13 @@ const TOOLS: readonly Tool[] = [ | ||
| 1152 | 1153 | required: ["app"], |
| 1153 | 1154 | }, |
| 1154 | 1155 | writes: false, |
| 1155 | run(ctx, user, args) { | |
| 1156 | async run(ctx, user, args) { | |
| 1156 | 1157 | const repo = planRepo(ctx, user, args); |
| 1157 | 1158 | if (isAnswer(repo)) return repo; |
| 1158 | 1159 | const graph = repoGraph(ctx.app.db, repo.id, { |
| 1159 | 1160 | ...sealedScope(ctx.app.db, repo, user), |
| 1160 | 1161 | }); |
| 1162 | const pillars = pillarView(ctx.app.db, repo); | |
| 1161 | 1163 | return good( |
| 1162 | 1164 | mapJson( |
| 1163 | 1165 | repo, |
| @@ -1168,7 +1170,8 @@ const TOOLS: readonly Tool[] = [ | ||
| 1168 | 1170 | graph, |
| 1169 | 1171 | listActiveRuns(ctx.app.db, [repo.id]), |
| 1170 | 1172 | ), |
| 1171 | pillarView(ctx.app.db, repo), | |
| 1173 | pillars, | |
| 1174 | await askToLiveFor(ctx.app, repo, pillars, user), | |
| 1172 | 1175 | ), |
| 1173 | 1176 | ); |
| 1174 | 1177 | }, |
| @@ -1191,14 +1194,15 @@ const TOOLS: readonly Tool[] = [ | ||
| 1191 | 1194 | required: ["app"], |
| 1192 | 1195 | }, |
| 1193 | 1196 | writes: false, |
| 1194 | run(ctx, user, args) { | |
| 1197 | async run(ctx, user, args) { | |
| 1195 | 1198 | const repo = planRepo(ctx, user, args); |
| 1196 | 1199 | 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)); | |
| 1198 | 1202 | const number = args["issue"]; |
| 1199 | 1203 | const data = |
| 1200 | 1204 | typeof number === "number" && Number.isInteger(number) && number > 0 |
| 1201 | ? milestoneData(ctx, repo, number) | |
| 1205 | ? await milestoneData(ctx, repo, number) | |
| 1202 | 1206 | : undefined; |
| 1203 | 1207 | return data === undefined |
| 1204 | 1208 | ? 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 { | ||
| 73 | 73 | import { studioRecord, tellHref } from "../studio/nouns.ts"; |
| 74 | 74 | import { milestoneHref } from "./milestones.ts"; |
| 75 | 75 | import { |
| 76 | type AskToLive, | |
| 77 | type AskToLiveCount, | |
| 76 | 78 | isStructural, |
| 77 | 79 | LATER, |
| 78 | 80 | NO_PILLAR, |
| @@ -81,6 +83,7 @@ import { | ||
| 81 | 83 | pillarView, |
| 82 | 84 | type PillarView, |
| 83 | 85 | } from "../../core/pillars.ts"; |
| 86 | import { askToLiveFor } from "../ask_to_live.ts"; | |
| 84 | 87 | |
| 85 | 88 | /** Where a card's own link goes: dev's issue page, by default. */ |
| 86 | 89 | const devIssueHref = (base: string, number: number): string => |
| @@ -921,6 +924,9 @@ export interface PillarRow { | ||
| 921 | 924 | readonly open: number; |
| 922 | 925 | readonly ready: number; |
| 923 | 926 | 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; | |
| 924 | 930 | /** For no pillar: the issues themselves, to be filed. */ |
| 925 | 931 | readonly issues?: readonly Issue[]; |
| 926 | 932 | } |
| @@ -936,6 +942,7 @@ export function pillarRows( | ||
| 936 | 942 | ready: Set<string>, |
| 937 | 943 | marks: Marks, |
| 938 | 944 | view: PillarView, |
| 945 | ask: readonly AskToLiveCount[] = [], | |
| 939 | 946 | ): PillarRow[] { |
| 940 | 947 | const tally = new Map< |
| 941 | 948 | string | null, |
| @@ -963,6 +970,8 @@ export function pillarRows( | ||
| 963 | 970 | name, |
| 964 | 971 | ...(umbrella.has(name) ? { umbrella: umbrella.get(name)! } : {}), |
| 965 | 972 | ...tally.get(name)!, |
| 973 | merged: view.merged.find((m) => m.pillar === name)?.merged ?? 0, | |
| 974 | askToLive: ask.find((a) => a.pillar === name)?.askToLive, | |
| 966 | 975 | })), |
| 967 | 976 | ...(loose.length === 0 |
| 968 | 977 | ? [] |
| @@ -970,6 +979,11 @@ export function pillarRows( | ||
| 970 | 979 | ]; |
| 971 | 980 | } |
| 972 | 981 | |
| 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 | ||
| 973 | 987 | /** Where a pillar's issues are: the list, narrowed to its label. */ |
| 974 | 988 | export const pillarHref = (base: string, name: string): string => |
| 975 | 989 | `${base}/issues?label=${encodeURIComponent(name)}`; |
| @@ -1011,6 +1025,16 @@ function pillarList(repo: Repo, rows: readonly PillarRow[]): Html { | ||
| 1011 | 1025 | >${r.name}</a |
| 1012 | 1026 | > |
| 1013 | 1027 | <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 | > | |
| 1014 | 1038 | </li>`, |
| 1015 | 1039 | )} |
| 1016 | 1040 | </ul> |
| @@ -1152,6 +1176,7 @@ export const mapCss = /* css */ ` | ||
| 1152 | 1176 | .map-list ol li::marker { color: var(--faint); font-variant-numeric: tabular-nums; } |
| 1153 | 1177 | .map-list li span { display: block; } |
| 1154 | 1178 | .map-list + .map-list { margin-top: 18px; } |
| 1179 | .map-ask { display: block; } | |
| 1155 | 1180 | .map-pillars ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; font-size: 13.5px; } |
| 1156 | 1181 | .map-chart { width: 100%; height: auto; display: block; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); } |
| 1157 | 1182 | .map-level { fill: none; stroke: var(--accent); stroke-width: 1.5; } |
| @@ -1373,7 +1398,7 @@ const studioEmpty = (base: string): Html => | ||
| 1373 | 1398 | html`<p>No tells to map yet. <a href="${base}#tell">Say one</a>, then link what it waits for.</p>`; |
| 1374 | 1399 | |
| 1375 | 1400 | /** GET /:owner/:name/map */ |
| 1376 | export function mapPage(ctx: Ctx): Response { | |
| 1401 | export async function mapPage(ctx: Ctx): Promise<Response> { | |
| 1377 | 1402 | const repo = loadRepo(ctx); |
| 1378 | 1403 | if (!repo) return notFoundPage(ctx); |
| 1379 | 1404 | const db = ctx.app.db; |
| @@ -1394,6 +1419,7 @@ export function mapPage(ctx: Ctx): Response { | ||
| 1394 | 1419 | graph.issues.map((i) => wordFor(i, readySet, marks)), |
| 1395 | 1420 | ); |
| 1396 | 1421 | const pillars = pillarView(db, repo); |
| 1422 | const ask = await askToLiveFor(ctx.app, repo, pillars, ctx.auth.user); | |
| 1397 | 1423 | const body = html`${repoHeader(ctx, repo, "map")} |
| 1398 | 1424 | <div class="page-head mt"> |
| 1399 | 1425 | <h1>Map</h1> |
| @@ -1422,7 +1448,10 @@ export function mapPage(ctx: Ctx): Response { | ||
| 1422 | 1448 | <aside> |
| 1423 | 1449 | ${pillars === undefined |
| 1424 | 1450 | ? null |
| 1425 | : pillarList(repo, pillarRows(repo, graph, readySet, marks, pillars))} | |
| 1451 | : pillarList( | |
| 1452 | repo, | |
| 1453 | pillarRows(repo, graph, readySet, marks, pillars, ask), | |
| 1454 | )} | |
| 1426 | 1455 | ${graph.issues.length === 0 |
| 1427 | 1456 | ? null |
| 1428 | 1457 | : rankedList( |
| @@ -1483,6 +1512,7 @@ export function mapJson( | ||
| 1483 | 1512 | graph: Graph, |
| 1484 | 1513 | marks: Marks = NO_MARKS, |
| 1485 | 1514 | pillars?: PillarView, |
| 1515 | ask: readonly AskToLiveCount[] = [], | |
| 1486 | 1516 | ): Record<string, unknown> { |
| 1487 | 1517 | const ready = readyNow(graph); |
| 1488 | 1518 | const readySet = new Set(ready.map((r) => r.issue.id)); |
| @@ -1537,7 +1567,7 @@ export function mapJson( | ||
| 1537 | 1567 | ...(pillars === undefined |
| 1538 | 1568 | ? {} |
| 1539 | 1569 | : { |
| 1540 | pillars: pillarRows(repo, graph, readySet, marks, pillars).map( | |
| 1570 | pillars: pillarRows(repo, graph, readySet, marks, pillars, ask).map( | |
| 1541 | 1571 | (r) => ({ |
| 1542 | 1572 | pillar: r.name, |
| 1543 | 1573 | ...(r.umbrella === undefined ? {} : { umbrella: r.umbrella }), |
| @@ -1546,7 +1576,15 @@ export function mapJson( | ||
| 1546 | 1576 | blocked: r.blocked, |
| 1547 | 1577 | }), |
| 1548 | 1578 | ), |
| 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 | }, | |
| 1550 | 1588 | }), |
| 1551 | 1589 | }; |
| 1552 | 1590 | } |
src/web/pages/milestones.ts +42 −6
| @@ -19,11 +19,18 @@ import { | ||
| 19 | 19 | type Milestone, |
| 20 | 20 | } from "../../core/milestones.ts"; |
| 21 | 21 | 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"; | |
| 23 | 29 | import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 24 | 30 | import { isWatching } from "../../core/watches.ts"; |
| 25 | 31 | import { watchMilestone } from "../../actions/watches.ts"; |
| 26 | 32 | import type { Ctx } from "../../http/context.ts"; |
| 33 | import { askToLiveFor } from "../ask_to_live.ts"; | |
| 27 | 34 | import { |
| 28 | 35 | crumbsFor, |
| 29 | 36 | loadRepo, |
| @@ -437,25 +444,53 @@ export async function milestoneWatch(ctx: Ctx): Promise<Response> { | ||
| 437 | 444 | } |
| 438 | 445 | |
| 439 | 446 | /** 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( | |
| 441 | 448 | ctx: Ctx, |
| 442 | 449 | repo: Repo, |
| 443 | ): Record<string, unknown> { | |
| 450 | ): Promise<Record<string, unknown>> { | |
| 444 | 451 | const plan = readPlan(ctx.app.db, repo, { |
| 445 | 452 | spend: canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user), |
| 446 | 453 | }); |
| 454 | const ask = await askByUmbrella(ctx, repo); | |
| 447 | 455 | return { |
| 448 | 456 | 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 | })), | |
| 450 | 461 | }; |
| 451 | 462 | } |
| 452 | 463 | |
| 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 | ||
| 453 | 488 | /** One milestone as data, or nothing for an issue that waits for nothing. */ |
| 454 | export function milestoneData( | |
| 489 | export async function milestoneData( | |
| 455 | 490 | ctx: Ctx, |
| 456 | 491 | repo: Repo, |
| 457 | 492 | number: number, |
| 458 | ): Record<string, unknown> | undefined { | |
| 493 | ): Promise<Record<string, unknown> | undefined> { | |
| 459 | 494 | 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 |
||
| 460 | 495 | const milestone = readPlan(ctx.app.db, repo, { spend: owner }).milestone( |
| 461 | 496 | number, |
| @@ -466,6 +501,7 @@ export function milestoneData( | ||
| 466 | 501 | : { |
| 467 | 502 | repo: { owner: repo.ownerName, name: repo.name }, |
| 468 | 503 | ...milestoneJson(milestone, owner), |
| 504 | ...askOf(await askByUmbrella(ctx, repo, number), number), | |
| 469 | 505 | // Whether the caller watches it; a caller who cannot has no answer. |
| 470 | 506 | ...(viewer?.kind === "user" |
| 471 | 507 | ? { |
test/pillars.test.ts +148 −1
| @@ -39,6 +39,7 @@ import { | ||
| 39 | 39 | type Issue, |
| 40 | 40 | } from "../src/core/issues.ts"; |
| 41 | 41 | import { pillarsOf, pillarView } from "../src/core/pillars.ts"; |
| 42 | import { askToLiveFor } from "../src/web/ask_to_live.ts"; | |
| 42 | 43 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 43 | 44 | import { createToken } from "../src/core/tokens.ts"; |
| 44 | 45 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| @@ -424,7 +425,13 @@ describe("pillars", () => { | ||
| 424 | 425 | ]; |
| 425 | 426 | expect(pillarView(app.db, fresh())!.merged).toEqual(want); |
| 426 | 427 | 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 | }); | |
| 428 | 435 | |
| 429 | 436 | const board = await get("/board"); |
| 430 | 437 | expect(board).toMatch(/merged in\s+7 days/); |
| @@ -440,6 +447,146 @@ describe("pillars", () => { | ||
| 440 | 447 | ); |
| 441 | 448 | }); |
| 442 | 449 | |
| 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 | ||
| 443 | 590 | test("a repository with no pillars shows none of it", async () => { |
| 444 | 591 | const made = await createRepo(app, alice, { |
| 445 | 592 | 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 | }); |
High: these issues and the pulls above are read with no sealed scope, so a sealed issue's number becomes
slowest.numberfor any reader. Take the caller'ssealedScopeand leave out the rows that caller cannot view.