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

A team's page lists the open pulls waiting on its review, oldest first #10171

Merged opened this

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

Merged

scout/forge:scout/10162-a-team-s-page-lists-the-open-pulls-waiti → main 7 commits fork 5 threads · 5 unresolved

  • Merged. scout/10162-a-team-s-page-lists-the-open-pulls-waiti was merged into main as fa9cc32.

Review

Sign in to review this change.
10 files changed +354 −11
docs/features/team-queue.md added +27
@@ -0,0 +1,27 @@
1 # The pulls waiting on a crew team (#10162)
2
3 A team's card on Settings › Teams (`/settings/teams`) lists the open pulls
4 the team still holds, **oldest first, at most 50**: `owner/repo #n` linked to
5 the pull, then its title and age, in the same plain rows (`status-rows`) as
6 the rest of the settings pages. A team with nothing waiting says "Nothing is
7 waiting on this team." in one muted line.
8
9 - **Holds** means what the merge gate means (`teamsHolding`): the pull closes
10 an issue naming the team, the team has a crew reviewer, and the pull has no
11 current approval on its head.

Untrue after the merge: the terminal does not use readTeamQueue. Fix the doc or route the screen through it.

12 - **Who reads:** both doors go through `readTeamQueue`
13 (`src/actions/teams.ts`): a forge admin who is a person, and no one else.
14 Pulls the reader may not read (private repositories, sealed reports) are
15 left out before the cap applies.
16 - **Web:** server-rendered on the card; timed as the `queue` phase.
17 - **Terminal:** open "Crew teams", move to a team and press `w`
18 (`src/tui/screens/teams.ts`); esc goes back. It reads `readTeamQueue`,
19 so it is the page's list under the page's gate.
20 - **MCP:** `org_teams` with `waiting: true` still answers from its own
21 `pullsWaitingOn` in `src/actions/issue_teams.ts` (#10173), under its
22 own gate (an admin-scoped token) and without the index; it is not this list.
23 - **Query:** `pullsWaitingOn` (`src/core/team_queue.ts`) reads the issues that
24 name the team (`issue_team_slug`), then walks open pulls in age order
25 (`issue_open_pull_age`): no sort, and none of the walk for a team nothing
26 names.
27 - Not live: the page refreshes on reload, not when a review lands.
src/actions/teams.ts +12
@@ -5,6 +5,7 @@
55
66 import type { App } from "../app.ts";
77 import { listAgents, type Agent } from "../core/agents.ts";
8 import { pullsWaitingOn, type QueuedPull } from "../core/team_queue.ts";
89 import { CHARTER_MAX, DIAL_MAX, getTeam, listTeams, parseDial, refusal, saveTeam, type Team, type TeamError } from "../core/teams.ts";
910 import type { User } from "../core/users.ts";
1011 import { err, ok, type Result } from "../kernel/result.ts";
@@ -35,6 +36,17 @@ export function readTeams(
3536 return who.ok ? ok({ teams: listTeams(app.db), agents: listAgents(app.db) }) : who;
3637 }
3738
39 /** The open pulls waiting on a team's review, as its viewer may read them: the page's list and the terminal's. */
40 export function readTeamQueue(
41 app: App,
42 actor: User | undefined,
43 slug: string,
44 ): Result<QueuedPull[], "not_found"> {
45 const who = admin(actor);
46 if (!who.ok) return who;
47 return getTeam(app.db, slug) ? ok(pullsWaitingOn(app.db, slug, who.value)) : err("not_found");
48 }
49
3850 /** What a change would make of a team, or why it is refused; nothing is written. */
3951 export function planTeam(
4052 app: App,
src/core/migration-pins/20261109160000-team-queue-indexes.pin added +1
@@ -0,0 +1,1 @@
1 eb2787b5fd85cae37d2321f656c6af4aec692cac1027ff23a2091c14ba523281
src/core/migration-pins/20261109170000-issue-team-by-slug.pin added +1
@@ -0,0 +1,1 @@
1 c6278df84b2d64f7ebfb31a1da2f8926e2737a9184cec1fe7dee28a19f7ce611
src/core/migrations/20261109160000-team-queue-indexes.ts added +6
@@ -0,0 +1,6 @@
1 // A team's page lists the open pulls waiting on its review, oldest first
2 // (#10162): open pulls are walked in age order from a partial index of just
3 // them, no sort and no table scan. Idempotent. Additive.
4 export default `
5 CREATE INDEX IF NOT EXISTS issue_open_pull_age ON issue(created_at, id) WHERE state = 'open' AND kind = 'pull';
6 `;
src/core/migrations/20261109170000-issue-team-by-slug.ts added +5
@@ -0,0 +1,5 @@
1 // The issues naming a team are read by team (#10162): the table's key leads
2 // with the issue, so a team's lookup gets its own index. Idempotent. Additive.
3 export default `
4 CREATE INDEX IF NOT EXISTS issue_team_slug ON issue_team(team_slug, issue_id);
5 `;
src/core/team_queue.ts added +81
@@ -0,0 +1,81 @@
1 /**

Both doors: no src/tui/ screen reads pullsWaitingOn and no docs/features/<name>.md explains it. Add both; the terminal must gate reads exactly as the page does.

2 * What waits on a crew team (#10162): the open pulls that close an issue
3 * naming the team and that the team still holds (`teamsHolding`), oldest
4 * first. Rows are cut at `limit` after the viewer's reading rights apply.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { closesFromBody } from "./pulls.ts";
9 import { teamsHolding } from "./issue_teams.ts";
10 import { canRead, getRepoById } from "./repos.ts";
11 import { mayView } from "./sealed.ts";
12 import type { User } from "./users.ts";
13
14 export const QUEUE_MAX = 50;
15
16 export interface QueuedPull {
17 readonly repoId: string;
18 readonly owner: string;
19 readonly repo: string;
20 readonly number: number;
21 readonly title: string;
22 readonly createdAt: string;
23 }
24
25 interface Row {
26 id: string;
27 repo_id: string;
28 number: number;
29 title: string;
30 body: string;
31 created_at: string;
32 sealed: number;
33 author_id: string;
34 head_oid: string;
35 }
36
37 /** The SQL of the walk, shared with the plan test. */
38 export const OPEN_PULLS_OLDEST_FIRST = `SELECT issue.id, issue.repo_id, issue.number, issue.title, issue.body,
39 issue.created_at, issue.sealed, issue.author_id, pull.head_oid
40 FROM issue JOIN pull ON pull.issue_id = issue.id
41 WHERE issue.state = 'open' AND issue.kind = 'pull' AND pull.merged_at IS NULL
42 ORDER BY issue.created_at, issue.id`;
43
44 /** The issues that name `slug`, as `repoId#number`: the only ones a pull can hold for it. */
45 export const NAMING_TEAM = `SELECT issue.repo_id, issue.number
46 FROM issue_team JOIN issue ON issue.id = issue_team.issue_id
47 WHERE issue_team.team_slug = ?1`;
48
49 export function pullsWaitingOn(
50 db: Database,
51 slug: string,
52 viewer: User | undefined,
53 limit = QUEUE_MAX,
54 ): QueuedPull[] {
55 const out: QueuedPull[] = [];
56 const naming = new Set(
57 db.query<{ repo_id: string; number: number }, [string]>(NAMING_TEAM).all(slug).map((r) => `${r.repo_id}#${r.number}`),
58 );
59 if (naming.size === 0) return out;
60 const rows = db.query<Row, []>(OPEN_PULLS_OLDEST_FIRST).iterate();
61 const repos = new Map<string, ReturnType<typeof getRepoById>>();
62 for (const r of rows) {
63 if (out.length >= limit) break;
64 if (!closesFromBody(r.body).some((n) => naming.has(`${r.repo_id}#${n}`))) continue;
65 if (!repos.has(r.repo_id)) repos.set(r.repo_id, getRepoById(db, r.repo_id));
66 const repo = repos.get(r.repo_id);
67 if (!repo || !canRead(db, repo, viewer)) continue;
68 if (!mayView(db, repo, { sealed: r.sealed === 1, authorId: r.author_id }, viewer)) continue;
69 const holding = teamsHolding(db, { id: r.id, repoId: r.repo_id, body: r.body, headOid: r.head_oid });
70 if (!holding.some((v) => v.team === slug)) continue;
71 out.push({
72 repoId: r.repo_id,
73 owner: repo.ownerName,
74 repo: repo.name,
75 number: r.number,
76 title: r.title,
77 createdAt: r.created_at,
78 });
79 }
80 return out;
81 }
src/tui/screens/teams.ts +8 −6
@@ -7,8 +7,7 @@
77 * nothing and sets nothing.
88 */
99
10 import { pullsWaitingOn } from "../../actions/issue_teams.ts";
11 import { changeTeam, readTeams, teamChanges, TEAM_SAYS } from "../../actions/teams.ts";
10 import { changeTeam, readTeamQueue, readTeams, teamChanges, TEAM_SAYS } from "../../actions/teams.ts";
1211 import type { Ctx, Screen } from "../app/app.ts";
1312 import { binding } from "../app/keys.ts";
1413 import { style } from "../term/style.ts";
@@ -18,11 +17,14 @@ import { fill, len, split } from "../ui/layout.ts";
1817 import { ListState, drawList, promptScreen, type ListRow } from "./common.ts";
1918 import type { PaletteEntry } from "./entries.ts";
2019
21 /** The open pulls a team's review holds, oldest first: `pullsWaitingOn`, as the MCP tool lists them. */
20 /**
21 * The open pulls a team's review holds, oldest first: `readTeamQueue`, the
22 * same gate and list as the team's card on /settings/teams.
23 */
2224 function waitingScreen(f: Session, ctx: Ctx, slug: string, name: string): Screen {
2325 const state = new ListState();
2426 const read = () => {
25 const w = pullsWaitingOn(f.app, f.user, slug);
27 const w = readTeamQueue(f.app, f.user, slug);
2628 return w.ok ? w.value : [];
2729 };
2830 return {
@@ -30,9 +32,9 @@ function waitingScreen(f: Session, ctx: Ctx, slug: string, name: string): Screen
3032 keys: [binding(["j", "k"], "move cursor"), binding(["esc"], "back")],
3133 render(area, s) {
3234 const rows: ListRow[] = read().map((p) => ({
33 title: `${p.repo}#${p.number} ${p.title}`,
35 title: `${p.owner}/${p.repo}#${p.number} ${p.title}`,
3436 secondary: "",
35 meta: `since ${p.since.slice(0, 10)}`,
37 meta: `since ${p.createdAt.slice(0, 10)}`,
3638 }));
3739 if (rows.length === 0)
3840 s.text(area.x + 1, area.y, `${name} is holding no pull.`, style(ctx.theme.c.mutedFg, ctx.theme.c.bg), area.x + area.w);
src/web/pages/teams.ts +22 −5
@@ -4,8 +4,9 @@
44 * else gets the not-found page, before a body is read.
55 */
66
7 import { changeTeam, readTeams, teamChanges, TEAM_SAYS } from "../../actions/teams.ts";
7 import { changeTeam, readTeamQueue, readTeams, teamChanges, TEAM_SAYS } from "../../actions/teams.ts";
88 import { CHARTER_MAX, DIAL_MAX } from "../../core/teams.ts";
9 import { timeAgo } from "../ui.ts";
910 import type { Ctx } from "../../http/context.ts";
1011 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1112 import { notFoundPage, settingsLayout } from "../chrome.ts";
@@ -28,9 +29,25 @@ function regions(ctx: Ctx, timed = false): Record<string, string> | undefined {
2829 const { teams, agents } = read.value;
2930 const changes = phase("changes", () => teamChanges(ctx.app, 10));
3031 const total = teams.reduce((n, t) => n + t.dial, 0);
31 const cards = teams.map((t) =>
32 html`<section>
32 const queues = phase("queue", () =>
33 new Map(teams.map((t) => {
34 const q = readTeamQueue(ctx.app, ctx.auth.user, t.slug);
35 return [t.slug, q.ok ? q.value : []] as const;
36 })),
37 );
38 const cards = teams.map((t) => {
39 const waiting = queues.get(t.slug) ?? [];
40 return html`<section>
outdated

Principles 1/3/7: a second heading per card explaining itself plus a sub-list inside a form card. Fold into the existing section rhythm and use the design system's row component (a dl/rows) rather than a bare h3 + ul.

3341 <h2>${icon("bot")} ${t.name}</h2>
42 ${waiting.length === 0
43 ? html`<p class="muted small">Nothing is waiting on this team.</p>`
44 : html`<p class="muted small">Waiting on this team, oldest first</p>
outdated

Hand-rolled li with inline link, title and age: on a 360px phone a long title wraps against the age with nothing separating them. Use the named row component so repo/title/age have defined columns and wrap safely.

45 <dl class="status-rows">${waiting.map(
46 (w) => html`<div class="status-row">
47 <dt><a href="/${w.owner}/${w.repo}/pulls/${String(w.number)}">${w.owner}/${w.repo} #${String(w.number)}</a></dt>
48 <dd>${w.title} <span class="muted">${timeAgo(w.createdAt)}</span></dd>
49 </div>`,
50 )}</dl>`}
3451 <form method="post" action="/settings/teams/${t.slug}">
3552 <div class="field">
3653 <label for="charter-${t.slug}">Charter</label>
@@ -58,8 +75,8 @@ function regions(ctx: Ctx, timed = false): Record<string, string> | undefined {
5875 ${btn("Save", { variant: "primary", label: `Save ${t.name}` })}
5976 </div>
6077 </form>
61 </section>`,
62 );
78 </section>`;
79 });
6380 const out: Record<string, Html> = {
6481 "teams-total": html`${String(total)}%`,
6582 "teams-cards": html`${cards}`,
test/team-queue.test.ts added +191
@@ -0,0 +1,191 @@
1 /** A team's page lists the open pulls waiting on its review, oldest first (#10162). */
2 import { afterEach, beforeEach, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { addToCrew, createAgent } from "../src/actions/crew.ts";
7 import { nameTeams } from "../src/actions/issue_teams.ts";
8 import { openIssue } from "../src/actions/issues.ts";
9 import { openPull } from "../src/actions/pulls.ts";
10 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import type { Repo } from "../src/core/repos.ts";
13 import { submitReview } from "../src/core/reviews.ts";
14 import { NAMING_TEAM, OPEN_PULLS_OLDEST_FIRST, pullsWaitingOn, QUEUE_MAX } from "../src/core/team_queue.ts";
15 import { saveTeam } from "../src/core/teams.ts";
16 import { createUser, getUserByName, type User } from "../src/core/users.ts";
17 import { createSession } from "../src/core/sessions.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { readTeamQueue } from "../src/actions/teams.ts";
20 import { createForgeApp } from "../src/tui/forge.ts";
21 import { headlessTerminal } from "../src/tui/term/terminal.ts";
22 import { settleApp } from "./helpers/tui.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24 import { expectWork } from "./helpers/work.ts";
25 import { statements } from "./helpers/statements.ts";
26 import { seatAs } from "./helpers/seat.ts";
27
28 quietLogs();
29
30 describe("pulls waiting on a team", () => {
31 let dataDir = "";
32 let app: App;
33 let servers: Servers | undefined;
34 let alice: User;
35 let repo: Repo;
36 let n = 0;
37 const id = (name: string) => getUserByName(app.db, name)!.id;
38
39 const pullNaming = async (title: string, teams: string[]) => {
40 const issue = openIssue(app, alice, repo, { title: "A chart" });
41 if (!issue.ok) throw new Error(issue.error);
42 const set = nameTeams(app, alice, repo, issue.value, teams);
43 if (!set.ok) throw new Error(set.error);
44 const branch = `h${++n}`;
45 const c = await commitToBranch(app, alice, repo, {
46 branch, base: "main", message: branch, files: [{ path: `${branch}.md`, content: "x\n" }],
47 });
48 if (!c.ok) throw new Error(JSON.stringify(c.error));
49 const o = await openPull(app, alice, repo, {
50 title, body: `Closes #${issue.value.number}`, baseBranch: "main", headBranch: branch,
51 });
52 if (!o.ok) throw new Error(String(o.error));
53 return o.value;
54 };
55
56 const person = async (username: string) => {
57 const made = await createUser(app.db, {
58 origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase",
59 });
60 if (!made.ok) throw new Error(made.error);
61 return made.value;
62 };
63
64 // Each test gets its own forge: nothing is carried from the one before.
65 beforeEach(async () => {
66 n = 0;
67 servers = undefined;
68 dataDir = await mkdtemp(join(tmpdir(), "forge-team-queue-"));
69 app = createApp({ dataDir, port: 0 });
70 const made = await createUser(app.db, {
71 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
72 password: "a-good-passphrase", adminIfFirst: true,
73 });
74 if (!made.ok) throw new Error(made.error);
75 alice = made.value;
76 const r = await createRepo(app, alice, { name: "garden", visibility: "private" });
77 if (!r.ok) throw new Error(String(r.error));
78 repo = r.value;
79 const seeded = await commitToBranch(app, alice, repo, {
80 message: "seed", files: [{ path: "README.md", content: "# garden\n" }],
81 });
82 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
83 const agent = await createAgent(app, alice, { username: "quill" });
84 if (!agent.ok) throw new Error(String(agent.error));
85 addToCrew(app, alice, repo, "quill");
86 seatAs(app.db, repo.id, id("quill"), "reviewer");
87 saveTeam(app.db, "design", { charter: "", dial: 0, members: [id("quill")] });
88 saveTeam(app.db, "security", { charter: "", dial: 0, members: [] });
89 }, 120_000);
90
91 afterEach(async () => {
92 await servers?.stop();
93 for (let i = 0; i < 20; i++) await new Promise((done) => setImmediate(done));
94 await Bun.sleep(100);
95 app?.close();
96 if (dataDir !== "") await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
97 }, 30_000);
98
99 test("lists held pulls oldest first, drops approved ones, hides what the viewer may not read", async () => {
100 const first = await pullNaming("<b>First</b>", ["design"]);
101 const second = await pullNaming("Second", ["design"]);
102 const cleared = await pullNaming("Cleared", ["design"]);
103 submitReview(app.db, { issueId: cleared.id, authorId: id("quill"), state: "approved", commitOid: cleared.headOid });
104 await pullNaming("Elsewhere", ["security"]);
105 expect(pullsWaitingOn(app.db, "design", alice).map((p) => p.title)).toEqual(["<b>First</b>", "Second"]);
106 expect(pullsWaitingOn(app.db, "design", alice)[0]).toMatchObject({ owner: "alice", repo: "garden", number: first.number });
107 expect(second.number).toBeGreaterThan(first.number);
108 // The repository is private: a stranger and a signed-out viewer see none of it.
109 expect(pullsWaitingOn(app.db, "design", await person("bob"))).toEqual([]);
outdated

Box 3 asks that a test measures the query time; this counts statements with a loose 600 budget. Also the page test uses an as never ctx cast and depends on state from the previous test. Make each test self-contained.

110 expect(pullsWaitingOn(app.db, "design", undefined)).toEqual([]);
111 });
112
113 test("the web page escapes the title, links the pull and says so when a team has nothing", async () => {
114 const first = await pullNaming("<b>First</b>", ["design"]);
115 servers = await serve(app);
116 const res = await fetch(`${servers.url}/settings/teams`, {
117 headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` },
118 });
119 const body = await res.text();
120 expect(res.status).toBe(200);
121 expect(body).toContain("Waiting on this team");
122 expect(body).toContain("&lt;b&gt;First&lt;/b&gt;");
123 expect(body).not.toContain("<b>First</b>");
124 expect(body).toContain(`/alice/garden/pulls/${first.number}`);
125 expect(body).toContain("Nothing is waiting on this team.");
126 expect(body).not.toContain("<h3>Waiting on this team");
127 });
128
129 test("the terminal reads through the page's gate: an admin lists, anyone else is refused", async () => {
130 await pullNaming("Terminal pull", ["design"]);
131 expect(readTeamQueue(app, alice, "design")).toMatchObject({ ok: true });
132 expect(readTeamQueue(app, await person("bob"), "design")).toMatchObject({ ok: false });
133 expect(readTeamQueue(app, undefined, "design")).toMatchObject({ ok: false });
134 expect(readTeamQueue(app, alice, "nope")).toEqual({ ok: false, error: "not_found" });
135
136 const open = async (username: string) => {
137 const terminal = headlessTerminal(140, 32);
138 const { app: tui } = createForgeApp(
139 app,
140 { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(app.db, username)! },
141 { terminal, exitHooks: false },
142 );
143 tui.start();
144 const key = async (k: string, ctrl = false) => {
145 tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
146 await settleApp(tui);
147 };
148 await key("k", true);
149 for (const ch of "Crew teams") await key(ch);
150 await key("enter");
151 await key("w");
152 const text = tui.screen.toText();
153 tui.stop();
154 return text;
155 };
156 const seen = await open("alice");
157 expect(seen).toContain("Terminal pull");
158 expect(seen).toContain("garden#");
159 expect((await open("bob")).includes("Terminal pull")).toBe(false);
160 });
161
162 test("200 open pulls: at most 50 listed, with bounded work, walking an index", async () => {
163 await pullNaming("First", ["design"]);
164 const db = app.db;
165 const src = db.query<Record<string, unknown>, []>("SELECT * FROM issue WHERE title = 'First'").get()!;
166 const pullRow = db.query<Record<string, unknown>, [string]>("SELECT * FROM pull WHERE issue_id = ?1").get(src.id as string)!;
167 const insert = (table: string, row: Record<string, unknown>) =>
168 db.query(`INSERT INTO ${table} (${Object.keys(row).join(",")}) VALUES (${Object.keys(row).map(() => "?").join(",")})`)
169 .run(...(Object.values(row) as never[]));
170 db.transaction(() => {
171 for (let i = 0; i < 200; i++) {
172 const nid = `bulk-${i}`;
173 insert("issue", { ...src, id: nid, number: 1000 + i, created_at: `2030-01-01T00:00:${String(i % 60).padStart(2, "0")}.${String(i).padStart(3, "0")}Z` });
174 insert("pull", { ...pullRow, issue_id: nid });
175 }
176 })();
177 // Work, not a clock (a stopwatch is a coin flip in a loaded check cell; see test/helpers/work.ts):
178 // statements run, bounded by the pulls walked — a handful of reads each, no N+1 beyond that.
179 const cost = (slug: string) => statements(db, async () => void pullsWaitingOn(db, slug, alice));
180 expect(pullsWaitingOn(db, "design", alice).length).toBe(QUEUE_MAX);
181 expectWork(await cost("design"), { max: 20 * QUEUE_MAX });
182 // A team holding nothing never walks the open pulls at all.
183 expect(pullsWaitingOn(db, "security", alice)).toEqual([]);
184 expectWork(await cost("security"), { max: 5 });
185 const plan = db.query(`EXPLAIN QUERY PLAN ${OPEN_PULLS_OLDEST_FIRST}`).all().map((r) => (r as { detail: string }).detail);
186 expect(plan.join("\n")).toContain("issue_open_pull_age");
187 for (const line of plan) expect(line).not.toContain("TEMP B-TREE");
188 const naming = db.query(`EXPLAIN QUERY PLAN ${NAMING_TEAM}`).all("x").map((r) => (r as { detail: string }).detail);
189 expect(naming.join("\n")).toContain("issue_team_slug");
190 });
191 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs