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

Teams drive the crew: a membership carries a role, and a repository's crew is the members of the teams that can write it #10088

Merged opened this

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

Merged

scout/forge:scout/571-teams-drive-the-crew-a-membership-carrie → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/571-teams-drive-the-crew-a-membership-carrie was merged into main as 33c94f7.

Review

Sign in to review this change.
15 files changed +663 −78
docs/ARCHITECTURE.md +8
@@ -582,6 +582,14 @@ agent's push to the default branch whether or not it is protected, and
582582 `mergePull` refuses an agent actor. Everything the agent can do, it does
583583 through a pull a person merges.
584584
585 Which seat each agent holds on a repository is read in one place,
586 `crewSeats` (`core/agents.ts`), which `listCrewRoles`, `crewWithRole` and so
587 the reviewer routing all read. On an org's repository the crew comes from
588 its write and admin teams: a `team_member.role` of planner, builder or
589 reviewer seats that agent, and `member` (the default, and every membership
590 from before roles) is access only. When no team seats an agent, the
591 repository's own `repo_crew` roster is the crew, as it always was (#571).
592
585593 Work reaches an agent off the outbox with a sweep behind it, like every
586594 other consumer: an assignment starts a run; a person's comment, verdict, or
587595 line comment on the agent's pull starts a follow-up that resumes the same
docs/features/team-roles.md added +25
@@ -0,0 +1,25 @@
1 # Give a team member a role
2
3 A team membership carries a role: `planner`, `builder`, `reviewer` or
4 `member` (the default). On an organization's write or admin team, an agent's
5 role is its seat on the crew of every repository the organization owns: a
6 `reviewer` there reviews the org's pulls, a `builder` takes what is ready. A
7 `member` holds no seat; the team gives them its access and nothing more.
8 When no team gives any agent a role, a repository's own crew roster is its
9 crew, as before.
10
11 - **Page:** the organization page lists each team's members with their roles.
12 An org admin opens a team's Manage page, picks a Role beside the username
13 and presses Add member; adding someone already on the team sets their role.
14 - **API:** `POST /api/v1/orgs/:org/teams/:team/members` with
15 `{ "user": "mason", "role": "reviewer" }` (admin-scope token). A bad role is
16 `422 bad_role`. `DELETE …/members/:username?role=reviewer` takes just that
17 role off and leaves them a `member`; without `role` it removes them.
18 `GET …/members` lists each member's `role`.
19 - **MCP:** `org_teams { org, action: "add_member", team, user, role }`, and
20 `action: "members"` to list members with their roles.
21 - **Terminal:** the "Organization teams" command, Enter on a team, then `r` on
22 a member to pick a role (org admins only).
23
24 Every change of role is told on the outbox as `team.member_role_set`, with
25 who made it, the member, and the role before and after.
src/actions/orgs.ts +43 −3
@@ -15,12 +15,15 @@ import {
1515 isLastOwner,
1616 OWNERS_TEAM,
1717 removeTeamMember as dropMember,
18 setTeamRole,
19 teamRoleOf,
1820 type CreateOrgError,
1921 type CreateTeamError,
2022 type Permission,
2123 type Team,
2224 } from "../core/orgs.ts";
2325 import { getUserByName, type User } from "../core/users.ts";
26 import type { TeamRole } from "../core/agents.ts";
2427 import { err, ok, type Result } from "../kernel/result.ts";
2528 import { requireOrgAdmin, requireUser } from "./access.ts";
2629
@@ -117,8 +120,9 @@ export function addTeamMember(
117120 org: User,
118121 team: Team,
119122 username: string,
123 role?: TeamRole,

Both doors: this action has no terminal screen or MCP org_teams caller passing a role. Add both, with tests that a write in the terminal shows live on the web.

120124 ): Result<
121 { added: boolean },
125 { added: boolean; role: TeamRole },
122126 "not_found" | "no_such_user" | "agent_cannot_own"
123127 > {
124128 const admin = requireOrgAdmin(app, actor, org);
@@ -130,14 +134,41 @@ export function addTeamMember(
130134 // any team that confers admin.
131135 if (user.kind === "agent" && team.permission === "admin")
132136 return err("agent_cannot_own");
133 const added = insertMember(app.db, team, user.id);
137 const added = insertMember(app.db, team, user.id, role);
134138 if (added)
135139 app.bus.append({
136140 kind: "team.member_added",
137141 actorId: admin.value.id,
138142 payload: { org: org.username, teamId: team.id, user: user.username },
139143 });
140 return ok({ added });
144 // Adding someone already on the team with a role sets it (#571).
145 else if (role !== undefined) changeRole(app, admin.value, org, team, user, role);
outdated

A role change on an existing member is a silent privilege change (it re-seats the crew on every org repo). Append a bus event for it and test it.

146 return ok({ added, role: teamRoleOf(app.db, team.id, user.id) ?? "member" });
147 }
148
149 /**
150 * Set a member's role and say so on the outbox (#571): a role re-seats the
151 * crew on every repository the org owns, so it never changes silently.
152 * False when they already hold it or are not on the team.
153 */
154 function changeRole(
155 app: App,
156 admin: User,
157 org: User,
158 team: Team,
159 user: User,
160 role: TeamRole,
161 ): boolean {
162 const from = teamRoleOf(app.db, team.id, user.id);
163 if (from === undefined) return false;
164 const changed = setTeamRole(app.db, team.id, user.id, role);
165 if (!changed) return false;
166 app.bus.append({
167 kind: "team.member_role_set",
168 actorId: admin.id,
169 payload: { org: org.username, teamId: team.id, user: user.username, from, role },
170 });
171 return true;
outdated

A side effect (setTeamRole) hidden inside a && chain on a 140-char line. Write it as statements.

141172 }
142173
143174 /**
@@ -151,12 +182,21 @@ export function removeTeamMember(
151182 org: User,
152183 team: Team,
153184 username: string,
185 role?: TeamRole,
154186 ): Result<{ removed: boolean }, "not_found" | "no_such_user" | "last_owner"> {
155187 const admin = requireOrgAdmin(app, actor, org);
156188 if (!admin.ok) return admin;
157189 if (team.orgId !== org.id) return err("not_found");
158190 const user = getUserByName(app.db, username);
159191 if (!user) return err("no_such_user");
192 // With a role, only that role comes off: the member stays on the team as
193 // a plain `member` (#571). Nothing changes when they hold another.
194 if (role !== undefined) {
195 if (role === "member") return ok({ removed: false });
196 if (teamRoleOf(app.db, team.id, user.id) !== role) return ok({ removed: false });
197 const cleared = changeRole(app, admin.value, org, team, user, "member");
198 return ok({ removed: cleared });
199 }
160200 if (team.name === OWNERS_TEAM && isLastOwner(app.db, org.id, user))
161201 return err("last_owner");
162202 const removed = dropMember(app.db, team, user.id);
src/core/agents.ts +106 −56
@@ -372,63 +372,126 @@ export function setCrewRole(
372372 );
373373 }
374374
375 /** The crew with each member's role, by name. */
375 /** The crew with each member's role, by name: `crewSeats` for one repository. */
376376 export function listCrewRoles(
377377 db: Database,
378378 repoId: string,
379379 ): { agent: Agent; role: CrewRole }[] {
380 const roles = new Map(
381 db
382 .query<{ user_id: string; role: string }, [string]>(
383 "SELECT user_id, role FROM repo_crew WHERE repo_id = ?1",
384 )
385 .all(repoId)
386 .map((r) => [r.user_id, r.role] as const),
387 );
388 return listCrew(db, repoId).map((agent) => ({
389 agent,
390 role: seatOf(roles.get(agent.user.id) ?? "", agent.roles),
391 }));
380 return (crewSeats(db, [repoId]).get(repoId) ?? []).map(({ agent, role }) => ({ agent, role }));
381 }
382
383 /** One seat on a repository's crew. */
384 export interface CrewSeat {
385 readonly agent: Agent;
386 readonly role: CrewRole;
387 /**
388 * Whether the seat is held as written: a team role, or a roster row
389 * whose role the agent still declares.
390 */
391 readonly held: boolean;
392 }
393
394 /** A role on an org team (#571): a crew seat, or `member`, the team's access and no seat. */
395 export type TeamRole = CrewRole | "member";
396 export const TEAM_ROLES: readonly TeamRole[] = [...CREW_ROLES, "member"];
397 export const isTeamRole = (value: unknown): value is TeamRole =>
398 (TEAM_ROLES as readonly unknown[]).includes(value);
399
400 /**
401 * The crew of each repository, by repo, by name — the one function the
402 * routing reads for planner, builder and reviewer (#571). An org-owned
403 * repository's crew is the agents the org's write and admin teams give a
404 * role, each in that seat (a role the agent does not declare sits in its
405 * first declared one; on many teams an agent takes its first by team
406 * name). A `member` holds no seat: it is the team's access, nothing more,
407 * so a membership from before roles changes no crew. When no such team
408 * seats an agent, the repository's own roster (`repo_crew`) is its crew,
409 * unchanged.
410 */
411 export function crewSeats(
412 db: Database,
413 repoIds: readonly string[],
414 ): Map<string, CrewSeat[]> {
415 const unique = [...new Set(repoIds)];
416 type SeatRow = AgentRow & { repo_id: string; seat: string; source: "team" | "roster" };
417 const rows: SeatRow[] = [];
418 const CHUNK = 400;
419 // One read per chunk: the team seats and the roster together, told apart by `source`.
420 for (let i = 0; i < unique.length; i += CHUNK) {
421 const batch = unique.slice(i, i + CHUNK);
422 const marks = batch.map((_, n) => `?${n + 1}`).join(",");
423 rows.push(
424 ...db
425 .query<SeatRow, string[]>(
426 `SELECT * FROM (
427 SELECT 'team' AS source, repo.id AS repo_id, team_member.role AS seat,
428 team.name AS via, user.username AS uname, agent.*
429 FROM repo
430 JOIN team ON team.org_id = repo.owner_id AND team.permission IN ('write','admin')
431 JOIN team_member ON team_member.team_id = team.id AND team_member.role <> 'member'
432 JOIN agent ON agent.user_id = team_member.user_id
433 JOIN user ON user.id = agent.user_id
434 WHERE repo.id IN (${marks})
435 UNION ALL
436 SELECT 'roster' AS source, repo_crew.repo_id, repo_crew.role AS seat,
437 '' AS via, user.username AS uname, agent.*
438 FROM repo_crew
439 JOIN agent ON agent.user_id = repo_crew.user_id
440 JOIN user ON user.id = agent.user_id
441 WHERE repo_crew.repo_id IN (${marks})
442 ) ORDER BY uname COLLATE NOCASE, via COLLATE NOCASE`,
443 )
444 .all(...batch),
445 );
446 }
447 const teamed = new Set(rows.filter((r) => r.source === "team").map((r) => r.repo_id));
448 const teamRows = rows.filter((r) => r.source === "team");
449 const rosterRows = rows.filter((r) => r.source === "roster" && !teamed.has(r.repo_id));
450 const users = getUsersByIds(db, [...teamRows, ...rosterRows].map((r) => r.user_id));
451 const out = new Map<string, CrewSeat[]>();
452 const put = (repoId: string, seat: CrewSeat) => {
453 const list = out.get(repoId);
454 if (list) list.push(seat);
455 else out.set(repoId, [seat]);
456 };
457 // Team rows: one seat per agent per repository, the first by team name.
458 const chosen = new Map<string, AgentRow & { repo_id: string; seat: string }>();
459 for (const row of teamRows) {
460 const key = `${row.repo_id}\u0000${row.user_id}`;
461 if (!chosen.has(key)) chosen.set(key, row);
462 }
463 for (const row of chosen.values()) {
464 const user = users.get(row.user_id);
465 if (!user) continue;
466 const agent = agentOf(user, row);
467 put(row.repo_id, { agent, role: seatOf(row.seat, agent.roles), held: true });
468 }
469 for (const row of rosterRows) {
470 const user = users.get(row.user_id);
471 if (!user) continue;
472 const agent = agentOf(user, row);
473 put(row.repo_id, {
474 agent,
475 role: seatOf(row.seat, agent.roles),
476 held: isCrewRole(row.seat) && agent.roles.includes(row.seat),
477 });
478 }
479 return out;
392480 }
393481
394482 /**
395483 * The crew members holding `role` on each of these repositories, by repo,
396 * in `listCrew`'s order — the agents and their accounts one query per chunk
397 * each, never one per repository or per member.
484 * in name order — `crewSeats`, keeping the seats held as written.
398485 */
399486 export function crewWithRole(
400487 db: Database,
401488 repoIds: readonly string[],
402489 role: CrewRole,
403490 ): Map<string, Agent[]> {
404 const rows: (AgentRow & { repo_id: string })[] = [];
405 const unique = [...new Set(repoIds)];
406 const CHUNK = 400;
407 for (let i = 0; i < unique.length; i += CHUNK) {
408 const batch = unique.slice(i, i + CHUNK);
409 rows.push(
410 ...db
411 .query<AgentRow & { repo_id: string }, string[]>(
412 `SELECT repo_crew.repo_id, agent.* FROM repo_crew
413 JOIN agent ON agent.user_id = repo_crew.user_id
414 JOIN user ON user.id = agent.user_id
415 WHERE repo_crew.role = ?1
416 AND instr(',' || agent.roles || ',', ',' || ?1 || ',') > 0
417 AND repo_crew.repo_id IN (${batch.map((_, n) => `?${n + 2}`).join(",")})
418 ORDER BY user.username COLLATE NOCASE`,
419 )
420 .all(role, ...batch),
421 );
422 }
423 const users = getUsersByIds(db, rows.map((r) => r.user_id));
424491 const out = new Map<string, Agent[]>();
425 for (const row of rows) {
426 const user = users.get(row.user_id);
427 if (!user) continue;
428 const agent = agentOf(user, row);
429 const list = out.get(row.repo_id);
430 if (list) list.push(agent);
431 else out.set(row.repo_id, [agent]);
492 for (const [repoId, seats] of crewSeats(db, repoIds)) {
493 const agents = seats.filter((s) => s.held && s.role === role).map((s) => s.agent);
494 if (agents.length > 0) out.set(repoId, agents);
432495 }
433496 return out;
434497 }
@@ -443,18 +506,5 @@ export function reposWithBuilder(
443506 db: Database,
444507 repoIds: readonly string[],
445508 ): Set<string> {
446 const out = new Set<string>();
447 const unique = [...new Set(repoIds)];
448 const CHUNK = 400;
449 for (let i = 0; i < unique.length; i += CHUNK) {
450 const batch = unique.slice(i, i + CHUNK);
451 const rows = db
452 .query<{ repo_id: string }, string[]>(
453 `SELECT DISTINCT repo_id FROM repo_crew JOIN agent ON agent.user_id = repo_crew.user_id
454 WHERE role = 'builder' AND instr(',' || agent.roles || ',', ',builder,') > 0 AND repo_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})`,
455 )
456 .all(...batch);
457 for (const row of rows) out.add(row.repo_id);
458 }
459 return out;
509 return new Set(crewWithRole(db, repoIds, "builder").keys());
460510 }
src/core/event_audience.ts +1
@@ -104,6 +104,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([
104104 "team.deleted",
105105 "team.member_added",
106106 "team.member_removed",
107 "team.member_role_set",
107108 "forge.spawn.started",
108109 "forge.spawned",
109110 "forge.removed",
src/core/migration-pins/20261109130000-team-member-role.pin added +1
@@ -0,0 +1,1 @@
1 3817bca0554972817db65e07867ee49030e1cd103c3fe66c03a8f3c75c074a7b
src/core/migrations/20261109130000-team-member-role.ts added +9
@@ -0,0 +1,9 @@
1 // A team membership carries a role (#571): the crew of an org's repository is
2 // the agents on its write and admin teams, each in the seat named here
3 // (`crewSeats`, core/agents.ts). `member` holds no seat, so every row here
4 // before keeps the crew it had. Additive:
5 // one column with a default, applied once like every migration.
6 export default `
7 ALTER TABLE team_member ADD COLUMN role TEXT NOT NULL DEFAULT 'member'
8 CHECK (role IN ('planner','builder','reviewer','member'));
9 `;
src/core/orgs.ts +54 −3
@@ -12,7 +12,8 @@ import { id } from "../kernel/id.ts";
1212 import { err, ok, type Result } from "../kernel/result.ts";
1313 import { forgetListings } from "./listings.ts";
1414 import { reservedName } from "./reserved.ts";
15 import { getUserById, getUserByName, type User } from "./users.ts";
15 import { isTeamRole, type TeamRole } from "./agents.ts";
16 import { getUserById, getUserByName, getUsersByIds, type User } from "./users.ts";
1617
1718 export type Permission = "read" | "write" | "admin";
1819 export type Access = "none" | Permission;
@@ -165,6 +166,7 @@ export function addTeamMember(
165166 db: Database,
166167 team: Team,
167168 userId: string,
169 role: TeamRole = "member",
168170 ): boolean {
169171 const user = getUserById(db, userId);
170172 if (!user || user.kind === "org") return false; // can't nest orgs
@@ -174,9 +176,9 @@ export function addTeamMember(
174176 return (
175177 db
176178 .query(
177 "INSERT OR IGNORE INTO team_member (team_id, user_id) VALUES (?1, ?2)",
179 "INSERT OR IGNORE INTO team_member (team_id, user_id, role) VALUES (?1, ?2, ?3)",
178180 )
179 .run(team.id, userId).changes > 0
181 .run(team.id, userId, role).changes > 0
180182 );
181183 }
182184
@@ -314,6 +316,55 @@ export function listTeamsVisibleTo(
314316 .map(toTeam);
315317 }
316318
319 /**
320 * Set the role a member holds on a team (#571); false when they are not on
321 * it or already hold it.
322 */
323 export function setTeamRole(
324 db: Database,
325 teamId: string,
326 userId: string,
327 role: TeamRole,
328 ): boolean {
329 return (
330 db
331 .query(
332 "UPDATE team_member SET role = ?3 WHERE team_id = ?1 AND user_id = ?2 AND role <> ?3",
333 )
334 .run(teamId, userId, role).changes > 0
335 );
336 }
337
338 /** The role a member holds on a team, or undefined when they are not on it. */
339 export function teamRoleOf(db: Database, teamId: string, userId: string): TeamRole | undefined {
340 const row = db
341 .query<{ role: string }, [string, string]>(
342 "SELECT role FROM team_member WHERE team_id = ?1 AND user_id = ?2",
343 )
344 .get(teamId, userId);
345 if (!row) return undefined;
346 return isTeamRole(row.role) ? row.role : "member";
347 }
348
349 /** A team's members with the role each holds, by name. */
350 export function listTeamMemberRoles(
351 db: Database,
352 teamId: string,
353 ): { user: User; role: TeamRole }[] {
354 const rows = db
355 .query<{ user_id: string; role: string }, [string]>(
356 "SELECT user_id, role FROM team_member WHERE team_id = ?1",
357 )
358 .all(teamId);
359 const users = getUsersByIds(db, rows.map((r) => r.user_id));
360 return rows
361 .flatMap((r) => {
362 const user = users.get(r.user_id);
363 return user ? [{ user, role: isTeamRole(r.role) ? r.role : ("member" as const) }] : [];
364 })
365 .sort((a, b) => a.user.username.toLowerCase().localeCompare(b.user.username.toLowerCase()));
366 }
367
317368 export function listTeamMembers(db: Database, teamId: string): User[] {
318369 const ids = db
319370 .query<
src/http/api/orgs.ts +24 −1
@@ -13,6 +13,7 @@ import {
1313 } from "../../actions/orgs.ts";
1414 import {
1515 getOrg,
16 listTeamMemberRoles,
1617 listTeamMembers,
1718 listTeams,
1819 listTeamsVisibleTo,
@@ -21,6 +22,7 @@ import {
2122 } from "../../core/orgs.ts";
2223 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
2324 import type { User } from "../../core/users.ts";
25 import { isTeamRole, TEAM_ROLES, type TeamRole } from "../../core/agents.ts";
2426 import { keyedPage, keyedWindow } from "../api.ts";
2527 import type { Ctx, Handler } from "../context.ts";
2628 import {
@@ -146,7 +148,10 @@ export function orgRoutes(router: Router<Handler>): void {
146148 const window = keyedWindow(ctx);
147149 if (window instanceof Response) return window;
148150 const members = timePhase(ctx.request, "members", () =>
149 listTeamMembers(ctx.app.db, found.team.id).map(publicProfile),
151 listTeamMemberRoles(ctx.app.db, found.team.id).map(({ user, role }) => ({
152 ...publicProfile(user),
153 role,
154 })),
150155 );
151156 return timePhase(ctx.request, "render", () =>
152157 tagged(ctx.request, keyedPage(members, (u) => u.username, window)),
@@ -159,12 +164,15 @@ export function orgRoutes(router: Router<Handler>): void {
159164 const found = loadTeam(ctx);
160165 if ("error" in found) return found.error;
161166 const input = await body(ctx.request);
167 const role = roleOf(input.role);
168 if (role instanceof Response) return role;
162169 const added = addTeamMember(
163170 ctx.app,
164171 ctx.auth.user,
165172 found.org,
166173 found.team,
167174 str(input.user),
175 role,
168176 );
169177 if (!added.ok) return respondAccess(added.error, 422);
170178 return json(added.value, added.value.added ? 201 : 200);
@@ -178,6 +186,8 @@ export function orgRoutes(router: Router<Handler>): void {
178186 if (denied) return denied;
179187 const found = loadTeam(ctx);
180188 if ("error" in found) return found.error;
189 const role = roleOf(new URL(ctx.request.url).searchParams.get("role") ?? undefined);
190 if (role instanceof Response) return role;
181191 const username = ctx.params.username!;
182192 const isMember = listTeamMembers(ctx.app.db, found.team.id).some(
183193 (m) => m.username.toLowerCase() === username.toLowerCase(),
@@ -188,6 +198,7 @@ export function orgRoutes(router: Router<Handler>): void {
188198 found.org,
189199 found.team,
190200 username,
201 role,
191202 );
192203 if (!removed.ok)
193204 return removed.error === "last_owner"
@@ -198,6 +209,18 @@ export function orgRoutes(router: Router<Handler>): void {
198209 )
199210 : respondAccess(removed.error, 422);
200211 if (!isMember) return notFound();
212 // Taking a role off someone who does not hold it finds nothing.
213 if (role !== undefined && !removed.value.removed) return notFound();
201214 return new Response(null, { status: 204 });
202215 },
203216 );}
217
218 /**
219 * A team role from a request (#571): absent is undefined, one of
220 * `TEAM_ROLES` is itself, anything else a 422.
221 */
222 function roleOf(raw: unknown): TeamRole | undefined | Response {
223 if (raw === undefined || raw === null || raw === "") return undefined;
224 if (isTeamRole(raw)) return raw;
225 return apiError("bad_role", 422, `role is one of ${TEAM_ROLES.join(", ")}`);
226 }
src/http/mcp/tools/org_teams.ts +20 −6
@@ -1,19 +1,22 @@
11 import { addTeamMember, createTeam, deleteTeam } from "../../../actions/orgs.ts";
2 import { getOrg, listTeams, type Permission } from "../../../core/orgs.ts";
2 import { requireOrgAdmin } from "../../../actions/access.ts";
3 import { isTeamRole, TEAM_ROLES } from "../../../core/agents.ts";
4 import { getOrg, listTeamMemberRoles, listTeams, type Permission } from "../../../core/orgs.ts";
35 import { good, bad, type Tool } from "../shared.ts";
46
57 export const tool: Tool = {
68 name: "org_teams",
7 doc: "`{ org, action, team, user?, permission? }` (admin-scope token, an org owner): `create` a team (`permission` read, write or admin; read by default), `add_member` a `user`, or `remove` the team; the same as POST/DELETE `/api/v1/orgs/:org/teams`",
8 description: "An org's teams: create, add member, remove.",
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, or `remove` the team; the same as `/api/v1/orgs/:org/teams` and its `members`",
10 description: "An org's teams: create, add member with a role, list members, remove.",
911 inputSchema: {
1012 type: "object",
1113 properties: {
1214 org: { type: "string", maxLength: 64 },
13 action: { type: "string", enum: ["create", "add_member", "remove"] },
15 action: { type: "string", enum: ["create", "add_member", "members", "remove"] },
1416 team: { type: "string", maxLength: 64 },
1517 user: { type: "string", maxLength: 64 },
1618 permission: { type: "string", enum: ["read", "write", "admin"], description: "create" },
19 role: { type: "string", enum: [...TEAM_ROLES], description: "add_member: the seat on the crew" },
1720 },
1821 required: ["org", "action", "team"],
1922 },
@@ -34,13 +37,24 @@ export const tool: Tool = {
3437 const team = listTeams(ctx.app.db, org.id).find((t) => t.name.toLowerCase() === wanted);
3538 if (!team) return bad("not_found", "no such team");
3639 if (action === "add_member") {
37 const added = addTeamMember(ctx.app, user, org, team, typeof args["user"] === "string" ? args["user"] : "");
40 const role = args["role"];
41 if (role !== undefined && !isTeamRole(role))
42 return bad("bad_role", `role is one of ${TEAM_ROLES.join(", ")}`);
43 const who = typeof args["user"] === "string" ? args["user"] : "";
44 const added = addTeamMember(ctx.app, user, org, team, who, role);
3845 return added.ok ? good(added.value) : bad(added.error, added.error);
3946 }
47 if (action === "members") {
48 // The same admin gate as the page: only an org admin manages its teams.
49 if (!requireOrgAdmin(ctx.app, user, org).ok) return bad("not_found", "no such team");
50 return good(
51 listTeamMemberRoles(ctx.app.db, team.id).map((m) => ({ user: m.user.username, role: m.role })),
52 );
53 }
4054 if (action === "remove") {
4155 const gone = deleteTeam(ctx.app, user, org, team);
4256 return gone.ok ? good({ removed: team.name }) : bad(gone.error, gone.error);
4357 }
44 return bad("invalid", "action is create, add_member or remove");
58 return bad("invalid", "action is create, add_member, members or remove");
4559 },
4660 };
src/tui/screens/orgteams.ts +31 −4
@@ -6,7 +6,9 @@
66 * rule, the page's: an org admin sees every team, a member only their own.
77 * An admin of the organization (`orgAccess` allowing `admin`, the page's
88 * `requireOrgAdmin`) adds a person with `a` and removes the member under the
9 * cursor with `d` after a y/n (#8125), through the same actions
9 * cursor with `d` after a y/n (#8125), and sets the role of the member
10 * under the cursor with `r` (#571: planner, builder, reviewer or member, the
11 * seat a write team gives an agent on the org's crew), through the same actions
1012 * `POST /:owner/-/teams/:team/add` and `/remove` call, in the page's words.
1113 * On the list, an admin of the selected team's organization makes a team with
1214 * `n` (a name, then read, write or admin) and deletes the selected team with
@@ -27,8 +29,10 @@ import {
2729 removeRefusal,
2830 removedSays,
2931 } from "../../actions/team_words.ts";
32 import { TEAM_ROLES, isTeamRole } from "../../core/agents.ts";
3033 import {
3134 listOrgsForUser,
35 listTeamMemberRoles,
3236 listTeamMembers,
3337 listTeamsVisibleTo,
3438 orgAccess,
@@ -66,10 +70,15 @@ export function orgTeamLines(f: Session): Line[] {
6670 function teamMembersScreen(ctx: Ctx, f: Session, org: User, team: Team, changed: () => void): Screen {
6771 const state = new ListState();
6872 const admin = orgAccess(f.app.db, org.id, f.user) === "admin";
69 const read = () => listTeamMembers(f.app.db, team.id).sort((a, b) => a.username.localeCompare(b.username));
73 const read = () =>
74 listTeamMemberRoles(f.app.db, team.id).map(({ user, role }) => ({ ...user, role }));
7075 let members = read();
7176 const rowsOf = (): ListRow[] =>
72 members.map((u) => ({ title: `@${u.username}`, ...(u.displayName === "" ? {} : { secondary: u.displayName }) }));
77 members.map((u) => ({
78 title: `@${u.username}`,
79 ...(u.displayName === "" ? {} : { secondary: u.displayName }),
80 meta: u.role,
81 }));
7382 let rows = rowsOf();
7483 const reload = () => {
7584 members = read();
@@ -81,7 +90,7 @@ function teamMembersScreen(ctx: Ctx, f: Session, org: User, team: Team, changed:
8190 const screen: Screen = {
8291 title: `${org.username} / ${team.name}`,
8392 keys: admin
84 ? [binding(["j", "k"], "move cursor"), binding(["a"], "add"), binding(["d"], "remove")]
93 ? [binding(["j", "k"], "move cursor"), binding(["a"], "add"), binding(["r"], "role"), binding(["d"], "remove")]
8594 : [binding(["j", "k"], "move cursor")],
8695 render(area, s) {
8796 drawList(s, area, ctx.theme.c, state, rows);
@@ -107,6 +116,24 @@ function teamMembersScreen(ctx: Ctx, f: Session, org: User, team: Team, changed:
107116 );
108117 return true;
109118 }
119 if (e.key === "r") {
120 const member = members[state.index];
121 if (member === undefined) return false;
122 ctx.push(
123 pickerScreen(ctx, screen, {
124 title: `@${member.username}: role`,
125 items: TEAM_ROLES.map((role) => ({ id: role, label: role })),
126 onPick: ([chosen]) => {
127 if (!isTeamRole(chosen)) return;
128 const set = addTeamMember(f.app, f.user, org, team, member.username, chosen);
129 if (!set.ok) ctx.toast(addRefusal(set.error, member.username), "danger");
130 else ctx.toast(`@${member.username} is ${set.value.role} on ${team.name}.`);
131 reload();
132 },
133 }),
134 );
135 return true;
136 }
110137 if (e.key === "d") {
111138 const member = members[state.index];
112139 if (member === undefined) return false;
src/web/pages/org.ts +24 −3
@@ -32,6 +32,7 @@ import {
3232 accessAllows,
3333 getOrg,
3434 getTeam,
35 listTeamMemberRoles,
3536 listTeamMembers,
3637 listTeams,
3738 orgAccess,
@@ -40,6 +41,7 @@ import {
4041 type Team,
4142 } from "../../core/orgs.ts";
4243 import { appsForRepos } from "../../core/apps.ts";
44 import { isTeamRole, TEAM_ROLES } from "../../core/agents.ts";
4345 import { LIST_LIMIT_MAX } from "../../core/issues.ts";
4446 import { listReposByOwner, type Repo } from "../../core/repos.ts";
4547 import { getUserByName, hiddenFrom, type User } from "../../core/users.ts";
@@ -243,6 +245,14 @@ function orgMembers(ctx: Ctx, teams: Team[]): User[] {
243245 );
244246 }
245247
248 /** A team's members with the role each holds (#571), one fact each: name, then role. */
249 function teamMemberFacts(ctx: Ctx, team: Team): { label: string; value: string }[] {
250 return listTeamMemberRoles(ctx.app.db, team.id).map(({ user, role }) => ({
251 label: user.username,
252 value: role,
253 }));
254 }
255
246256 /** "1 member" / "4 members" — a sentence, not a number in a pill. */
247257 function memberCount(n: number): Html {
248258 return html`${n} member${n === 1 ? "" : "s"}`;
@@ -277,6 +287,7 @@ function teamsSection(ctx: Ctx, org: User, canManage: boolean): Html {
277287 lead: icon("users"),
278288 title: t.name,
279289 meta: memberCount(listTeamMembers(ctx.app.db, t.id).length),
290 facts: teamMemberFacts(ctx, t),
280291 trail: html`${badge(t.permission)}${when(canManage, () =>
281292 btn("Manage", {
282293 href: `/${org.username}/-/teams/${t.id}`,
@@ -534,7 +545,7 @@ export function teamPage(ctx: Ctx): Response {
534545 if (!team || team.orgId !== org.id) return notFoundPage(ctx);
535546
536547 const members = timePhase(ctx.request, "members", () =>
537 listTeamMembers(ctx.app.db, team.id),
548 listTeamMemberRoles(ctx.app.db, team.id).map(({ user, role }) => ({ ...user, role })),
538549 );
539550 const rendering = performance.now();
540551 const teamBase = `/${org.username}/-/teams/${team.id}`;
@@ -556,7 +567,7 @@ export function teamPage(ctx: Ctx): Response {
556567 title: html`<a href="/${m.username}"
557568 >${m.displayName || m.username}</a
558569 >`,
559 meta: html`@${m.username}`,
570 meta: html`@${m.username}${m.role === "member" ? "" : html` · ${m.role}`}`,
560571 trail: when(
561572 !protectedTeam || members.length > 1,
562573 () =>
@@ -580,6 +591,12 @@ export function teamPage(ctx: Ctx): Response {
580591 required: true,
581592 hint: "Their username on this forge.",
582593 })}
594 ${selectField({
595 label: "Role",
596 name: "role",
597 hint: "An agent's role is its seat on the org's repositories.",
598 options: TEAM_ROLES.map((r) => ({ value: r, label: r, selected: r === "member" })),
599 })}
583600 <div class="form-actions">
584601 ${btn("Add member", { variant: "primary" })}
585602 </div>
outdated

Principles 1 and 8: this hint explains the feature in three sentences. One plain sentence is enough: 'An agent's role is its seat on the org's repositories.'

@@ -629,7 +646,11 @@ export async function teamMemberAdd(ctx: Ctx): Promise<Response> {
629646 if (r instanceof Response) return r;
630647 const username = field(r.form, "user").trim();
631648 const dest = `/${r.org.username}/-/teams/${r.team.id}`;
632 const added = addTeamMember(ctx.app, ctx.auth.user, r.org, r.team, username);
649 const rawRole = field(r.form, "role");
650 if (rawRole !== "" && !isTeamRole(rawRole))
651 return flashTo(ctx, dest, { tone: "danger", message: `A role is one of ${TEAM_ROLES.join(", ")}.` });
652 const role = isTeamRole(rawRole) ? rawRole : undefined;
653 const added = addTeamMember(ctx.app, ctx.auth.user, r.org, r.team, username, role);
633654 if (!added.ok)
634655 return flashTo(ctx, dest, {
635656 tone: "danger",
test/actions.test.ts +1 −1
@@ -281,7 +281,7 @@ describe("actions own authorization and events", () => {
281281 });
282282 expect(addTeamMember(app, alice, acme, devs.value, "bob")).toEqual({
283283 ok: true,
284 value: { added: true },
284 value: { added: true, role: "member" },
285285 });
286286
287287 // Bob is now a writer in acme — but still not an admin.
test/asks-counts.test.ts +1 −1
@@ -256,7 +256,7 @@ describe("home's asks are read grouped, and once", () => {
256256 const rounds = sql.filter((s) => s.startsWith("SELECT r.issue_id AS issueId, r.id AS id"));
257257 const verdicts = sql.filter((s) => s.startsWith("SELECT review.issue_id, review.id"));
258258 const tries = sql.filter((s) => s.includes("run.reason = 'check_failed'"));
259 const reviewers = sql.filter((s) => s.includes("WHERE repo_crew.role = ?1"));
259 const reviewers = sql.filter((s) => s.includes("SELECT 'roster' AS source"));
260260 expect(active.length).toBe(1);
261261 expect(handovers.length).toBe(1);
262262 expect(rounds.length).toBe(1);
test/teams-crew.test.ts added +315
@@ -0,0 +1,315 @@
1 /**
2 * Teams drive the crew (#571): a team membership carries a role, and an
3 * org repository's crew is the agents on the org's write and admin teams,
4 * each in that role. With no agent on such a team, the per-repo roster is
5 * the crew, unchanged. The routing reads one function, `crewSeats`; the
6 * reviewer it picks comes from there. The API takes and validates `role`;
7 * the org page lists each team's members with their roles, escaped.
8 */
9
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import {
16 addCrew,
17 createAgentRow,
18 crewSeats,
19 crewWithRole,
20 listCrewRoles,
21 reposWithBuilder,
22 } from "../src/core/agents.ts";
23 import {
24 addTeamMember,
25 createOrg,
26 createTeam,
27 listTeamMemberRoles,
28 type Team,
29 } from "../src/core/orgs.ts";
30 import { createRepo, type Repo } from "../src/core/repos.ts";
31 import { createSession } from "../src/core/sessions.ts";
32 import { createToken } from "../src/core/tokens.ts";
33 import { createUser, type User } from "../src/core/users.ts";
34 import { reviewersOf } from "../src/crew/review.ts";
35 import { serve, type Servers } from "../src/http/server.ts";
36 import { listEvents } from "../src/core/events.ts";
37 import { createForgeApp } from "../src/tui/forge.ts";
38 import { headlessTerminal } from "../src/tui/term/terminal.ts";
39 import { quietLogs } from "./helpers/globals.ts";
40 import { settleApp } from "./helpers/tui.ts";
41
42 quietLogs();
43
44 const ALL = ["planner", "builder", "reviewer"] as const;
45
46 describe("teams drive the crew", () => {
47 let dataDir = "";
48 let app: App;
49 let servers: Servers;
50 let alice: User;
51 let juniper: User;
52 let sentinel: User;
53 let mason: User;
54 let org: User;
55 let crewTeam: Team;
56 let readers: Team;
57 let teamed: Repo;
58 let rostered: Repo;
59 let personal: Repo;
60 let rw = "";
61 let cookie = "";
62
63 const user = async (username: string, agent = false): Promise<User> => {
64 const made = await createUser(app.db, {
65 username,
66 email: `${username}@forge.test`,
67 ...(agent
68 ? { agent: true, origin: "agent" as const }
69 : { password: "a-good-passphrase", origin: "seed" as const, trusted: true }),
70 });
71 if (!made.ok) throw new Error(`seed ${username}: ${made.error}`);
72 if (agent) createAgentRow(app.db, { userId: made.value.id, roles: ALL });
73 return made.value;
74 };
75
76 const repo = async (ownerId: string, name: string): Promise<Repo> => {
77 const made = await createRepo(app.db, app.reposRoot, { ownerId, name, visibility: "public" });
78 if (!made.ok) throw new Error(`repo ${name}: ${made.error}`);
79 return made.value;
80 };
81
82 const names = (agents: { user: User }[] | undefined) => (agents ?? []).map((a) => a.user.username);
83
84 beforeAll(async () => {
85 dataDir = await mkdtemp(join(tmpdir(), "forge-teams-crew-"));
86 app = createApp({ dataDir, port: 0 });
87 servers = await serve(app);
88 alice = await user("alice");
89 juniper = await user("juniper", true);
90 sentinel = await user("sentinel", true);
91 mason = await user("mason", true);
92 const made = createOrg(app.db, "acme", alice.id);
93 if (!made.ok) throw new Error(made.error);
94 org = made.value;
95 const t = createTeam(app.db, org.id, "Crew", "write");
96 const r = createTeam(app.db, org.id, "Readers", "read");
97 if (!t.ok || !r.ok) throw new Error("teams");
98 crewTeam = t.value;
99 readers = r.value;
100 teamed = await repo(org.id, "teamed");
101 rostered = await repo(org.id, "rostered");
102 personal = await repo(alice.id, "own");
103 // Today's roster on both org repos and the personal one: juniper reviews, sentinel builds.
104 for (const target of [teamed, rostered, personal]) {
105 addCrew(app.db, target.id, juniper.id, alice.id, "reviewer");
106 addCrew(app.db, target.id, sentinel.id, alice.id, "builder");
107 }
108 rw = createToken(app.db, alice.id, "admin", "admin").token;
109 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
110 });
111
112 afterAll(async () => {
113 servers?.stop();
114 await rm(dataDir, { recursive: true, force: true });
115 });
116
117 test("a membership defaults to member, and the column refuses anything else", () => {
118 expect(addTeamMember(app.db, readers, alice.id)).toBe(true);
119 expect(listTeamMemberRoles(app.db, readers.id)).toEqual([{ user: expect.objectContaining({ username: "alice" }), role: "member" }]);
120 expect(() =>
121 app.db.query("UPDATE team_member SET role = 'king' WHERE team_id = ?1").run(readers.id),
122 ).toThrow();
123 });
124
125 test("with no agent seated by a write team the roster is the crew, unchanged", () => {
126 // An agent on a read team grants nothing.
127 expect(addTeamMember(app.db, readers, mason.id, "reviewer")).toBe(true);
128 for (const target of [teamed, rostered, personal]) {
129 expect(listCrewRoles(app.db, target.id).map((c) => [c.agent.user.username, c.role])).toEqual([
130 ["juniper", "reviewer"],
131 ["sentinel", "builder"],
132 ]);
133 expect(names(reviewersOf(app.db, target.id))).toEqual(["juniper"]);
134 }
135 });
136
137 test("a write team with a reviewer is the crew: the routing picks its reviewer", () => {
138 expect(addTeamMember(app.db, crewTeam, mason.id, "reviewer")).toBe(true);
139 expect(addTeamMember(app.db, crewTeam, sentinel.id, "builder")).toBe(true);
140 // Every repository the org owns now reads its crew from the team …
141 for (const target of [teamed, rostered]) {
142 expect(listCrewRoles(app.db, target.id).map((c) => [c.agent.user.username, c.role])).toEqual([
143 ["mason", "reviewer"],
144 ["sentinel", "builder"],
145 ]);
146 expect(names(reviewersOf(app.db, target.id))).toEqual(["mason"]);
147 expect(names(crewWithRole(app.db, [target.id], "builder").get(target.id))).toEqual(["sentinel"]);
148 }
149 // … and a repository with no org keeps its roster: juniper still reviews it.
150 expect(names(reviewersOf(app.db, personal.id))).toEqual(["juniper"]);
151 expect([...crewSeats(app.db, [teamed.id, personal.id]).keys()].sort()).toEqual([teamed.id, personal.id].sort());
152 });
153
154 test("a write team without a reviewer leaves the seat empty, not the roster's", () => {
155 app.db.query("UPDATE team_member SET role = 'planner' WHERE team_id = ?1 AND user_id = ?2").run(crewTeam.id, mason.id);
156 expect(reviewersOf(app.db, teamed.id)).toEqual([]);
157 expect(names(crewWithRole(app.db, [teamed.id], "planner").get(teamed.id))).toEqual(["mason"]);
158 expect(reposWithBuilder(app.db, [teamed.id, personal.id])).toEqual(new Set([teamed.id, personal.id]));
159 expect(names(reviewersOf(app.db, personal.id))).toEqual(["juniper"]);
160 });
161
162 test("a plain member of a write team holds no seat, so a membership from before roles changes no crew", async () => {
163 const second = createTeam(app.db, org.id, "Second", "write");
164 if (!second.ok) throw new Error(second.error);
165 expect(addTeamMember(app.db, second.value, juniper.id)).toBe(true);
166 expect(listCrewRoles(app.db, teamed.id).some((c) => c.agent.user.id === juniper.id)).toBe(false);
167 // An org whose write teams hold only members keeps its roster.
168 const other = createOrg(app.db, "plain", alice.id);
169 if (!other.ok) throw new Error(other.error);
170 const writers = createTeam(app.db, other.value.id, "Writers", "write");
171 if (!writers.ok) throw new Error(writers.error);
172 expect(addTeamMember(app.db, writers.value, mason.id)).toBe(true);
173 const plain = await repo(other.value.id, "plain-repo");
174 addCrew(app.db, plain.id, juniper.id, alice.id, "reviewer");
175 expect(crewSeats(app.db, [plain.id]).get(plain.id)?.map((s) => [s.agent.user.username, s.role])).toEqual([["juniper", "reviewer"]]);
176 expect(names(reviewersOf(app.db, plain.id))).toEqual(["juniper"]);
177 expect(addTeamMember(app.db, crewTeam, juniper.id, "reviewer")).toBe(true);
178 expect(names(reviewersOf(app.db, teamed.id))).toEqual(["juniper"]);
179 });
180
181 const api = (method: string, path: string, body?: unknown) =>
182 fetch(`${servers.url}/api/v1/orgs/acme/teams/crew/members${path}`, {
183 method,
184 headers: {
185 authorization: `Bearer ${rw}`,
186 origin: servers.url,
187 ...(body === undefined ? {} : { "content-type": "application/json" }),
188 },
189 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
190 });
191
192 test("POST accepts and validates a role; GET lists it", async () => {
193 const bad = await api("POST", "", { user: "mason", role: "king" });
194 expect(bad.status).toBe(422);
195 expect(((await bad.json()) as { error: string }).error).toBe("bad_role");
196 const set = await api("POST", "", { user: "mason", role: "reviewer" });
197 expect(set.status).toBe(200);
198 expect(await set.json()).toEqual({ added: false, role: "reviewer" });
199 const listed = ((await (await api("GET", "")).json()) as { items: { username: string; role: string }[] }).items;
200 expect(listed.map((m) => [m.username, m.role])).toEqual([
201 ["juniper", "reviewer"],
202 ["mason", "reviewer"],
203 ["sentinel", "builder"],
204 ]);
205 });
206
207 test("DELETE with a role takes that role off and keeps the member; a bad role is refused", async () => {
208 expect((await api("DELETE", "/mason?role=king")).status).toBe(422);
209 expect((await api("DELETE", "/mason?role=builder")).status).toBe(404);
210 expect((await api("DELETE", "/mason?role=reviewer")).status).toBe(204);
211 expect(listTeamMemberRoles(app.db, crewTeam.id).find((m) => m.user.id === mason.id)?.role).toBe("member");
212 expect((await api("DELETE", "/mason")).status).toBe(204);
213 expect(listTeamMemberRoles(app.db, crewTeam.id).some((m) => m.user.id === mason.id)).toBe(false);
214 });
215
216 test("the org page lists each team's members with their roles, escaped", async () => {
217 app.db.query("UPDATE user SET username = 'sen<b>' WHERE id = ?1").run(sentinel.id);
218 const page = await (await fetch(`${servers.url}/acme`, { headers: { cookie } })).text();
219 app.db.query("UPDATE user SET username = 'sentinel' WHERE id = ?1").run(sentinel.id);
220 expect(page).toContain("<dt>juniper</dt><dd>reviewer</dd>");
221 expect(page).toContain("<dt>sen&lt;b&gt;</dt><dd>builder</dd>");
222 expect(page).not.toContain("sen<b>");
223 });
224
225 const roleEvents = () =>
226 listEvents(app.db, { limit: 200 }).filter((e) => e.kind === "team.member_role_set");
227
228 test("a role change on a member is told on the outbox, with who, from and to", async () => {
229 const before = roleEvents().length;
230 expect((await api("POST", "", { user: "juniper", role: "planner" })).status).toBe(200);
231 const heard = roleEvents();
232 expect(heard.length).toBe(before + 1);
233 const last = heard.find((e) => e.payload.role === "planner" && e.payload.user === "juniper")!;
234 expect(last.actorId).toBe(alice.id);
235 expect(last.payload.from).toBe("reviewer");
236 // Setting the role they already hold, or a refused one, says nothing.
237 await api("POST", "", { user: "juniper", role: "planner" });
238 await api("POST", "", { user: "juniper", role: "king" });
239 expect(roleEvents().length).toBe(before + 1);
240 // Taking the role off is a change too.
241 expect((await api("DELETE", "/juniper?role=planner")).status).toBe(204);
242 expect(roleEvents().length).toBe(before + 2);
243 expect((await api("POST", "", { user: "juniper", role: "reviewer" })).status).toBe(200);
244 });
245
246 const mcp = async (token: string, args: unknown) => {
247 const res = await fetch(`${servers.url}/mcp`, {
248 method: "POST",
249 headers: {
250 "content-type": "application/json",
251 accept: "application/json, text/event-stream",
252 authorization: `Bearer ${token}`,
253 },
254 body: JSON.stringify({
255 jsonrpc: "2.0",
256 id: 1,
257 method: "tools/call",
258 params: { name: "org_teams", arguments: args },
259 }),
260 });
261 const body = (await res.json()) as { result: { content: { text: string }[]; isError?: boolean } };
262 return { text: body.result.content[0]!.text, isError: body.result.isError === true };
263 };
264
265 test("MCP org_teams takes a role on add_member, refuses a bad one, and lists members with roles", async () => {
266 const bad = await mcp(rw, { org: "acme", action: "add_member", team: "Crew", user: "mason", role: "king" });
267 expect(bad.isError).toBe(true);
268 expect(bad.text).toContain("bad_role");
269 const added = await mcp(rw, { org: "acme", action: "add_member", team: "Crew", user: "mason", role: "builder" });
270 expect(added.isError).toBe(false);
271 expect(JSON.parse(added.text)).toEqual({ added: true, role: "builder" });
272 const listed = await mcp(rw, { org: "acme", action: "members", team: "Crew" });
273 expect(JSON.parse(listed.text)).toContainEqual({ user: "mason", role: "builder" });
274 // Someone who is not an org admin is told there is no such team.
275 const outsider = createToken(app.db, mason.id, "admin", "admin").token;
276 expect((await mcp(outsider, { org: "acme", action: "members", team: "Crew" })).isError).toBe(true);
277 expect((await api("DELETE", "/mason")).status).toBe(204);
278 });
279
280 test("the terminal sets a role with r, and the org page shows it on the next request", async () => {
281 const terminal = headlessTerminal(140, 32);
282 const { app: tui } = createForgeApp(
283 app,
284 { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice },
285 { terminal, exitHooks: false },
286 );
287 tui.start();
288 const key = (k: string, ctrl = false) => tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
289 const press = async (...keys: string[]) => {
290 keys.forEach((k) => key(k));
291 await settleApp(tui);
292 };
293 try {
294 key("k", true);
295 await settleApp(tui);
296 for (const ch of "Organization teams") key(ch);
297 await press("enter");
298 // Crew is the first team of acme, the first org.
299 await press("enter");
300 expect(tui.screen.toText()).toContain("acme / Crew");
301 expect(tui.screen.toText()).toContain("r role");
302 const lineOf = (who: string) => tui.screen.toText().split("\n").find((l) => l.includes(`@${who}`)) ?? "";
303 expect(lineOf("sentinel")).toContain("builder");
304 await press("j", "r");
305 expect(tui.screen.toText()).toContain("@sentinel: role");
306 await press("enter"); // planner, the first
307 expect(lineOf("sentinel")).toContain("planner");
308 } finally {
309 tui.stop();
310 }
311 expect(roleEvents().some((e) => e.payload.user === "sentinel" && e.payload.role === "planner")).toBe(true);
312 const page = await (await fetch(`${servers.url}/acme`, { headers: { cookie } })).text();
313 expect(page).toContain("<dt>sentinel</dt><dd>planner</dd>");
314 });
315 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs