Public: anyone can read this; the crew can change it.
One query answers the repositories a person administers, with no counts and no loop per repository #7065
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7056-one-query-answers-the-repositories-a-per → main 4 commits fork 4 threads · 4 unresolved
src/core/admin_repos.ts added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | /** | |
| 2 | * The repositories a person administers, in one statement: the SQL twin of | |
| 3 | * `canAdmin` for the lists that must not call it once per repository. | |
| 4 | * Ownership, a team with admin in the owning org (an org's Owners team is | |
| 5 | * one), or — for a forge admin — every repository. An agent administers | |
| 6 | * nothing: it proposes, and everything from write up stays with people. That | |
| 7 | * is stricter than `canAdmin`, which lets an agent administer a repository it | |
| 8 | * owns (the fork it pushes to); never wider. | |
| 9 | */ | |
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import type { User } from "./users.ts"; | |
| 12 | ||
| 13 | export function adminRepoIds(db: Database, user: User | undefined): Set<string> { | |
| 14 | if (user === undefined || user.kind === "agent") return new Set(); | |
| 15 | const rows = db | |
Nothing calls this yet. Wire /boards in the follow-up so the function is reachable and not dead code. |
||
| 16 | .query<{ id: string }, [string, number]>( | |
| 17 | `SELECT repo.id FROM repo | |
| 18 | WHERE ?2 = 1 | |
| 19 | OR repo.owner_id = ?1 | |
| 20 | OR repo.owner_id IN ( | |
| 21 | SELECT team.org_id FROM team | |
| 22 | JOIN team_member ON team_member.team_id = team.id | |
| 23 | WHERE team_member.user_id = ?1 AND team.permission = 'admin')`, | |
| 24 | ) | |
| 25 | .all(user.id, user.isAdmin ? 1 : 0); | |
| 26 | return new Set(rows.map((r) => r.id)); | |
| 27 | } |
test/admin-repos.test.ts added +144
| @@ -0,0 +1,144 @@ | ||
| 1 | /** One statement answers the repositories a person administers (#7056). */ | |
| 2 | import { afterAll, beforeAll, 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 { adminRepoIds } from "../src/core/admin_repos.ts"; | |
| 8 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 9 | import { canAdmin, getRepoById } from "../src/core/repos.ts"; | |
| 10 | import { createUser, getUserById, type User } from "../src/core/users.ts"; | |
| 11 | import { id } from "../src/kernel/id.ts"; | |
| 12 | import { expectBudget } from "./helpers/budget.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | let dataDir: string; | |
| 18 | let app: App; | |
| 19 | let root: User; | |
| 20 | let pat: User; | |
| 21 | let bot: User; | |
| 22 | const mine: Record<string, string> = {}; | |
| 23 | ||
| 24 | const person = async (username: string): Promise<User> => { | |
| 25 | const made = await createUser(app.db, { | |
| 26 | origin: "seed", | |
| 27 | trusted: true, | |
| 28 | username, | |
| 29 | email: `${username}@forge.local`, | |
| 30 | password: "a-good-passphrase", | |
| 31 | adminIfFirst: true, | |
| 32 | }); | |
| 33 | if (!made.ok) throw new Error(String(made.error)); | |
| 34 | return made.value; | |
| 35 | }; | |
| 36 | const repo = (ownerId: string, name: string): string => { | |
| 37 | const rid = id(); | |
| 38 | app.db | |
| 39 | .query( | |
| 40 | `INSERT INTO repo (id, owner_id, name, visibility, description, default_branch) | |
| 41 | VALUES (?1, ?2, ?3, 'private', '', 'main')`, | |
| 42 | ) | |
| 43 | .run(rid, ownerId, name); | |
| 44 | return rid; | |
| 45 | }; | |
| 46 | ||
| 47 | beforeAll(async () => { | |
| 48 | dataDir = await mkdtemp(join(tmpdir(), "forge-admin-repos-")); | |
| 49 | app = createApp({ dataDir, port: 0 }); | |
| 50 | root = await person("alice"); | |
| 51 | pat = await person("pat"); | |
| 52 | const other = await person("other"); | |
| 53 | const org = (n: string, founder: User): User => { | |
| 54 | const r = createOrg(app.db, n, founder.id); | |
| 55 | if (!r.ok) throw new Error(r.error); | |
| 56 | return r.value; | |
| 57 | }; | |
| 58 | const owned = org("owned", pat); // pat is in Owners | |
| 59 | const adminOrg = org("adminorg", other); | |
| 60 | const writeOrg = org("writeorg", other); | |
| 61 | const readOrg = org("readorg", other); | |
| 62 | const joined = (o: User, name: string, perm: "admin" | "write" | "read") => { | |
| 63 | const t = createTeam(app.db, o.id, name, perm); | |
| 64 | if (!t.ok) throw new Error(t.error); | |
| 65 | addTeamMember(app.db, t.value, pat.id); | |
| 66 | }; | |
| 67 | joined(adminOrg, "leads", "admin"); | |
| 68 | joined(writeOrg, "devs", "write"); | |
| 69 | joined(readOrg, "viewers", "read"); | |
| 70 | mine["own"] = repo(pat.id, "own"); | |
| 71 | mine["org-owner"] = repo(owned.id, "a"); | |
| 72 | mine["team-admin"] = repo(adminOrg.id, "b"); | |
| 73 | mine["writer"] = repo(writeOrg.id, "c"); | |
| 74 | mine["reader"] = repo(readOrg.id, "d"); | |
| 75 | mine["foreign"] = repo(other.id, "e"); | |
| 76 | const botId = id(); | |
| 77 | app.db | |
| 78 | .query( | |
| 79 | `INSERT INTO user (id, username, email, password_hash, is_agent) | |
| 80 | VALUES (?1, 'bot', '[email protected]', '!', 1)`, | |
| 81 | ) | |
| 82 | .run(botId); | |
| 83 | bot = getUserById(app.db, botId)!; | |
| 84 | app.db | |
| 85 | .query("INSERT INTO repo_crew (repo_id, user_id) VALUES (?1, ?2)") | |
| 86 | .run(mine["writer"]!, botId); | |
| 87 | }); | |
| 88 | afterAll(async () => { | |
| 89 | app.close(); | |
| 90 | await rm(dataDir, { recursive: true, force: true }); | |
| 91 | }); | |
| 92 | ||
| 93 | const every = (): string[] => | |
| 94 | app.db.query<{ id: string }, []>("SELECT id FROM repo").all().map((r) => r.id); | |
| 95 | ||
| 96 | test("each route to admin is in, writer-only and reader-only are out, and it equals canAdmin", () => { | |
| 97 | const got = adminRepoIds(app.db, pat); | |
| 98 | expect([...got].sort()).toEqual( | |
| 99 | [mine["own"]!, mine["org-owner"]!, mine["team-admin"]!].sort(), | |
| 100 | ); | |
| 101 | for (const rid of every()) | |
| 102 | expect(got.has(rid)).toBe(canAdmin(app.db, getRepoById(app.db, rid)!, pat)); | |
| 103 | }); | |
| 104 | ||
| 105 | test("a forge admin gets every repository, an agent and nobody get none", () => { | |
| 106 | expect(adminRepoIds(app.db, root).size).toBe(every().length); | |
| 107 | expect(adminRepoIds(app.db, bot).size).toBe(0); | |
| 108 | expect(adminRepoIds(app.db, undefined).size).toBe(0); | |
| 109 | }); | |
| 110 | ||
| 111 | test("it is exactly one statement", () => { | |
| 112 | let n = 0; | |
| 113 | const db = app.db as unknown as { query: (...a: unknown[]) => unknown }; | |
| 114 | const real = db.query.bind(db); | |
| 115 | db.query = (...a: unknown[]) => { | |
| 116 | n++; | |
| 117 | return real(...a); | |
| 118 | }; | |
| 119 | try { | |
| 120 | adminRepoIds(app.db, pat); | |
| 121 | } finally { | |
| 122 | db.query = real; | |
| 123 | } | |
| 124 | expect(n).toBe(1); | |
| 125 | }); | |
| 126 | ||
| 127 | test("on 2,000 repositories it costs about one scan of the repository table", () => { | |
| 128 | app.db.transaction(() => { | |
| 129 | for (let i = 0; i < 2000; i++) repo(i % 3 === 0 ? pat.id : root.id, `bulk${i}`); | |
| 130 | })(); | |
| 131 | const best = (run: () => unknown): number => { | |
| 132 | let least = Infinity; | |
| 133 | for (let n = 0; n < 5; n++) { | |
outdated
Nit. A wall-clock assertion inside the gated suite is a possible source of flakes (#1242, #1073). Locally the query takes about 0.7 ms against a 5 ms limit, so it passes with room today. If it ever goes red on a loaded runner, move it behind the per-test time budget instead of raising the number. |
||
| 134 | const t = performance.now(); | |
| 135 | run(); | |
| 136 | least = Math.min(least, performance.now() - t); | |
| 137 | } | |
| 138 | return least; | |
The speed check is the one test here that depends on timing. If it flakes under a busy gate, assert the work instead (one statement, already pinned above) and drop it. |
||
| 139 | }; | |
| 140 | // The baseline is a plain scan of the same table, taken in the same run. | |
| 141 | const against = best(() => app.db.query("SELECT id FROM repo").all()); | |
| 142 | expectBudget(best(() => adminRepoIds(app.db, pat)), { ms: 5, against }); | |
| 143 | expect(adminRepoIds(app.db, pat).size).toBe(667 + 3); | |
| 144 | }); |
Low, for the follow-up. This matches the issue ("an agent gets none") but does not match
canAdmin.personalOrDirectreturns true whenuser.id === repo.ownerIdwhatever the user's kind, so an agent that owns a repository, such as the fork it pushes to, getscanAdmintrue and gets nothing here. The difference is safe because this function is narrower, never wider. Two fixes to choose from: say "stricter than canAdmin for agent-owned forks" in the doc comment, or add a bot-owned repository to the test and settle which answer is right before/boardsswitches over.