Public: anyone can read this; the crew can change it.
A pure read counts the open pulls from outside the repository's writers, and the oldest one's age #6450
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6444-a-pure-read-counts-the-open-pulls-from-o → main 2 commits fork 2 threads · 2 unresolved
2 files
changed +158
−0
src/flow/outsiders.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** | |
| 2 | * The outsider count (#5930): open, unmerged pulls whose author is neither a | |
| 3 | * writer of the repository nor one of its crew agents. An outsider's pull | |
| 4 | * waits for a person, so it is counted, with the age of the oldest. | |
| 5 | * | |
| 6 | * The author rule is the API's `untrusted` one (`speaksForCrew`), and an | |
| 7 | * imported pull is an outsider's whoever connected it (`voiceOf`). | |
| 8 | * `publicOnly` is `readVitals`'s: public repositories alone. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { Database } from "bun:sqlite"; | |
| 12 | import { getRepoById, type Repo } from "../core/repos.ts"; | |
| 13 | import { getUserById, type User } from "../core/users.ts"; | |
| 14 | import { speaksForCrew } from "../crew/direct.ts"; | |
| 15 | ||
| 16 | const HOUR_MS = 3_600_000; | |
| 17 | ||
| 18 | export interface Outsiders { | |
| 19 | readonly open: number; | |
| 20 | /** Whole hours the oldest outsider pull has waited; 0 when there is none. */ | |
| 21 | readonly oldestHours: number; | |
| 22 | } | |
| 23 | ||
| 24 | interface Row { | |
| 25 | readonly repo_id: string; | |
| 26 | readonly author_id: string; | |
| 27 | readonly origin_author: string | null; | |
| 28 | readonly created_at: string; | |
| 29 | } | |
| 30 | ||
| 31 | export function readOutsiders(db: Database, now = new Date(), publicOnly = false): Outsiders { | |
| 32 | const rows = db | |
| 33 | .query<Row, [number]>( | |
| 34 | `SELECT i.repo_id, i.author_id, i.origin_author, i.created_at | |
| 35 | FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id | |
| 36 | WHERE (?1 = 0 OR r.visibility = 'public') AND i.state = 'open' AND p.merged_at IS NULL`, | |
| 37 | ) | |
| 38 | .all(publicOnly ? 1 : 0); | |
| 39 | const repos = new Map<string, Repo | undefined>(); | |
| 40 | const authors = new Map<string, User | undefined>(); | |
| 41 | const author = (userId: string) => { | |
| 42 | if (!authors.has(userId)) authors.set(userId, getUserById(db, userId)); | |
| 43 | return authors.get(userId); | |
| 44 | }; | |
| 45 | let open = 0; | |
| 46 | let oldest = Infinity; | |
| 47 | for (const row of rows) { | |
outdated
The imported branch has no test. Add a writer or crew author with origin_author set (counted) and with an empty string (not counted). |
||
| 48 | if (!repos.has(row.repo_id)) repos.set(row.repo_id, getRepoById(db, row.repo_id)); | |
| 49 | const repo = repos.get(row.repo_id); | |
| 50 | if (repo === undefined) continue; | |
| 51 | const imported = row.origin_author !== null && row.origin_author !== ""; | |
| 52 | if (!imported && speaksForCrew(db, repo, author(row.author_id))) continue; | |
| 53 | open++; | |
| 54 | const at = Date.parse(row.created_at); | |
| 55 | if (at < oldest) oldest = at; | |
| 56 | } | |
| 57 | return { | |
| 58 | open, | |
| 59 | oldestHours: open === 0 ? 0 : Math.max(0, Math.floor((now.getTime() - oldest) / HOUR_MS)), | |
| 60 | }; | |
| 61 | } |
test/flow-outsiders.test.ts added +97
| @@ -0,0 +1,97 @@ | ||
| 1 | /** The outsider count (#5930): a writer's and a crew agent's pulls are left out; a stranger's is counted with its age. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp } from "../src/app.ts"; | |
| 7 | import { addCrew, createAgentRow } from "../src/core/agents.ts"; | |
| 8 | import { createRepo } from "../src/core/repos.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { id } from "../src/kernel/id.ts"; | |
| 11 | import { readOutsiders } from "../src/flow/outsiders.ts"; | |
| 12 | ||
| 13 | const NOW = new Date(); | |
| 14 | const ago = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString(); | |
| 15 | ||
| 16 | async function world() { | |
| 17 | const app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-outsiders-")), port: 0 }); | |
| 18 | const person = async (username: string, extra: object = {}) => { | |
| 19 | const made = await createUser(app.db, { username, email: `${username}@forge.test`, password: "a-good-passphrase", origin: "seed", trusted: true, ...extra }); | |
| 20 | if (!made.ok) throw new Error(made.error); | |
| 21 | return made.value; | |
| 22 | }; | |
| 23 | const alice = await person("alice", { adminIfFirst: true }); | |
| 24 | const stranger = await person("stranger"); | |
| 25 | const atlasMade = await createUser(app.db, { origin: "seed", username: "atlas", email: "[email protected]", agent: true }); | |
| 26 | if (!atlasMade.ok) throw new Error("agent"); | |
| 27 | createAgentRow(app.db, { userId: atlasMade.value.id, brief: "plan", createdBy: alice.id }); | |
| 28 | const repo = async (name: string, visibility: "public" | "private") => { | |
| 29 | const r = await createRepo(app.db, app.reposRoot, { ownerId: alice.id, name, visibility, description: name }); | |
| 30 | if (!r.ok) throw new Error(r.error); | |
| 31 | return r.value; | |
| 32 | }; | |
| 33 | let n = 0; | |
| 34 | const pull = (repoId: string, branch: string, authorId: string, opened: string, merged: string | null = null, state = "open", originAuthor: string | null = null) => { | |
| 35 | const issueId = id(); | |
| 36 | n++; | |
| 37 | app.db | |
| 38 | .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at, origin_author) VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, ?6, ?7, ?8)`) | |
| 39 | .run(issueId, repoId, n, `P${n}`, authorId, state, opened, originAuthor); | |
| 40 | app.db | |
| 41 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, ?2, ?3, '', '', ?4, ?5)`) | |
| 42 | .run(issueId, branch, `b/${n}`, repoId, merged); | |
| 43 | }; | |
| 44 | return { app, alice, stranger, atlas: atlasMade.value, repo, pull }; | |
| 45 | } | |
| 46 | ||
| 47 | describe("flow outsiders", () => { | |
| 48 | test("a writer's and a crew pull are left out; a stranger's is counted with its age", async () => { | |
| 49 | const w = await world(); | |
| 50 | const r = await w.repo("o", "public"); | |
| 51 | addCrew(w.app.db, r.id, w.atlas.id, w.alice.id); | |
| 52 | const b = r.defaultBranch; | |
| 53 | expect(readOutsiders(w.app.db, NOW)).toEqual({ open: 0, oldestHours: 0 }); | |
| 54 | w.pull(r.id, b, w.alice.id, ago(100)); | |
| 55 | w.pull(r.id, b, w.atlas.id, ago(90)); | |
| 56 | expect(readOutsiders(w.app.db, NOW)).toEqual({ open: 0, oldestHours: 0 }); | |
| 57 | w.pull(r.id, b, w.stranger.id, ago(5)); | |
| 58 | w.pull(r.id, b, w.stranger.id, ago(30.5)); | |
| 59 | w.pull(r.id, b, w.stranger.id, ago(200), ago(1), "closed"); // merged: not waiting | |
| 60 | w.pull(r.id, b, w.stranger.id, ago(300), null, "closed"); // closed: not waiting | |
| 61 | expect(readOutsiders(w.app.db, NOW)).toEqual({ open: 2, oldestHours: 30 }); | |
| 62 | }); | |
| 63 | ||
| 64 | test("an open pull with merged_at set is not waiting", async () => { | |
| 65 | const w = await world(); | |
| 66 | const r = await w.repo("o", "public"); | |
| 67 | w.pull(r.id, r.defaultBranch, w.stranger.id, ago(10), ago(1), "open"); | |
| 68 | expect(readOutsiders(w.app.db, NOW)).toEqual({ open: 0, oldestHours: 0 }); | |
| 69 | }); | |
| 70 | ||
| 71 | test("an imported pull is an outsider's even with a writer's id; an empty origin author is not", async () => { | |
| 72 | const w = await world(); | |
| 73 | const r = await w.repo("o", "public"); | |
| 74 | addCrew(w.app.db, r.id, w.atlas.id, w.alice.id); | |
| 75 | w.pull(r.id, r.defaultBranch, w.alice.id, ago(7), null, "open", "octocat"); | |
| 76 | w.pull(r.id, r.defaultBranch, w.atlas.id, ago(8), null, "open", ""); | |
| 77 | w.pull(r.id, r.defaultBranch, w.alice.id, ago(9), null, "open", ""); | |
| 78 | expect(readOutsiders(w.app.db, NOW)).toEqual({ open: 1, oldestHours: 7 }); | |
| 79 | }); | |
| 80 | ||
| 81 | test("an agent that is not this repository's crew is an outsider", async () => { | |
| 82 | const w = await world(); | |
| 83 | const r = await w.repo("o", "public"); | |
| 84 | w.pull(r.id, r.defaultBranch, w.atlas.id, ago(2)); | |
| 85 | expect(readOutsiders(w.app.db, NOW).open).toBe(1); | |
| 86 | }); | |
| 87 | ||
| 88 | test("publicOnly hides private repositories", async () => { | |
| 89 | const w = await world(); | |
| 90 | const pub = await w.repo("pub", "public"); | |
| 91 | const priv = await w.repo("priv", "private"); | |
| 92 | w.pull(pub.id, pub.defaultBranch, w.stranger.id, ago(3)); | |
| 93 | w.pull(priv.id, priv.defaultBranch, w.stranger.id, ago(40)); | |
| 94 | expect(readOutsiders(w.app.db, NOW)).toEqual({ open: 2, oldestHours: 40 }); | |
| 95 | expect(readOutsiders(w.app.db, NOW, true)).toEqual({ open: 1, oldestHours: 3 }); | |
| 96 | }); | |
| 97 | }); |
new above
offline
The merged_at IS NULL clause needs a test with state open and merged_at set; the current test cannot fail without it.