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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10163-mcp-org-teams-with-waiting-true-answers → main 3 commits fork 1 thread · 1 unresolved
6 files
changed +329
−5
docs/features/team-roles.md +1
| @@ -20,6 +20,7 @@ crew, as before. | ||
| 20 | 20 | `action: "remove_member", team, user, role?` (the same as the DELETE above: |
| 21 | 21 | with `role` only that role comes off), and `action: "members"` to list |
| 22 | 22 | members with their roles. |
| 23 | - **MCP, waiting:** see docs/features/team-waiting.md (`org_teams { org, team, waiting: true }`). | |
| 23 | 24 | - **Terminal:** the "Organization teams" command, Enter on a team, then `r` on |
| 24 | 25 | a member to pick a role (org admins only). |
| 25 | 26 |
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 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | 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"; | |
| 8 | 8 | 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"; | |
| 10 | 13 | 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"; | |
| 12 | 15 | import { isMirror } from "../origins/write-through.ts"; |
| 13 | 16 | import { requireWriter, type AccessError } from "./access.ts"; |
| 14 | 17 | import { MIRRORED_DO_IT_THERE } from "./mirror_words.ts"; |
| @@ -43,3 +46,56 @@ export function nameTeams( | ||
| 43 | 46 | }); |
| 44 | 47 | return set; |
| 45 | 48 | } |
| 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 @@ | ||
| 1 | 1 | import { addTeamMember, createTeam, deleteTeam, removeTeamMember } from "../../../actions/orgs.ts"; |
| 2 | import { pullsWaitingOn } from "../../../actions/issue_teams.ts"; | |
| 2 | 3 | import { requireOrgAdmin } from "../../../actions/access.ts"; |
| 3 | 4 | import { isTeamRole, TEAM_ROLES } from "../../../core/agents.ts"; |
| 4 | 5 | import { getOrg, listTeamMemberRoles, listTeamMembers, listTeams, type Permission } from "../../../core/orgs.ts"; |
| @@ -6,7 +7,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 6 | 7 | |
| 7 | 8 | export const tool: Tool = { |
| 8 | 9 | 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`", | |
| 10 | 11 | description: "An org's teams: create, add member with a role, list members, remove.", |
| 11 | 12 | inputSchema: { |
| 12 | 13 | type: "object", |
| @@ -16,9 +17,10 @@ export const tool: Tool = { | ||
| 16 | 17 | team: { type: "string", maxLength: 64 }, |
| 17 | 18 | user: { type: "string", maxLength: 64 }, |
| 18 | 19 | permission: { type: "string", enum: ["read", "write", "admin"], description: "create" }, |
| 20 | waiting: { type: "boolean", description: "list the open pulls the team's review holds" }, | |
| 19 | 21 | role: { type: "string", enum: [...TEAM_ROLES], description: "add_member, remove_member: the seat on the crew" }, |
| 20 | 22 | }, |
| 21 | required: ["org", "action", "team"], | |
| 23 | required: ["org", "team"], | |
| 22 | 24 | }, |
| 23 | 25 | writes: true, |
| 24 | 26 | needsAdminScope: true, |
| @@ -26,6 +28,10 @@ export const tool: Tool = { | ||
| 26 | 28 | const org = getOrg(ctx.app.db, typeof args["org"] === "string" ? args["org"] : ""); |
| 27 | 29 | if (!org) return bad("not_found", "no such organization"); |
| 28 | 30 | 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 | } | |
| 29 | 35 | const action = args["action"]; |
| 30 | 36 | if (action === "create") { |
| 31 | 37 | const p = args["permission"]; |
src/tui/screens/teams.ts +33
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * nothing and sets nothing. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { pullsWaitingOn } from "../../actions/issue_teams.ts"; | |
| 10 | 11 | import { changeTeam, readTeams, teamChanges, TEAM_SAYS } from "../../actions/teams.ts"; |
| 11 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| 12 | 13 | import { binding } from "../app/keys.ts"; |
| @@ -17,6 +18,36 @@ import { fill, len, split } from "../ui/layout.ts"; | ||
| 17 | 18 | import { ListState, drawList, promptScreen, type ListRow } from "./common.ts"; |
| 18 | 19 | import type { PaletteEntry } from "./entries.ts"; |
| 19 | 20 | |
| 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 | ||
| 20 | 51 | export function teamsScreen(f: Session, ctx: Ctx): Screen { |
| 21 | 52 | const state = new ListState(); |
| 22 | 53 | let note = ""; |
| @@ -48,6 +79,7 @@ export function teamsScreen(f: Session, ctx: Ctx): Screen { | ||
| 48 | 79 | binding(["enter"], "dial"), |
| 49 | 80 | binding(["c"], "charter"), |
| 50 | 81 | binding(["m"], "members"), |
| 82 | binding(["w"], "pulls it holds"), | |
| 51 | 83 | ], |
| 52 | 84 | render(area, s) { |
| 53 | 85 | const c = ctx.theme.c; |
| @@ -77,6 +109,7 @@ export function teamsScreen(f: Session, ctx: Ctx): Screen { | ||
| 77 | 109 | ask("a whole number from 0 to 100", String(team.dial), (v) => save(team.slug, { dial: v })); |
| 78 | 110 | else if (e.key === "c") |
| 79 | 111 | 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)); | |
| 80 | 113 | else if (e.key === "m") { |
| 81 | 114 | const byName = new Map(r.value.agents.map((a) => [a.user.username, a.user.id])); |
| 82 | 115 | 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 | }); |
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.