Public: anyone can read this; the crew can change it.
Home's Now line and the account menu's Repositories go where they say #8757
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8743-home-s-now-line-and-the-account-menu-s-r → main 5 commits fork
18 files
changed +546
−7
docs/design/audit.md +1
| @@ -65,6 +65,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 65 | 65 | - `/pipeline` — `pages/pipeline.ts` · the four steps a change follows (check, review, land, live), for a newcomer · a pull page's step line, `/flow` · none seen. |
| 66 | 66 | - `/map` — `pages/map.ts` (1,806 lines) · the dependency map of work across the forge · `/board`, issue dependency lists, `/:owner/:name/map` · colours 3, sizes 12: the largest private vocabulary. |
| 67 | 67 | - `/audit` — `pages/audit.ts` · every app's contract, who can do what · the app page's one-line policy · none seen. Names the audit of apps, not this document. |
| 68 | - `/repos` — `pages/my-repos.ts` · the repositories you may write, from the account menu · `/` (your apps), `/:owner` (a profile's repositories) · none seen. | |
| 68 | 69 | - `/releases` — `pages/releases.ts` · the forge's kept releases and rollback · `/settings/backups` (both are "go back") · none seen. |
| 69 | 70 | - `/changelog` — `pages/changelog.ts` · the last 30 releases with their dates and pulls, public · `/releases` (the signed-in kept releases) · none seen. |
| 70 | 71 | - `/api` — `pages/api.ts` · the forge's OpenAPI operations by area, with search and Try it, public · the footer, the OpenAPI document, the terminal's API screen · none seen. An app's own spec is its `/:owner/:name/api` tab. |
docs/features/now-pages.md added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | # Now pages and Repositories | |
| 2 | ||
| 3 | - Account menu → **Repositories** opens `/repos`: the repositories you may write, newest first. | |
| 4 | - Home's Now line (site admins) links each word to what it counts: `/now/check`, `/now/review`, `/now/land`, `/now/live`. The list is `readNowRows` (`src/flow/now_rows.ts`), the same predicates `pipeline.ts` tallies, so the length is the number on home. `test/now-links.test.ts` walks both. |
forge.json +1 −1
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | "api.anthropic.com" |
| 4 | 4 | ], |
| 5 | 5 | "app": { |
| 6 | "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/notes", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/talk", "/contribute", "/flow", "/resilience", "/settings"], | |
| 6 | "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/notes", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/repos", "/map", "/system", "/talk", "/contribute", "/flow", "/resilience", "/settings"], | |
| 7 | 7 | "run": "bun run src/main.ts", |
| 8 | 8 | "install": "", |
| 9 | 9 | "health": "/healthz", |
src/core/reserved.ts +1
| @@ -91,6 +91,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 91 | 91 | "oauth", |
| 92 | 92 | "og", |
| 93 | 93 | "organizations", |
| 94 | "now", | |
| 94 | 95 | "resilience", |
| 95 | 96 | "origins", |
| 96 | 97 | "robots.txt", |
src/flow/now_rows.ts added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | /** | |
| 2 | * What each word of home's Now line counts, as the rows themselves: check, | |
| 3 | * review, land and live. The same predicates as `pipeline.ts` tallies, so a | |
| 4 | * list's length is the number on home. Plain data out, forge-wide: the | |
| 5 | * caller has checked the viewer is a site admin, as home does. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | ||
| 10 | export const NOW_STAGES = ["check", "review", "land", "live"] as const; | |
| 11 | export type NowStage = (typeof NOW_STAGES)[number]; | |
| 12 | ||
| 13 | export const NOW_WORDS: Record<NowStage, string> = { | |
| 14 | check: "Check", | |
| 15 | review: "Review", | |
| 16 | land: "Land", | |
| 17 | live: "Live", | |
| 18 | }; | |
| 19 | ||
| 20 | /** What the word counts, in a line under the heading. */ | |
| 21 | export const NOW_HOLDS: Record<NowStage, string> = { | |
| 22 | check: "Checks queued or running, across every repository.", | |
| 23 | review: "Reviews queued or running on open pulls.", | |
| 24 | land: "Open pulls approved and waiting to land.", | |
| 25 | live: "Production deploys that went live in the last 24 hours.", | |
| 26 | }; | |
| 27 | ||
| 28 | export interface NowRow { | |
| 29 | readonly title: string; | |
| 30 | readonly href: string; | |
| 31 | /** `owner/name`. */ | |
| 32 | readonly repo: string; | |
| 33 | readonly at: string; | |
| 34 | /** What this row is, in a word: "pull #3", "main", "deploy". */ | |
| 35 | readonly note: string; | |
| 36 | } | |
| 37 | ||
| 38 | interface Raw { | |
| 39 | owner: string; | |
| 40 | name: string; | |
| 41 | number: number | null; | |
| 42 | title: string | null; | |
| 43 | run_id: string | null; | |
| 44 | env: string; | |
| 45 | at: string; | |
| 46 | } | |
| 47 | ||
| 48 | const DAY_MS = 24 * 3_600_000; | |
| 49 | ||
| 50 | // A queued run paused on a limit (`not_before` ahead) is not waiting for a worker: `runTally`'s rule. | |
| 51 | const RUNS = (kind: string, where: string): string => ` | |
| 52 | SELECT u.username AS owner, r.name AS name, i.number AS number, i.title AS title, | |
| 53 | x.id AS run_id, x.env AS env, x.created_at AS at | |
| 54 | FROM run x JOIN repo r ON r.id = x.repo_id JOIN user u ON u.id = r.owner_id | |
| 55 | LEFT JOIN issue i ON i.id = x.issue_id | |
| 56 | WHERE x.kind = '${kind}' AND ${where} | |
| 57 | AND (x.state = 'running' OR x.not_before IS NULL OR x.not_before <= ?1) | |
| 58 | ORDER BY x.created_at DESC, x.id DESC`; | |
| 59 | ||
| 60 | const SQL: Record<NowStage, string> = { | |
| 61 | check: RUNS("check", "x.state IN ('queued','running')"), | |
| 62 | review: RUNS("agent", "x.reason = 'review' AND x.state IN ('queued','running')"), | |
| 63 | land: ` | |
| 64 | SELECT u.username AS owner, r.name AS name, i.number AS number, i.title AS title, | |
| 65 | NULL AS run_id, '' AS env, v.created_at AS at | |
| 66 | FROM issue i JOIN pull p ON p.issue_id = i.id | |
| 67 | JOIN review v ON v.id = (SELECT id FROM review WHERE issue_id = i.id AND state != 'commented' | |
| 68 | ORDER BY created_at DESC, id DESC LIMIT 1) | |
| 69 | JOIN repo r ON r.id = i.repo_id JOIN user u ON u.id = r.owner_id | |
| 70 | WHERE i.state = 'open' AND v.state = 'approved' | |
| 71 | ORDER BY v.created_at DESC`, | |
| 72 | live: ` | |
| 73 | SELECT u.username AS owner, r.name AS name, NULL AS number, NULL AS title, | |
| 74 | x.id AS run_id, x.env AS env, x.serving_at AS at | |
| 75 | FROM run x JOIN repo r ON r.id = x.repo_id JOIN user u ON u.id = r.owner_id | |
| 76 | WHERE x.kind = 'deploy' AND x.env = 'production' AND x.serving_at >= ?1 | |
| 77 | ORDER BY x.serving_at DESC, x.id DESC`, | |
| 78 | }; | |
| 79 | ||
| 80 | /** Rows read and listed: the queue can grow, a page of it cannot. */ | |
| 81 | export const NOW_LISTED = 200; | |
| 82 | ||
| 83 | /** Whether the viewer is shown the Now line and its lists: a site admin who is a person, as home decides it. */ | |
| 84 | export const mayReadNow = (user: { isAdmin: boolean; kind: string } | undefined): boolean => | |
| 85 | user !== undefined && user.isAdmin && user.kind !== "agent"; | |
| 86 | ||
| 87 | export interface NowList { | |
| 88 | /** What home counts for the stage. */ | |
| 89 | readonly total: number; | |
| 90 | /** The newest `NOW_LISTED` of them; fewer than `total` only when the list is cut off. */ | |
| 91 | readonly rows: NowRow[]; | |
| 92 | } | |
| 93 | ||
| 94 | /** The stage's items, newest first: `check` and `review` are runs; `land` is pulls; `live` is deploys. */ | |
| 95 | export function readNowRows(db: Database, stage: NowStage, now = new Date()): NowList { | |
| 96 | const args: string[] = stage === "land" ? [] : [stage === "live" ? new Date(now.getTime() - DAY_MS).toISOString() : now.toISOString()]; | |
| 97 | const counted = db.query<{ n: number }, string[]>(`SELECT COUNT(*) AS n FROM (${SQL[stage]})`).get(...args); | |
| 98 | const rows = db.query<Raw, string[]>(`${SQL[stage]} LIMIT ${NOW_LISTED}`).all(...args); | |
| 99 | return { | |
| 100 | total: counted?.n ?? 0, | |
| 101 | rows: rows.map((r) => { | |
| 102 | const repo = `${r.owner}/${r.name}`; | |
| 103 | const base = `/${r.owner}/${r.name}`; | |
| 104 | if (r.number !== null && stage === "land") | |
| 105 | return { title: r.title ?? "", href: `${base}/pulls/${r.number}`, repo, at: r.at, note: `pull #${r.number}` }; | |
| 106 | const where = r.number !== null ? `pull #${r.number}` : r.env === "" ? "main" : r.env; | |
| 107 | return { | |
| 108 | title: r.title ?? (stage === "live" ? "Deploy to production" : `Check of ${where}`), | |
| 109 | href: r.run_id === null ? base : `${base}/runs/${r.run_id}`, | |
| 110 | repo, | |
| 111 | at: r.at, | |
| 112 | note: where, | |
| 113 | }; | |
| 114 | }), | |
| 115 | }; | |
| 116 | } |
src/http/server.ts +4
| @@ -25,6 +25,8 @@ import { settingsRoutes } from "./settings_routes.ts"; | ||
| 25 | 25 | import { boardsPage } from "../web/pages/boards.ts"; |
| 26 | 26 | import { boardNew, boardPin, boardPost, pinPost } from "../web/pages/boards-edit.ts"; |
| 27 | 27 | import { boardsHoldPost } from "../web/pages/boards-holds.ts"; |
| 28 | import { nowPage } from "../web/pages/now.ts"; | |
| 29 | import { myReposPage } from "../web/pages/my-repos.ts"; | |
| 28 | 30 | import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts"; |
| 29 | 31 | import { actingVia } from "../core/acting_via.ts"; |
| 30 | 32 | import { |
| @@ -751,6 +753,8 @@ export function buildRouter(): Router<Handler> { | ||
| 751 | 753 | // is registered before /:owner/:name can claim the shape. |
| 752 | 754 | router.add("GET", "/-/confirm", confirmPage); |
| 753 | 755 | router.add("GET", "/new", newRepoPage); |
| 756 | router.add("GET", "/repos", myReposPage); | |
| 757 | router.add("GET", "/now/:stage", nowPage); | |
| 754 | 758 | router.add("GET", "/audit", auditPage); |
| 755 | 759 | router.add("GET", "/releases", releasesPage); |
| 756 | 760 | router.add("GET", "/changelog", changelogPage); |
src/tui/screens/entries.ts +4
| @@ -28,6 +28,8 @@ import { entry as limits } from "./limits.ts"; | ||
| 28 | 28 | import { modelCeilingEntry, modelGrantEntry } from "./model.ts"; |
| 29 | 29 | import { entry as orgGrants } from "./orggrants.ts"; |
| 30 | 30 | import { entry as orgTeams } from "./orgteams.ts"; |
| 31 | import { entry as now } from "./nowlist.ts"; | |
| 32 | import { entry as repos } from "./repolist.ts"; | |
| 31 | 33 | import { entry as owner } from "./owner.ts"; |
| 32 | 34 | import { entry as people } from "./people.ts"; |
| 33 | 35 | import { entry as pinned } from "./pinned.ts"; |
| @@ -95,6 +97,8 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 95 | 97 | owner, |
| 96 | 98 | forges, |
| 97 | 99 | profile, |
| 100 | now, | |
| 101 | repos, | |
| 98 | 102 | ]; |
| 99 | 103 | |
| 100 | 104 | /** Put each entry the session is offered into `commands`, after the command it names or at the end. */ |
src/tui/screens/nowlist.ts added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | /** | |
| 2 | * Home's Now line as a screen, a site admin's (/now/:stage on the web): check, | |
| 3 | * review, land and live, `1`–`4` to switch, each listing what the word counts | |
| 4 | * from `readNowRows`, so the number is home's. Enter opens the row on the | |
| 5 | * web. Anyone else, and an agent, is offered nothing. | |
| 6 | */ | |
| 7 | ||
| 8 | import { mayReadNow, NOW_HOLDS, NOW_LISTED, NOW_STAGES, NOW_WORDS, readNowRows } from "../../flow/now_rows.ts"; | |
| 9 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 10 | import { binding } from "../app/keys.ts"; | |
| 11 | import { style } from "../term/style.ts"; | |
| 12 | import { truncate } from "../term/width.ts"; | |
| 13 | import type { Session } from "../session.ts"; | |
| 14 | import { fill, len, split } from "../ui/layout.ts"; | |
| 15 | import { ListState, drawList, openOnWeb, type ListRow } from "./common.ts"; | |
| 16 | import type { PaletteEntry } from "./entries.ts"; | |
| 17 | ||
| 18 | export function nowScreen(f: Session, ctx: Ctx, first: (typeof NOW_STAGES)[number] = "check"): Screen { | |
| 19 | const state = new ListState(); | |
| 20 | let stage = first; | |
| 21 | const read = () => (mayReadNow(f.user) ? readNowRows(f.db, stage) : { total: 0, rows: [] }); | |
| 22 | const screen: Screen = { | |
| 23 | title: "Now", | |
| 24 | keys: [binding(["j", "k"], "move cursor"), binding(["1", "2", "3", "4"], "check, review, land, live"), binding(["enter"], "open on the web")], | |
| 25 | render(area, s) { | |
| 26 | const c = ctx.theme.c; | |
| 27 | const { total, rows } = read(); | |
| 28 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 29 | const words = NOW_STAGES.map((w) => (w === stage ? `[${NOW_WORDS[w]}]` : NOW_WORDS[w])).join(" "); | |
| 30 | const cut = total > rows.length ? ` Newest ${NOW_LISTED} shown.` : ""; | |
| 31 | const text = mayReadNow(f.user) ? `${words} ${total} · ${NOW_HOLDS[stage]}${cut}` : "Now is a site admin's."; | |
| 32 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 33 | const shown: ListRow[] = rows.map((r) => ({ title: r.title, secondary: `${r.repo} · ${r.note}`, meta: r.at.slice(0, 16).replace("T", " ") })); | |
| 34 | drawList(s, list!, c, state, shown); | |
| 35 | }, | |
| 36 | key(e) { | |
| 37 | if (!mayReadNow(f.user)) return false; | |
| 38 | const { rows } = read(); | |
| 39 | if (state.key(e, rows.length)) return true; | |
| 40 | if (e.ctrl || e.alt) return false; | |
| 41 | const picked = NOW_STAGES[Number(e.key) - 1]; | |
| 42 | if (picked !== undefined) { | |
| 43 | stage = picked; | |
| 44 | state.index = 0; | |
| 45 | state.offset = 0; | |
| 46 | return true; | |
| 47 | } | |
| 48 | if (e.key === "enter" && rows[state.index] !== undefined) { | |
| 49 | openOnWeb(f, ctx, rows[state.index]!.href, "That row"); | |
| 50 | return true; | |
| 51 | } | |
| 52 | return false; | |
| 53 | }, | |
| 54 | }; | |
| 55 | return screen; | |
| 56 | } | |
| 57 | ||
| 58 | /** The Now line's lists, as /now/:stage has them: a site admin's. */ | |
| 59 | export const entry: PaletteEntry = { | |
| 60 | id: "now", | |
| 61 | title: "Now", | |
| 62 | hint: "what check, review, land and live count, with the rows", | |
| 63 | after: ["boards"], | |
| 64 | offered: (session) => mayReadNow(session.user), | |
| 65 | run: (session, ctx) => ctx.push(nowScreen(session, ctx)), | |
| 66 | }; |
src/tui/screens/repolist.ts added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | /** | |
| 2 | * Your repositories (/repos on the web): every repository the viewer may | |
| 3 | * write, from `listReposWritableTo`. Enter opens one as the dashboard does. | |
| 4 | */ | |
| 5 | ||
| 6 | import { listReposWritableTo, type Repo } from "../../core/repos.ts"; | |
| 7 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 8 | import { binding } from "../app/keys.ts"; | |
| 9 | import { style } from "../term/style.ts"; | |
| 10 | import { truncate } from "../term/width.ts"; | |
| 11 | import type { Session } from "../session.ts"; | |
| 12 | import { fill, len, split } from "../ui/layout.ts"; | |
| 13 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 14 | import type { PaletteEntry } from "./entries.ts"; | |
| 15 | import { repoScreen } from "./repo.ts"; | |
| 16 | import { local } from "./where.ts"; | |
| 17 | ||
| 18 | export function repoListScreen(f: Session, ctx: Ctx): Screen { | |
| 19 | const state = new ListState(); | |
| 20 | const read = (): Repo[] => listReposWritableTo(f.db, f.user, { limit: 200, withoutArchived: true }); | |
| 21 | return { | |
| 22 | title: "Your repositories", | |
| 23 | keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "open")], | |
| 24 | render(area, s) { | |
| 25 | const c = ctx.theme.c; | |
| 26 | const repos = read(); | |
| 27 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 28 | const text = repos.length === 0 ? "No repositories yet." : `${repos.length} repositories you may write`; | |
| 29 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 30 | const rows: ListRow[] = repos.map((r) => ({ title: `${r.ownerName}/${r.name}` })); | |
| 31 | drawList(s, list!, c, state, rows); | |
| 32 | }, | |
| 33 | key(e) { | |
| 34 | const repos = read(); | |
| 35 | if (state.key(e, repos.length)) return true; | |
| 36 | const repo = repos[state.index]; | |
| 37 | if (e.ctrl || e.alt || e.key !== "enter" || repo === undefined) return false; | |
| 38 | ctx.push(repoScreen(f, ctx, local(f, repo))); | |
| 39 | return true; | |
| 40 | }, | |
| 41 | }; | |
| 42 | } | |
| 43 | ||
| 44 | /** Your repositories, as /repos lists them: anyone signed in. */ | |
| 45 | export const entry: PaletteEntry = { | |
| 46 | id: "repos", | |
| 47 | title: "Repositories", | |
| 48 | hint: "the repositories you may write", | |
| 49 | after: ["profile"], | |
| 50 | offered: (session) => session.user !== undefined, | |
| 51 | run: (session, ctx) => ctx.push(repoListScreen(session, ctx)), | |
| 52 | }; |
src/web/layout.ts +1 −1
| @@ -237,7 +237,7 @@ function userMenu( | ||
| 237 | 237 | ): Html { |
| 238 | 238 | const items: (MenuItem | Html)[] = [ |
| 239 | 239 | { label: "Your profile", href: `/${user.username}`, icon: "user" }, |
| 240 | { label: "Your repositories", href: "/", icon: "repo" }, | |
| 240 | { label: "Repositories", href: "/repos", icon: "repo" }, | |
| 241 | 241 | { label: "Apps", href: "/apps", icon: "rocket" }, |
| 242 | 242 | { label: "Notes", href: "/notes", icon: "comment" }, |
| 243 | 243 | { label: "Talk", href: "/talk", icon: "comment" }, |
src/web/pages/my-repos.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | /** | |
| 2 | * Your repositories: every repository the viewer may write, their own and | |
| 3 | * their organizations', newest first, each a link to it. | |
| 4 | */ | |
| 5 | ||
| 6 | import { listReposWritableTo } from "../../core/repos.ts"; | |
| 7 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 8 | import type { Ctx } from "../../http/context.ts"; | |
| 9 | import { repoBase, signInFirst } from "../chrome.ts"; | |
| 10 | import { html } from "../html.ts"; | |
| 11 | import { page } from "../layout.ts"; | |
| 12 | import { btn, empty, listCard, row } from "../ui.ts"; | |
| 13 | ||
| 14 | // GET /repos | |
| 15 | export function myReposPage(ctx: Ctx): Response { | |
| 16 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 17 | const user = ctx.auth.user; | |
| 18 | if (user === undefined) return signInFirst(ctx); | |
| 19 | const reading = performance.now(); | |
| 20 | const repos = listReposWritableTo(ctx.app.db, user, { limit: 200, withoutArchived: true }); | |
| 21 | const body = | |
| 22 | repos.length === 0 | |
| 23 | ? empty({ icon: "repo", title: "No repositories yet", action: btn("New repository", { href: "/new", variant: "primary" }) }) | |
| 24 | : listCard( | |
| 25 | repos.map((r) => | |
| 26 | row({ href: repoBase(r), nav: true, title: html`<span class="truncate">${r.ownerName}/${r.name}</span>` }), | |
| 27 | ), | |
| 28 | ); | |
| 29 | notePhase(ctx.request, "render", performance.now() - reading); | |
| 30 | return page({ | |
| 31 | ctx, | |
| 32 | title: "Your repositories", | |
| 33 | body: html`<div class="page-head"><h1>Your repositories</h1>${repos.length === 0 ? null : btn("New repository", { href: "/new" })}</div>${body}`, | |
| 34 | }); | |
| 35 | } |
src/web/pages/now.ts added +50
| @@ -0,0 +1,50 @@ | ||
| 1 | /** | |
| 2 | * The four words of home's Now line, each opening what it counts: the checks, | |
| 3 | * the reviews, the approved pulls waiting to land, the deploys that are live. | |
| 4 | * The list is `readNowRows`, so its length is the number on home. A site | |
| 5 | * admin's, as the line is: the counts cover every repository. | |
| 6 | */ | |
| 7 | ||
| 8 | import { mayReadNow, NOW_HOLDS, NOW_LISTED, NOW_STAGES, NOW_WORDS, readNowRows, type NowStage } from "../../flow/now_rows.ts"; | |
| 9 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 10 | import type { Ctx } from "../../http/context.ts"; | |
| 11 | import { notFoundPage, signInFirst } from "../chrome.ts"; | |
| 12 | import { html } from "../html.ts"; | |
| 13 | import { page } from "../layout.ts"; | |
| 14 | import { empty, listCard, row, timeAgo } from "../ui.ts"; | |
| 15 | ||
| 16 | /** Where a word of the Now line goes. */ | |
| 17 | export const nowHref = (stage: NowStage): string => `/now/${stage}`; | |
| 18 | ||
| 19 | // GET /now/:stage | |
| 20 | export function nowPage(ctx: Ctx): Response { | |
| 21 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 22 | const user = ctx.auth.user; | |
| 23 | if (user === undefined) return signInFirst(ctx); | |
| 24 | const stage = NOW_STAGES.find((s) => s === ctx.params["stage"]); | |
| 25 | if (stage === undefined || !mayReadNow(user)) return notFoundPage(ctx); | |
| 26 | const reading = performance.now(); | |
| 27 | const { total, rows } = readNowRows(ctx.app.db, stage); | |
| 28 | const body = | |
| 29 | total === 0 | |
| 30 | ? empty({ icon: "activity", title: `Nothing in ${NOW_WORDS[stage].toLowerCase()} right now`, note: NOW_HOLDS[stage] }) | |
| 31 | : listCard( | |
| 32 | rows.map((r) => | |
| 33 | row({ | |
| 34 | href: r.href, | |
| 35 | nav: true, | |
| 36 | title: html`<span class="truncate">${r.title}</span>`, | |
| 37 | meta: html`${r.repo} · ${r.note}`, | |
| 38 | trail: html`<span class="muted small">${timeAgo(r.at)}</span>`, | |
| 39 | }), | |
| 40 | ), | |
| 41 | ); | |
| 42 | notePhase(ctx.request, "render", performance.now() - reading); | |
| 43 | return page({ | |
| 44 | ctx, | |
| 45 | title: NOW_WORDS[stage], | |
| 46 | body: html`<div class="page-head"><h1>${NOW_WORDS[stage]}</h1></div> | |
| 47 | <p class="muted small" id="now-count"><strong>${String(total)}</strong> · ${NOW_HOLDS[stage]}${total > rows.length ? ` Showing the newest ${NOW_LISTED}.` : ""}</p> | |
| 48 | ${body}`, | |
| 49 | }); | |
| 50 | } |
src/web/studio/living.ts +6 −4
| @@ -16,6 +16,8 @@ import { html, type Html } from "../html.ts"; | ||
| 16 | 16 | import { fragment } from "../layout.ts"; |
| 17 | 17 | import { btn, listCard, row, timeAgo } from "../ui.ts"; |
| 18 | 18 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 19 | import type { NowStage } from "../../flow/now_rows.ts"; | |
| 20 | import { nowHref } from "../pages/now.ts"; | |
| 19 | 21 | import { writableAppsOf } from "./asks.ts"; |
| 20 | 22 | import { FORGE_TARGET, sharedDraft } from "./feed.ts"; |
| 21 | 23 | import { repoBase } from "../chrome.ts"; |
| @@ -35,18 +37,18 @@ export function pipelineNow(db: Database, at = Date.now()): Now { | ||
| 35 | 37 | return now; |
| 36 | 38 | } |
| 37 | 39 | |
| 38 | /** `check 10 › review 1 › land 3 › live`: each word links to the boards where the same stage is. */ | |
| 40 | /** `check 10 › review 1 › land 3 › live`: each word opens what it counts (`/now/:stage`). */ | |
| 39 | 41 | export function nowLine(now: Now, boards = true): Html { |
| 40 | 42 | // An agent admin has no boards to open: the word is then plain. |
| 41 | const part = (word: string, n: number) => | |
| 42 | boards ? html`<a href="/boards"><strong>${String(n)}</strong> ${word}</a>` : html`<span><strong>${String(n)}</strong> ${word}</span>`; | |
| 43 | const part = (stage: NowStage, n: number) => | |
| 44 | boards ? html`<a href="${nowHref(stage)}"><strong>${String(n)}</strong> ${stage}</a>` : html`<span><strong>${String(n)}</strong> ${stage}</span>`; | |
| 43 | 45 | return html`<section aria-label="Now"> |
| 44 | 46 | <h2 class="small muted">Now</h2> |
| 45 | 47 | <p class="bar" id="now-line"> |
| 46 | 48 | ${part("check", now.check)} <span class="muted" aria-hidden="true">›</span> |
| 47 | 49 | ${part("review", now.review)} <span class="muted" aria-hidden="true">›</span> |
| 48 | 50 | ${part("land", now.land)} <span class="muted" aria-hidden="true">›</span> |
| 49 | <a href="/releases"><strong>${String(now.live)}</strong> live</a> | |
| 51 | ${part("live", now.live)} | |
| 50 | 52 | </p> |
| 51 | 53 | </section>`; |
| 52 | 54 | } |
test/forge-looks.test.ts +1
| @@ -51,6 +51,7 @@ const NOT_A_PAGE: Record<string, string> = { | ||
| 51 | 51 | "/apple-touch-icon.png": "an icon, not a page", |
| 52 | 52 | "/apple-touch-icon-precomposed.png": "an icon, not a page", |
| 53 | 53 | "/events": "a live event stream that never ends", |
| 54 | "/migrate": "an import form, nothing to picture until a job runs; the look slot went to /repos", | |
| 54 | 55 | }; |
| 55 | 56 | |
| 56 | 57 | let dir: string; |
test/home-living.test.ts +1 −1
| @@ -117,7 +117,7 @@ describe("home", () => { | ||
| 117 | 117 | const page = await home(rootCookie); |
| 118 | 118 | expect(page).toContain('id="now-line"'); |
| 119 | 119 | for (const word of ["check", "review", "land"]) expect(page).toContain(`</strong> ${word}</a>`); |
| 120 | expect(page).toMatch(/href="\/releases"><strong>\d+<\/strong> live<\/a>/); | |
| 120 | expect(page).toMatch(/href="\/now\/live"><strong>\d+<\/strong> live<\/a>/); | |
| 121 | 121 | }); |
| 122 | 122 | |
| 123 | 123 | test("the forge-wide pipeline counts never reach a viewer who is not a site admin", async () => { |
test/now-links.test.ts added +197
| @@ -0,0 +1,197 @@ | ||
| 1 | /** | |
| 2 | * Every link in the account menu and home's Now line lands on a page that is | |
| 3 | * its label, and each word of the Now line opens exactly the rows it counts (#8743). | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createIssue } from "../src/core/issues.ts"; | |
| 12 | import { createSession } from "../src/core/sessions.ts"; | |
| 13 | import { createUser, type User } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { NOW_LISTED, NOW_STAGES, NOW_WORDS, readNowRows } from "../src/flow/now_rows.ts"; | |
| 16 | import { pipelineNow } from "../src/web/studio/living.ts"; | |
| 17 | import { getUserByName } from "../src/core/users.ts"; | |
| 18 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 19 | import { nowScreen, entry as nowEntry } from "../src/tui/screens/nowlist.ts"; | |
| 20 | import { repoListScreen, entry as reposEntry } from "../src/tui/screens/repolist.ts"; | |
| 21 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 22 | import { settleApp } from "./helpers/tui.ts"; | |
| 23 | import { promote } from "./helpers/members.ts"; | |
| 24 | ||
| 25 | let dataDir: string; | |
| 26 | let app: App; | |
| 27 | let servers: Servers; | |
| 28 | let rootCookie: string; | |
| 29 | let aliceCookie: string; | |
| 30 | let alice: User; | |
| 31 | ||
| 32 | const get = (path: string, cookie: string) => fetch(`${servers.url}${path}`, { headers: { cookie }, redirect: "manual" }); | |
| 33 | const hrefs = (html: string, from: string) => [...html.matchAll(/<a [^>]*href="([^"]+)"[^>]*>/g)].map((m) => m[1]!).filter((h) => h.startsWith(from)); | |
| 34 | ||
| 35 | beforeAll(async () => { | |
| 36 | dataDir = await mkdtemp(join(tmpdir(), "forge-now-links-")); | |
| 37 | app = createApp({ dataDir, port: 0 }); | |
| 38 | servers = await serve(app); | |
| 39 | const mk = async (username: string, first = false) => { | |
| 40 | const r = await createUser(app.db, { origin: "seed", trusted: true, username, allowReserved: true, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first }); | |
| 41 | if (!r.ok) throw new Error(r.error); | |
| 42 | promote(app.db, r.value); | |
| 43 | return r.value; | |
| 44 | }; | |
| 45 | const root = await mk("root", true); | |
| 46 | rootCookie = `forge_session=${createSession(app.db, root.id).token}`; | |
| 47 | alice = await mk("alice"); | |
| 48 | aliceCookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 49 | const repo = await createRepo(app, alice, { name: "notes", visibility: "public" }); | |
| 50 | if (!repo.ok) throw new Error("repo"); | |
| 51 | const pull = (title: string) => { | |
| 52 | const made = createIssue(app.db, { repoId: repo.value.id, authorId: alice.id, title, kind: "pull" }); | |
| 53 | if (!made.ok) throw new Error(made.error); | |
| 54 | app.db | |
| 55 | .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?, 'main', ?, 'b', 'h')") | |
| 56 | .run(made.value.id, `h-${title}`); | |
| 57 | return made.value; | |
| 58 | }; | |
| 59 | const run = (kind: string, state: string, issueId: string | null, extra: { reason?: string; env?: string; serving?: boolean } = {}) => | |
| 60 | app.db | |
| 61 | .query("INSERT INTO run (id, repo_id, kind, issue_id, env, state, reason, serving_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") | |
| 62 | .run(crypto.randomUUID(), repo.value.id, kind, issueId, extra.env ?? "", state, extra.reason ?? "", extra.serving ? new Date().toISOString() : null); | |
| 63 | const a = pull("Checked pull"); | |
| 64 | const b = pull("Reviewed pull"); | |
| 65 | const c = pull("Approved pull"); | |
| 66 | run("check", "queued", a.id); | |
| 67 | run("check", "running", a.id); | |
| 68 | run("agent", "running", b.id, { reason: "review" }); | |
| 69 | run("deploy", "ok", null, { env: "production", serving: true }); | |
| 70 | app.db.query("INSERT INTO review (id, issue_id, author_id, state) VALUES (?, ?, ?, 'approved')").run(crypto.randomUUID(), c.id, alice.id); | |
| 71 | }); | |
| 72 | ||
| 73 | afterAll(async () => { | |
| 74 | servers.stop(); | |
| 75 | app.close(); | |
| 76 | await rm(dataDir, { recursive: true, force: true }); | |
| 77 | }); | |
| 78 | ||
| 79 | test("each word of the Now line opens a page titled for it, listing as many rows as the number on home", async () => { | |
| 80 | const home = await (await get("/", rootCookie)).text(); | |
| 81 | const now = pipelineNow(app.db, Date.now() + 10_000); | |
| 82 | const line = home.slice(home.indexOf('id="now-line"')); | |
| 83 | for (const stage of NOW_STAGES) { | |
| 84 | const link = line.match(new RegExp(`<a href="([^"]+)"><strong>(\\d+)</strong> ${stage}</a>`)); | |
| 85 | expect(link, stage).not.toBeNull(); | |
| 86 | expect(link![1]).toBe(`/now/${stage}`); | |
| 87 | const res = await get(link![1]!, rootCookie); | |
| 88 | expect(res.status).toBe(200); | |
| 89 | const page = await res.text(); | |
| 90 | expect(page).toContain(`<h1>${NOW_WORDS[stage]}</h1>`); | |
| 91 | const counted = Number(link![2]); | |
| 92 | expect(counted).toBe(now[stage]); | |
| 93 | expect(counted).toBeGreaterThan(0); | |
| 94 | expect(readNowRows(app.db, stage).total).toBe(counted); | |
| 95 | expect(readNowRows(app.db, stage).rows.length).toBe(counted); | |
| 96 | expect(page).toContain(`<strong>${counted}</strong>`); | |
| 97 | expect(hrefs(page, "/alice/notes").length).toBe(counted); | |
| 98 | } | |
| 99 | }); | |
| 100 | ||
| 101 | test("a person who is not a site admin gets no Now pages, and an unknown stage is not found", async () => { | |
| 102 | expect((await get("/now/check", aliceCookie)).status).toBe(404); | |
| 103 | expect((await get("/now/nope", rootCookie)).status).toBe(404); | |
| 104 | }); | |
| 105 | ||
| 106 | /** What each account-menu label's page calls itself (its <title>, before " · forge"). A label with no entry fails the test. */ | |
| 107 | const MENU_TITLES: Record<string, string> = { | |
| 108 | "Your profile": "root", | |
| 109 | Repositories: "Your repositories", | |
| 110 | Apps: "Apps", | |
| 111 | Notes: "Notes", | |
| 112 | Talk: "Talk", | |
| 113 | Explore: "Explore", | |
| 114 | Map: "Map", | |
| 115 | "New app": "New app", | |
| 116 | "New repository": "New repository", | |
| 117 | Migrate: "Migrate", | |
| 118 | "New forge": "New forge", | |
| 119 | Settings: "Settings", | |
| 120 | "Access tokens": "Personal access tokens", | |
| 121 | "SSH keys": "SSH keys", | |
| 122 | Origins: "Origins", | |
| 123 | Crew: "Crew", | |
| 124 | Boards: "Boards", | |
| 125 | "System memory": "System", | |
| 126 | }; | |
| 127 | ||
| 128 | test("every link in the account menu lands on a page that is its label", async () => { | |
| 129 | const home = await (await get("/", rootCookie)).text(); | |
| 130 | const menu = home.slice(home.indexOf("Signed in as root"), home.indexOf('class="menu-sep"', home.indexOf("Boards"))); | |
| 131 | const items = [...menu.matchAll(/href="([^"]+)"[^>]*>\s*<svg.*?<\/svg><span class="grow">([^<]+)<\/span/gs)].map((m) => ({ href: m[1]!, label: m[2]! })); | |
| 132 | expect(items.find((i) => i.label === "Repositories")?.href).toBe("/repos"); | |
| 133 | expect(items.length).toBeGreaterThan(10); | |
| 134 | for (const item of items) { | |
| 135 | const want = MENU_TITLES[item.label]; | |
| 136 | expect(want, `no expected title for the menu label "${item.label}"`).toBeDefined(); | |
| 137 | const res = await get(item.href, rootCookie); | |
| 138 | expect(res.status, item.href).toBe(200); | |
| 139 | const title = (await res.text()).match(/<title>(.*?) · forge<\/title>/s)?.[1] ?? ""; | |
| 140 | expect(title, item.href).toBe(want!); | |
| 141 | } | |
| 142 | }); | |
| 143 | ||
| 144 | test("no word of the Now line lands on the boards or the releases", async () => { | |
| 145 | const home = await (await get("/", rootCookie)).text(); | |
| 146 | const line = home.slice(home.indexOf('id="now-line"')); | |
| 147 | const links = [...line.slice(0, line.indexOf("</p>")).matchAll(/href="([^"]+)"/g)].map((m) => m[1]); | |
| 148 | expect(links).toEqual(NOW_STAGES.map((s) => `/now/${s}`)); | |
| 149 | }); | |
| 150 | ||
| 151 | test("/repos lists the repositories you may write, and asks a visitor to sign in", async () => { | |
| 152 | const page = await (await get("/repos", aliceCookie)).text(); | |
| 153 | expect(page).toContain("alice/notes"); | |
| 154 | const out = await get("/repos", ""); | |
| 155 | expect(out.status).toBe(303); | |
| 156 | expect(out.headers.get("location") ?? "").toContain("/sign"); | |
| 157 | }); | |
| 158 | ||
| 159 | async function screenText(user: User, make: (f: never, ctx: never) => unknown): Promise<string> { | |
| 160 | const { app: tui, session } = createForgeApp( | |
| 161 | app, | |
| 162 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, | |
| 163 | { terminal: headlessTerminal(200, 40), exitHooks: false }, | |
| 164 | ); | |
| 165 | tui.start(); | |
| 166 | tui.ctx.push(make(session as never, tui.ctx as never) as never); | |
| 167 | await settleApp(tui); | |
| 168 | const text = tui.screen.toText(); | |
| 169 | tui.stop?.(); | |
| 170 | return text; | |
| 171 | } | |
| 172 | ||
| 173 | test("the terminal has both doors: Now shows home's numbers to a site admin only, Repositories lists the same repositories", async () => { | |
| 174 | const rootUser = getUserByName(app.db, "root")!; | |
| 175 | const now = pipelineNow(app.db, Date.now() + 20_000); | |
| 176 | const text = await screenText(rootUser, (f, ctx) => nowScreen(f, ctx)); | |
| 177 | expect(text).toContain(`${readNowRows(app.db, "check").total} · `); | |
| 178 | expect(text).toContain("[Check]"); | |
| 179 | expect(now.check).toBeGreaterThan(0); | |
| 180 | expect(await screenText(alice, (f, ctx) => nowScreen(f, ctx))).toContain("Now is a site admin's."); | |
| 181 | expect(nowEntry.offered({ user: alice } as never)).toBe(false); | |
| 182 | expect(nowEntry.offered({ user: rootUser } as never)).toBe(true); | |
| 183 | expect(await screenText(alice, (f, ctx) => repoListScreen(f, ctx))).toContain("alice/notes"); | |
| 184 | expect(reposEntry.offered({ user: undefined } as never)).toBe(false); | |
| 185 | }); | |
| 186 | ||
| 187 | test("a queue longer than the cap lists the newest and says so, while the number stays the true count", async () => { | |
| 188 | const repo = app.db.query<{ id: string }, []>("SELECT id FROM repo LIMIT 1").get()!; | |
| 189 | for (let i = 0; i < NOW_LISTED + 5; i++) | |
| 190 | app.db.query("INSERT INTO run (id, repo_id, kind, state) VALUES (?, ?, 'check', 'queued')").run(crypto.randomUUID(), repo.id); | |
| 191 | const { total, rows } = readNowRows(app.db, "check"); | |
| 192 | expect(total).toBeGreaterThan(NOW_LISTED); | |
| 193 | expect(rows.length).toBe(NOW_LISTED); | |
| 194 | const page = await (await get("/now/check", rootCookie)).text(); | |
| 195 | expect(page).toContain(`<strong>${total}</strong>`); | |
| 196 | expect(page).toContain(`Showing the newest ${NOW_LISTED}.`); | |
| 197 | }); |
test/palette-roster.test.ts +4
| @@ -48,6 +48,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 48 | 48 | ["home", "Home", "your repositories"], |
| 49 | 49 | ["explore", "Explore", "on the web"], |
| 50 | 50 | ["profile", "Open a profile", "username or org"], |
| 51 | ["repos", "Repositories", "the repositories you may write"], | |
| 51 | 52 | ["repo", "Open a repository", "owner/name"], |
| 52 | 53 | ["issues", "Issues in a repository", "owner/name"], |
| 53 | 54 | ["held", "Held by the Later gate", "owner/name"], |
| @@ -75,6 +76,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 75 | 76 | ["sentinel", "Sentinel", "the forge's open faults"], |
| 76 | 77 | ["boards", "Boards", "the forge's boards and their panels"], |
| 77 | 78 | ["home-pins", "Pinned panels", "the panels pinned to home: reorder and unpin"], |
| 79 | ["now", "Now", "what check, review, land and live count, with the rows"], | |
| 78 | 80 | ["origin-apps", "Origin apps", "your OAuth apps for connecting origins; a adds, d removes"], |
| 79 | 81 | ["system-memory", "System memory", "RSS beside the JS heap, hourly for 24 h"], |
| 80 | 82 | ["flow", "Flow", "the pipeline's bottleneck and throughput"], |
| @@ -134,6 +136,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 134 | 136 | ["home", "Home", "your repositories"], |
| 135 | 137 | ["explore", "Explore", "on the web"], |
| 136 | 138 | ["profile", "Open a profile", "username or org"], |
| 139 | ["repos", "Repositories", "the repositories you may write"], | |
| 137 | 140 | ["repo", "Open a repository", "owner/name"], |
| 138 | 141 | ["issues", "Issues in a repository", "owner/name"], |
| 139 | 142 | ["held", "Held by the Later gate", "owner/name"], |
| @@ -192,6 +195,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 192 | 195 | ["home", "Home", "your repositories"], |
| 193 | 196 | ["explore", "Explore", "on the web"], |
| 194 | 197 | ["profile", "Open a profile", "username or org"], |
| 198 | ["repos", "Repositories", "the repositories you may write"], | |
| 195 | 199 | ["repo", "Open a repository", "owner/name"], |
| 196 | 200 | ["issues", "Issues in a repository", "owner/name"], |
| 197 | 201 | ["held", "Held by the Later gate", "owner/name"], |
test/security-floor-routes.test.ts +2
| @@ -42,6 +42,7 @@ const FLEET = "needs a fleet operation, started by an operator action"; | ||
| 42 | 42 | const SECRET = "needs a secret scope an operator has set"; |
| 43 | 43 | const GO = "needs a short code minted by a share; the seed shares nothing"; |
| 44 | 44 | const ARCH = "needs an archived-home operation id; the seed archives nothing"; |
| 45 | const NOW = "the stage is one of check, review, land or live; the seed fills no word with a name it cannot guess"; | |
| 45 | 46 | const GATE = "needs a kept gate log, written only when a full gate runs on land"; |
| 46 | 47 | const HOOK = "needs a webhook, which the seed does not register"; |
| 47 | 48 | |
| @@ -84,6 +85,7 @@ const UNSEEDED: Record<string, string> = { | ||
| 84 | 85 | "/go/:code": GO, |
| 85 | 86 | "/home/archived/:op": ARCH, |
| 86 | 87 | "/migrate/jobs/:job": MIGRATE, |
| 88 | "/now/:stage": NOW, | |
| 87 | 89 | "/origins/:id/r/:owner/:name": ORIGIN, |
| 88 | 90 | "/origins/:id/r/:owner/:name/blob/:ref/*path": ORIGIN, |
| 89 | 91 | "/origins/:id/r/:owner/:name/branches": ORIGIN, |
new above
offline