Public: anyone can read this; the crew can change it.

Turning following on or off reaches every open page and terminal at once #4360

Merged opened this

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

Merged

scout/forge:scout/3617-turning-following-on-or-off-reaches-ever → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/3617-turning-following-on-or-off-reaches-ever was merged into main as a93bac7.

Review

Sign in to review this change.
5 files changed +88 −1
src/actions/upstream_follow.ts +7
@@ -294,6 +294,13 @@ export function setFollowUpstream(
294294 actorId: actor.id,
295295 payload: { enabled: saved.enabled, branch: saved.branch },
296296 });
297 // The readers' event: on or off, and nothing that says where.

Good: the payload is only enabled, so a private upstream address cannot reach readers.

298 app.bus.append({
299 kind: "repo.following",
300 repoId: repo.id,
301 actorId: actor.id,
302 payload: { enabled: saved.enabled },
303 });
297304 return ok(saved);
298305 }
299306
src/core/event_audience.ts +3
@@ -32,6 +32,9 @@ import type { User } from "./users.ts";
3232 export type EventAudience = "readers" | "admins" | "actor" | "app";
3333
3434 const READER_KINDS: ReadonlySet<string> = new Set([
35 // Whether a repository follows an upstream, never where: the address is
36 // the admins' (`repo.upstream_follow`).
37 "repo.following",
3538 "issue.labeled",
3639 "issue.closed",
3740 "issue.commented",
src/design/activity.ts +1
@@ -121,6 +121,7 @@ const WORDS: Record<string, string> = {
121121 "repo.forked": "forked a repository",
122122 "repo.settings_updated": "changed repository settings",
123123 "repo.upstream_follow": "changed which upstream the repository follows",
124 "repo.following": "turned following an upstream on or off",
124125 "repo.crew_outsiders": "changed who may trigger the crew",
125126 "repo.mirrored": "mirrored a repository",
126127 "repo.imported": "imported issues and pulls",
src/web/live.ts +1 −1
@@ -302,7 +302,7 @@ export const liveClientJs = /* js */ `
302302 'label.created', 'label.updated', 'label.deleted',
303303 'run.queued', 'run.started', 'run.finished', 'app.serving',
304304 'app.created', 'app.updated', 'app.deleted', 'app.stopped',
305 'repo.upstream_checked', 'limb.moved', 'limb.replan', 'limb.replan_taken_back', 'issue.assigned', 'pull.assigned', 'crew.added', 'crew.removed',
305 'repo.upstream_checked', 'repo.upstream_follow', 'repo.following', 'limb.moved', 'limb.replan', 'limb.replan_taken_back', 'issue.assigned', 'pull.assigned', 'crew.added', 'crew.removed',
306306 ];
307307 var extra = (cfg.getAttribute('data-repos') || '').split(' ').filter(Boolean);
308308 var watching = [repo].concat(extra).filter(function (r, i, all) {
test/upstream-follow-live.test.ts added +76
@@ -0,0 +1,76 @@
1 /** Turning following on or off reaches readers without the address (#3617). */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { setFollowUpstream } from "../src/actions/upstream_follow.ts";
10 import { audienceFilter, eventAudience } from "../src/core/event_audience.ts";
11 import { listEvents } from "../src/core/events.ts";
12 import { createUser, type User } from "../src/core/users.ts";
13 import type { Repo } from "../src/core/repos.ts";
14
15 let dataDir = "";
16 let app: App;
17 let alice: User;
18 let guest: User;
19 let repo: Repo;
20 const URL_SECRET = "https://example.com/private-owner/secret-repo.git";
21
22 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
23 if (!r.ok) throw new Error(JSON.stringify(r.error));
24 return r.value;
25 };
26
27 beforeAll(async () => {
28 dataDir = await mkdtemp(join(tmpdir(), "forge-follow-live-"));
29 app = createApp({ dataDir, port: 0 });
30 const user = (username: string, extra: object = {}) =>
31 createUser(app.db, {
32 origin: "seed",
33 trusted: true,
34 username,
35 email: `${username}@forge.local`,
36 password: "a-good-passphrase",
37 ...extra,
38 });
39 alice = ok(await user("alice", { adminIfFirst: true }));
40 guest = ok(await user("guest"));
41 repo = ok(await createRepo(app, alice, { name: "square", visibility: "public" }));
42 });
43
44 afterAll(async () => {
45 app?.close();
46 await rm(dataDir, { recursive: true, force: true });
47 });
48
49 test("a flip emits an admins' event and a readers' event without the URL", () => {
50 ok(setFollowUpstream(app, alice, repo, { url: URL_SECRET, branch: "main", enabled: true }));
51 const events = listEvents(app.db, {}).filter((e) => e.repoId === repo.id);
52 const admin = events.find((e) => e.kind === "repo.upstream_follow")!;
53 const reader = events.find((e) => e.kind === "repo.following")!;
54 expect(eventAudience("repo.upstream_follow")).toBe("admins");
55 expect(eventAudience("repo.following")).toBe("readers");
56 expect(reader.payload).toEqual({ enabled: true });
57 expect(JSON.stringify(reader)).not.toContain("secret-repo");
58 expect(JSON.stringify(reader)).not.toContain("example.com");
59
60 const forGuest = audienceFilter(app.db, guest);
61 expect(forGuest(reader)).toBe(true);
62 expect(forGuest(admin)).toBe(false);
63 expect(audienceFilter(app.db, alice)(admin)).toBe(true);
64 });
65
66 test("turning it off sends enabled false", () => {
67 ok(setFollowUpstream(app, alice, repo, { url: "", branch: "", enabled: false }));
68 const last = listEvents(app.db, {}).filter((e) => e.kind === "repo.following").sort((a, b) => (a.id < b.id ? 1 : -1))[0]!;
69 expect(last.payload).toEqual({ enabled: false });
70 });
71
72 test("the live client listens for both kinds", async () => {
73 const src = await Bun.file(join(import.meta.dir, "../src/web/live.ts")).text();

This reads the source text, so it would still pass if the listener never fired. Drive a real stream per audience instead.

74 expect(src).toContain("'repo.upstream_follow'");
75 expect(src).toContain("'repo.following'");
76 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs