Public: anyone can read this; the crew can change it.
src/core/repos.ts has the reads a display lists from: listAdminOwnedRepos, readableRepoIds, repoDatesFor and listAllReposByOwner #9303
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9273-src-core-repos-ts-has-the-reads-a-displa → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +164
−0
src/core/repos.ts +75
| @@ -1364,3 +1364,78 @@ export function setDirectionIssue( | ||
| 1364 | 1364 | issueId, |
| 1365 | 1365 | ); |
| 1366 | 1366 | } |
| 1367 | ||
| 1368 | // Reads a display lists from (display/known.ts, display/items.ts). | |
| 1369 | ||
| 1370 | /** | |
| 1371 | * When each repository was created and last pushed to (the newest `push` | |
| 1372 | * event, null when there is none), for a list of forks: one query per chunk. | |
| 1373 | */ | |
| 1374 | export function repoDatesFor( | |
| 1375 | db: Database, | |
| 1376 | repoIds: readonly string[], | |
| 1377 | ): Map<string, { createdAt: string; pushedAt: string | null }> { | |
| 1378 | const out = new Map<string, { createdAt: string; pushedAt: string | null }>(); | |
| 1379 | for (let i = 0; i < repoIds.length; i += 500) { | |
| 1380 | const chunk = repoIds.slice(i, i + 500); | |
| 1381 | const marks = chunk.map((_, j) => `?${j + 1}`).join(","); | |
| 1382 | const rows = db | |
| 1383 | .query<{ id: string; created_at: string; pushed_at: string | null }, string[]>( | |
| 1384 | `SELECT repo.id, repo.created_at, | |
| 1385 | (SELECT MAX(event.created_at) FROM event | |
| 1386 | WHERE event.repo_id = repo.id AND event.kind = 'push') AS pushed_at | |
| 1387 | FROM repo WHERE repo.id IN (${marks})`, | |
| 1388 | ) | |
| 1389 | .all(...chunk); | |
| 1390 | for (const r of rows) | |
| 1391 | out.set(r.id, { createdAt: r.created_at, pushedAt: r.pushed_at }); | |
| 1392 | } | |
| 1393 | return out; | |
| 1394 | } | |
| 1395 | ||
| 1396 | /** | |
| 1397 | * Every repo one user or org owns, whoever looks: for a display, whose | |
| 1398 | * showcased demos are published from private repositories too | |
| 1399 | * (`display/items.ts`). Never a listing a person sees as repositories. | |
| 1400 | */ | |
| 1401 | export function listAllReposByOwner(db: Database, ownerName: string, limit: number): Repo[] { | |
| 1402 | return db | |
| 1403 | .query<RepoRow, [string, number]>(`${SELECT} WHERE user.username = ?1 COLLATE NOCASE ORDER BY repo.id DESC LIMIT ?2`) | |
| 1404 | .all(ownerName, limit) | |
| 1405 | .map(toRepo); | |
| 1406 | } | |
| 1407 | ||
| 1408 | /** | |
| 1409 | * Which of these repositories the viewer may read — `REPO_VISIBLE` (or | |
| 1410 | * `PUBLIC_VISIBLE` signed out) for many rows in one query per chunk: the | |
| 1411 | * rule every listing applies, so a row never links a page that would 404. | |
| 1412 | */ | |
| 1413 | export function readableRepoIds( | |
| 1414 | db: Database, | |
| 1415 | viewer: User | undefined, | |
| 1416 | repoIds: readonly string[], | |
| 1417 | ): Set<string> { | |
| 1418 | const out = new Set<string>(); | |
| 1419 | const unique = [...new Set(repoIds)]; | |
| 1420 | const CHUNK = 400; | |
| 1421 | for (let i = 0; i < unique.length; i += CHUNK) { | |
| 1422 | const batch = unique.slice(i, i + CHUNK); | |
| 1423 | const rows = db | |
| 1424 | .query<{ id: string }, string[]>( | |
| 1425 | `SELECT repo.id FROM repo | |
| 1426 | WHERE repo.id IN (${batch.map((_, n) => `?${n + 2}`).join(",")}) | |
| 1427 | AND ${viewer ? REPO_VISIBLE : PUBLIC_VISIBLE}`, | |
| 1428 | ) | |
| 1429 | .all(viewer?.id ?? "", ...batch); | |
| 1430 | for (const row of rows) out.add(row.id); | |
| 1431 | } | |
| 1432 | return out; | |
| 1433 | } | |
| 1434 | ||
| 1435 | /** Every repository a site admin owns (a person, never an agent), its owner in standing. */ | |
| 1436 | export function listAdminOwnedRepos(db: Database): Repo[] { | |
| 1437 | return db | |
| 1438 | .query<RepoRow, []>(`${SELECT} WHERE user.is_admin = 1 AND user.is_agent = 0 AND ${OWNER_STANDING} ORDER BY owner_name, repo.name`) | |
| 1439 | .all() | |
| 1440 | .map(toRepo); | |
| 1441 | } |
test/repos-display-reads.test.ts added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | /** The reads a display lists from: admin-owned repos, readable ids, dates, all repos by owner (#9273). */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createUser, type User } from "../src/core/users.ts"; | |
| 9 | import { | |
| 10 | listAdminOwnedRepos, | |
| 11 | listAllReposByOwner, | |
| 12 | readableRepoIds, | |
| 13 | repoDatesFor, | |
| 14 | type Repo, | |
| 15 | } from "../src/core/repos.ts"; | |
| 16 | import { promote } from "./helpers/members.ts"; | |
| 17 | ||
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let root: User; | |
| 21 | let alice: User; | |
| 22 | let bob: User; | |
| 23 | const repos: Record<string, Repo> = {}; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dataDir = await mkdtemp(join(tmpdir(), "forge-display-reads-")); | |
| 27 | app = createApp({ dataDir, port: 0 }); | |
| 28 | const mk = async (username: string, first = false) => { | |
| 29 | const r = await createUser(app.db, { | |
| 30 | origin: "seed", trusted: true, username, allowReserved: true, | |
| 31 | email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first, | |
| 32 | }); | |
| 33 | if (!r.ok) throw new Error(r.error); | |
| 34 | promote(app.db, r.value); | |
| 35 | return r.value; | |
| 36 | }; | |
| 37 | root = await mk("root", true); | |
| 38 | alice = await mk("alice"); | |
| 39 | bob = await mk("bob"); | |
| 40 | const make = async (key: string, who: User, name: string, visibility: "public" | "private") => { | |
| 41 | const r = await createRepo(app, who, { name, visibility }); | |
| 42 | if (!r.ok) throw new Error(`${key}: ${r.error}`); | |
| 43 | repos[key] = r.value; | |
| 44 | }; | |
| 45 | await make("rootPub", root, "rootpub", "public"); | |
| 46 | await make("rootPriv", root, "rootpriv", "private"); | |
| 47 | await make("a1", alice, "one", "public"); | |
| 48 | await make("a2", alice, "two", "private"); | |
| 49 | await make("a3", alice, "three", "public"); | |
| 50 | await make("bobPriv", bob, "bobs", "private"); | |
| 51 | }); | |
| 52 | afterAll(async () => { | |
| 53 | app.db.close(); | |
| 54 | await rm(dataDir, { recursive: true, force: true }); | |
| 55 | }); | |
| 56 | ||
| 57 | describe("display reads", () => { | |
| 58 | test("listAdminOwnedRepos lists only repositories an admin owns", () => { | |
| 59 | const names = listAdminOwnedRepos(app.db).map((r) => `${r.ownerName}/${r.name}`); | |
| 60 | expect(names.every((n) => n.startsWith("root/"))).toBe(true); | |
| 61 | expect(names.sort()).toEqual(["root/rootpriv", "root/rootpub"]); | |
| 62 | }); | |
| 63 | ||
| 64 | test("readableRepoIds excludes a private repository the viewer cannot read", () => { | |
| 65 | const ids = Object.values(repos).map((r) => r.id); | |
| 66 | const mine = readableRepoIds(app.db, alice, ids); | |
| 67 | expect(mine.has(repos.a2!.id)).toBe(true); | |
| 68 | expect(mine.has(repos.rootPub!.id)).toBe(true); | |
| 69 | expect(mine.has(repos.bobPriv!.id)).toBe(false); | |
| 70 | expect(mine.has(repos.rootPriv!.id)).toBe(false); | |
| 71 | const anon = readableRepoIds(app.db, undefined, ids); | |
| 72 | expect([...anon].sort()).toEqual([repos.rootPub!.id, repos.a1!.id, repos.a3!.id].sort()); | |
| 73 | expect(readableRepoIds(app.db, alice, []).size).toBe(0); | |
| 74 | }); | |
| 75 | ||
| 76 | test("listAllReposByOwner honours its limit and ignores visibility", () => { | |
| 77 | expect(listAllReposByOwner(app.db, "alice", 10).length).toBe(3); | |
| 78 | expect(listAllReposByOwner(app.db, "ALICE", 2).length).toBe(2); | |
| 79 | expect(listAllReposByOwner(app.db, "nobody", 5)).toEqual([]); | |
| 80 | }); | |
| 81 | ||
| 82 | test("repoDatesFor returns one entry per id asked", () => { | |
| 83 | const ids = [repos.a1!.id, repos.a2!.id, repos.a1!.id]; | |
| 84 | const dates = repoDatesFor(app.db, [...ids, "missing"]); | |
| 85 | expect([...dates.keys()].sort()).toEqual([repos.a1!.id, repos.a2!.id].sort()); | |
| 86 | expect(dates.get(repos.a1!.id)!.createdAt).toBeTruthy(); | |
| 87 | expect(repoDatesFor(app.db, []).size).toBe(0); | |
| 88 | }); | |
| 89 | }); |
new above
offline
Nothing calls these four reads yet. Fine as a staged import, but the follow-up pull that adds the display callers should land next so they are not dead code.