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

MCP `org_teams` with `waiting: true` answers the open pulls a team's review holds #10173

Merged opened this

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

Merged

torque/forge:torque/10163-mcp-org-teams-with-waiting-true-answers → main 3 commits fork 1 thread · 1 unresolved

  • Merged. torque/10163-mcp-org-teams-with-waiting-true-answers was merged into main as bdf6072.

Review

Sign in to review this change.
6 files changed +329 −5
docs/features/team-roles.md +1
@@ -20,6 +20,7 @@ crew, as before.
2020 `action: "remove_member", team, user, role?` (the same as the DELETE above:
2121 with `role` only that role comes off), and `action: "members"` to list
2222 members with their roles.
23 - **MCP, waiting:** see docs/features/team-waiting.md (`org_teams { org, team, waiting: true }`).
2324 - **Terminal:** the "Organization teams" command, Enter on a team, then `r` on
2425 a member to pick a role (org admins only).
2526
docs/features/team-waiting.md added +18
@@ -0,0 +1,18 @@
1 # The pulls a crew team's review holds
2
3 A pull that closes an issue naming a crew team waits on that team's reviewer
4 until it approves the head (`teamsHolding` in `src/core/issue_teams.ts`, the
5 question the merge gate asks). `pullsWaitingOn` in `src/actions/issue_teams.ts`
6 lists them: oldest first (by when the pull was opened), at most 50, only pulls
7 the asker may read (repository access and sealed issues). One function serves
8 both doors.
9
10 - **MCP:** `org_teams { org, team, waiting: true }` (admin-scope token) answers
11 `{ items: [{ repo, number, title, since }] }`. `team` is a crew-team slug or
12 name; an unknown one is `not_found`.
13 - **Terminal:** Crew teams (forge admins), select a team, press `w`.
14
15 Crew teams belong to the whole forge, not to an organization, so `org` only has
16 to exist and does not narrow the answer. That is safe: what a caller sees is
17 decided by their read access to each repository, not by the org they name.
18 There is no web page for it.
src/actions/issue_teams.ts +59 −3
@@ -4,11 +4,14 @@
44 */
55
66 import type { App } from "../app.ts";
7 import { setIssueTeams, TEAMS_MAX, type IssueTeamsError } from "../core/issue_teams.ts";
7 import { setIssueTeams, TEAMS_MAX, teamsHolding, type IssueTeamsError } from "../core/issue_teams.ts";
88 import type { Issue } from "../core/issues.ts";
9 import type { Repo } from "../core/repos.ts";
9 import { openPullPileRows, pullsByIssueIds } from "../core/pulls.ts";
10 import { canRead, getReposByIds, type Repo } from "../core/repos.ts";
11 import { mayView } from "../core/sealed.ts";
12 import { listTeams } from "../core/teams.ts";
1013 import type { User } from "../core/users.ts";
11 import { err, type Result } from "../kernel/result.ts";
14 import { err, ok, type Result } from "../kernel/result.ts";
1215 import { isMirror } from "../origins/write-through.ts";
1316 import { requireWriter, type AccessError } from "./access.ts";
1417 import { MIRRORED_DO_IT_THERE } from "./mirror_words.ts";
@@ -43,3 +46,56 @@ export function nameTeams(
4346 });
4447 return set;
4548 }
49
50 /** The most pulls one answer lists. */
51 export const WAITING_MAX = 50;
52
53 export interface WaitingPull {
54 readonly repo: string;
55 readonly number: number;
56 readonly title: string;
57 /** When the pull was opened (ISO), the age it has waited from. */
58 readonly since: string;
59 }
60
61 /**
62 * The open pulls a crew team's review still holds (`teamsHolding`, the merge
63 * gate's own question), oldest first, at most `limit`, and only those `actor`
64 * may read (repository and sealed-issue rules). `team` is a crew-team slug or
65 * name; crew teams are forge-wide, so no organization narrows the answer. One
66 * function for the MCP tool and the terminal.
67 */
68 export function pullsWaitingOn(
69 app: App,
70 actor: User | undefined,
71 team: string,
72 limit = WAITING_MAX,
73 ): Result<WaitingPull[], "not_found"> {
74 const db = app.db;
75 const wanted = team.trim().toLowerCase();
76 const crew = listTeams(db).find((t) => t.slug === wanted || t.name.toLowerCase() === wanted);
77 if (!crew) return err("not_found");
78 const rows = openPullPileRows(db);
79 const repos = getReposByIds(db, [...new Set(rows.map((r) => r.repoId))]);
80 const readable = new Map<string, boolean>();
81 const visible = rows.filter((r) => {
82 const repo = repos.get(r.repoId);
83 if (repo === undefined) return false;
84 let may = readable.get(r.repoId);
85 if (may === undefined) readable.set(r.repoId, (may = canRead(db, repo, actor)));
86 return may && mayView(db, repo, r, actor);
87 });
88 const found: WaitingPull[] = [];
89 const BATCH = 100;
90 for (let i = 0; i < visible.length; i += BATCH)
91 for (const p of pullsByIssueIds(db, visible.slice(i, i + BATCH).map((r) => r.id))) {
92 if (!teamsHolding(db, p).some((v) => v.team === crew.slug)) continue;
93 const repo = repos.get(p.repoId)!;
94 found.push({ repo: `${repo.ownerName}/${repo.name}`, number: p.number, title: p.title, since: p.createdAt });
95 }
96 // Oldest first, stated here and not left to the query's order.
97 found.sort((a, b) =>
98 a.since < b.since ? -1 : a.since > b.since ? 1 : a.repo < b.repo ? -1 : a.repo > b.repo ? 1 : a.number - b.number,
99 );
100 return ok(found.slice(0, limit));
101 }
src/http/mcp/tools/org_teams.ts +8 −2
@@ -1,4 +1,5 @@
11 import { addTeamMember, createTeam, deleteTeam, removeTeamMember } from "../../../actions/orgs.ts";
2 import { pullsWaitingOn } from "../../../actions/issue_teams.ts";
23 import { requireOrgAdmin } from "../../../actions/access.ts";
34 import { isTeamRole, TEAM_ROLES } from "../../../core/agents.ts";
45 import { getOrg, listTeamMemberRoles, listTeamMembers, listTeams, type Permission } from "../../../core/orgs.ts";
@@ -6,7 +7,7 @@ import { good, bad, type Tool } from "../shared.ts";
67
78 export const tool: Tool = {
89 name: "org_teams",
9 doc: "`{ org, action, team, user?, permission?, role? }` (admin-scope token, an org owner): `create` a team (`permission` read, write or admin; read by default), `add_member` a `user` with an optional `role` (planner, builder, reviewer or member; setting it on someone already there changes their role), list its `members` with their roles, `remove_member` a `user` (with `role`, only that role comes off and they stay a member; without it they leave the team), or `remove` the team; the same as `/api/v1/orgs/:org/teams` and its `members`",
10 doc: "`{ org, action, team, user?, permission?, role? }` (admin-scope token, an org owner): `create` a team (`permission` read, write or admin; read by default), `add_member` a `user` with an optional `role` (planner, builder, reviewer or member; setting it on someone already there changes their role), list its `members` with their roles, `remove_member` a `user` (with `role`, only that role comes off and they stay a member; without it they leave the team), or `remove` the team; with `waiting: true` (and no action) the open pulls the crew team (slug or name) still holds, `{ items: [{ repo, number, title, since }] }` oldest first, at most 50, only repositories you may read — the same list as the terminal's crew-teams screen (`w`); crew teams are forge-wide, so `org` only has to exist; the same as `/api/v1/orgs/:org/teams` and its `members`",
1011 description: "An org's teams: create, add member with a role, list members, remove.",
1112 inputSchema: {
1213 type: "object",
@@ -16,9 +17,10 @@ export const tool: Tool = {
1617 team: { type: "string", maxLength: 64 },
1718 user: { type: "string", maxLength: 64 },
1819 permission: { type: "string", enum: ["read", "write", "admin"], description: "create" },
20 waiting: { type: "boolean", description: "list the open pulls the team's review holds" },
outdated

Move this into a shared module used by MCP, web and terminal (both doors), and sort oldest first explicitly instead of relying on query order.

1921 role: { type: "string", enum: [...TEAM_ROLES], description: "add_member, remove_member: the seat on the crew" },
2022 },
21 required: ["org", "action", "team"],
23 required: ["org", "team"],
2224 },
2325 writes: true,
2426 needsAdminScope: true,
@@ -26,6 +28,10 @@ export const tool: Tool = {
2628 const org = getOrg(ctx.app.db, typeof args["org"] === "string" ? args["org"] : "");
2729 if (!org) return bad("not_found", "no such organization");
2830 const name = typeof args["team"] === "string" ? args["team"] : "";
31 if (args["waiting"] === true) {
32 const w = pullsWaitingOn(ctx.app, user, name);
33 return w.ok ? good({ items: w.value }) : bad("not_found", "no such team");
34 }
2935 const action = args["action"];
3036 if (action === "create") {
3137 const p = args["permission"];
src/tui/screens/teams.ts +33
@@ -7,6 +7,7 @@
77 * nothing and sets nothing.
88 */
99
10 import { pullsWaitingOn } from "../../actions/issue_teams.ts";
1011 import { changeTeam, readTeams, teamChanges, TEAM_SAYS } from "../../actions/teams.ts";
1112 import type { Ctx, Screen } from "../app/app.ts";
1213 import { binding } from "../app/keys.ts";
@@ -17,6 +18,36 @@ import { fill, len, split } from "../ui/layout.ts";
1718 import { ListState, drawList, promptScreen, type ListRow } from "./common.ts";
1819 import type { PaletteEntry } from "./entries.ts";
1920
21 /** The open pulls a team's review holds, oldest first: `pullsWaitingOn`, as the MCP tool lists them. */
22 function waitingScreen(f: Session, ctx: Ctx, slug: string, name: string): Screen {
23 const state = new ListState();
24 const read = () => {
25 const w = pullsWaitingOn(f.app, f.user, slug);
26 return w.ok ? w.value : [];
27 };
28 return {
29 title: `${name} is holding`,
30 keys: [binding(["j", "k"], "move cursor"), binding(["esc"], "back")],
31 render(area, s) {
32 const rows: ListRow[] = read().map((p) => ({
33 title: `${p.repo}#${p.number} ${p.title}`,
34 secondary: "",
35 meta: `since ${p.since.slice(0, 10)}`,
36 }));
37 if (rows.length === 0)
38 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);
39 else drawList(s, area, ctx.theme.c, state, rows);
40 },
41 key(e) {
42 if (e.key === "esc" || e.key === "q") {
43 ctx.pop();
44 return true;
45 }
46 return state.key(e, read().length);
47 },
48 };
49 }
50
2051 export function teamsScreen(f: Session, ctx: Ctx): Screen {
2152 const state = new ListState();
2253 let note = "";
@@ -48,6 +79,7 @@ export function teamsScreen(f: Session, ctx: Ctx): Screen {
4879 binding(["enter"], "dial"),
4980 binding(["c"], "charter"),
5081 binding(["m"], "members"),
82 binding(["w"], "pulls it holds"),
5183 ],
5284 render(area, s) {
5385 const c = ctx.theme.c;
@@ -77,6 +109,7 @@ export function teamsScreen(f: Session, ctx: Ctx): Screen {
77109 ask("a whole number from 0 to 100", String(team.dial), (v) => save(team.slug, { dial: v }));
78110 else if (e.key === "c")
79111 ask("what it owns, what it reviews", team.charter, (v) => save(team.slug, { charter: v.trim() }));
112 else if (e.key === "w") ctx.push(waitingScreen(f, ctx, team.slug, team.name));
80113 else if (e.key === "m") {
81114 const byName = new Map(r.value.agents.map((a) => [a.user.username, a.user.id]));
82115 const names = new Map(r.value.agents.map((a) => [a.user.id, a.user.username]));
test/mcp-org-teams-waiting.test.ts added +210
@@ -0,0 +1,210 @@
1 /** `org_teams { waiting: true }` lists the open pulls a crew team's review holds, for readers only. */
2 import { afterAll, beforeAll, 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 { pullsWaitingOn } from "../src/actions/issue_teams.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { createForgeApp } from "../src/tui/forge.ts";
14 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { settleApp } from "./helpers/tui.ts";
16 import { createOrg } from "../src/core/orgs.ts";
17 import type { Repo } from "../src/core/repos.ts";
18 import { submitReview } from "../src/core/reviews.ts";
19 import { saveTeam } from "../src/core/teams.ts";
20 import { createToken } from "../src/core/tokens.ts";
21 import { createUser, getUserByName, type User } from "../src/core/users.ts";
22 import { serve, type Servers } from "../src/http/server.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24 import { seatAs } from "./helpers/seat.ts";
25
26 quietLogs();
27
28 describe("MCP org_teams waiting", () => {
29 let dataDir = "";
30 let app: App;
31 let servers: Servers;
32 let alice: User;
33 let aliceTok: string;
34 let bobTok: string;
35 let pub: Repo;
36 let priv: Repo;
37 let id = 1;
38 let n = 0;
39
40 const call = async (token: string, args: unknown) => {
41 const res = await fetch(`${servers.url}/mcp`, {
42 method: "POST",
43 headers: {
44 "content-type": "application/json",
45 accept: "application/json, text/event-stream",
46 authorization: `Bearer ${token}`,
47 },
48 body: JSON.stringify({
49 jsonrpc: "2.0",
50 id: id++,
51 method: "tools/call",
52 params: { name: "org_teams", arguments: args },
53 }),
54 });
55 const body = (await res.json()) as any;
56 if (body.error) return { text: String(body.error.message), isError: true };
57 return { text: body.result.content[0].text as string, isError: body.result.isError === true };
58 };
59 const waiting = async (token: string) => {
60 const r = await call(token, { org: "acme", team: "design", waiting: true });
61 expect(r.isError).toBe(false);
62 return JSON.parse(r.text).items as { repo: string; number: number; title: string; since: string }[];
63 };
64
65 const pullIn = async (repo: Repo, title: string) => {
66 const issue = openIssue(app, alice, repo, { title });
67 if (!issue.ok) throw new Error(issue.error);
68 const set = nameTeams(app, alice, repo, issue.value, ["design"]);
69 if (!set.ok) throw new Error(set.error);
70 const branch = `w${++n}`;
71 const c = await commitToBranch(app, alice, repo, {
72 branch,
73 base: "main",
74 message: branch,
75 files: [{ path: `${branch}.md`, content: "x\n" }],
76 });
77 if (!c.ok) throw new Error(JSON.stringify(c.error));
78 const o = await openPull(app, alice, repo, {
79 title,
80 body: `Closes #${issue.value.number}`,
81 baseBranch: "main",
82 headBranch: branch,
83 });
84 if (!o.ok) throw new Error(String(o.error));
85 return o.value;
86 };
87
88 beforeAll(async () => {
89 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-waiting-"));
90 app = createApp({ dataDir, port: 0 });
91 servers = await serve(app);
92 const mk = async (username: string) => {
93 const u = await createUser(app.db, {
94 origin: "seed",
95 trusted: true,
96 username,
97 email: `${username}@forge.local`,
98 password: "a-good-passphrase",
99 adminIfFirst: true,
100 });
101 if (!u.ok) throw new Error(u.error);
102 return u.value;
103 };
104 alice = await mk("alice");
105 const bob = await mk("bob");
106 aliceTok = createToken(app.db, alice.id, "admin", "admin").token;
107 bobTok = createToken(app.db, bob.id, "admin", "admin").token;
108 const org = createOrg(app.db, "acme", alice.id);
109 if (!org.ok) throw new Error(org.error);
110 for (const [name, visibility] of [["open", "public"], ["secret", "private"]] as const) {
111 const r = await createRepo(app, alice, { name, visibility });
112 if (!r.ok) throw new Error(String(r.error));
113 const seeded = await commitToBranch(app, alice, r.value, {
114 message: "seed",
115 files: [{ path: "README.md", content: "# x\n" }],
116 });
117 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
118 if (name === "open") pub = r.value;
119 else priv = r.value;
120 }
121 const agent = await createAgent(app, alice, { username: "quill" });
122 if (!agent.ok) throw new Error(String(agent.error));
123 const quill = getUserByName(app.db, "quill")!;
124 for (const r of [pub, priv]) {
125 addToCrew(app, alice, r, "quill");
126 seatAs(app.db, r.id, quill.id, "reviewer");
127 }
128 saveTeam(app.db, "design", { charter: "", dial: 0, members: [quill.id] });
129 }, 120_000);
130
131 afterAll(async () => {
132 servers.stop();
133 for (let i = 0; i < 20; i++) await new Promise((done) => setImmediate(done));
134 await Bun.sleep(100);
135 app?.close();
136 if (dataDir !== "") await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
137 }, 30_000);
138
139 test("a held pull is listed, an approved one is not, a private one is hidden from a non-reader", async () => {
140 const held = await pullIn(pub, "Held chart");
141 const done = await pullIn(pub, "Approved chart");
142 const hidden = await pullIn(priv, "Secret chart");
143 submitReview(app.db, {
144 issueId: done.id,
145 authorId: getUserByName(app.db, "quill")!.id,
146 state: "approved",
147 commitOid: done.headOid,
148 });
149 const mine = await waiting(aliceTok);
150 expect(mine.map((i) => [i.repo, i.number, i.title])).toEqual([
151 ["alice/open", held.number, "Held chart"],
152 ["alice/secret", hidden.number, "Secret chart"],
153 ]);
154 expect(typeof mine[0]!.since).toBe("string");
155 const theirs = await waiting(bobTok);
156 expect(theirs.map((i) => i.title)).toEqual(["Held chart"]);
157 });
158
159 test("oldest first by when the pull was opened, not by query order", async () => {
160 const older = await pullIn(pub, "Opened first, listed by age");
161 app.db.query("UPDATE issue SET created_at = '2000-01-01T00:00:00.000Z' WHERE id = ?1").run(older.id);
162 const items = await waiting(aliceTok);
163 expect(items[0]!.title).toBe("Opened first, listed by age");
164 const sinces = items.map((i) => i.since);
165 expect([...sinces].sort()).toEqual(sinces);
166 expect(sinces.length).toBeGreaterThan(2);
167 });
168
169 test("the answer stops at its cap", () => {
170 const got = pullsWaitingOn(app, alice, "design", 2);
171 expect(got.ok && got.value.length).toBe(2);
172 const all = pullsWaitingOn(app, alice, "design");
173 expect(all.ok && all.value.length).toBeGreaterThan(2);
174 });
175
176 test("a token without admin scope is refused", async () => {
177 const rw = createToken(app.db, alice.id, "rw", "rw").token;
178 const r = await call(rw, { org: "acme", team: "design", waiting: true });
179 expect(r.isError).toBe(true);
180 expect(r.text).not.toContain("Held chart");
181 });
182
183 test("the crew-teams screen lists them on w, for the same reader", async () => {
184 const terminal = headlessTerminal(140, 32);
185 const { app: tui } = createForgeApp(
186 app,
187 { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice },
188 { terminal, exitHooks: false },
189 );
190 tui.start();
191 const press = async (...keys: string[]) => {
192 for (const key of keys) tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
193 await settleApp(tui);
194 };
195 tui.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
196 await settleApp(tui);
197 for (const ch of "Crew teams") tui.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false });
198 await settleApp(tui);
199 await press("enter", "w");
200 const text = tui.screen.toText();
201 expect(text).toContain("Held chart");
202 expect(text).toContain("alice/open#");
203 tui.stop();
204 });
205
206 test("an unknown team is not found", async () => {
207 const r = await call(aliceTok, { org: "acme", team: "nope", waiting: true });
208 expect(r.isError).toBe(true);
209 });
210 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs