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

A pull that starts waiting on a team's review shows one line in that team's members' feed #10166

Merged opened this

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

Merged

mason/forge:mason/10164-a-pull-that-starts-waiting-on-a-team-s-r → main 5 commits fork 1 thread · 1 unresolved

  • Merged. mason/10164-a-pull-that-starts-waiting-on-a-team-s-r was merged into main as 9a61c84.

Review

Sign in to review this change.
9 files changed +356 −3
docs/features/team-waits.md added +11
@@ -0,0 +1,11 @@
1 # A team is told once that a pull waits on its review (#10164)
2
3 When a pull first becomes held by a named team (`teamsHolding`, `src/core/issue_teams.ts`), one `pull.team_waiting` event goes on the outbox. The team's members who can read the repository see "owner/repo#12 waits on Design's review" in the feed: web `/feed`, MCP `feed` and the terminal's Feed screen all read `candidatesFor` (`src/feed/items.ts`).
4
5 - **When it is said:** `announceTeamWaits` (`src/actions/team_waits.ts`) runs after a local pull opens, after a review is recorded, and when `nameTeams` names teams on an issue that open pulls close (at most the 50 newest open pulls whose body mentions `#N`). It compares with the newest `pull.team_waiting` / `pull.team_answered` for that pull and team, in one read on the kind index, so a held pull is never told twice.
6 - **When it ends:** the team's current approval on the head stops the hold; `pull.team_answered` closes the wait (never a feed post), so a later hold is news again.
7 - **Who is told:** both kinds have the `team` audience (`src/core/event_audience.ts`): only active members of `payload.team`, on every surface that lists events (the feed, `/:owner/:name/events`, the API and MCP `events`, the event streams and the activity rail). Not the repository's other readers, admins included.
8 - **Who can read:** the feed's own window is cut by owner and org access; a team member who reads a private repository by being on its crew gets the wait too (`listTeamEventsFor` then `canRead`, the check the pull's page asks).
9 - **Not announced:** a push that makes an old approval stale, and pulls opened through a mirror's origin.
10 - **Try it:** name a team on an issue, open a pull that says `Closes #N`, and open `/feed` as a member of that team.
11 - Tests: `test/feed-team-waits.test.ts`.
src/actions/issue_teams.ts +2
@@ -12,6 +12,7 @@ import { err, type Result } from "../kernel/result.ts";
1212 import { isMirror } from "../origins/write-through.ts";
1313 import { requireWriter, type AccessError } from "./access.ts";
1414 import { MIRRORED_DO_IT_THERE } from "./mirror_words.ts";
15 import { announceIssueTeamWaits } from "./team_waits.ts";
1516
1617 /** One sentence per refusal, said the same by the page, API, MCP tool and terminal. */
1718 export const ISSUE_TEAMS_SAY: Record<string, string> = {
@@ -41,5 +42,6 @@ export function nameTeams(
4142 actorId: who.value.id,
4243 payload: { number: issue.number, teams: set.value },
4344 });
45 announceIssueTeamWaits(app, repo, issue.number, who.value.id);
4446 return set;
4547 }
src/actions/pulls.ts +3
@@ -8,6 +8,7 @@ import { teamsHolding } from "../core/issue_teams.ts";
88 import { isContributionFork, type GuestRefusal } from "../core/contribute.ts";
99 import { takeGuestPull } from "./guests.ts";
1010 import { requireAbility } from "./roles.ts";
11 import { announceTeamWaits } from "./team_waits.ts";
1112 import { addComment as addIssueComment } from "../core/issues.ts";
1213 import { landedNote, leftNote, unticked } from "../core/done_on_main.ts";
1314 import { resolveOpen } from "../core/decisions.ts";
@@ -356,6 +357,7 @@ async function createThePull(
356357 ...(assigned ? { assignee: user.username } : {}),
357358 },
358359 });
360 announceTeamWaits(app, baseRepo, created.value, user.id);
359361 return assigned ? ok({ ...created.value, ...assigneeOf(assigned) }) : created;
360362 }
361363
@@ -813,6 +815,7 @@ export async function submitReview(
813815 reviewId: recorded.value.id,
814816 },
815817 });
818 announceTeamWaits(app, repo, { ...pull, headOid: status.headOid }, user.id);
816819 // The boxes the reviewer read at this head: only a crew reviewer's approval
817820 // counts. A pull yet to land holds them for its landing, which applies them
818821 // only if the head is still this one; one that landed takes them now.
src/actions/team_waits.ts added +76
@@ -0,0 +1,76 @@
1 /**
2 * A pull that starts waiting on a named team's review says so once on the
3 * outbox (#10164): `pull.team_waiting`, which only that team's members are
4 * told (`event_audience.ts`, the "team" audience). While it stays held
5 * nothing more is said; when the team's approval ends the wait,
6 * `pull.team_answered` closes it, so a later hold is news again.
7 * `teamsHolding` decides; this only compares with what was last said.
8 */
9
10 import type { App } from "../app.ts";
11 import { teamsHolding } from "../core/issue_teams.ts";
12 import { closesFromBody, getPull, type Pull } from "../core/pulls.ts";
13 import type { Repo } from "../core/repos.ts";
14
15 /** Open pulls read when an issue's teams change: enough for any real fan-in, bounded either way. */
16 const CLOSERS_MAX = 50;
17
18 /**
19 * Whether each team's newest word on this pull was that it waits. One read:
20 * `+repo_id` keeps the planner on the kind index, so it walks only the
21 * team-wait events, never the repository's whole outbox.
22 */
23 function waitingNow(app: App, repoId: string, number: number): Map<string, boolean> {
24 const rows = app.db
25 .query<{ kind: string; team: string }, [string, number]>(
26 `SELECT kind, json_extract(payload, '$.team') AS team FROM event
27 WHERE kind IN ('pull.team_waiting', 'pull.team_answered') AND +repo_id = ?1
28 AND json_extract(payload, '$.number') = ?2
29 ORDER BY seq`,
30 )
31 .all(repoId, number);
32 const out = new Map<string, boolean>();
33 for (const r of rows) out.set(r.team, r.kind === "pull.team_waiting");
34 return out;
35 }
36
37 /** Say which named teams newly hold the pull, and which have stopped. */
38 export function announceTeamWaits(app: App, repo: Repo, pull: Pull, actorId: string | undefined): void {
39 const holding = teamsHolding(app.db, pull);
40 const said = waitingNow(app, repo.id, pull.number);
41 for (const v of holding) {
42 if (said.get(v.team) === true) continue;
43 app.bus.append({
44 kind: "pull.team_waiting",
45 repoId: repo.id,
46 actorId,
47 payload: { number: pull.number, team: v.team, name: v.name },
48 });
49 }
50 const held = new Set(holding.map((v) => v.team));
51 for (const [team, waiting] of said) {
52 if (!waiting || held.has(team)) continue;
53 app.bus.append({
54 kind: "pull.team_answered",
55 repoId: repo.id,
56 actorId,
57 payload: { number: pull.number, team },
58 });
59 }
60 }
61
62 /** Naming teams on an issue can newly hold the open pulls that close it. */
63 export function announceIssueTeamWaits(app: App, repo: Repo, issueNumber: number, actorId: string | undefined): void {
64 // Only pulls whose body mentions `#N` at all; `closesFromBody` decides which close it.
65 const open = app.db
66 .query<{ number: number }, [string, string]>(
67 `SELECT number FROM issue
68 WHERE repo_id = ?1 AND kind = 'pull' AND state = 'open' AND instr(body, ?2) > 0
69 ORDER BY number DESC LIMIT ${CLOSERS_MAX}`,
70 )
71 .all(repo.id, `#${issueNumber}`);
72 for (const { number } of open) {
73 const pull = getPull(app.db, repo.id, number);
74 if (pull !== undefined && closesFromBody(pull.body).includes(issueNumber)) announceTeamWaits(app, repo, pull, actorId);
75 }
76 }
src/core/event_audience.ts +16 −1
@@ -27,10 +27,11 @@ import type { Database } from "bun:sqlite";
2727 import type { ForgeEvent } from "./events.ts";
2828 import { getAppByRepo } from "./apps.ts";
2929 import { personOwners } from "./orgs.ts";
30 import { getTeam } from "./teams.ts";
3031 import { canAdmin, canEnterApp, canWrite, canSeeOwnerFacts, getRepoById } from "./repos.ts";
3132 import type { User } from "./users.ts";
3233
33 export type EventAudience = "readers" | "admins" | "actor" | "app" | "writers";
34 export type EventAudience = "readers" | "admins" | "actor" | "app" | "writers" | "team";
3435
3536 /**
3637 * Whether this viewer answers for a deleted repository: the forge's admin, who
@@ -261,6 +262,9 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
261262 // (#620, #599).
262263 if (kind.startsWith("run.") || kind.startsWith("app.") || kind.startsWith("look."))
263264 return "app";
265 // A pull waiting on a named team's review is that team's news alone
266 // (`actions/team_waits.ts`, #10164): its members, among those who may read.
267 if (kind === "pull.team_waiting" || kind === "pull.team_answered") return "team";
264268 if (READER_KINDS.has(kind)) return "readers";
265269 return undefined;
266270 }
@@ -337,6 +341,15 @@ export function audienceFilter(
337341 subjects.set(key, rows.get(key) ?? null);
338342 }
339343 }
344 const teams = new Map<string, boolean>();
345 const onTeam = (slug: unknown): boolean => {
346 if (typeof slug !== "string" || viewer === undefined || !isActive(viewer)) return false;
347 const known = teams.get(slug);
348 if (known !== undefined) return known;
349 const yes = getTeam(db, slug)?.members.includes(viewer.id) ?? false;
350 teams.set(slug, yes);
351 return yes;
352 };
340353 const hidden = (event: ForgeEvent): boolean => {
341354 const subject = eventSubject(event);
342355 if (subject === undefined) return false;
@@ -363,6 +376,8 @@ export function audienceFilter(
363376 return event.repoId !== null && mayWrite(event.repoId);
364377 case "actor":
365378 return viewer !== undefined && event.actorId === viewer.id;
379 case "team":
380 return onTeam(event.payload.team);
366381 case "admins":
367382 return event.repoId === null
368383 ? viewer?.isAdmin === true && isActive(viewer)
src/core/events.ts +24
@@ -175,6 +175,30 @@ export function listEventsVisibleTo(
175175 .map(toEvent);
176176 }
177177
178 /**
179 * The newest events of one kind addressed to a team this user is on
180 * (`payload.team`), from any repository: the caller owes the read check.
181 * The feed reads team waits this way (#10164) because its own window is
182 * cut by owner and org access, and a crew member who reads a private
183 * repository is not in it.
184 */
185 export function listTeamEventsFor(
186 db: Database,
187 userId: string,
188 kind: string,
189 limit = 50,
190 ): ForgeEvent[] {
191 return db
192 .query<EventRow, [string, string, number]>(
193 `SELECT * FROM event
194 WHERE kind = ?2 AND repo_id IS NOT NULL
195 AND json_extract(payload, '$.team') IN (SELECT team_slug FROM crew_team_member WHERE user_id = ?1)
196 ORDER BY seq DESC LIMIT ?3`,
197 )
198 .all(userId, kind, limit)
199 .map(toEvent);
200 }
201
178202 interface EventRow {
179203 id: string;
180204 kind: string;
src/design/activity.ts +2
@@ -164,6 +164,8 @@ const WORDS: Record<string, string> = {
164164 "pull.guest_approved": "approved a guest pull request",
165165 "pull.labeled": "labelled a pull request",
166166 "pull.held": "held a pull request",
167 "pull.team_waiting": "waits on a team's review",
168 "pull.team_answered": "was answered by a team's review",
167169 "pull.released": "released a pull request",
168170 "pull.thread_resolved": "resolved a review thread",
169171 "repo.created": "created a repository",
src/feed/items.ts +26 −2
@@ -11,7 +11,7 @@ import { isEnvName, type EnvName } from "../apps/data.ts";
1111 import type { Chips } from "../core/feed_chips.ts";
1212 import { listDecidedIn } from "../core/decisions.ts";
1313 import { audienceFilter } from "../core/event_audience.ts";
14 import { listEventsVisibleTo, type ForgeEvent } from "../core/events.ts";
14 import { listEventsVisibleTo, listTeamEventsFor, type ForgeEvent } from "../core/events.ts";
1515 import { getReview } from "../core/reviews.ts";
1616 import { commentAnchor, getIssuesByIds, getIssuesByNumbers, numberKey, type Issue } from "../core/issues.ts";
1717 import {
@@ -21,6 +21,7 @@ import {
2121 } from "../core/origins.ts";
2222 import { joinMany, toldBy, type Pull } from "../core/pulls.ts";
2323 import {
24 canRead,
2425 canSeeOwnerFacts,
2526 onCrew,
2627 getReposByIds,
@@ -126,6 +127,23 @@ const heardCache = new WeakMap<Database, Stamped<Heard>>();
126127 * count (both read it under the same write stamp). A quiet run older than the
127128 * window stays a post, so none vanishes uncounted.
128129 */
130 /**
131 * The team waits told to this viewer (#10164) from repositories they read
132 * that the feed's window leaves out — a private repository whose crew they
133 * are on — by the same `canRead` the pull's page asks, then the audience.
134 */
135 function teamWaitsHeard(db: Database, viewer: User, heard: readonly ForgeEvent[]): ForgeEvent[] {
136 const seen = new Set(heard.map((e) => e.id));
137 const fresh = listTeamEventsFor(db, viewer.id, "pull.team_waiting").filter((e) => !seen.has(e.id));
138 if (fresh.length === 0) return [];
139 const repos = getReposByIds(db, fresh.map((e) => e.repoId!));
140 const meant = audienceFilter(db, viewer, fresh);
141 return fresh.filter((e) => {
142 const repo = repos.get(e.repoId!);
143 return repo !== undefined && canRead(db, repo, viewer) && meant(e);
144 });
145 }
146
129147 function heardBy(db: Database, viewer: User, now: number): Heard {
130148 const work = (): Heard => {
131149 const recent = listEventsVisibleTo(db, viewer, { limit: EVENT_WINDOW });
@@ -326,7 +344,7 @@ export function candidatesFor(
326344 ): Candidate[] {
327345 // A quiet run is counted at the end of the first page, not posted.
328346 const { heard, quiet } = heardBy(db, viewer, now);
329 const events = heard.filter((e) => !quiet.has(e.id));
347 const events = [...heard.filter((e) => !quiet.has(e.id)), ...teamWaitsHeard(db, viewer, heard)];
330348 const writable = listReposWritableTo(db, viewer, { limit: 200 });
331349 const asks = withAsks ? askLinesFor(db, viewer, chips) : [];
332350 const decided = listDecidedIn(
@@ -714,6 +732,12 @@ function fromEvent(
714732 what: said === "" ? `replied on “${on}”` : `on “${on}”: ${clip(said)}`,
715733 };
716734 }
735 case "pull.team_waiting": {
736 const name = typeof p["name"] === "string" ? p["name"] : "";
737 if (number === undefined || name === "") return undefined;
738 const what = `${slug(repo)}#${number} waits on ${clip(name, 60)}'s review`;
739 return { ...common, kind: "pull", link: change, what, said: what };
740 }
717741 case "clog.cleared": {
718742 const note = typeof p["note"] === "string" ? p["note"] : "";
719743 if (note === "") return undefined;
test/feed-team-waits.test.ts added +196
@@ -0,0 +1,196 @@
1 /**
2 * A pull that starts waiting on a named team's review is told once to that
3 * team's members (#10164): one line, none for others, none again while it
4 * stays held, ended by the team's approval; the team's name is escaped.
5 */
6
7 import { seatAs } from "./helpers/seat.ts";
8 import { afterAll, beforeAll, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { nameTeams } from "../src/actions/issue_teams.ts";
14 import { openIssue } from "../src/actions/issues.ts";
15 import { openPull, submitReview } from "../src/actions/pulls.ts";
16 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
17 import { createApp, type App } from "../src/app.ts";
18 import { eventsPage } from "../src/core/events_page.ts";
19 import type { Pull } from "../src/core/pulls.ts";
20 import { canRead, type Repo } from "../src/core/repos.ts";
21 import { saveTeam } from "../src/core/teams.ts";
22 import { createToken } from "../src/core/tokens.ts";
23 import { createUser, getUserByName, type User } from "../src/core/users.ts";
24 import { candidatesFor } from "../src/feed/items.ts";
25 import { serve } from "../src/http/server.ts";
26 import { quietLogs } from "./helpers/globals.ts";
27
28 quietLogs();
29
30 let dataDir = "";
31 let app: App;
32 let alice: User;
33 let repo: Repo;
34 let n = 0;
35 const user = (name: string): User => getUserByName(app.db, name)!;
36 const waits = (who: string): string[] =>
37 candidatesFor(app.db, user(who), Date.now())
38 .map((c) => c.what)
39 .filter((w) => w.includes("waits on"));
40 const events = (kind: string) =>
41 app.db.query<{ id: string; payload: string }, [string]>("SELECT id, payload FROM event WHERE kind = ?1").all(kind);
42
43 const pullNaming = async (teams: string[], opts: { late?: true; in?: Repo } = {}): Promise<Pull> => {
44 const where = opts.in ?? repo;
45 const issue = openIssue(app, alice, where, { title: "A chart" });
46 if (!issue.ok) throw new Error(issue.error);
47 const name = () => {
48 const named = nameTeams(app, alice, where, issue.value, teams);
49 if (!named.ok) throw new Error(named.error);
50 };
51 if (opts.late !== true) name();
52 const branch = `w${++n}`;
53 await commitToBranch(app, alice, where, {
54 branch,
55 base: "main",
56 message: branch,
57 files: [{ path: `${branch}.md`, content: "x\n" }],
58 });
59 const o = await openPull(app, alice, where, {
60 title: branch,
61 body: `Closes #${issue.value.number}`,
62 baseBranch: "main",
63 headBranch: branch,
64 });
65 if (!o.ok) throw new Error(String(o.error));
66 if (opts.late === true) name();
67 return o.value;
68 };
69
70 beforeAll(async () => {
71 dataDir = await mkdtemp(join(tmpdir(), "forge-feed-team-waits-"));
72 app = createApp({ dataDir, port: 0 });
73 const made = await createUser(app.db, {
74 origin: "seed",
75 trusted: true,
76 username: "alice",
77 email: "[email protected]",
78 password: "a-good-passphrase",
79 adminIfFirst: true,
80 });
81 if (!made.ok) throw new Error(made.error);
82 alice = made.value;
83 const r = await createRepo(app, alice, { name: "garden", visibility: "public" });
84 if (!r.ok) throw new Error(String(r.error));
85 repo = r.value;
86 await commitToBranch(app, alice, repo, {
87 message: "seed",
88 files: [{ path: "README.md", content: "# garden\n" }],
89 });
90 for (const name of ["quill", "pike"]) {
91 const agent = await createAgent(app, alice, { username: name });
92 if (!agent.ok) throw new Error(String(agent.error));
93 addToCrew(app, alice, repo, name);
94 seatAs(app.db, repo.id, user(name).id, "reviewer");
95 }
96 saveTeam(app.db, "design", { charter: "", dial: 0, members: [user("quill").id] });
97 saveTeam(app.db, "data-science", { charter: "", dial: 0, members: [user("pike").id] });
98 }, 120_000);
99
100 afterAll(async () => {
101 for (let i = 0; i < 20; i++) await new Promise((done) => setImmediate(done));
102 await Bun.sleep(100);
103 app?.close();
104 if (dataDir !== "") await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
105 }, 30_000);
106
107 test("members see the wait once, non-members do not, approval ends it", async () => {
108 const pull = await pullNaming(["design"]);
109 const line = `alice/garden#${pull.number} waits on Design's review`;
110 expect(waits("quill")).toEqual([line]);
111 expect(waits("pike")).toEqual([]);
112 expect(events("pull.team_waiting")).toHaveLength(1);
113
114 // A comment review leaves it held: no second line.
115 await submitReview(app, user("pike"), repo, pull, { state: "commented", body: "hm" });
116 expect(events("pull.team_waiting")).toHaveLength(1);
117 expect(waits("quill")).toEqual([line]);
118
119 // The team approves: the wait is closed.
120 const approved = await submitReview(app, user("quill"), repo, pull, { state: "approved" });
121 expect(approved.ok).toBe(true);
122 expect(events("pull.team_answered")).toHaveLength(1);
123 expect(events("pull.team_waiting")).toHaveLength(1);
124 });
125
126 test("naming a team on an already-open pull's issue announces it once", async () => {
127 const pull = await pullNaming(["design"], { late: true });
128 expect(waits("quill")).toContain(`alice/garden#${pull.number} waits on Design's review`);
129 expect(events("pull.team_waiting").filter((e) => e.payload.includes(`"number":${pull.number},`))).toHaveLength(1);
130 });
131
132 test("on a private repository, a member who reads it sees the line and one who cannot does not", async () => {
133 const vault = await createRepo(app, alice, { name: "vault", visibility: "private" });
134 if (!vault.ok) throw new Error(String(vault.error));
135 await commitToBranch(app, alice, vault.value, {
136 message: "seed",
137 files: [{ path: "README.md", content: "# vault\n" }],
138 });
139 const agent = await createAgent(app, alice, { username: "moss" });
140 if (!agent.ok) throw new Error(String(agent.error));
141 // pike is on the vault's crew, so reads it; moss shares the team and is not.
142 addToCrew(app, alice, vault.value, "pike");
143 seatAs(app.db, vault.value.id, user("pike").id, "reviewer");
144 saveTeam(app.db, "data-science", { charter: "", dial: 0, members: [user("pike").id, user("moss").id] });
145 expect(canRead(app.db, vault.value, user("pike"))).toBe(true);
146 expect(canRead(app.db, vault.value, user("moss"))).toBe(false);
147 const pull = await pullNaming(["data-science"], { in: vault.value });
148 expect(waits("pike")).toContain(`alice/vault#${pull.number} waits on Data science's review`);
149 expect(waits("moss")).toEqual([]);
150 });
151
152 test("the events list shows the wait to the team's members and no other reader", async () => {
153 saveTeam(app.db, "design", { charter: "", dial: 0, members: [user("quill").id] });
154 const pull = await pullNaming(["design"]);
155 const mine = (who: User | undefined) =>
156 eventsPage(app.db, repo.id, who, 200).items.filter(
157 (e) => e.kind === "pull.team_waiting" && e.payload["number"] === pull.number,
158 );
159 expect(mine(user("quill"))).toHaveLength(1);
160 // pike reads this public repository and is not on Design; alice owns it and is not either.
161 expect(mine(user("pike"))).toEqual([]);
162 expect(mine(alice)).toEqual([]);
163 expect(mine(undefined)).toEqual([]);
164 });
165
166 test("the team's answer is never a post", async () => {
167 saveTeam(app.db, "design", { charter: "", dial: 0, members: [user("quill").id] });
168 const pull = await pullNaming(["design"]);
169 const approved = await submitReview(app, user("quill"), repo, pull, { state: "approved" });
170 expect(approved.ok).toBe(true);
171 const answered = app.db
172 .query<{ id: string }, []>("SELECT id FROM event WHERE kind = 'pull.team_answered'")
173 .all()
174 .map((e) => `event:${e.id}`);
175 expect(answered.length).toBeGreaterThan(0);
176 const ids = candidatesFor(app.db, user("quill"), Date.now()).map((c) => c.id);
177 expect(ids.some((id) => answered.includes(id))).toBe(false);
178 });
179
180 test("the line is escaped on the page a member reads", async () => {
181 const servers = await serve(app);
182 try {
183 const pull = await pullNaming(["design"]);
184 const token = createToken(app.db, user("quill").id, "ro", "ro").token;
185 const res = await fetch(`${servers.url}/feed`, { headers: { authorization: `Bearer ${token}` } });
186 const page = await res.text();
187 // The line is built from the repository's path (a validated slug) and the
188 // team's name, which only the migration that seeds teams sets: no door
189 // renames a team, so no real path can put < > & or " in it. The
190 // apostrophe in "Design's" is the one character a real path reaches.
191 expect(page).toContain(`garden#${pull.number} waits on Design&#39;s review`);
192 expect(page).not.toContain(`garden#${pull.number} waits on Design's review`);
193 } finally {
194 await servers.stop();
195 }
196 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs