Public: anyone can read this; the crew can change it.
Milestones at the front door: how close the big things are, what moved this week, and the wave that moved them #10987
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1083-milestones-at-the-front-door-how-close-t → main 9 commits fork 5 threads · 5 unresolved
17 files
changed +675
−16
docs/design/areas/milestones.md +4
| @@ -25,3 +25,7 @@ a `ul.list.setting-list` with no `.card` around it, and a folded tail is a | ||
| 25 | 25 | `details` under the rows, not a card foot. How far it is stands as a plain |
| 26 | 26 | `#ms-progress` stack. No new component class; `limb.ts` and the re-plan page |
| 27 | 27 | share the same rows. |
| 28 | ||
| 29 | ## Front door lines | |
| 30 | ||
| 31 | The few biggest open milestones on the home and an app's page are plain `row`s in a `ul.list.setting-list`, the same rows as the milestone list; the "Last moved in wave" line on a milestone is one such row. They paint nothing when nothing moved. No new component class. | |
docs/features/front-milestones.md added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | # Milestones at the front door (#1083) | |
| 2 | ||
| 3 | The few biggest open milestones sit on the home, on the signed-out door and on an app's page, as plain rows: the name, how far it is, what landed in 7 days (or "quiet this week"), and the estimate when there is one. A region paints nothing when none of them moved this week. No spend is ever shown: that is the owner's, on the milestones page. | |
| 4 | ||
| 5 | - Read: `biggestMilestones` in `src/web/studio/milestone_lines.ts`, over the plan the milestones pages use (`planFor`). At most 3 milestones from at most 30 apps. | |
| 6 | - Whose apps: `frontApps`. A signed-in person gets their own apps (an admin, every one); a guest gets the public ones. An app's page reads only that app, for whoever may read it. | |
| 7 | - Live: `GET /live/milestones` and `GET /:owner/:name/app/milestones` redraw the region on `issue.*`, `pull.merged` and `pull.closed`. | |
| 8 | - The wave that moved a milestone: its page says "Last moved in wave X", and its JSON has `lastWave` and `movedThisWeek`. The newest 8 wave files are read from git once per repository's latest event (`src/story/wave_last.ts`), then kept for a minute. | |
| 9 | - Terminal: the palette entry **Milestones** (`src/tui/screens/frontms.ts`) lists the same rows, gated by the same `frontApps`; `enter` opens one on the web. | |
| 10 | - Tests: `test/milestone-front-door.test.ts`, `test/tui-front-milestones.test.ts`. | |
Non-blocking: test/tui-front-milestones.test.ts is not in the pull. Name test/front-milestones-private.test.ts instead. |
src/core/milestones.ts +2
| @@ -649,6 +649,8 @@ function weightJson(weight: Weight): Record<string, unknown> { | ||
| 649 | 649 | open: weight.open, |
| 650 | 650 | building: weight.building, |
| 651 | 651 | mergedInDays: { days: PILLAR_DAYS, merged: weight.merged }, |
| 652 | // What landed in the last 7 days, named for who asks "what moved". | |
| 653 | movedThisWeek: weight.merged, | |
| 652 | 654 | lastMovement: weight.last, |
| 653 | 655 | ...(weight.spentUsd === undefined ? {} : { spentUsd: weight.spentUsd }), |
| 654 | 656 | }; |
src/http/server.ts +6
| @@ -240,6 +240,10 @@ import { spaceJson, spacePage } from "../web/pages/space.ts"; | ||
| 240 | 240 | import { firstSubmit } from "../web/pages/first.ts"; |
| 241 | 241 | import { nextPage, nextSubmit } from "../web/pages/next.ts"; |
| 242 | 242 | import { weekPage, weekSubmit } from "../web/pages/week.ts"; |
| 243 | import { | |
| 244 | appMilestonesFragment, | |
| 245 | liveMilestonesFragment, | |
| 246 | } from "../web/studio/milestone_lines.ts"; | |
| 243 | 247 | import { |
| 244 | 248 | milestonePage, |
| 245 | 249 | milestonesPage, |
| @@ -980,6 +984,7 @@ export function buildRouter(): Router<Handler> { | ||
| 980 | 984 | router.add("GET", "/live/on-you", liveOnYouFragment); |
| 981 | 985 | router.add("GET", "/live/client-apps", liveClientAppsFragment); |
| 982 | 986 | router.add("GET", "/live/feed", liveFeedFragment); |
| 987 | router.add("GET", "/live/milestones", liveMilestonesFragment); | |
| 983 | 988 | router.add("GET", "/live/moving", liveMovingFragment); |
| 984 | 989 | router.add("GET", "/live/chips", liveChipsFragment); |
| 985 | 990 | router.add("GET", "/feed", feedPage); |
| @@ -1333,6 +1338,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1333 | 1338 | router.add("GET", "/:owner/:name/app/state", appStateFragment); |
| 1334 | 1339 | // "Now" on the same page, alone: the region's own address. |
| 1335 | 1340 | router.add("GET", "/:owner/:name/app/now", appNowFragment); |
| 1341 | router.add("GET", "/:owner/:name/app/milestones", appMilestonesFragment); | |
| 1336 | 1342 | router.add("GET", "/:owner/:name/app/answers", appAnswersFragment); |
| 1337 | 1343 | router.add("GET", "/:owner/:name/app/:env/open", appOpen); |
| 1338 | 1344 | // Where a private app sends a person who arrived by its link. |
src/story/wave_last.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | /** | |
| 2 | * The wave that last moved each milestone: read the newest waves, ask each | |
| 3 | * which milestones its pulls moved (`wave_moved.ts`), and keep, per milestone, | |
| 4 | * the first (newest) wave to name it. Bounded: only the newest few waves are | |
| 5 | * read, so a milestone that moved long ago has no wave rather than a slow page. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import type { Plan } from "../core/milestones.ts"; | |
| 10 | import { canRead, type Repo } from "../core/repos.ts"; | |
| 11 | import type { User } from "../core/users.ts"; | |
| 12 | import { repoDir } from "../git/repos.ts"; | |
| 13 | import { movedForViewer } from "./wave_moved.ts"; | |
| 14 | import { ViewCache } from "../view/cache.ts"; | |
| 15 | import { repoHeads } from "../web/map_cache.ts"; | |
| 16 | import { pullRefs, readWaves, wavePath, type WaveEntry } from "./waves.ts"; | |
| 17 | ||
| 18 | /** How many of the newest waves are read for this. */ | |
| 19 | export const WAVES_READ = 8; | |
| 20 | ||
| 21 | export interface LastWave { | |
| 22 | version: string; | |
| 23 | date: string; | |
| 24 | headline: string; | |
| 25 | /** Where the wave's page is on this forge. */ | |
| 26 | href: string; | |
| 27 | } | |
| 28 | ||
| 29 | /** | |
| 30 | * The parsed wave files, kept per repository and its latest event: a push to | |
outdated
Reads the git wave files on every call, uncached. Cache by repo head. |
||
| 31 | * the branch is an event, so a new wave reads fresh; the TTL covers the rest. | |
| 32 | * Nothing about a viewer is in here, only what the files say. | |
| 33 | */ | |
| 34 | const waveFiles = new ViewCache({ maxEntries: 64 }); | |
| 35 | const WAVES_TTL_MS = 60_000; | |
| 36 | ||
| 37 | /** Milestone number to the newest wave that moved it. */ | |
| 38 | export async function lastWaves(input: { | |
| 39 | db: Database; | |
| 40 | reposRoot: string; | |
| 41 | repo: Repo; | |
| 42 | viewer: User | undefined; | |
| 43 | plan: () => Plan; | |
| 44 | }): Promise<Map<number, LastWave>> { | |
| 45 | const { repo } = input; | |
| 46 | // Defence in depth: the routes gate the repository, and so does this. | |
| 47 | if (!canRead(input.db, repo, input.viewer)) return new Map(); | |
| 48 | const key = `${repo.id}:${repoHeads(input.db, [repo.id]).get(repo.id) ?? 0}:${repo.defaultBranch}`; | |
| 49 | let entries = waveFiles.get<WaveEntry[]>(key); | |
| 50 | if (entries === undefined) { | |
| 51 | entries = ( | |
| 52 | await readWaves( | |
| 53 | repoDir(input.reposRoot, repo.ownerName, repo.name), | |
| 54 | repo.defaultBranch, | |
| 55 | ) | |
| 56 | ).slice(0, WAVES_READ); | |
| 57 | waveFiles.set(key, entries, WAVES_TTL_MS); | |
| 58 | } | |
| 59 | const out = new Map<number, LastWave>(); | |
| 60 | let plan: Plan | undefined; | |
| 61 | for (const e of entries) { | |
| 62 | if (!e.ok) continue; | |
| 63 | const moved = movedForViewer({ | |
| 64 | db: input.db, | |
| 65 | repo, | |
| 66 | refs: pullRefs(e.wave), | |
| 67 | viewer: input.viewer, | |
| 68 | plan: () => (plan ??= input.plan()), | |
| 69 | }); | |
| 70 | for (const m of moved) | |
| 71 | if (!out.has(m.number)) | |
| 72 | out.set(m.number, { | |
| 73 | version: e.wave.version, | |
| 74 | date: e.wave.date, | |
| 75 | headline: e.wave.headline, | |
| 76 | href: wavePath(repo.ownerName, repo.name, e.wave.version), | |
| 77 | }); | |
| 78 | } | |
| 79 | return out; | |
| 80 | } |
src/tui/screens/entries.ts +2
| @@ -39,6 +39,7 @@ import { entry as features } from "./featureswitch.ts"; | ||
| 39 | 39 | import { entry as forgeView } from "./forgeview.ts"; |
| 40 | 40 | import { entry as flowsAll } from "./flowsall.ts"; |
| 41 | 41 | import { entry as front } from "./front.ts"; |
| 42 | import { entry as frontMilestones } from "./frontms.ts"; | |
| 42 | 43 | import { entry as forges } from "./forges.ts"; |
| 43 | 44 | import { entry as homePins } from "./home_pins.ts"; |
| 44 | 45 | import { entry as invites } from "./invites.ts"; |
| @@ -156,6 +157,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 156 | 157 | profile, |
| 157 | 158 | dotfiles, |
| 158 | 159 | now, |
| 160 | frontMilestones, | |
| 159 | 161 | forgeView, |
| 160 | 162 | repos, |
| 161 | 163 | displays, |
src/tui/screens/frontms.ts added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | /** | |
| 2 | * The milestones at the front door as a screen (#1083): the few biggest open | |
| 3 | * milestones, each with how far it is, what landed in 7 days and its | |
| 4 | * estimate. The same read as the home's lines (`biggestMilestones` over | |
| 5 | * `frontApps`): a guest sees the public apps' only, a signed-in person their | |
| 6 | * own apps', and no spend, which is the owner's and has its own page. Enter | |
| 7 | * opens the milestone on the web. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 11 | import { binding } from "../app/keys.ts"; | |
| 12 | import { biggestMilestones, frontApps, lineFacts } from "../../web/studio/milestone_lines.ts"; | |
| 13 | import { milestoneHref } from "../../web/pages/milestones.ts"; | |
| 14 | import { repoBase } from "../../web/chrome.ts"; | |
| 15 | import { style } from "../term/style.ts"; | |
| 16 | import { truncate } from "../term/width.ts"; | |
| 17 | import type { Session } from "../session.ts"; | |
| 18 | import { fill, len, split } from "../ui/layout.ts"; | |
| 19 | import { ListState, drawList, openOnWeb, type ListRow } from "./common.ts"; | |
| 20 | import type { PaletteEntry } from "./entries.ts"; | |
| 21 | ||
| 22 | /** The lines the screen draws, read each time so a change shows on the next key. */ | |
| 23 | export function frontLines(f: Session) { | |
| 24 | return biggestMilestones(f, frontApps(f, f.user)); | |
| 25 | } | |
| 26 | ||
| 27 | export function frontMilestonesScreen(f: Session, ctx: Ctx): Screen { | |
| 28 | const state = new ListState(); | |
| 29 | return { | |
| 30 | title: "Milestones", | |
| 31 | keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "open on the web")], | |
| 32 | render(area, s) { | |
| 33 | const c = ctx.theme.c; | |
| 34 | const lines = frontLines(f); | |
| 35 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 36 | const note = lines.length === 0 ? "No open milestones." : f.user ? "The biggest open milestones of your apps." : "The biggest open milestones of the public apps."; | |
| 37 | s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 38 | const rows: ListRow[] = lines.map((r) => ({ title: r.limb.name, secondary: lineFacts(r, false) })); | |
| 39 | drawList(s, list!, c, state, rows); | |
| 40 | }, | |
| 41 | key(e) { | |
| 42 | const lines = frontLines(f); | |
| 43 | if (state.key(e, lines.length)) return true; | |
| 44 | const at = lines[state.index]; | |
| 45 | if (e.key === "enter" && !e.ctrl && !e.alt && at !== undefined) { | |
| 46 | openOnWeb(f, ctx, milestoneHref(repoBase(at.repo), at.limb.issue.number), "That milestone"); | |
| 47 | return true; | |
| 48 | } | |
| 49 | return false; | |
| 50 | }, | |
| 51 | }; | |
| 52 | } | |
| 53 | ||
| 54 | /** Offered to everyone: what it lists is gated as the page's lines are. */ | |
| 55 | export const entry: PaletteEntry = { | |
| 56 | id: "front-milestones", | |
| 57 | title: "Milestones", | |
| 58 | hint: "the biggest open milestones: how far, what landed this week", | |
| 59 | after: ["now"], | |
| 60 | offered: () => true, | |
| 61 | run: (session, ctx) => ctx.push(frontMilestonesScreen(session, ctx)), | |
| 62 | }; |
src/web/pages/milestones.ts +52 −11
| @@ -40,9 +40,10 @@ import { watchMilestone } from "../../actions/watches.ts"; | ||
| 40 | 40 | import { replanResult } from "../../actions/limb_replan.ts"; |
| 41 | 41 | import { given, pendingFor } from "../../core/limb_replan.ts"; |
| 42 | 42 | import type { Ctx } from "../../http/context.ts"; |
| 43 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 43 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; | |
| 44 | 44 | import { cachedPlan } from "../map_cache.ts"; |
| 45 | 45 | import { milestonePath } from "../../story/wave_plan.ts"; |
| 46 | import { lastWaves, type LastWave } from "../../story/wave_last.ts"; | |
| 46 | 47 | import { askToLiveFor } from "../ask_to_live.ts"; |
| 47 | 48 | import { |
| 48 | 49 | crumbsFor, |
| @@ -203,7 +204,7 @@ function limbLine(base: string, limb: Limb, move: Html | null = null): Html { | ||
| 203 | 204 | } |
| 204 | 205 | |
| 205 | 206 | /** The repository's plan, kept while nothing it reads changes. */ |
| 206 | export const planFor = (ctx: Ctx, repo: Repo, spend: boolean, now: Date): Plan => | |
| 207 | export const planFor = (ctx: Pick<Ctx, "app"> & { request?: Request }, repo: Repo, spend: boolean, now: Date, named = true): Plan => | |
| 207 | 208 | cachedPlan( |
| 208 | 209 | { |
| 209 | 210 | repoId: repo.id, |
| @@ -215,7 +216,9 @@ export const planFor = (ctx: Ctx, repo: Repo, spend: boolean, now: Date): Plan = | ||
| 215 | 216 | readPlan(ctx.app.db, repo, { |
| 216 | 217 | spend, |
| 217 | 218 | now, |
| 218 | stretch: (name, ms) => notePhase(ctx.request, `plan-${name}`, ms), | |
| 219 | ...(named && ctx.request !== undefined | |
| 220 | ? { stretch: (name: string, ms: number) => notePhase(ctx.request!, `plan-${name}`, ms) } | |
| 221 | : {}), | |
| 219 | 222 | }), |
| 220 | 223 | ); |
| 221 | 224 | |
| @@ -548,7 +551,10 @@ function milestoneBody( | ||
| 548 | 551 | } |
| 549 | 552 | |
| 550 | 553 | /** GET /:owner/:name/milestones/:number — one milestone, opened. */ |
| 551 | export function milestonePage(ctx: Ctx, now: Date = new Date()): Response { | |
| 554 | export async function milestonePage( | |
| 555 | ctx: Ctx, | |
| 556 | now: Date = new Date(), | |
| 557 | ): Promise<Response> { | |
| 552 | 558 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 553 | 559 | const repo = timePhase(ctx.request, "repo", () => loadRepo(ctx)); |
| 554 | 560 | if (!repo) return notFoundPage(ctx); |
| @@ -559,8 +565,19 @@ export function milestonePage(ctx: Ctx, now: Date = new Date()): Response { | ||
| 559 | 565 | : undefined; |
| 560 | 566 | const milestone = plan?.milestone(number); |
| 561 | 567 | if (plan === undefined || milestone === undefined) return notFoundPage(ctx); |
| 562 | const rendering = performance.now(); | |
| 563 | 568 | const viewer = ctx.auth.user; |
| 569 | const wave = ( | |
| 570 | await timePhaseAsync(ctx.request, "waves", () => | |
| 571 | lastWaves({ | |
| 572 | db: ctx.app.db, | |
| 573 | reposRoot: ctx.app.reposRoot, | |
| 574 | repo, | |
| 575 | viewer, | |
| 576 | plan: () => plan, | |
| 577 | }), | |
| 578 | ) | |
| 579 | ).get(number); | |
| 580 | const rendering = performance.now(); | |
| 564 | 581 | // Watch is each person's own; acting on the whole limb is for whoever |
| 565 | 582 | // administers the repository. One row, Watch first. |
| 566 | 583 | const watch = |
| @@ -595,6 +612,7 @@ export function milestonePage(ctx: Ctx, now: Date = new Date()): Response { | ||
| 595 | 612 | palette: repoPalette(ctx, repo), |
| 596 | 613 | body: html`${repoHeader(ctx, repo, "map")}<div data-milestone> |
| 597 | 614 | ${milestoneBody(repo, milestone, owner, actions, move)} |
| 615 | ${wave === undefined ? null : html`<ul class="list setting-list" id="ms-wave">${row({ nav: true, href: wave.href, title: `Last moved in wave ${wave.version}`, meta: html`${wave.headline}`, trail: html`${wave.date}` })}</ul>`} | |
| 598 | 616 | ${replanChanged(ctx, repo, milestone)}</div> |
| 599 | 617 | ${liveMount({ repo: repoBase(repo) })}`, |
| 600 | 618 | scriptJs: liveClientJs, |
| @@ -638,15 +656,32 @@ export async function milestonesJson( | ||
| 638 | 656 | spend: canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user), |
| 639 | 657 | }); |
| 640 | 658 | const ask = askByUmbrella(ctx, repo); |
| 659 | const waves = await lastWaves({ | |
| 660 | db: ctx.app.db, | |
| 661 | reposRoot: ctx.app.reposRoot, | |
| 662 | repo, | |
| 663 | viewer: ctx.auth.user, | |
| 664 | plan: () => plan, | |
| 665 | }); | |
| 641 | 666 | return { |
| 642 | 667 | repo: { owner: repo.ownerName, name: repo.name }, |
| 643 | 668 | limbs: plan.limbs.map((limb) => ({ |
| 644 | 669 | ...limbJson(limb), |
| 645 | 670 | ...askOf(ask, limb.issue.number), |
| 671 | ...lastWaveOf(waves, limb.issue.number), | |
| 646 | 672 | })), |
| 647 | 673 | }; |
| 648 | 674 | } |
| 649 | 675 | |
| 676 | /** The wave that last moved a milestone, as data: absent when none did. */ | |
| 677 | const lastWaveOf = ( | |
| 678 | waves: ReadonlyMap<number, LastWave>, | |
| 679 | number: number, | |
| 680 | ): { lastWave?: LastWave } => { | |
| 681 | const w = waves.get(number); | |
| 682 | return w === undefined ? {} : { lastWave: w }; | |
| 683 | }; | |
| 684 | ||
| 650 | 685 | /** Each pillar's ask-to-live, by its umbrella's number: what its limb shows too. */ |
| 651 | 686 | function askByUmbrella( |
| 652 | 687 | ctx: Ctx, |
| @@ -679,16 +714,22 @@ export async function milestoneData( | ||
| 679 | 714 | now: Date = new Date(), |
| 680 | 715 | ): Promise<Record<string, unknown> | undefined> { |
| 681 | 716 | const owner = canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user); |
| 682 | const milestone = readPlan(ctx.app.db, repo, { spend: owner, now }).milestone( | |
| 683 | number, | |
| 684 | ); | |
| 717 | const plan = readPlan(ctx.app.db, repo, { spend: owner, now }); | |
| 718 | const milestone = plan.milestone(number); | |
| 685 | 719 | const viewer = ctx.auth.user; |
| 686 | return milestone === undefined | |
| 687 | ? undefined | |
| 688 | : { | |
| 720 | if (milestone === undefined) return undefined; | |
| 721 | const waves = await lastWaves({ | |
| 722 | db: ctx.app.db, | |
| 723 | reposRoot: ctx.app.reposRoot, | |
| 724 | repo, | |
| 725 | viewer, | |
| 726 | plan: () => plan, | |
| 727 | }); | |
| 728 | return { | |
| 689 | 729 | repo: { owner: repo.ownerName, name: repo.name }, |
| 690 | 730 | ...milestoneJson(milestone, owner), |
| 691 | 731 | ...askOf(askByUmbrella(ctx, repo, number), number), |
| 732 | ...lastWaveOf(waves, number), | |
| 692 | 733 | // Whether the caller watches it; a caller who cannot has no answer. |
| 693 | 734 | ...(viewer?.kind === "user" |
| 694 | 735 | ? { |
src/web/studio/app.ts +3
| @@ -53,6 +53,7 @@ import { | ||
| 53 | 53 | row, |
| 54 | 54 | } from "../ui.ts"; |
| 55 | 55 | import { answersRegion } from "./answers.ts"; |
| 56 | import { appMilestones } from "./milestone_lines.ts"; | |
| 56 | 57 | import { nowCard, nowRegion } from "./now.ts"; |
| 57 | 58 | import { repoAsks } from "./asks.ts"; |
| 58 | 59 | import { notesCard } from "./notes.ts"; |
| @@ -486,6 +487,7 @@ export async function studioApp( | ||
| 486 | 487 | ${tellRegion} |
| 487 | 488 | <div id="app-previews"></div> |
| 488 | 489 | ${nowRegion(repo, now)} |
| 490 | ${appMilestones(ctx, repo)} | |
| 489 | 491 | ${more} |
| 490 | 492 | </div> |
| 491 | 493 | ${liveMount({ repo: base, app: true })}` |
| @@ -514,6 +516,7 @@ export async function studioApp( | ||
| 514 | 516 | ${tellRegion} |
| 515 | 517 | <div id="app-previews">${previews}</div> |
| 516 | 518 | ${nowRegion(repo, now)} |
| 519 | ${appMilestones(ctx, repo)} | |
| 517 | 520 | ${notesCard(ctx, repo, record)} |
| 518 | 521 | </div> |
| 519 | 522 | <aside class="stack"> |
src/web/studio/asks.ts +1 −1
| @@ -98,7 +98,7 @@ export function appsOf(ctx: Pick<Ctx, "app">, user: User | undefined, archived = | ||
| 98 | 98 | * repositories, with their records. This is "your apps": the studio home's |
| 99 | 99 | * shelf and its cost line, never widened by what a stranger merely made |
| 100 | 100 | * public. */ |
| 101 | export function writableAppsOf(ctx: Ctx, user: User | undefined) { | |
| 101 | export function writableAppsOf(ctx: Pick<Ctx, "app">, user: User | undefined) { | |
| 102 | 102 | return appsFiltered( |
| 103 | 103 | ctx, |
| 104 | 104 | listReposWritableTo(ctx.app.db, user, { |
src/web/studio/home.ts +7 −1
| @@ -39,6 +39,7 @@ import { html, when, type Html } from "../html.ts"; | ||
| 39 | 39 | import { fragment, page, redirect } from "../layout.ts"; |
| 40 | 40 | import { siteStructuredData } from "../structured_data.ts"; |
| 41 | 41 | import { running } from "../../kernel/version.ts"; |
| 42 | import { homeMilestones } from "./milestone_lines.ts"; | |
| 42 | 43 | import { liveClientJs } from "../live.ts"; |
| 43 | 44 | import { |
| 44 | 45 | btn, |
| @@ -359,6 +360,7 @@ export async function studioHome( | ||
| 359 | 360 | const allowed = untoldAnswer(ctx.app.db, user.id) === "allowed"; |
| 360 | 361 | if (allowed) markTold(ctx.app.db, user.id, new Date().toISOString()); |
| 361 | 362 | const needsYou = html`${requests}${waiting}${held}${alarms}`; |
| 363 | const mine = writableAppsOf(ctx, user).repos; | |
| 362 | 364 | const body = html`<h1 class="sr-only">Home</h1> |
| 363 | 365 | ${allowed ? html`<div class="toast" data-tone="success" role="status" id="youre-in"><strong>${YOURE_IN_MESSAGE}</strong> Your account is open: tell the crew what you want to build.</div>` : null} |
| 364 | 366 | ${tellBox(ctx, user)} |
| @@ -366,9 +368,10 @@ export async function studioHome( | ||
| 366 | 368 | ${when(String(needsYou).trim() !== "", () => html`<section class="stack stack-tight" id="needs-you" aria-label="Needs you"><h2 class="small muted">Needs you</h2>${needsYou}</section>`)} |
| 367 | 369 | ${when(String(since).trim() !== "", () => html`<section aria-label="Since you were here">${since}</section>`)} |
| 368 | 370 | <section aria-label="Live">${timePhase(ctx.request, "moving", () => movingRegion(ctx.app.db, user))}</section> |
| 371 | ${homeMilestones(ctx, mine)} | |
| 369 | 372 | <section class="stack" aria-label="The forge">${kpis}</section> |
| 370 | 373 | ${pinnedSlot(ctx.app.db, user)} |
| 371 | ${appChips(ctx, user)} | |
| 374 | ${appChips(ctx, user, mine)} | |
| 372 | 375 | ${when( |
| 373 | 376 | steps, |
| 374 | 377 | () => html`<p class="small muted" id="lean-home"><a href="/">Just my apps</a></p>`, |
| @@ -869,6 +872,8 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 869 | 872 | </ul> |
| 870 | 873 | </section> |
| 871 | 874 | |
| 875 | <div class="door-section">${homeMilestones(ctx)}</div> | |
| 876 | ||
| 872 | 877 | <section class="door-section door-split" id="yours"> |
| 873 | 878 | <div class="stack"> |
| 874 | 879 | <h2>Get your own forge</h2> |
| @@ -904,6 +909,7 @@ bun run src/main.ts</code></pre> | ||
| 904 | 909 | title: "forge: software for what you're building, yours to keep", |
| 905 | 910 | description: "Your own forge, with a private AI crew that builds your apps, games and business tools. Open source, and yours to keep.", |
| 906 | 911 | body, |
| 912 | scriptJs: `${liveClientJs}\n${liveRegionsJs}`, | |
| 907 | 913 | jsonLd: siteStructuredData(origin, running().version), |
| 908 | 914 | ...(visitor |
| 909 | 915 | ? { |
src/web/studio/living.ts +3 −2
| @@ -18,6 +18,7 @@ import { btn, disclosure, row, timeAgo } from "../ui.ts"; | ||
| 18 | 18 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 19 | 19 | import type { NowStage } from "../../flow/now_rows.ts"; |
| 20 | 20 | import { nowHref } from "../pages/now.ts"; |
| 21 | import type { Repo } from "../../core/repos.ts"; | |
| 21 | 22 | import { writableAppsOf } from "./asks.ts"; |
| 22 | 23 | import { FORGE_TARGET } from "./feed.ts"; |
| 23 | 24 | import { COMPOSER_FIELDS, TELL_MAX, sharedDraft, shareToken } from "../share_target.ts"; |
| @@ -127,8 +128,8 @@ export function tellBox(ctx: Ctx, user: User): Html { | ||
| 127 | 128 | } |
| 128 | 129 | |
| 129 | 130 | /** Your apps as chips, each a link to the app, and a plus for a new one. */ |
| 130 | export function appChips(ctx: Ctx, user: User): Html { | |
| 131 | const { repos } = writableAppsOf(ctx, user); | |
| 131 | export function appChips(ctx: Ctx, user: User, apps?: readonly Repo[]): Html { | |
| 132 | const repos = apps ?? writableAppsOf(ctx, user).repos; | |
| 132 | 133 | return html`<section aria-label="Your apps" id="live-chips" data-live="app.serving,app.stopped,app.created,app.deleted" data-live-src="/live/chips"> |
| 133 | 134 | <h2 class="small muted">Your apps</h2> |
| 134 | 135 | <p class="bar small"> |
src/web/studio/milestone_lines.ts added +132
| @@ -0,0 +1,132 @@ | ||
| 1 | /** | |
Both doors: no |
||
| 2 | * Milestones at the front door (#1083): the few biggest open milestones as | |
| 3 | * quiet lines — name, done of total, what landed this week, the estimate — | |
| 4 | * on the home and on an app's page. Read from `core/milestones.ts`, the | |
| 5 | * same numbers as the milestones pages; never spend, which is the owner's | |
| 6 | * and has its own page. A region that has nothing moved paints nothing. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Limb, Plan } from "../../core/milestones.ts"; | |
| 10 | import type { Repo } from "../../core/repos.ts"; | |
| 11 | import { PILLAR_DAYS } from "../../core/pillars.ts"; | |
| 12 | import type { Ctx } from "../../http/context.ts"; | |
| 13 | import { fragment } from "../layout.ts"; | |
| 14 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 15 | import { loadRepo, repoBase } from "../chrome.ts"; | |
| 16 | import { html, type Html } from "../html.ts"; | |
| 17 | import { row } from "../ui.ts"; | |
| 18 | import type { User } from "../../core/users.ts"; | |
| 19 | import { milestoneHref, planFor } from "../pages/milestones.ts"; | |
| 20 | import { appsOf, writableAppsOf } from "./asks.ts"; | |
| 21 | ||
| 22 | /** How many milestones the front door names. */ | |
| 23 | export const FRONT_MILESTONES = 3; | |
| 24 | /** How many apps the home reads plans of, newest first: a bound on the work. */ | |
| 25 | const REPOS_READ = 30; | |
| 26 | /** The events that can move a milestone. */ | |
| 27 | const MILESTONE_LIVE = "issue.*,pull.merged,pull.closed"; | |
| 28 | ||
| 29 | export interface Line { | |
| 30 | readonly repo: Repo; | |
| 31 | readonly limb: Limb; | |
| 32 | readonly estimate: string; | |
| 33 | } | |
| 34 | ||
| 35 | /** The biggest open milestones of these repositories, by open work beneath them. */ | |
| 36 | export function biggestMilestones( | |
| 37 | ctx: Pick<Ctx, "app">, | |
| 38 | repos: readonly Repo[], | |
| 39 | count = FRONT_MILESTONES, | |
| 40 | ): Line[] { | |
| 41 | const all: { repo: Repo; limb: Limb; plan: Plan }[] = []; | |
| 42 | for (const repo of repos.slice(0, REPOS_READ)) { | |
| 43 | // Spend is never read here: the front door shows no owner facts. | |
| 44 | const plan = planFor(ctx, repo, false, new Date(), false); | |
| 45 | for (const limb of plan.limbs) | |
| 46 | if (!limb.deferred && limb.issue.state === "open" && limb.weight.open > 0) | |
| 47 | all.push({ repo, limb, plan }); | |
| 48 | } | |
| 49 | all.sort( | |
| 50 | (a, b) => | |
| 51 | b.limb.weight.open - a.limb.weight.open || | |
| 52 | a.limb.issue.number - b.limb.issue.number, | |
| 53 | ); | |
| 54 | return all.slice(0, count).map(({ repo, limb, plan }) => ({ | |
| 55 | repo, | |
| 56 | limb, | |
| 57 | estimate: plan.milestone(limb.issue.number)?.estimate ?? "", | |
| 58 | })); | |
| 59 | } | |
| 60 | ||
| 61 | /** What a line says after its name: how far, what landed, the estimate if there is one. */ | |
| 62 | export function lineFacts(r: Line, scoped: boolean): string { | |
| 63 | const w = r.limb.weight; | |
| 64 | return [ | |
| 65 | scoped ? "" : r.repo.name, | |
| 66 | `${w.done} of ${w.total} done`, | |
| 67 | w.merged > 0 ? `${w.merged} landed in ${PILLAR_DAYS} days` : "quiet this week", | |
| 68 | r.estimate, | |
| 69 | ] | |
| 70 | .filter((part) => part !== "") | |
| 71 | .join(" · "); | |
| 72 | } | |
| 73 | ||
| 74 | /** The lines: nothing at all when nothing among them moved this week. */ | |
| 75 | function lines(rows: readonly Line[], scoped: boolean): Html | null { | |
| 76 | if (!rows.some((r) => r.limb.weight.merged > 0)) return null; | |
| 77 | return html`<ul class="list setting-list"> | |
| 78 | ${rows.map((r) => | |
| 79 | row({ | |
| 80 | nav: true, | |
| 81 | href: milestoneHref(repoBase(r.repo), r.limb.issue.number), | |
| 82 | title: r.limb.name, | |
| 83 | meta: html`${lineFacts(r, scoped)}`, | |
| 84 | }), | |
| 85 | )} | |
| 86 | </ul>`; | |
| 87 | } | |
| 88 | ||
| 89 | /** The apps whose milestones the home names: the viewer's own, or the public ones for a visitor. */ | |
| 90 | export function frontApps(ctx: Pick<Ctx, "app">, user: User | undefined): Repo[] { | |
| 91 | return (user ? writableAppsOf(ctx, user) : appsOf(ctx, undefined)).repos; | |
| 92 | } | |
| 93 | ||
| 94 | /** The home's region: the viewer's apps, or the public ones for a visitor. */ | |
| 95 | export function homeMilestones(ctx: Ctx, apps?: readonly Repo[]): Html { | |
| 96 | const repos = apps ?? frontApps(ctx, ctx.auth.user); | |
| 97 | return html`<div | |
| 98 | id="live-milestones" | |
| 99 | data-live="${MILESTONE_LIVE}" | |
| 100 | data-live-src="/live/milestones" | |
| 101 | > | |
| 102 | ${lines(biggestMilestones(ctx, repos), false)} | |
| 103 | </div>`; | |
| 104 | } | |
| 105 | ||
| 106 | /** An app's region: its own milestones, for whoever may read it. */ | |
| 107 | export function appMilestones(ctx: Ctx, repo: Repo): Html { | |
| 108 | return html`<div | |
| 109 | id="live-milestones" | |
| 110 | data-live="${MILESTONE_LIVE}" | |
| 111 | data-live-repo="${repo.ownerName}/${repo.name}" | |
| 112 | data-live-src="${repoBase(repo)}/app/milestones" | |
| 113 | > | |
| 114 | ${lines(biggestMilestones(ctx, [repo]), true)} | |
| 115 | </div>`; | |
| 116 | } | |
| 117 | ||
| 118 | // GET /live/milestones — the home's lines alone. | |
| 119 | export function liveMilestonesFragment(ctx: Ctx): Response { | |
| 120 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 121 | notePhase(ctx.request, "render", 0); | |
| 122 | return fragment(homeMilestones(ctx), { request: ctx.request }); | |
| 123 | } | |
| 124 | ||
| 125 | // GET /:owner/:name/app/milestones — an app's lines alone. | |
| 126 | export function appMilestonesFragment(ctx: Ctx): Response { | |
| 127 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 128 | notePhase(ctx.request, "render", 0); | |
| 129 | const repo = loadRepo(ctx); | |
| 130 | if (!repo) return fragment(html``, { status: 404 }); | |
| 131 | return fragment(appMilestones(ctx, repo), { request: ctx.request }); | |
| 132 | } |
test/front-milestones-private.test.ts added +158
| @@ -0,0 +1,158 @@ | ||
| 1 | /** | |
| 2 | * Milestones at the front door never carry a private repository to a guest or | |
| 3 | * to another user: not its lines on `/live/milestones`, not its region at | |
| 4 | * `/app/milestones`, not its wave through `lastWave` (page, list and single | |
| 5 | * JSON, and `lastWaves` itself). The terminal screen reads the same. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { linkIssue } from "../src/actions/dependencies.ts"; | |
| 12 | import { openIssue } from "../src/actions/issues.ts"; | |
| 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { createApp as createAppRecord } from "../src/core/apps.ts"; | |
| 16 | import { getIssueById, setIssueState, type Issue } from "../src/core/issues.ts"; | |
| 17 | import { readPlan } from "../src/core/milestones.ts"; | |
| 18 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 19 | import { createSession } from "../src/core/sessions.ts"; | |
| 20 | import { createUser, type User } from "../src/core/users.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { lastWaves } from "../src/story/wave_last.ts"; | |
| 23 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 24 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 25 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 26 | import { quietLogs } from "./helpers/globals.ts"; | |
| 27 | import { settleApp } from "./helpers/tui.ts"; | |
| 28 | import { mergedPull } from "./helpers/wave-pulls.ts"; | |
| 29 | import { writeWave } from "../src/story/wave.ts"; | |
| 30 | ||
| 31 | quietLogs(); | |
| 32 | ||
| 33 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 34 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 35 | return r.value; | |
| 36 | }; | |
| 37 | ||
| 38 | let dir: string; | |
| 39 | let app: App; | |
| 40 | let servers: Servers; | |
| 41 | let alice: User; | |
| 42 | let bob: User; | |
| 43 | const cookies = new Map<string, string>(); | |
| 44 | const repos = new Map<string, { repo: Repo; goal: Issue }>(); | |
| 45 | ||
| 46 | async function build(name: string, visibility: "public" | "private", goalTitle: string, headline: string) { | |
| 47 | let repo = must(await createRepo(app, alice, { name, visibility })); | |
| 48 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 49 | must(await commitToBranch(app, alice, repo, { message: "init", files: [{ path: "README.md", content: "hi\n" }] })); | |
| 50 | must(createAppRecord(app.db, { repoId: repo.id, slug: name, access: "public" })); | |
| 51 | const open = (title: string): Issue => must(openIssue(app, alice, fresh(), { title })); | |
| 52 | const goal = open(goalTitle); | |
| 53 | const parts = [open(`${name} part a`), open(`${name} part b`)]; | |
| 54 | for (const p of parts) must(linkIssue(app, alice, fresh(), getIssueById(app.db, goal.id)!, getIssueById(app.db, p.id)!)); | |
| 55 | setIssueState(app.db, parts[0]!.id, "closed", "done"); | |
| 56 | const n = await mergedPull(app, alice, repo, `${name} work`); | |
| 57 | app.db.query("UPDATE issue SET body = ? WHERE repo_id = ? AND number = ?").run(`Closes #${parts[0]!.number}`, repo.id, n); | |
| 58 | app.db.query("UPDATE pull SET merged_at = ? WHERE issue_id = (SELECT id FROM issue WHERE repo_id = ? AND number = ?)").run(new Date().toISOString(), repo.id, n); | |
| 59 | const w = writeWave({ | |
| 60 | version: "0.1.0", | |
| 61 | date: "2026-03-05", | |
| 62 | headline, | |
| 63 | highlights: [1, 2, 3].map((k) => ({ title: `Thing ${k}`, why: "because" })), | |
| 64 | pulls: [{ title: `#${n} as written`, url: `https://forge.example/alice/${name}/pulls/${n}` }], | |
| 65 | }); | |
| 66 | if (!w.ok) throw w.error; | |
| 67 | must(await commitToBranch(app, alice, fresh(), { message: "wave", files: [{ path: "waves/0.1.0.md", content: w.value }] })); | |
| 68 | repo = fresh(); | |
| 69 | repos.set(name, { repo, goal }); | |
| 70 | } | |
| 71 | ||
| 72 | beforeAll(async () => { | |
| 73 | dir = await mkdtemp(join(tmpdir(), "forge-front-private-")); | |
| 74 | app = createApp({ dataDir: dir, port: 0 }); | |
| 75 | servers = await serve(app); | |
| 76 | const mk = async (username: string, adminIfFirst: boolean) => | |
| 77 | must(await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.dev`, password: "a-good-passphrase", adminIfFirst })); | |
| 78 | alice = await mk("alice", true); | |
| 79 | bob = await mk("bob", false); | |
| 80 | for (const u of [alice, bob]) cookies.set(u.username, `forge_session=${createSession(app.db, u.id).token}; forge_mode=dev`); | |
| 81 | await build("open", "public", "Open goal", "Open wave headline"); | |
| 82 | await build("hidden", "private", "Hidden goal", "Hidden wave headline"); | |
| 83 | }, 60_000); | |
| 84 | ||
| 85 | afterAll(async () => { | |
| 86 | servers.stop(); | |
| 87 | app.close(); | |
| 88 | await rm(dir, { recursive: true, force: true }); | |
| 89 | }); | |
| 90 | ||
| 91 | const as = (who: "guest" | "alice" | "bob") => (path: string) => | |
| 92 | fetch(`${servers.url}${path}`, { headers: who === "guest" ? {} : { cookie: cookies.get(who)! } }).then(async (r) => ({ status: r.status, text: await r.text() })); | |
| 93 | ||
| 94 | const SECRETS = ["Hidden goal", "Hidden wave headline", "hidden part", "/alice/hidden/"]; | |
| 95 | ||
| 96 | test("a guest and another user get none of a private repository's lines, region, page or data", async () => { | |
| 97 | const n = repos.get("hidden")!.goal.number; | |
| 98 | for (const who of ["guest", "bob"] as const) { | |
| 99 | const get = as(who); | |
| 100 | const live = await get("/live/milestones"); | |
| 101 | expect(live.status).toBe(200); | |
| 102 | for (const s of SECRETS) expect(live.text).not.toContain(s); | |
| 103 | for (const path of ["/alice/hidden/app/milestones", `/alice/hidden/milestones/${n}`, "/alice/hidden/milestones", "/api/v1/repos/alice/hidden/milestones", `/api/v1/repos/alice/hidden/milestones/${n}`]) { | |
| 104 | const got = await get(path); | |
| 105 | expect([path, got.status]).toEqual([path, 404]); | |
| 106 | for (const s of SECRETS) expect(got.text).not.toContain(s); | |
| 107 | } | |
| 108 | } | |
| 109 | // The same doors do answer the one who may read it. | |
| 110 | const mine = await as("alice")("/alice/hidden/app/milestones"); | |
| 111 | expect(mine.text).toContain("Hidden goal"); | |
| 112 | const page = await as("alice")(`/alice/hidden/milestones/${n}`); | |
| 113 | expect(page.text).toContain("Hidden wave headline"); | |
| 114 | }); | |
| 115 | ||
| 116 | test("a public repository still shows to a guest, without its private neighbour", async () => { | |
| 117 | const live = await as("guest")("/live/milestones"); | |
| 118 | expect(live.text).toContain("Open goal"); | |
| 119 | expect(live.text).not.toContain("Hidden goal"); | |
| 120 | }); | |
| 121 | ||
| 122 | test("lastWaves answers nothing for a viewer who may not read the repository", async () => { | |
| 123 | const { repo, goal } = repos.get("hidden")!; | |
| 124 | const plan = () => readPlan(app.db, repo, { spend: false, now: new Date() }); | |
| 125 | for (const viewer of [undefined, bob]) { | |
| 126 | const got = await lastWaves({ db: app.db, reposRoot: app.reposRoot, repo, viewer, plan }); | |
| 127 | expect(got.size).toBe(0); | |
| 128 | } | |
| 129 | const owner = await lastWaves({ db: app.db, reposRoot: app.reposRoot, repo, viewer: alice, plan }); | |
| 130 | expect(owner.get(goal.number)?.headline).toBe("Hidden wave headline"); | |
| 131 | }); | |
| 132 | ||
| 133 | test("the terminal's Milestones screen lists the same: a guest the public apps only, the owner both", async () => { | |
| 134 | const screen = async (user: User | undefined) => { | |
| 135 | const terminal = headlessTerminal(140, 32); | |
| 136 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 137 | const { app: tui } = createForgeApp(app, identity, { terminal, exitHooks: false }); | |
| 138 | tui.start(); | |
| 139 | const key = (k: string, ctrl = false) => tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 140 | key("k", true); | |
| 141 | await settleApp(tui); | |
| 142 | for (const ch of "Milestones") key(ch); | |
| 143 | await settleApp(tui); | |
| 144 | key("enter"); | |
| 145 | await settleApp(tui); | |
| 146 | const text = tui.screen.toText(); | |
| 147 | tui.stop(); | |
| 148 | return text; | |
| 149 | }; | |
| 150 | const guest = await screen(undefined); | |
| 151 | expect(guest).toContain("Open goal"); | |
| 152 | expect(guest).not.toContain("Hidden goal"); | |
| 153 | const other = await screen(bob); | |
| 154 | expect(other).not.toContain("Hidden goal"); | |
| 155 | const owner = await screen(alice); | |
| 156 | expect(owner).toContain("Hidden goal"); | |
| 157 | expect(owner).toContain("1 of 2 done"); | |
| 158 | }); |
test/milestone-front-door.test.ts added +148
| @@ -0,0 +1,148 @@ | ||
| 1 | /** Milestones at the front door (#1083): live lines, the wave that moved one, and the data's `movedThisWeek` and `lastWave`. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { linkIssue } from "../src/actions/dependencies.ts"; | |
| 7 | import { openIssue } from "../src/actions/issues.ts"; | |
| 8 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { createApp as createAppRecord } from "../src/core/apps.ts"; | |
| 11 | import { getIssueById, setIssueState, type Issue } from "../src/core/issues.ts"; | |
| 12 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 13 | import { createSession } from "../src/core/sessions.ts"; | |
| 14 | import { createUser, type User } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { mergedPull, waveNaming } from "./helpers/wave-pulls.ts"; | |
| 17 | ||
| 18 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 19 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 20 | return r.value; | |
| 21 | }; | |
| 22 | ||
| 23 | let dir: string; | |
| 24 | let app: App; | |
| 25 | let servers: Servers; | |
| 26 | let alice: User; | |
| 27 | let cookie: string; | |
| 28 | let repo: Repo; | |
| 29 | let goal: Issue; | |
| 30 | let quiet: Issue; | |
| 31 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 32 | const open = (title: string): Issue => must(openIssue(app, alice, fresh(), { title })); | |
| 33 | const link = (waiter: Issue, on: Issue): void => { | |
| 34 | must(linkIssue(app, alice, fresh(), getIssueById(app.db, waiter.id)!, getIssueById(app.db, on.id)!)); | |
| 35 | }; | |
| 36 | ||
| 37 | beforeAll(async () => { | |
| 38 | dir = await mkdtemp(join(tmpdir(), "forge-front-ms-")); | |
| 39 | app = createApp({ dataDir: dir, port: 0 }); | |
| 40 | servers = await serve(app); | |
| 41 | alice = must( | |
| 42 | await createUser(app.db, { | |
| 43 | origin: "seed", | |
| 44 | trusted: true, | |
| 45 | username: "alice", | |
| 46 | email: "[email protected]", | |
| 47 | password: "a-good-passphrase", | |
| 48 | adminIfFirst: true, | |
| 49 | }), | |
| 50 | ); | |
| 51 | cookie = `forge_session=${createSession(app.db, alice.id).token}; forge_mode=dev`; | |
| 52 | repo = must(await createRepo(app, alice, { name: "plan", visibility: "public" })); | |
| 53 | must(await commitToBranch(app, alice, repo, { message: "init", files: [{ path: "README.md", content: "hi\n" }] })); | |
| 54 | must(createAppRecord(app.db, { repoId: repo.id, slug: "plan", access: "public" })); | |
| 55 | goal = open("Ship the front door"); | |
| 56 | const parts = [open("Part A"), open("Part B"), open("Part C")]; | |
| 57 | for (const p of parts) link(goal, p); | |
| 58 | setIssueState(app.db, parts[0]!.id, "closed", "done"); | |
| 59 | const n = await mergedPull(app, alice, repo, "Part A work"); | |
| 60 | app.db.query("UPDATE issue SET body = ? WHERE repo_id = ? AND number = ?").run(`Closes #${parts[0]!.number}`, repo.id, n); | |
| 61 | // It merged just now: what landed this week. | |
| 62 | app.db.query("UPDATE pull SET merged_at = ? WHERE issue_id = (SELECT id FROM issue WHERE repo_id = ? AND number = ?)").run(new Date().toISOString(), repo.id, n); | |
| 63 | must( | |
| 64 | await commitToBranch(app, alice, fresh(), { | |
| 65 | message: "wave", | |
| 66 | files: [{ path: "waves/0.1.0.md", content: waveNaming("0.1.0", [{ owner: "alice", name: "plan", number: n }]) }], | |
| 67 | }), | |
| 68 | ); | |
| 69 | // A milestone with open work and nothing landed: it has no wave. | |
| 70 | quiet = open("Quiet goal"); | |
| 71 | link(quiet, open("Quiet part")); | |
| 72 | }, 60_000); | |
| 73 | ||
| 74 | afterAll(() => { | |
| 75 | servers.stop(); | |
| 76 | app.close(); | |
| 77 | }); | |
| 78 | ||
| 79 | const get = (path: string, auth = true) => | |
| 80 | fetch(`${servers.url}${path}`, { headers: auth ? { cookie } : {} }).then((r) => r.text()); | |
| 81 | ||
| 82 | test("the home and an app's page carry live lines: name, done of total, moved this week", async () => { | |
| 83 | for (const [path, src] of [ | |
| 84 | ["/?all=1", "/live/milestones"], | |
| 85 | ["/alice/plan/app", "/alice/plan/app/milestones"], | |
| 86 | ] as const) { | |
| 87 | const text = await get(path); | |
| 88 | expect(text).toContain(`data-live-src="${src}"`); | |
| 89 | expect(text).toContain("Ship the front door"); | |
| 90 | expect(text).toContain("1 of 3 done"); | |
| 91 | expect(text).toContain("1 landed in 7 days"); | |
| 92 | } | |
| 93 | const live = await get("/live/milestones"); | |
| 94 | expect(live).toContain(`/alice/plan/milestones/${goal.number}`); | |
| 95 | // Biggest by open work first; the one that moved nothing says so. | |
| 96 | expect(live.indexOf("Ship the front door")).toBeLessThan(live.indexOf("Quiet goal")); | |
| 97 | expect(live).toContain("quiet this week"); | |
| 98 | // A milestone with no estimate leaves no dangling separator. | |
| 99 | expect(live).not.toMatch(/·\s*<\/span>/); | |
| 100 | expect(live).not.toMatch(/·\s*$/m); | |
| 101 | }); | |
| 102 | ||
| 103 | test("a visitor sees a public repository's milestones, without spend", async () => { | |
| 104 | const text = await get("/alice/plan/app/milestones", false); | |
| 105 | expect(text).toContain("Ship the front door"); | |
| 106 | expect(text).not.toMatch(/\$\d/); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a region paints nothing when nothing moved", async () => { | |
| 110 | const empty = await get("/alice/plan/app/milestones").then((t) => t.includes("row-title")); | |
| 111 | expect(empty).toBe(true); | |
| 112 | app.db.query("UPDATE pull SET merged_at = '2020-01-01T00:00:00.000Z'").run(); | |
| 113 | expect(await get("/live/milestones")).not.toContain("row-title"); | |
| 114 | }); | |
| 115 | ||
| 116 | test("the milestone page links back to the wave that moved it; one no wave moved has none", async () => { | |
| 117 | const page = await get(`/alice/plan/milestones/${goal.number}`); | |
| 118 | expect(page).toContain(`id="ms-wave"`); | |
| 119 | expect(page).toContain(`href="/alice/plan/waves/0.1.0"`); | |
| 120 | expect(await get(`/alice/plan/milestones/${quiet.number}`)).not.toContain("ms-wave"); | |
| 121 | }); | |
| 122 | ||
| 123 | test("the data carries movedThisWeek and the last wave per milestone", async () => { | |
| 124 | const list = JSON.parse(await get("/api/v1/repos/alice/plan/milestones")); | |
| 125 | const limb = list.items.find((l: { number: number }) => l.number === goal.number); | |
| 126 | expect(limb.movedThisWeek).toBeNumber(); | |
| 127 | expect(limb.lastWave.version).toBe("0.1.0"); | |
| 128 | const one = JSON.parse(await get(`/api/v1/repos/alice/plan/milestones/${goal.number}`)); | |
| 129 | expect(one.lastWave.href).toBe("/alice/plan/waves/0.1.0"); | |
| 130 | const none = JSON.parse(await get(`/api/v1/repos/alice/plan/milestones/${quiet.number}`)); | |
| 131 | expect(none.lastWave).toBeUndefined(); | |
| 132 | }); | |
| 133 | ||
| 134 | test("the waves are read from git once per latest event, and a push shows at once", async () => { | |
| 135 | const { lastWaves } = await import("../src/story/wave_last.ts"); | |
| 136 | const { readPlan } = await import("../src/core/milestones.ts"); | |
| 137 | const input = { db: app.db, reposRoot: app.reposRoot, repo: fresh(), viewer: alice, plan: () => readPlan(app.db, fresh(), { spend: false, now: new Date() }) }; | |
| 138 | const first = await lastWaves(input); | |
| 139 | expect(first.get(goal.number)?.version).toBe("0.1.0"); | |
| 140 | // Unchanged repository: the same kept answer, no read of the tree. | |
| 141 | const again = await lastWaves(input); | |
| 142 | expect(again.get(goal.number)).toEqual(first.get(goal.number)!); | |
| 143 | // A newer wave naming the same pull: a push is an event, so the next read sees it. | |
| 144 | const naming = waveNaming("0.2.0", [{ owner: "alice", name: "plan", number: Number(app.db.query<{ number: number }, [string]>("SELECT MAX(number) AS number FROM issue WHERE repo_id = ? AND id IN (SELECT issue_id FROM pull)").get(repo.id)!.number) }]); | |
| 145 | must(await commitToBranch(app, alice, fresh(), { message: "wave 2", files: [{ path: "waves/0.2.0.md", content: naming }] })); | |
| 146 | const after = await lastWaves({ ...input, repo: fresh() }); | |
| 147 | expect(after.get(goal.number)?.version).toBe("0.2.0"); | |
| 148 | }); |
test/milestone-pace.test.ts +1 −1
| @@ -88,7 +88,7 @@ const ctxFor = (number: number): Ctx => | ||
| 88 | 88 | |
| 89 | 89 | /** The page's pace line and the MCP's data, both at NOW. */ |
| 90 | 90 | async function both(top: Issue) { |
| 91 | const html = await milestonePage(ctxFor(top.number), NOW).text(); | |
| 91 | const html = await (await milestonePage(ctxFor(top.number), NOW)).text(); | |
| 92 | 92 | const line = /id="ms-pace"[\s\S]*?<span>([^<]*)<\/span>\s*<\/div>/.exec(html)?.[1]; |
| 93 | 93 | const data = (await milestoneData(ctxFor(top.number), repo, top.number, NOW))!; |
| 94 | 94 | return { html, line, data }; |
test/palette-roster.test.ts +4
| @@ -100,6 +100,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 100 | 100 | ["bugs", "Bugs", "open bugs and the bug clock"], |
| 101 | 101 | ["home-pins", "Pinned panels", "the panels pinned to home: reorder and unpin"], |
| 102 | 102 | ["now", "Now", "what check, review, land and live count, with the rows"], |
| 103 | ["front-milestones", "Milestones", "the biggest open milestones: how far, what landed this week"], | |
| 103 | 104 | ["forge-view", "Forge view", "the forge's counts, and Foundry when it is installed"], |
| 104 | 105 | ["origin-apps", "Connection apps", "your OAuth apps for connecting accounts; a adds, d removes"], |
| 105 | 106 | ["system-memory", "System memory", "RSS beside the JS heap, hourly for 24 h"], |
| @@ -245,6 +246,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 245 | 246 | ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"], |
| 246 | 247 | ["lend-credit", "Lend a child credit", "set the dollars a forge you sent may spend on this one's model credential"], |
| 247 | 248 | ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"], |
| 249 | ["front-milestones", "Milestones", "the biggest open milestones: how far, what landed this week"], | |
| 248 | 250 | ["forge-view", "Forge view", "the forge's counts, and Foundry when it is installed"], |
| 249 | 251 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 250 | 252 | ["chain", "Chain", "the forges this forge's code descends from, root first"], |
| @@ -324,6 +326,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 324 | 326 | ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"], |
| 325 | 327 | ["profile", "Your profile", "display name and email"], |
| 326 | 328 | ["dotfiles", "Your dotfiles", "the repository every shell you open copies from"], |
| 329 | ["front-milestones", "Milestones", "the biggest open milestones: how far, what landed this week"], | |
| 327 | 330 | ["forge-view", "Forge view", "the forge's counts, and Foundry when it is installed"], |
| 328 | 331 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 329 | 332 | ["chain", "Chain", "the forges this forge's code descends from, root first"], |
| @@ -362,6 +365,7 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 362 | 365 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 363 | 366 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 364 | 367 | ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"], |
| 368 | ["front-milestones", "Milestones", "the biggest open milestones: how far, what landed this week"], | |
| 365 | 369 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 366 | 370 | ["chain", "Chain", "the forges this forge's code descends from, root first"], |
| 367 | 371 | ["quit", "Quit", "disconnect"], |
Contradicts 'No new component class' earlier in this file. Better: remove the class and reuse rows.