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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10164-a-pull-that-starts-waiting-on-a-team-s-r → main 5 commits fork 1 thread · 1 unresolved
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"; | ||
| 12 | 12 | import { isMirror } from "../origins/write-through.ts"; |
| 13 | 13 | import { requireWriter, type AccessError } from "./access.ts"; |
| 14 | 14 | import { MIRRORED_DO_IT_THERE } from "./mirror_words.ts"; |
| 15 | import { announceIssueTeamWaits } from "./team_waits.ts"; | |
| 15 | 16 | |
| 16 | 17 | /** One sentence per refusal, said the same by the page, API, MCP tool and terminal. */ |
| 17 | 18 | export const ISSUE_TEAMS_SAY: Record<string, string> = { |
| @@ -41,5 +42,6 @@ export function nameTeams( | ||
| 41 | 42 | actorId: who.value.id, |
| 42 | 43 | payload: { number: issue.number, teams: set.value }, |
| 43 | 44 | }); |
| 45 | announceIssueTeamWaits(app, repo, issue.number, who.value.id); | |
| 44 | 46 | return set; |
| 45 | 47 | } |
src/actions/pulls.ts +3
| @@ -8,6 +8,7 @@ import { teamsHolding } from "../core/issue_teams.ts"; | ||
| 8 | 8 | import { isContributionFork, type GuestRefusal } from "../core/contribute.ts"; |
| 9 | 9 | import { takeGuestPull } from "./guests.ts"; |
| 10 | 10 | import { requireAbility } from "./roles.ts"; |
| 11 | import { announceTeamWaits } from "./team_waits.ts"; | |
| 11 | 12 | import { addComment as addIssueComment } from "../core/issues.ts"; |
| 12 | 13 | import { landedNote, leftNote, unticked } from "../core/done_on_main.ts"; |
| 13 | 14 | import { resolveOpen } from "../core/decisions.ts"; |
| @@ -356,6 +357,7 @@ async function createThePull( | ||
| 356 | 357 | ...(assigned ? { assignee: user.username } : {}), |
| 357 | 358 | }, |
| 358 | 359 | }); |
| 360 | announceTeamWaits(app, baseRepo, created.value, user.id); | |
| 359 | 361 | return assigned ? ok({ ...created.value, ...assigneeOf(assigned) }) : created; |
| 360 | 362 | } |
| 361 | 363 | |
| @@ -813,6 +815,7 @@ export async function submitReview( | ||
| 813 | 815 | reviewId: recorded.value.id, |
| 814 | 816 | }, |
| 815 | 817 | }); |
| 818 | announceTeamWaits(app, repo, { ...pull, headOid: status.headOid }, user.id); | |
| 816 | 819 | // The boxes the reviewer read at this head: only a crew reviewer's approval |
| 817 | 820 | // counts. A pull yet to land holds them for its landing, which applies them |
| 818 | 821 | // 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"; | ||
| 27 | 27 | import type { ForgeEvent } from "./events.ts"; |
| 28 | 28 | import { getAppByRepo } from "./apps.ts"; |
| 29 | 29 | import { personOwners } from "./orgs.ts"; |
| 30 | import { getTeam } from "./teams.ts"; | |
| 30 | 31 | import { canAdmin, canEnterApp, canWrite, canSeeOwnerFacts, getRepoById } from "./repos.ts"; |
| 31 | 32 | import type { User } from "./users.ts"; |
| 32 | 33 | |
| 33 | export type EventAudience = "readers" | "admins" | "actor" | "app" | "writers"; | |
| 34 | export type EventAudience = "readers" | "admins" | "actor" | "app" | "writers" | "team"; | |
| 34 | 35 | |
| 35 | 36 | /** |
| 36 | 37 | * Whether this viewer answers for a deleted repository: the forge's admin, who |
| @@ -261,6 +262,9 @@ export function classifiedAudience(kind: string): EventAudience | undefined { | ||
| 261 | 262 | // (#620, #599). |
| 262 | 263 | if (kind.startsWith("run.") || kind.startsWith("app.") || kind.startsWith("look.")) |
| 263 | 264 | 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"; | |
| 264 | 268 | if (READER_KINDS.has(kind)) return "readers"; |
| 265 | 269 | return undefined; |
| 266 | 270 | } |
| @@ -337,6 +341,15 @@ export function audienceFilter( | ||
| 337 | 341 | subjects.set(key, rows.get(key) ?? null); |
| 338 | 342 | } |
| 339 | 343 | } |
| 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 | }; | |
| 340 | 353 | const hidden = (event: ForgeEvent): boolean => { |
| 341 | 354 | const subject = eventSubject(event); |
| 342 | 355 | if (subject === undefined) return false; |
| @@ -363,6 +376,8 @@ export function audienceFilter( | ||
| 363 | 376 | return event.repoId !== null && mayWrite(event.repoId); |
| 364 | 377 | case "actor": |
| 365 | 378 | return viewer !== undefined && event.actorId === viewer.id; |
| 379 | case "team": | |
| 380 | return onTeam(event.payload.team); | |
| 366 | 381 | case "admins": |
| 367 | 382 | return event.repoId === null |
| 368 | 383 | ? viewer?.isAdmin === true && isActive(viewer) |
src/core/events.ts +24
| @@ -175,6 +175,30 @@ export function listEventsVisibleTo( | ||
| 175 | 175 | .map(toEvent); |
| 176 | 176 | } |
| 177 | 177 | |
| 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 | ||
| 178 | 202 | interface EventRow { |
| 179 | 203 | id: string; |
| 180 | 204 | kind: string; |
src/design/activity.ts +2
| @@ -164,6 +164,8 @@ const WORDS: Record<string, string> = { | ||
| 164 | 164 | "pull.guest_approved": "approved a guest pull request", |
| 165 | 165 | "pull.labeled": "labelled a pull request", |
| 166 | 166 | "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", | |
| 167 | 169 | "pull.released": "released a pull request", |
| 168 | 170 | "pull.thread_resolved": "resolved a review thread", |
| 169 | 171 | "repo.created": "created a repository", |
src/feed/items.ts +26 −2
| @@ -11,7 +11,7 @@ import { isEnvName, type EnvName } from "../apps/data.ts"; | ||
| 11 | 11 | import type { Chips } from "../core/feed_chips.ts"; |
| 12 | 12 | import { listDecidedIn } from "../core/decisions.ts"; |
| 13 | 13 | 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"; | |
| 15 | 15 | import { getReview } from "../core/reviews.ts"; |
| 16 | 16 | import { commentAnchor, getIssuesByIds, getIssuesByNumbers, numberKey, type Issue } from "../core/issues.ts"; |
| 17 | 17 | import { |
| @@ -21,6 +21,7 @@ import { | ||
| 21 | 21 | } from "../core/origins.ts"; |
| 22 | 22 | import { joinMany, toldBy, type Pull } from "../core/pulls.ts"; |
| 23 | 23 | import { |
| 24 | canRead, | |
| 24 | 25 | canSeeOwnerFacts, |
| 25 | 26 | onCrew, |
| 26 | 27 | getReposByIds, |
| @@ -126,6 +127,23 @@ const heardCache = new WeakMap<Database, Stamped<Heard>>(); | ||
| 126 | 127 | * count (both read it under the same write stamp). A quiet run older than the |
| 127 | 128 | * window stays a post, so none vanishes uncounted. |
| 128 | 129 | */ |
| 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 | ||
| 129 | 147 | function heardBy(db: Database, viewer: User, now: number): Heard { |
| 130 | 148 | const work = (): Heard => { |
| 131 | 149 | const recent = listEventsVisibleTo(db, viewer, { limit: EVENT_WINDOW }); |
| @@ -326,7 +344,7 @@ export function candidatesFor( | ||
| 326 | 344 | ): Candidate[] { |
| 327 | 345 | // A quiet run is counted at the end of the first page, not posted. |
| 328 | 346 | 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)]; | |
| 330 | 348 | const writable = listReposWritableTo(db, viewer, { limit: 200 }); |
| 331 | 349 | const asks = withAsks ? askLinesFor(db, viewer, chips) : []; |
| 332 | 350 | const decided = listDecidedIn( |
| @@ -714,6 +732,12 @@ function fromEvent( | ||
| 714 | 732 | what: said === "" ? `replied on “${on}”` : `on “${on}”: ${clip(said)}`, |
| 715 | 733 | }; |
| 716 | 734 | } |
| 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 | } | |
| 717 | 741 | case "clog.cleared": { |
| 718 | 742 | const note = typeof p["note"] === "string" ? p["note"] : ""; |
| 719 | 743 | 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's review`); | |
| 192 | expect(page).not.toContain(`garden#${pull.number} waits on Design's review`); | |
| 193 | } finally { | |
| 194 | await servers.stop(); | |
| 195 | } | |
| 196 | }); |