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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3612-every-event-kind-the-forge-emits-is-clas → main 1 commit fork

  • Merged. scout/3612-every-event-kind-the-forge-emits-is-clas was merged into main as 0d17a69.

Review

Sign in to review this change.
3 files changed +311 −7
src/core/event_audience.ts +119 −6
@@ -11,7 +11,7 @@
1111 * Every surface that hands events to a reader — the JSON outbox, the two
1212 * event streams, the activity rail, the terminal's feed — asks here first, so
1313 * 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.
1515 */
1616
1717 import { isActive } from "./users.ts";
@@ -31,7 +31,110 @@ import type { User } from "./users.ts";
3131
3232 export type EventAudience = "readers" | "admins" | "actor" | "app";
3333
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 {
35138 if (
36139 kind.startsWith("webhook.") ||
37140 kind.startsWith("app.env_") ||
@@ -47,10 +150,14 @@ export function eventAudience(kind: string): EventAudience {
47150 // A report from outside waits for a person who decides; its arrival is
48151 // theirs to see, never the readers' (`actions/reports.ts`).
49152 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.") ||
50160 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" ||
54161 kind === "repo.crew_outsiders"
55162 )
56163 return "admins";
@@ -68,7 +175,13 @@ export function eventAudience(kind: string): EventAudience {
68175 // (#620, #599).
69176 if (kind.startsWith("run.") || kind.startsWith("app.") || kind.startsWith("look."))
70177 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";
72185 }
73186
74187 /**
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", () => {
113113 expect(eventAudience("prompt.source_added")).toBe("admins");
114114 expect(eventAudience("repo.pinned")).toBe("actor");
115115 expect(eventAudience("push")).toBe("readers");
116 expect(eventAudience("a.kind.nobody.classified")).toBe("readers");
116 expect(eventAudience("a.kind.nobody.classified")).toBe("admins");
117117 });
118118
119119 test("the filter: admins see configuration, an actor their own choice, a stranger neither", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs