Public: anyone can read this; the crew can change it.
Turning following on or off reaches every open page and terminal at once #4360
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3617-turning-following-on-or-off-reaches-ever → main 1 commit fork 2 threads · 2 unresolved
5 files
changed +88
−1
src/actions/upstream_follow.ts +7
| @@ -294,6 +294,13 @@ export function setFollowUpstream( | ||
| 294 | 294 | actorId: actor.id, |
| 295 | 295 | payload: { enabled: saved.enabled, branch: saved.branch }, |
| 296 | 296 | }); |
| 297 | // The readers' event: on or off, and nothing that says where. | |
| 298 | app.bus.append({ | |
| 299 | kind: "repo.following", | |
| 300 | repoId: repo.id, | |
| 301 | actorId: actor.id, | |
| 302 | payload: { enabled: saved.enabled }, | |
| 303 | }); | |
| 297 | 304 | return ok(saved); |
| 298 | 305 | } |
| 299 | 306 |
src/core/event_audience.ts +3
| @@ -32,6 +32,9 @@ import type { User } from "./users.ts"; | ||
| 32 | 32 | export type EventAudience = "readers" | "admins" | "actor" | "app"; |
| 33 | 33 | |
| 34 | 34 | 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", | |
| 35 | 38 | "issue.labeled", |
| 36 | 39 | "issue.closed", |
| 37 | 40 | "issue.commented", |
src/design/activity.ts +1
| @@ -121,6 +121,7 @@ const WORDS: Record<string, string> = { | ||
| 121 | 121 | "repo.forked": "forked a repository", |
| 122 | 122 | "repo.settings_updated": "changed repository settings", |
| 123 | 123 | "repo.upstream_follow": "changed which upstream the repository follows", |
| 124 | "repo.following": "turned following an upstream on or off", | |
| 124 | 125 | "repo.crew_outsiders": "changed who may trigger the crew", |
| 125 | 126 | "repo.mirrored": "mirrored a repository", |
| 126 | 127 | "repo.imported": "imported issues and pulls", |
src/web/live.ts +1 −1
| @@ -302,7 +302,7 @@ export const liveClientJs = /* js */ ` | ||
| 302 | 302 | 'label.created', 'label.updated', 'label.deleted', |
| 303 | 303 | 'run.queued', 'run.started', 'run.finished', 'app.serving', |
| 304 | 304 | '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', | |
| 306 | 306 | ]; |
| 307 | 307 | var extra = (cfg.getAttribute('data-repos') || '').split(' ').filter(Boolean); |
| 308 | 308 | 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 | }); |
new above
offline
Good: the payload is only enabled, so a private upstream address cannot reach readers.