Public: anyone can read this; the crew can change it.
Every event kind the forge emits is classified for its audience, and a test fails on one that is not #3627
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3612-every-event-kind-the-forge-emits-is-clas → main 1 commit fork
3 files
changed +311
−7
src/core/event_audience.ts +119 −6
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | * Every surface that hands events to a reader — the JSON outbox, the two |
| 12 | 12 | * event streams, the activity rail, the terminal's feed — asks here first, so |
| 13 | 13 | * a new surface cannot forget and a new kind is classified once. An event of |
| 14 | * a kind nobody classified is for readers, as it always was. | |
| 14 | * a kind nobody classified is for admins; kinds for readers are listed. | |
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | 17 | import { isActive } from "./users.ts"; |
| @@ -31,7 +31,110 @@ import type { User } from "./users.ts"; | ||
| 31 | 31 | |
| 32 | 32 | export type EventAudience = "readers" | "admins" | "actor" | "app"; |
| 33 | 33 | |
| 34 | export function eventAudience(kind: string): EventAudience { | |
| 34 | const READER_KINDS: ReadonlySet<string> = new Set([ | |
| 35 | "issue.labeled", | |
| 36 | "issue.closed", | |
| 37 | "issue.commented", | |
| 38 | "usage.delta", | |
| 39 | "issue.raised", | |
| 40 | "issue.blocked", | |
| 41 | "pull.pushed", | |
| 42 | "forge.spawn.live", | |
| 43 | "forge.spawn.failed", | |
| 44 | "fork.diverged", | |
| 45 | "push", | |
| 46 | "account.parent_linked", | |
| 47 | "account.parent_unlinked", | |
| 48 | "account.edge_linked", | |
| 49 | "account.edge_unlinked", | |
| 50 | "account.notify_set", | |
| 51 | "limb.moved", | |
| 52 | "repo.imported", | |
| 53 | "repo.mirrored", | |
| 54 | "account.signed_out", | |
| 55 | "session.ended", | |
| 56 | "label.created", | |
| 57 | "pull.labeled", | |
| 58 | "issue.edited", | |
| 59 | "pull.edited", | |
| 60 | "issue.sealed", | |
| 61 | "issue.unsealed", | |
| 62 | "issue.assigned", | |
| 63 | "pull.assigned", | |
| 64 | "pull.commented", | |
| 65 | "issue.reopened", | |
| 66 | "pull.closed", | |
| 67 | "pull.reopened", | |
| 68 | "pull.opened", | |
| 69 | "pull.merged", | |
| 70 | "issue.opened", | |
| 71 | "spend.reported", | |
| 72 | "forge.proposal", | |
| 73 | "pull.held", | |
| 74 | "pull.released", | |
| 75 | "org.created", | |
| 76 | "team.created", | |
| 77 | "team.deleted", | |
| 78 | "team.member_added", | |
| 79 | "team.member_removed", | |
| 80 | "forge.spawn.started", | |
| 81 | "forge.spawned", | |
| 82 | "forge.claim_reissued", | |
| 83 | "agent.created", | |
| 84 | "agent.updated", | |
| 85 | "agent.deleted", | |
| 86 | "crew.added", | |
| 87 | "crew.removed", | |
| 88 | "account.model_granted", | |
| 89 | "account.model_added", | |
| 90 | "account.ceiling_set", | |
| 91 | "pull.reviewed", | |
| 92 | "pull.thread_resolved", | |
| 93 | "pull.run_granted", | |
| 94 | "team.changed", | |
| 95 | "forge.severed", | |
| 96 | "forge.follow_mode", | |
| 97 | "token.created", | |
| 98 | "token.deleted", | |
| 99 | "issue.teams", | |
| 100 | "account.role_set", | |
| 101 | "account.email_verified", | |
| 102 | "account.recovered", | |
| 103 | "limb.replan", | |
| 104 | "limb.replan_taken_back", | |
| 105 | "invite.minted", | |
| 106 | "invite.revoked", | |
| 107 | "user.created", | |
| 108 | "user.recovered", | |
| 109 | "board.saved", | |
| 110 | "board.deleted", | |
| 111 | "repo.created", | |
| 112 | "repo.forked", | |
| 113 | "repo.deleted", | |
| 114 | "label.updated", | |
| 115 | "label.deleted", | |
| 116 | "telemetry.granted", | |
| 117 | "telemetry.revoked", | |
| 118 | "account.suspended", | |
| 119 | "account.unsuspended", | |
| 120 | "org.retired", | |
| 121 | "account.removed", | |
| 122 | "branch.created", | |
| 123 | "branch.deleted", | |
| 124 | "invite.used", | |
| 125 | "user.updated", | |
| 126 | "user.key_added", | |
| 127 | "user.key_removed", | |
| 128 | "issue.unblocked", | |
| 129 | "issue.confirmed", | |
| 130 | "capacity.changed", | |
| 131 | ]); | |
| 132 | ||
| 133 | /** | |
| 134 | * The audience a kind was classified for, or undefined when nobody did. | |
| 135 | * `test/event-audience-coverage.test.ts` fails on an emitted kind that is. | |
| 136 | */ | |
| 137 | export function classifiedAudience(kind: string): EventAudience | undefined { | |
| 35 | 138 | if ( |
| 36 | 139 | kind.startsWith("webhook.") || |
| 37 | 140 | kind.startsWith("app.env_") || |
| @@ -47,10 +150,14 @@ export function eventAudience(kind: string): EventAudience { | ||
| 47 | 150 | // A report from outside waits for a person who decides; its arrival is |
| 48 | 151 | // theirs to see, never the readers' (`actions/reports.ts`). |
| 49 | 152 | kind.startsWith("report.") || |
| 153 | // An upstream's address, a connected host and a cloud's write switch are | |
| 154 | // configuration, as a webhook's URL is (#3565, #3568). | |
| 155 | kind === "forge.upstream" || | |
| 156 | kind === "forge.updates_taken" || | |
| 157 | kind === "cloud.write_allowed" || | |
| 158 | // Which of a person's connected accounts a mirror syncs through is too. | |
| 159 | kind.startsWith("origin.") || | |
| 50 | 160 | kind === "repo.settings_updated" || |
| 51 | // Which of a person's connected accounts a mirror now syncs through is | |
| 52 | // the repository's configuration, as a webhook's URL is. | |
| 53 | kind === "origin.mirror_reattached" || | |
| 54 | 161 | kind === "repo.crew_outsiders" |
| 55 | 162 | ) |
| 56 | 163 | return "admins"; |
| @@ -68,7 +175,13 @@ export function eventAudience(kind: string): EventAudience { | ||
| 68 | 175 | // (#620, #599). |
| 69 | 176 | if (kind.startsWith("run.") || kind.startsWith("app.") || kind.startsWith("look.")) |
| 70 | 177 | return "app"; |
| 71 | return "readers"; | |
| 178 | if (READER_KINDS.has(kind)) return "readers"; | |
| 179 | return undefined; | |
| 180 | } | |
| 181 | ||
| 182 | /** Private by default: a kind nobody classified is for admins. */ | |
| 183 | export function eventAudience(kind: string): EventAudience { | |
| 184 | return classifiedAudience(kind) ?? "admins"; | |
| 72 | 185 | } |
| 73 | 186 | |
| 74 | 187 | /** |
test/event-audience-coverage.test.ts added +191
| @@ -0,0 +1,191 @@ | ||
| 1 | /** | |
| 2 | * Every event kind the source emits is classified in `event_audience.ts`. | |
| 3 | * | |
| 4 | * An unclassified kind is for admins only, so a kind that should reach readers | |
| 5 | * and was never listed would vanish quietly. This scan reads every | |
| 6 | * `bus.append({ kind: … })` in `src/` and names the kinds nobody classified. | |
| 7 | * A kind it cannot read literally fails the scan — it is never skipped. | |
| 8 | */ | |
| 9 | ||
| 10 | import { describe, expect, test } from "bun:test"; | |
| 11 | import { Glob } from "bun"; | |
| 12 | import { readFileSync } from "node:fs"; | |
| 13 | import { classifiedAudience, eventAudience } from "../src/core/event_audience.ts"; | |
| 14 | ||
| 15 | /** | |
| 16 | * Kinds built from a name the scan cannot read in place, spelled out by hand. | |
| 17 | * Keyed by file and the expression as written; a new one fails the scan until | |
| 18 | * it is added here with every kind it can take. | |
| 19 | */ | |
| 20 | const COMPUTED: Record<string, readonly string[]> = { | |
| 21 | "src/actions/runs.ts kind": ["run.queued", "run.started", "run.finished"], | |
| 22 | }; | |
| 23 | ||
| 24 | /** The values of `${issue.kind}`: a thread is an issue or a pull. */ | |
| 25 | const THREAD_KINDS = ["issue", "pull"]; | |
| 26 | ||
| 27 | /** Index of the bracket closing the one at `open`, skipping strings and templates. */ | |
| 28 | function closing(text: string, open: number): number { | |
| 29 | const pairs: Record<string, string> = { "{": "}", "(": ")", "[": "]" }; | |
| 30 | const stack: string[] = []; | |
| 31 | for (let i = open; i < text.length; i++) { | |
| 32 | const c = text[i]!; | |
| 33 | if (c === '"' || c === "'" || c === "`") { | |
| 34 | for (i++; i < text.length && text[i] !== c; i++) { | |
| 35 | if (text[i] === "\\") i++; | |
| 36 | else if (c === "`" && text[i] === "$" && text[i + 1] === "{") { | |
| 37 | i = closing(text, i + 1); | |
| 38 | } | |
| 39 | } | |
| 40 | } else if (c === "/" && text[i + 1] === "/") { | |
| 41 | while (i < text.length && text[i] !== "\n") i++; | |
| 42 | } else if (c === "/" && text[i + 1] === "*") { | |
| 43 | i = text.indexOf("*/", i + 2) + 1; | |
| 44 | } else if (pairs[c] !== undefined) stack.push(pairs[c]!); | |
| 45 | else if (c === stack[stack.length - 1]) { | |
| 46 | stack.pop(); | |
| 47 | if (stack.length === 0) return i; | |
| 48 | } | |
| 49 | } | |
| 50 | throw new Error("unbalanced brackets"); | |
| 51 | } | |
| 52 | ||
| 53 | /** `text` cut at top-level occurrences of `sep` (outside brackets and strings). */ | |
| 54 | function splitTop(text: string, sep: string): string[] { | |
| 55 | const parts: string[] = []; | |
| 56 | let from = 0; | |
| 57 | for (let i = 0; i < text.length; i++) { | |
| 58 | const c = text[i]!; | |
| 59 | if (c === '"' || c === "'" || c === "`" || c === "{" || c === "(" || c === "[") { | |
| 60 | i = c === '"' || c === "'" || c === "`" ? closingQuote(text, i) : closing(text, i); | |
| 61 | } else if (c === sep) { | |
| 62 | parts.push(text.slice(from, i)); | |
| 63 | from = i + 1; | |
| 64 | } | |
| 65 | } | |
| 66 | parts.push(text.slice(from)); | |
| 67 | return parts; | |
| 68 | } | |
| 69 | ||
| 70 | function closingQuote(text: string, open: number): number { | |
| 71 | const q = text[open]!; | |
| 72 | let i = open + 1; | |
| 73 | for (; i < text.length && text[i] !== q; i++) { | |
| 74 | if (text[i] === "\\") i++; | |
| 75 | else if (q === "`" && text[i] === "$" && text[i + 1] === "{") { | |
| 76 | i = closing(text, i + 1); | |
| 77 | } | |
| 78 | } | |
| 79 | return i; | |
| 80 | } | |
| 81 | ||
| 82 | /** Every kind `expr` can be, or undefined when it is not readable literally. */ | |
| 83 | function resolve(expr: string, where: string): string[] | undefined { | |
| 84 | const e = expr.trim(); | |
| 85 | if (COMPUTED[`${where} ${e}`] !== undefined) return [...COMPUTED[`${where} ${e}`]!]; | |
| 86 | const plain = /^"([^"\\]*)"$/.exec(e) ?? /^'([^'\\]*)'$/.exec(e); | |
| 87 | if (plain) return [plain[1]!]; | |
| 88 | if (e.startsWith("`") && closingQuote(e, 0) === e.length - 1) { | |
| 89 | let out = [""]; | |
| 90 | const body = e.slice(1, -1); | |
| 91 | for (let i = 0; i < body.length; ) { | |
| 92 | let options: string[]; | |
| 93 | let next: number; | |
| 94 | if (body[i] === "$" && body[i + 1] === "{") { | |
| 95 | next = closing(body, i + 1) + 1; | |
| 96 | const inner = body.slice(i + 2, next - 1).trim(); | |
| 97 | const got = inner === "issue.kind" ? THREAD_KINDS : resolve(inner, where); | |
| 98 | if (got === undefined) return undefined; | |
| 99 | options = got; | |
| 100 | } else { | |
| 101 | const stop = body.indexOf("${", i); | |
| 102 | next = stop === -1 ? body.length : stop; | |
| 103 | options = [body.slice(i, next)]; | |
| 104 | } | |
| 105 | out = out.flatMap((a) => options.map((b) => a + b)); | |
| 106 | i = next; | |
| 107 | } | |
| 108 | return out; | |
| 109 | } | |
| 110 | const ternary = splitTop(e, "?"); | |
| 111 | if (ternary.length === 2 && !e.startsWith("(")) { | |
| 112 | const branches = splitTop(ternary[1]!, ":"); | |
| 113 | if (branches.length === 2) { | |
| 114 | const a = resolve(branches[0]!, where); | |
| 115 | const b = resolve(branches[1]!, where); | |
| 116 | return a && b ? [...a, ...b] : undefined; | |
| 117 | } | |
| 118 | } | |
| 119 | if (e.startsWith("(") && closing(e, 0) === e.length - 1) { | |
| 120 | return resolve(e.slice(1, -1), where); | |
| 121 | } | |
| 122 | return undefined; | |
| 123 | } | |
| 124 | ||
| 125 | /** What the source emits: each kind, and each place whose kind cannot be read. */ | |
| 126 | export function emittedKinds(files: Record<string, string>): { | |
| 127 | kinds: Map<string, string>; | |
| 128 | unreadable: string[]; | |
| 129 | } { | |
| 130 | const kinds = new Map<string, string>(); | |
| 131 | const unreadable: string[] = []; | |
| 132 | for (const [path, text] of Object.entries(files)) { | |
| 133 | for (const call of text.matchAll(/\.append\(\s*\{/g)) { | |
| 134 | const open = call.index! + call[0].length - 1; | |
| 135 | const body = text.slice(open + 1, closing(text, open)); | |
| 136 | const line = text.slice(0, open).split("\n").length; | |
| 137 | for (const prop of splitTop(body, ",")) { | |
| 138 | const m = /^\s*kind\s*(?::([\s\S]*))?$/.exec(prop); | |
| 139 | if (!m) continue; | |
| 140 | const expr = m[1] ?? "kind"; | |
| 141 | const got = resolve(expr, path); | |
| 142 | if (got === undefined) unreadable.push(`${path}:${line} kind: ${expr.trim()}`); | |
| 143 | else for (const k of got) if (!kinds.has(k)) kinds.set(k, `${path}:${line}`); | |
| 144 | } | |
| 145 | } | |
| 146 | } | |
| 147 | return { kinds, unreadable }; | |
| 148 | } | |
| 149 | ||
| 150 | async function sources(): Promise<Record<string, string>> { | |
| 151 | const out: Record<string, string> = {}; | |
| 152 | for await (const path of new Glob("src/**/*.ts").scan(".")) { | |
| 153 | out[path] = readFileSync(path, "utf8"); | |
| 154 | } | |
| 155 | return out; | |
| 156 | } | |
| 157 | ||
| 158 | describe("event audience coverage", () => { | |
| 159 | test("every kind the source emits is classified", async () => { | |
| 160 | const { kinds, unreadable } = emittedKinds(await sources()); | |
| 161 | expect(kinds.size).toBeGreaterThan(50); | |
| 162 | const missing = [...kinds] | |
| 163 | .filter(([kind]) => classifiedAudience(kind) === undefined) | |
| 164 | .map(([kind, at]) => `${kind} (${at})`); | |
| 165 | expect(missing).toEqual([]); | |
| 166 | expect(unreadable).toEqual([]); | |
| 167 | }); | |
| 168 | ||
| 169 | test("an unclassified kind is for admins, never readers", () => { | |
| 170 | expect(classifiedAudience("brand.new_kind")).toBeUndefined(); | |
| 171 | expect(eventAudience("brand.new_kind")).toBe("admins"); | |
| 172 | }); | |
| 173 | ||
| 174 | test("the scan names a kind it cannot read literally", () => { | |
| 175 | const { unreadable } = emittedKinds({ | |
| 176 | "src/x.ts": "app.bus.append({ kind: pick(a), payload: {} });", | |
| 177 | }); | |
| 178 | expect(unreadable).toEqual(["src/x.ts:1 kind: pick(a)"]); | |
| 179 | }); | |
| 180 | ||
| 181 | test("the scan reads ternaries and thread templates", () => { | |
| 182 | const { kinds, unreadable } = emittedKinds({ | |
| 183 | "src/x.ts": | |
| 184 | 'bus.append({ kind: ok ? "a.b" : "c.d" });\nbus.append({\n kind: `${issue.kind}.${s ? "x" : "y"}`,\n});', | |
| 185 | }); | |
| 186 | expect([...kinds.keys()].sort()).toEqual( | |
| 187 | ["a.b", "c.d", "issue.x", "issue.y", "pull.x", "pull.y"].sort(), | |
| 188 | ); | |
| 189 | expect(unreadable).toEqual([]); | |
| 190 | }); | |
| 191 | }); |
test/event-audience.test.ts +1 −1
| @@ -113,7 +113,7 @@ describe("who an event is for", () => { | ||
| 113 | 113 | expect(eventAudience("prompt.source_added")).toBe("admins"); |
| 114 | 114 | expect(eventAudience("repo.pinned")).toBe("actor"); |
| 115 | 115 | expect(eventAudience("push")).toBe("readers"); |
| 116 | expect(eventAudience("a.kind.nobody.classified")).toBe("readers"); | |
| 116 | expect(eventAudience("a.kind.nobody.classified")).toBe("admins"); | |
| 117 | 117 | }); |
| 118 | 118 | |
| 119 | 119 | test("the filter: admins see configuration, an actor their own choice, a stranger neither", () => { |
new above
offline