Public: anyone can read this; the crew can change it.
Features row shows provenance: repository, upstream if a fork, who last changed the declaration #9811
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9770-features-row-shows-provenance-repository → main 5 commits fork 7 threads · 7 unresolved
docs/design/areas/features.md +1 −1
| @@ -3,4 +3,4 @@ | ||
| 3 | 3 | `src/web/pages/features.ts` adds no component class. The list and a feature's page are a heading over rows, as the |
| 4 | 4 | home page is: a `<section class="settings-block">` with an `<h2>` (a `.bar` when a badge sits beside it) over a |
| 5 | 5 | `.setting-list` of `row()`s. No card around the list or a feature's form, and no icon in a row's lead. Classes used are |
| 6 | named in `docs/design/areas/settings.md`. | |
| 6 | named in `docs/design/areas/settings.md`. Where an app comes from (fork of, last changed by) is the row's `facts` (`.row-facts`, in `docs/design/areas/lists.md`) on the list, and `.status-rows` `dl` rows on a feature's page. |
docs/design/areas/lists.md +4
| @@ -12,6 +12,10 @@ trails a palette row. | ||
| 12 | 12 | end, ellipsised past 45% of the row. Written by `src/web/client.ts` (the file finder's rows and the command |
| 13 | 13 | palette's `row()`), never in server markup. Styled in `src/web/style/components.ts` (`.palette-item .cat`) and |
| 14 | 14 | `src/web/style/content.ts` (`.finder .palette-item .cat`). |
| 15 | - `.row-facts` — the `dl` of a §5 row's facts (`row({ facts })` in `src/web/ui.ts`): label and value pairs under the | |
| 16 | meta, wrapping as a phone narrows, in the meta's size and colour. The row's main column is then a `<div>`, so the | |
| 17 | `dl` is valid. First used by Settings › Features for where an app comes from (#9770). Styled in | |
| 18 | `src/web/style/components.ts` (list rows). | |
| 15 | 19 | - `.flow-row` — a §5 `.row` link on the flow panel (`flow-panel.ts`: idle pulls and the "and K more" row) with a 44px |
| 16 | 20 | minimum height, so it is a touch target on a 360px phone. Styled in `src/web/style/components.ts` (list rows). |
| 17 | 21 |
docs/features/absorb.md +9
| @@ -107,3 +107,12 @@ their own "Features from apps" heading. The declared `summary` is the entry's `t | ||
| 107 | 107 | Open the palette and choose "Menu entries to review" (site admins who are people only). It lists the |
| 108 | 108 | absorbed apps whose declaration changed; `j`/`k` move, `a` accepts the selected row. The accept posts the |
| 109 | 109 | name and page the screen showed, so one that changed since is refused ("review it again"), as on the page. |
| 110 | ||
| 111 | ## Where an app comes from | |
| 112 | ||
| 113 | Beside each app on Settings › Features, on its own page, and in the terminal's "Menu entries to review", two facts may show (the repository is already in the title): | |
| 114 | ||
| 115 | - **Fork of**: the repository the app's repository was forked from. It shows only to someone who may read that repository, so a fork of a private repository does not name it to anyone else. | |
| 116 | - **Last changed by**: the author and date of the latest commit on the default branch that touched `forge.json`. The author is the name the commit carries, not a verified identity: anyone who can push can write any name. | |
| 117 | ||
| 118 | Nothing shows when there is nothing to say. The last change is read from git once per tip and kept. |
src/actions/features.ts +91 −7
| @@ -34,6 +34,7 @@ import { | ||
| 34 | 34 | import { canRead, getRepoById, type Repo } from "../core/repos.ts"; |
| 35 | 35 | import type { User } from "../core/users.ts"; |
| 36 | 36 | import { resolveOid } from "../git/merge.ts"; |
| 37 | import { commitLog } from "../git/read.ts"; | |
| 37 | 38 | import { repoDir } from "../git/repos.ts"; |
| 38 | 39 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 39 | 40 | |
| @@ -80,6 +81,75 @@ export interface FeatureView { | ||
| 80 | 81 | /** Why the declaration is not usable, when it is not; `settings` is then empty. */ |
| 81 | 82 | readonly problem: string | null; |
| 82 | 83 | readonly settings: readonly SettingView[]; |
| 84 | /** Where the app comes from: the upstream only when the reader may read it. */ | |
| 85 | readonly provenance: Provenance; | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * Who stands behind an app's declaration: its repository, the repository it | |
| 90 | * was forked from, and who last changed forge.json. | |
| 91 | */ | |
| 92 | export interface Provenance { | |
| 93 | readonly repo: string; | |
| 94 | readonly upstream: string | null; | |
| 95 | readonly changedBy: string | null; | |
| 96 | readonly changedAt: string | null; | |
| 97 | } | |
| 98 | ||
| 99 | /** The last change by tip. Bounded on its own, apart from the declarations': cleared whole when full. */ | |
| 100 | const changed = new Map<string, { by: string; at: string } | null>(); | |
| 101 | const CHANGED_MAX = 500; | |
| 102 | ||
| 103 | /** | |
| 104 | * The facts a door shows for a provenance, label and value, in the order | |
| 105 | * shown. The repository is not one: the row's title has it. | |
| 106 | */ | |
outdated
Principle 8: the value 'X (commit author) on 2026-11-01' is clumsy. Use plain 'X, 2026-11-01' and keep the commit-author caveat in docs/features/absorb.md. |
||
| 107 | export function provenanceFacts(p: Provenance): { label: string; value: string }[] { | |
| 108 | const facts: { label: string; value: string }[] = []; | |
| 109 | if (p.upstream !== null) facts.push({ label: "Fork of", value: p.upstream }); | |
| 110 | if (p.changedBy !== null) { | |
| 111 | const on = p.changedAt === null ? "" : `, ${p.changedAt.slice(0, 10)}`; | |
| 112 | facts.push({ label: "Last changed by", value: `${p.changedBy}${on}` }); | |
outdated
High: |
||
| 113 | } | |
| 114 | return facts; | |
| 115 | } | |
| 116 | ||
| 117 | /** | |
| 118 | * The latest commit at or under `tip` that touched forge.json: its author and | |
| 119 | * date. A commit never changes, so it is read from git once per tip and kept; | |
| 120 | * `read` is git's log, or a stand-in that counts. | |
| 121 | */ | |
| 122 | export async function lastChange( | |
| 123 | dir: string, | |
| 124 | tip: string, | |
| 125 | read: typeof commitLog = commitLog, | |
| 126 | ): Promise<{ by: string; at: string } | null> { | |
| 127 | if (changed.has(tip)) return changed.get(tip) ?? null; | |
| 128 | const log = await read(dir, tip, { limit: 1, path: "forge.json" }); | |
| 129 | const c = log.ok ? log.value[0] : undefined; | |
| 130 | const last = c === undefined ? null : { by: c.author, at: c.date }; | |
| 131 | if (changed.size >= CHANGED_MAX) changed.clear(); | |
| 132 | changed.set(tip, last); | |
| 133 | return last; | |
| 134 | } | |
| 135 | ||
| 136 | /** | |
| 137 | * The repository's provenance as the actor may see it: the upstream is named | |
| 138 | * only to one who may read it, so a fork does not leak a private parent's name. | |
| 139 | * The last change is the default branch's latest commit touching forge.json, kept by tip. | |
| 140 | */ | |
| 141 | async function provenanceOf(app: App, actor: User | undefined, repo: Repo): Promise<Provenance> { | |
| 142 | const found = repo.forkParentId === null ? undefined : getRepoById(app.db, repo.forkParentId); | |
| 143 | const parent = found !== undefined && canRead(app.db, found, actor) ? found : undefined; | |
| 144 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 145 | const tip = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`); | |
| 146 | const last = tip === undefined ? null : await lastChange(dir, tip); | |
| 147 | return { | |
| 148 | repo: `${repo.ownerName}/${repo.name}`, | |
| 149 | upstream: parent === undefined ? null : `${parent.ownerName}/${parent.name}`, | |
| 150 | changedBy: last?.by ?? null, | |
| 151 | changedAt: last?.at ?? null, | |
| 152 | }; | |
| 83 | 153 | } |
| 84 | 154 | |
| 85 | 155 | const siteAdmin = (actor: User | undefined): actor is User => |
| @@ -132,7 +202,14 @@ export async function declaresFeature(app: App, actor: User | undefined, repo: R | ||
| 132 | 202 | return read.ok && read.value !== undefined; |
| 133 | 203 | } |
| 134 | 204 | |
| 135 | function viewOf(record: AppRecord, repo: Repo, app: App, spec: FeatureSpec | undefined, problem: string | null): FeatureView { | |
| 205 | function viewOf( | |
| 206 | record: AppRecord, | |
| 207 | repo: Repo, | |
| 208 | app: App, | |
| 209 | spec: FeatureSpec | undefined, | |
| 210 | problem: string | null, | |
| 211 | provenance: Provenance, | |
| 212 | ): FeatureView { | |
| 136 | 213 | const kept = featureSettings(app.db, record.id); |
| 137 | 214 | const absorbed = getAbsorbed(app.db, record.id); |
| 138 | 215 | const env = spec === undefined ? {} : featureEnv(spec, kept); |
| @@ -152,6 +229,7 @@ function viewOf(record: AppRecord, repo: Repo, app: App, spec: FeatureSpec | und | ||
| 152 | 229 | absorbedBy: absorbed?.absorbedBy ?? null, |
| 153 | 230 | absorbedAt: absorbed?.absorbedAt ?? null, |
| 154 | 231 | problem, |
| 232 | provenance, | |
| 155 | 233 | settings: (spec?.settings ?? []).map((s) => { |
| 156 | 234 | const name = settingEnvName(s.key); |
| 157 | 235 | return { |
| @@ -193,7 +271,9 @@ export async function listFeatures(app: App, actor: User | undefined): Promise<R | ||
| 193 | 271 | if (repo === undefined) continue; |
| 194 | 272 | const declared = await declaredFeature(app, repo); |
| 195 | 273 | if (declared.ok && declared.value === undefined && getAbsorbed(app.db, record.id) === undefined) continue; |
| 196 | out.push(viewOf(record, repo, app, declared.ok ? declared.value : undefined, declared.ok ? null : declared.error)); | |
| 274 | const spec = declared.ok ? declared.value : undefined; | |
| 275 | const provenance = await provenanceOf(app, actor, repo); | |
| 276 | out.push(viewOf(record, repo, app, spec, declared.ok ? null : declared.error, provenance)); | |
| 197 | 277 | } |
| 198 | 278 | return ok(out.sort((a, b) => Number(b.absorbed) - Number(a.absorbed) || a.name.localeCompare(b.name))); |
| 199 | 279 | } |
| @@ -232,7 +312,9 @@ export async function getFeature(app: App, actor: User | undefined, slug: string | ||
| 232 | 312 | const declared = await declaredFeature(app, found.repo); |
| 233 | 313 | if (declared.ok && declared.value === undefined && getAbsorbed(app.db, found.record.id) === undefined) |
| 234 | 314 | return err("not_a_feature"); |
| 235 | return ok(viewOf(found.record, found.repo, app, declared.ok ? declared.value : undefined, declared.ok ? null : declared.error)); | |
| 315 | const spec = declared.ok ? declared.value : undefined; | |
| 316 | const provenance = await provenanceOf(app, actor, found.repo); | |
| 317 | return ok(viewOf(found.record, found.repo, app, spec, declared.ok ? null : declared.error, provenance)); | |
| 236 | 318 | } |
| 237 | 319 | |
| 238 | 320 | async function redeployAll(app: App, record: AppRecord, repo: Repo): Promise<void> { |
| @@ -250,7 +332,7 @@ export async function absorbApp(app: App, actor: User | undefined, slug: string) | ||
| 250 | 332 | app.bus.append({ kind: "app.absorbed", repoId: found.repo.id, actorId: actor.id, payload: { slug, name: declared.value.name } }); |
| 251 | 333 | await redeployAll(app, found.record, found.repo); |
| 252 | 334 | } |
| 253 | return ok(viewOf(found.record, found.repo, app, declared.value, null)); | |
| 335 | return ok(viewOf(found.record, found.repo, app, declared.value, null, await provenanceOf(app, actor, found.repo))); | |
| 254 | 336 | } |
| 255 | 337 | |
| 256 | 338 | /** |
| @@ -283,7 +365,7 @@ export async function acceptDeclaration( | ||
| 283 | 365 | payload: { slug, name, page, was: { name: before.name, page: before.page } }, |
| 284 | 366 | }); |
| 285 | 367 | } |
| 286 | return ok(viewOf(found.record, found.repo, app, declared.value, null)); | |
| 368 | return ok(viewOf(found.record, found.repo, app, declared.value, null, await provenanceOf(app, actor, found.repo))); | |
| 287 | 369 | } |
| 288 | 370 | |
| 289 | 371 | /** Put an absorbed app back as an ordinary app. Its settings are kept for the next absorb. */ |
| @@ -295,7 +377,9 @@ export async function unabsorbApp(app: App, actor: User | undefined, slug: strin | ||
| 295 | 377 | app.bus.append({ kind: "app.unabsorbed", repoId: found.repo.id, actorId: actor.id, payload: { slug } }); |
| 296 | 378 | await redeployAll(app, found.record, found.repo); |
| 297 | 379 | const declared = await declaredFeature(app, found.repo); |
| 298 | return ok(viewOf(found.record, found.repo, app, declared.ok ? declared.value : undefined, declared.ok ? null : declared.error)); | |
| 380 | const spec = declared.ok ? declared.value : undefined; | |
| 381 | const provenance = await provenanceOf(app, actor, found.repo); | |
| 382 | return ok(viewOf(found.record, found.repo, app, spec, declared.ok ? null : declared.error, provenance)); | |
| 299 | 383 | } |
| 300 | 384 | |
| 301 | 385 | /** |
| @@ -336,5 +420,5 @@ export async function changeFeatureSettings( | ||
| 336 | 420 | }); |
| 337 | 421 | await redeployAll(app, found.record, found.repo); |
| 338 | 422 | } |
| 339 | return ok(viewOf(found.record, found.repo, app, declared.value, null)); | |
| 423 | return ok(viewOf(found.record, found.repo, app, declared.value, null, await provenanceOf(app, actor, found.repo))); | |
| 340 | 424 | } |
src/tui/screens/features.ts +2 −2
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | * refused. Offered to a site admin who is a person, as the page is. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { acceptDeclaration, listFeatures, type FeatureView } from "../../actions/features.ts"; | |
| 9 | import { acceptDeclaration, listFeatures, provenanceFacts, type FeatureView } from "../../actions/features.ts"; | |
| 10 | 10 | import type { Ctx, Screen } from "../app/app.ts"; |
| 11 | 11 | import { binding } from "../app/keys.ts"; |
| 12 | 12 | import { style } from "../term/style.ts"; |
| @@ -50,7 +50,7 @@ export function featuresScreen(f: Session, ctx: Ctx): FeaturesScreen { | ||
| 50 | 50 | s.text(head!.x, head!.y, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 51 | 51 | const list: ListRow[] = rows.map((r) => ({ |
| 52 | 52 | title: `${r.name} (${r.page})`, |
| 53 | secondary: `${r.repo} declares ${r.pending!.name} (${r.pending!.page})`, | |
| 53 | secondary: [`${r.repo} declares ${r.pending!.name} (${r.pending!.page})`, ...provenanceFacts(r.provenance).map((x) => `${x.label} ${x.value}`)].join(" · "), | |
| 54 | 54 | meta: "", |
| 55 | 55 | })); |
| 56 | 56 | drawList(s, body!, c, state, list); |
src/web/pages/features.ts +10
| @@ -15,6 +15,7 @@ import { | ||
| 15 | 15 | unabsorbApp, |
| 16 | 16 | type FeatureView, |
| 17 | 17 | type SettingView, |
| 18 | provenanceFacts, | |
| 18 | 19 | } from "../../actions/features.ts"; |
| 19 | 20 | import { DAYS } from "../../apps/feature.ts"; |
| 20 | 21 | import type { Ctx } from "../../http/context.ts"; |
| @@ -51,12 +52,20 @@ export function switchNote(f: Pick<FeatureView, "name" | "absorbed">): string { | ||
| 51 | 52 | return f.absorbed ? `Un-absorbing takes ${f.name} off every menu; its settings are kept.` : `Absorbing restarts ${f.name} and adds it to the menu.`; |
| 52 | 53 | } |
| 53 | 54 | |
| 55 | /** Where the app comes from, on a feature's own page: `dl` rows, the upstream when a fork and who last changed it. */ | |
outdated
Principles 1, 7 and 8, and a new unregistered class. See points 1, 2 and 3 in the review body. The repo is already in the title, and a fact belongs in a |
||
| 56 | export function provenanceRows(p: FeatureView["provenance"]) { | |
| 57 | const facts = provenanceFacts(p); | |
| 58 | if (facts.length === 0) return null; | |
| 59 | return html`<dl class="status-rows">${facts.map((f) => html`<div class="status-row"><dt>${f.label}</dt><dd>${f.value}</dd></div>`)}</dl>`; | |
| 60 | } | |
| 61 | ||
| 54 | 62 | function featureRow(f: FeatureView) { |
| 55 | 63 | const base = `/settings/features/${encodeURIComponent(f.slug)}`; |
| 56 | 64 | return row({ |
| 57 | 65 | title: html`${f.name} <span class="muted small mono">${f.repo}</span> |
| 58 | 66 | ${f.absorbed ? badge("absorbed", { tone: "success" }) : null} |
| 59 | 67 | ${f.pending !== null ? badge("declaration changed: review", { tone: "attn" }) : null}`, |
| 68 | facts: provenanceFacts(f.provenance), | |
outdated
A block-level |
||
| 60 | 69 | meta: html`${f.pending !== null |
| 61 | 70 | ? html`Menu: <strong>${f.name}</strong> at <span class="mono">${f.page}</span> → app: <strong>${f.pending.name}</strong> at |
| 62 | 71 | <span class="mono">${f.pending.page}</span>${f.pending.summary !== null |
| @@ -183,6 +192,7 @@ function featureView(ctx: Ctx, f: FeatureView, refused?: Refused): Response { | ||
| 183 | 192 | <h2>${f.name}</h2> |
| 184 | 193 | ${f.absorbed ? badge("absorbed", { tone: "success" }) : badge("not absorbed", { variant: "outline" })} |
| 185 | 194 | </div> |
| 195 | ${provenanceRows(f.provenance)} | |
| 186 | 196 | ${f.settings.length === 0 |
| 187 | 197 | ? html`<p class="muted small">${f.problem ?? "This feature declares no settings."}</p>` |
| 188 | 198 | : html`<form method="post" action="${base}"> |
src/web/style/components.ts +4
| @@ -466,6 +466,10 @@ a.row:hover .row-title { color: var(--accent); } | ||
| 466 | 466 | font-variant-numeric: tabular-nums; |
| 467 | 467 | } |
| 468 | 468 | .row-meta .byline { display: inline; } |
| 469 | .row-facts { display: flex; flex-wrap: wrap; gap: 0 16px; margin: 0; font-size: 12.5px; color: var(--muted-fg); line-height: 1.5; } | |
| 470 | .row-facts > div { display: flex; gap: 6px; min-width: 0; } | |
| 471 | .row-facts dt { font-weight: 550; } | |
| 472 | .row-facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } | |
| 469 | 473 | .row-meta .avatar { width: 16px; height: 16px; font-size: var(--fs-min); vertical-align: -0.28em; } |
| 470 | 474 | .row-meta .byline-name { margin-left: 4px; } |
| 471 | 475 | .row-lead { color: var(--faint); display: inline-flex; flex: none; } |
src/web/ui.ts +14 −2
| @@ -946,6 +946,9 @@ export interface RowOptions { | ||
| 946 | 946 | href?: string; |
| 947 | 947 | title: Html | string; |
| 948 | 948 | meta?: Html; |
| 949 | /** Facts about the row, one `dt`/`dd` pair each, in a `dl.row-facts` under the meta. With facts the row's | |
| 950 | * main column is a `<div>`, so the `dl` is valid HTML: not for a `fold` row, whose `<summary>` takes phrasing only. */ | |
| 951 | facts?: readonly { label: string; value: Html | string }[]; | |
| 949 | 952 | /** Draw `.row-meta` even when `meta` is empty, for a script to write into. */ |
| 950 | 953 | metaSlot?: boolean; |
| 951 | 954 | lead?: Html; |
| @@ -975,17 +978,26 @@ export interface RowOptions { | ||
| 975 | 978 | * controls, `<summary>` when it folds — the hover affordance follows |
| 976 | 979 | * automatically. */ |
| 977 | 980 | export function row(o: RowOptions): Html { |
| 981 | const facts = o.facts !== undefined && o.facts.length > 0 && o.fold === undefined; | |
| 978 | 982 | const inner = html`${o.lead |
| 979 | 983 | ? html`<span class="row-lead">${o.lead}</span>` |
| 980 | 984 | : null} |
| 981 | <span class="row-main"> | |
| 985 | ${facts ? html`<div class="row-main"> | |
| 982 | 986 | <span |
| 983 | 987 | class="row-title" |
| 984 | 988 | ${o.titleAttr ? html`title="${o.titleAttr}"` : null} |
| 985 | 989 | >${o.title}</span |
| 986 | 990 | > |
Non-blocking: this branch copies the title and meta markup from the else branch. Share the content and vary only the wrapper element. |
||
| 987 | 991 | ${o.meta || o.metaSlot ? html`<span class="row-meta">${o.meta}</span>` : null} |
| 988 | </span> | |
| 992 | <dl class="row-facts">${o.facts!.map((f) => html`<div><dt>${f.label}</dt><dd>${f.value}</dd></div>`)}</dl> | |
| 993 | </div>` : html`<span class="row-main"> | |
| 994 | <span | |
| 995 | class="row-title" | |
| 996 | ${o.titleAttr ? html`title="${o.titleAttr}"` : null} | |
| 997 | >${o.title}</span | |
| 998 | > | |
| 999 | ${o.meta || o.metaSlot ? html`<span class="row-meta">${o.meta}</span>` : null} | |
| 1000 | </span>`} | |
| 989 | 1001 | ${o.trail && o.trail.text.trim() !== "" |
| 990 | 1002 | ? html`<span class="row-trail">${o.trail}</span>` |
| 991 | 1003 | : null}`; |
test/features.test.ts +86 −1
| @@ -15,8 +15,12 @@ import { join } from "node:path"; | ||
| 15 | 15 | import { statements } from "./helpers/statements.ts"; |
| 16 | 16 | import { expectWork } from "./helpers/work.ts"; |
| 17 | 17 | import { createApp, type App } from "../src/app.ts"; |
| 18 | import { absorbApp, acceptDeclaration, declaresFeature, absorbedForNav, declaredFeature, changeFeatureSettings, getFeature, unabsorbApp } from "../src/actions/features.ts"; | |
| 18 | import { absorbApp, acceptDeclaration, declaresFeature, absorbedForNav, declaredFeature, changeFeatureSettings, getFeature, lastChange, provenanceFacts, unabsorbApp } from "../src/actions/features.ts"; | |
| 19 | 19 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 20 | import { forkRepo } from "../src/core/repos.ts"; | |
| 21 | import { resolveOid } from "../src/git/merge.ts"; | |
| 22 | import { commitLog } from "../src/git/read.ts"; | |
| 23 | import { repoDir } from "../src/git/repos.ts"; | |
| 20 | 24 | import { checkSetting, featureEnv, parseFeature, settingEnvName } from "../src/apps/feature.ts"; |
| 21 | 25 | import { featureVarsAt } from "../src/apps/feature_env.ts"; |
| 22 | 26 | import { appEnv } from "../src/apps/process.ts"; |
| @@ -27,6 +31,7 @@ import type { Repo } from "../src/core/repos.ts"; | ||
| 27 | 31 | import { createToken } from "../src/core/tokens.ts"; |
| 28 | 32 | import { createUser, type User } from "../src/core/users.ts"; |
| 29 | 33 | import { eventVerb } from "../src/design/activity.ts"; |
| 34 | import { provenanceRows } from "../src/web/pages/features.ts"; | |
| 30 | 35 | import { serve, type Servers } from "../src/http/server.ts"; |
| 31 | 36 | import { quietLogs } from "./helpers/globals.ts"; |
| 32 | 37 | import { mcpTool } from "./helpers/mcp.ts"; |
| @@ -324,6 +329,85 @@ describe("absorbing an app", () => { | ||
| 324 | 329 | expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar"); |
| 325 | 330 | }); |
| 326 | 331 | |
| 332 | test("a row says who last changed the declaration, on the list and on the feature's page", async () => { | |
| 333 | const view = await getFeature(app, ada, "radar"); | |
| 334 | expect(view.ok && view.value.provenance).toMatchObject({ upstream: null, changedBy: "cy" }); | |
| 335 | const detail = await (await web(adaCookie, "/settings/features/radar")).text(); | |
| 336 | expect(detail).toContain("<dt>Last changed by</dt>"); | |
| 337 | const list = await (await web(adaCookie, "/settings/features")).text(); | |
| 338 | expect(list).toMatch(/<dl class="row-facts"><div><dt>Last changed by<\/dt><dd>cy, \d{4}-\d{2}-\d{2}<\/dd><\/div><\/dl>/); | |
outdated
These two negative assertions guard markup that does not exist in this version of the change; they would pass without the feature. Remove them. |
||
| 339 | }); | |
| 340 | ||
| 341 | test("the facts are plain and escaped", () => { | |
| 342 | const facts = provenanceFacts({ repo: "me/r", upstream: "them/r", changedBy: "<b>x</b>", changedAt: "2026-11-01T10:00:00Z" }); | |
| 343 | expect(facts).toEqual([ | |
| 344 | { label: "Fork of", value: "them/r" }, | |
| 345 | { label: "Last changed by", value: "<b>x</b>, 2026-11-01" }, | |
| 346 | ]); | |
| 347 | const rows = provenanceRows({ repo: "me/r", upstream: null, changedBy: "<b>x</b>", changedAt: null })!.toString(); | |
| 348 | expect(rows).toContain("<b>x</b>"); | |
| 349 | expect(provenanceRows({ repo: "me/r", upstream: null, changedBy: null, changedAt: null })).toBeNull(); | |
| 350 | }); | |
| 351 | ||
| 352 | test("the last change is read from git once per tip", async () => { | |
| 353 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 354 | const tip = (await resolveOid(dir, `refs/heads/${repo.defaultBranch}`))!; | |
| 355 | let reads = 0; | |
| 356 | const counting: typeof commitLog = (...args) => { | |
| 357 | reads++; | |
| 358 | return commitLog(...args); | |
| 359 | }; | |
| 360 | const first = await lastChange(dir, tip, counting); | |
| 361 | const again = await lastChange(dir, tip, counting); | |
| 362 | expect(again).toEqual(first); | |
| 363 | expect(reads).toBeLessThanOrEqual(1); | |
| 364 | // A tip never seen: read once, then kept, even when it found nothing. | |
| 365 | const before = reads; | |
| 366 | const fresh = `${"0".repeat(39)}1`; | |
| 367 | expect(await lastChange(dir, fresh, counting)).toBeNull(); | |
| 368 | expect(await lastChange(dir, fresh, counting)).toBeNull(); | |
| 369 | expect(reads - before).toBe(1); | |
| 370 | }); | |
| 371 | ||
| 372 | test("a fork names its upstream only to whoever may read the upstream, on the page and in the terminal", async () => { | |
| 373 | // bob forks cy's private radar: he may read his fork, not its parent. | |
| 374 | const forked = await forkRepo(app.db, app.reposRoot, repo, bob.id, "radar-fork"); | |
| 375 | if (!forked.ok) throw new Error(String(forked.error)); | |
| 376 | const declare = (name: string) => | |
| 377 | commitToBranch(app, bob, forked.value, { | |
| 378 | message: name, | |
| 379 | files: [{ path: "forge.json", content: JSON.stringify({ app: { run: "x" }, feature: { ...FEATURE, name } }) }], | |
| 380 | }); | |
| 381 | await declare("Radar Fork"); | |
| 382 | const row = insertApp(app.db, { repoId: forked.value.id, slug: "radar-fork", access: "private" }); | |
| 383 | if (!row.ok) throw new Error(row.error); | |
| 384 | try { | |
| 385 | const upstream = `${repo.ownerName}/${repo.name}`; | |
| 386 | const seen = await getFeature(app, ada, "radar-fork"); | |
| 387 | expect(seen.ok && seen.value.provenance.upstream).toBe(upstream); | |
| 388 | const hidden = await getFeature(app, bob, "radar-fork"); | |
| 389 | expect(hidden.ok && hidden.value.provenance.upstream).toBeNull(); | |
| 390 | expect(JSON.stringify(hidden)).not.toContain(upstream); | |
| 391 | const list = await (await web(adaCookie, "/settings/features")).text(); | |
| 392 | expect(list).toContain(`<dt>Fork of</dt><dd>${upstream}</dd>`); | |
| 393 | ||
| 394 | expect((await absorbApp(app, ada, "radar-fork")).ok).toBe(true); | |
| 395 | await declare("Radar Fork Later"); | |
| 396 | const { featuresScreen } = await import("../src/tui/screens/features.ts"); | |
| 397 | const { Screen: Surface } = await import("../src/tui/term/screen.ts"); | |
| 398 | const { theme } = await import("../src/tui/ui/theme.ts"); | |
| 399 | const ctx = { theme: theme("dark"), invalidate() {}, push() {}, pop() {}, copy() {}, toast() {} } as never; | |
| 400 | const screen = featuresScreen({ app, db: app.db, user: ada } as never, ctx); | |
| 401 | await screen.busy(); | |
| 402 | const shown = new Surface(200, 20); | |
| 403 | screen.render({ x: 0, y: 0, w: 200, h: 20 }, shown); | |
| 404 | expect(shown.toText()).toContain(`Fork of ${upstream}`); | |
| 405 | } finally { | |
| 406 | await unabsorbApp(app, ada, "radar-fork"); | |
| 407 | deleteApp(app.db, row.value.id); | |
| 408 | } | |
| 409 | }); | |
| 410 | ||
| 327 | 411 | test("a changed summary alone is pending, and accepting stores it for the menu title", async () => { |
| 328 | 412 | const before = absorbedForNav(app, ada)[0]!; |
| 329 | 413 | expect(before.summary).toBe(FEATURE.summary); |
| @@ -378,6 +462,7 @@ describe("absorbing an app", () => { | ||
| 378 | 462 | const shown = new Surface(110, 20); |
| 379 | 463 | (await open(ada)).render({ x: 0, y: 0, w: 110, h: 20 }, shown); |
| 380 | 464 | expect(shown.toText()).toContain("Radar TUI"); |
| 465 | expect(shown.toText()).toContain("Last changed by"); | |
| 381 | 466 | const denied = await open(cy); |
| 382 | 467 | expect(denied.key!({ key: "a" } as never)).toBe(false); |
| 383 | 468 | expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar"); |
Use
changed.has(tip) ? changed.get(tip) : fetch. Do not borrow DECLARED_MAX for a different cache; give it its own bound.