Public: anyone can read this; the crew can change it.
While "flow only" is on, home and the repository header say so in one line #6969
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6394-while-flow-only-is-on-home-and-the-repos → main 2 commits fork
5 files
changed +126
−1
README.md +1 −1
| @@ -367,7 +367,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 367 | 367 | - [](docs/WEBSPEC-PREVIEWS.md) — the website specification run over every preview, report-only. |
| 368 | 368 | - [](docs/CREW.md) — the crew: agents, how a run works, review, live triage, the merge queue, spend, roles and invites, the sandbox, passports, and the direction a vision pull will write. |
| 369 | 369 | - [](docs/SENTINEL.md) — the forge hunting its own faults; [](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`). |
| 370 | - Flow only: a repository's admin (a person, never an agent) sets one switch from Settings → On its own, `PATCH /api/v1/repos/:owner/:name` with `flowOnly`, MCP `repo_settings`, or `w` on the terminal's About card (`I`); turning it on makes the `flow` label. Nothing obeys it yet. | |
| 370 | - Flow only: a repository's admin (a person, never an agent) sets one switch from Settings → On its own, `PATCH /api/v1/repos/:owner/:name` with `flowOnly`, MCP `repo_settings`, or `w` on the terminal's About card (`I`); turning it on makes the `flow` label. While it is on, the repository header and its admin's home say so in one line (the line links to the setting for an admin only; on the home it appears under "Show everything", not the lean apps-first view). Nothing obeys it yet. | |
| 371 | 371 | - [](docs/FLOW.md) — where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h. |
| 372 | 372 | - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [](docs/FLOW.md). |
| 373 | 373 | - Under it, the panel says the crew's build yield over 24 h (runs finished, pushed, landed; `src/flow/build_yield.ts`) and the median hours to first review (`src/flow/review_latency.ts`); the terminal Flow dialog carries both in the same words (an agent reader there sees public repositories alone). `/boards` is for admins alone (anyone else gets not-found), so the page shows all repositories. |
src/web/chrome.ts +2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * `ui.ts` may not. That is the line between the two files. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { flowOnlyLine } from "./flow_only_line.ts"; | |
| 11 | 12 | import { sshCommand } from "../ssh/command.ts"; |
| 12 | 13 | import { mayReadRuns } from "../actions/runs.ts"; |
| 13 | 14 | import { getAppByRepo } from "../core/apps.ts"; |
| @@ -336,6 +337,7 @@ export function scopeHeader( | ||
| 336 | 337 | () => html`<p class="muted small">${facts.description}</p>`, |
| 337 | 338 | )} |
| 338 | 339 | ${repo === undefined ? null : lineageLine(ctx.app.db, repo, parent)} |
| 340 | ${repo === undefined ? null : flowOnlyLine(repo, admin)} | |
| 339 | 341 | ${when( |
| 340 | 342 | scope.upstream !== undefined && upstreamHref(scope) === undefined, |
| 341 | 343 | // The where strip carries "fork of octo/widgets" and links it, which |
src/web/flow_only_line.ts added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | /** | |
| 2 | * The FLOW ONLY switch, said once and quietly (#6394): one line on the | |
| 3 | * repository header and one per repository on its owner's home, each linking | |
| 4 | * to the setting. Nothing at all while the switch is off. | |
| 5 | */ | |
| 6 | import type { Database } from "bun:sqlite"; | |
| 7 | import { canAdmin, listReposWritableTo, type Repo } from "../core/repos.ts"; | |
| 8 | import type { User } from "../core/users.ts"; | |
| 9 | import { html, type Html } from "./html.ts"; | |
| 10 | ||
| 11 | const TEXT = "Flow only: building flow issues alone"; | |
| 12 | ||
| 13 | /** The line for one repository; null while its switch is off. */ | |
| 14 | export function flowOnlyLine(repo: Repo, admin: boolean, withName = false): Html | null { | |
| 15 | if (!repo.flowOnly) return null; | |
| 16 | const base = `/${repo.ownerName}/${repo.name}`; | |
| 17 | // The setting is an admin's page: for anyone else the words are plain. | |
| 18 | return html`<p class="muted small"> | |
| 19 | ${withName ? html`<a href="${base}">${repo.ownerName}/${repo.name}</a>: ` : null}${admin | |
| 20 | ? html`<a href="${base}/settings/crew#flow-only">${TEXT}</a>` | |
| 21 | : TEXT} | |
| 22 | </p>`; | |
| 23 | } | |
| 24 | ||
| 25 | /** One line for each repository this person administers that has the switch on. */ | |
| 26 | export function flowOnlyHomeLines(db: Database, user: User): Html | null { | |
| 27 | const lines = listReposWritableTo(db, user, { withoutArchived: true }) | |
| 28 | .filter((r) => r.flowOnly && canAdmin(db, r, user)) | |
| 29 | .map((r) => flowOnlyLine(r, true, true)); | |
| 30 | return lines.length === 0 ? null : html`${lines}`; | |
| 31 | } |
src/web/studio/home.ts +2
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * best of what is public (`actions/front.ts`). |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { flowOnlyHomeLines } from "../flow_only_line.ts"; | |
| 9 | 10 | import { forgeCeiling } from "../../flow/ceiling.ts"; |
| 10 | 11 | import { liveLine } from "../pages/releases.ts"; |
| 11 | 12 | import { |
| @@ -302,6 +303,7 @@ export async function studioHome( | ||
| 302 | 303 | <a href="/">Just my apps</a> |
| 303 | 304 | </p>`, |
| 304 | 305 | )} |
| 306 | ${flowOnlyHomeLines(ctx.app.db, user)} | |
| 305 | 307 | ${since} |
| 306 | 308 | ${homeTabs("feed", asking)} |
| 307 | 309 | <div class="split" data-rail="wide"> |
test/flow-only-line.test.ts added +90
| @@ -0,0 +1,90 @@ | ||
| 1 | /** | |
| 2 | * FLOW ONLY said quietly (#6394): one line on the repository header and one | |
| 3 | * per repository on its admin's home while the switch is on; none when off. | |
| 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, getRepoById, updateRepoSettings, type Repo } from "../src/core/repos.ts"; | |
| 11 | import { createSession } from "../src/core/sessions.ts"; | |
| 12 | import { createUser, type User } from "../src/core/users.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | const LINE = "Flow only: building flow issues alone"; | |
| 19 | let dataDir: string; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let owner: User; | |
| 23 | let repo: Repo; | |
| 24 | ||
| 25 | const get = async (path: string, who?: User): Promise<string> => | |
| 26 | ( | |
| 27 | await fetch(`${servers.url}${path}`, { | |
| 28 | headers: who ? { cookie: `forge_session=${createSession(app.db, who.id).token}` } : {}, | |
| 29 | }) | |
| 30 | ).text(); | |
| 31 | const setSwitch = (on: boolean) => { | |
| 32 | const r = getRepoById(app.db, repo.id)!; | |
| 33 | updateRepoSettings(app.db, r, { flowOnly: on }); | |
| 34 | }; | |
| 35 | ||
| 36 | beforeAll(async () => { | |
| 37 | dataDir = await mkdtemp(join(tmpdir(), "forge-flow-line-")); | |
| 38 | app = createApp({ dataDir, port: 0 }); | |
| 39 | servers = await serve(app); | |
| 40 | const made = await createUser(app.db, { | |
| 41 | origin: "seed", | |
| 42 | trusted: true, | |
| 43 | username: "maker", | |
| 44 | email: "[email protected]", | |
| 45 | password: "a-good-passphrase", | |
| 46 | adminIfFirst: true, | |
| 47 | }); | |
| 48 | if (!made.ok) throw new Error("user"); | |
| 49 | owner = made.value; | |
| 50 | const r = await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "svc", visibility: "public" }); | |
| 51 | if (!r.ok) throw new Error("repo"); | |
| 52 | repo = r.value; | |
| 53 | }, 30_000); | |
| 54 | ||
| 55 | afterAll(async () => { | |
| 56 | servers?.stop(); | |
| 57 | app?.close(); | |
| 58 | await rm(dataDir, { recursive: true, force: true }); | |
| 59 | }); | |
| 60 | ||
| 61 | test("the line is absent while the switch is off, on the header and the home", async () => { | |
| 62 | expect(await get("/maker/svc")).not.toContain(LINE); | |
| 63 | expect(await get("/?all=1", owner)).not.toContain(LINE); | |
| 64 | }); | |
| 65 | ||
| 66 | test("the repository header links to the setting for its admin", async () => { | |
| 67 | setSwitch(true); | |
| 68 | const asOwner = await get("/maker/svc", owner); | |
| 69 | expect(asOwner).toContain(LINE); | |
| 70 | expect(asOwner).toContain('href="/maker/svc/settings/crew#flow-only"'); | |
| 71 | setSwitch(false); | |
| 72 | expect(await get("/maker/svc")).not.toContain(LINE); | |
| 73 | }); | |
| 74 | ||
| 75 | test("a reader who is not admin sees the words with no link to the setting", async () => { | |
| 76 | setSwitch(true); | |
| 77 | const anon = await get("/maker/svc"); | |
| 78 | expect(anon).toContain(LINE); | |
| 79 | expect(anon).not.toContain("#flow-only"); | |
| 80 | setSwitch(false); | |
| 81 | }); | |
| 82 | ||
| 83 | test("the owner's home says it once for the repository", async () => { | |
| 84 | setSwitch(true); | |
| 85 | const home = await get("/?all=1", owner); | |
| 86 | expect(home.split(LINE).length - 1).toBe(1); | |
| 87 | expect(home).toContain("maker/svc</a>: "); | |
| 88 | setSwitch(false); | |
| 89 | expect(await get("/?all=1", owner)).not.toContain(LINE); | |
| 90 | }); |
new above
offline