Public: anyone can read this; the crew can change it.
The terminal lists the teams of your organizations and who is in each, as the organization's Teams page does #8102
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7997-the-terminal-lists-the-teams-of-your-org → main 7 commits fork
4 files
changed +239
−0
README.md +2
| @@ -75,6 +75,8 @@ In the terminal, `T` on a shipped pull's screen takes it back, as the pull page' | ||
| 75 | 75 | |
| 76 | 76 | In the terminal, `m` on a local repository's issue list opens the plan screen (`src/tui/screens/plan.ts`): Working, Ready now, Waiting and Parked twice, each headed with its count. It reads what MCP `map` reads (`repoGraph` under the reader's sealed scope, `pillarView`, `mapHeldFor`, the runs in flight), so a sealed issue the reader may not see is left out. Enter opens the issue, `r` reads again, and the headings are skipped over. It is not offered on the pulls list or on a live browse of another forge. |
| 77 | 77 | |
| 78 | In the terminal, the palette's **Organization teams** (offered to anyone on a team of an organization) lists each organization's teams you may see, as `/<org>/-/teams` does (an org admin sees every team, a member only their own), each with its member count and permission; Enter lists the team's members. It is read-only. | |
| 79 | ||
| 78 | 80 | In the terminal, the palette's **Connected origins** lists the hosts your mirrors come from (a signed-in person's, not an agent's): `s` syncs the selected one now, `d` disconnects it after a confirm; see `docs/ORIGINS.md`. |
| 79 | 81 | |
| 80 | 82 | A writer puts a ready issue **First**: the dispatcher starts it before the rest, and the map's Ready now list (and the plan screen's) says `first, by <who>`. On the map page it is the First button on each ready row (a same-origin POST to `/:owner/:name/issues/:number/first`, no JavaScript; `Accept: application/json` gets JSON); in the terminal it is `f` on a ready line of the plan screen. Both call `putIssueFirst` (`src/actions/first.ts`). Refused, changing nothing: a reader or an agent ("Only someone with write access can put an issue first."), a signed-out post (sent to sign in), a read-only token, a cross-origin post, and an issue that is closed or still waits for another ("Only an open issue that nothing waits in front of can go first."). The rank ends when the issue closes, and it is an `issue.first` event, so open maps and plan screens refresh. No MCP tool yet. |
src/tui/screens/entries.ts +2
| @@ -23,6 +23,7 @@ import { entry as forges } from "./forges.ts"; | ||
| 23 | 23 | import { entry as invites } from "./invites.ts"; |
| 24 | 24 | import { entry as limits } from "./limits.ts"; |
| 25 | 25 | import { modelCeilingEntry, modelGrantEntry } from "./model.ts"; |
| 26 | import { entry as orgTeams } from "./orgteams.ts"; | |
| 26 | 27 | import { entry as owner } from "./owner.ts"; |
| 27 | 28 | import { entry as people } from "./people.ts"; |
| 28 | 29 | import { entry as quotas } from "./quotas.ts"; |
| @@ -69,6 +70,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 69 | 70 | quotas, |
| 70 | 71 | limits, |
| 71 | 72 | teams, |
| 73 | orgTeams, | |
| 72 | 74 | agents, |
| 73 | 75 | invites, |
| 74 | 76 | bulk, |
src/tui/screens/orgteams.ts added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | /** | |
| 2 | * Organization teams in the terminal (#7997): for each organization the | |
| 3 | * viewer belongs to, the teams they may see with a member count, as | |
| 4 | * /<org>/-/teams shows them. Enter on a team lists its members, `@username` | |
| 5 | * and display name; esc goes back. Which teams show is `listTeamsVisibleTo`'s | |
| 6 | * rule, the page's: an org admin sees every team, a member only their own. | |
| 7 | * Read-only: no key here adds or removes anyone, so the lines are read once | |
| 8 | * when the screen opens, not on every frame. | |
| 9 | */ | |
| 10 | ||
| 11 | import { listOrgsForUser, listTeamMembers, listTeamsVisibleTo, type Team } from "../../core/orgs.ts"; | |
| 12 | import type { User } from "../../core/users.ts"; | |
| 13 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 14 | import { binding } from "../app/keys.ts"; | |
| 15 | import type { Session } from "../session.ts"; | |
| 16 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 17 | import type { PaletteEntry } from "./entries.ts"; | |
| 18 | ||
| 19 | /** One line of the list: a team of an organization, with its member count. */ | |
| 20 | type Line = { readonly org: User; readonly team: Team; readonly members: number }; | |
| 21 | ||
| 22 | /** | |
| 23 | * Per organization the viewer is in, the teams they may see, in name order. | |
| 24 | * `listOrgsForUser` only names orgs where the viewer is on a team, so every | |
| 25 | * org here has at least that team to show. | |
| 26 | */ | |
| 27 | export function orgTeamLines(f: Session): Line[] { | |
| 28 | const user = f.user; | |
| 29 | if (user === undefined) return []; | |
| 30 | const lines: Line[] = []; | |
| 31 | const orgs = listOrgsForUser(f.app.db, user.id).sort((a, b) => a.username.localeCompare(b.username)); | |
| 32 | for (const org of orgs) { | |
| 33 | for (const team of listTeamsVisibleTo(f.app.db, org.id, user)) | |
| 34 | lines.push({ org, team, members: listTeamMembers(f.app.db, team.id).length }); | |
| 35 | } | |
| 36 | return lines; | |
| 37 | } | |
| 38 | ||
| 39 | function teamMembersScreen(ctx: Ctx, f: Session, org: User, team: Team): Screen { | |
| 40 | const state = new ListState(); | |
| 41 | const rows: ListRow[] = listTeamMembers(f.app.db, team.id) | |
| 42 | .sort((a, b) => a.username.localeCompare(b.username)) | |
| 43 | .map((u) => ({ title: `@${u.username}`, ...(u.displayName === "" ? {} : { secondary: u.displayName }) })); | |
| 44 | return { | |
| 45 | title: `${org.username} / ${team.name}`, | |
| 46 | keys: [binding(["j", "k"], "move cursor")], | |
| 47 | render(area, s) { | |
| 48 | drawList(s, area, ctx.theme.c, state, rows); | |
| 49 | }, | |
| 50 | key(e) { | |
| 51 | return state.key(e, rows.length); | |
| 52 | }, | |
| 53 | }; | |
| 54 | } | |
| 55 | ||
| 56 | export function orgTeamsScreen(f: Session, ctx: Ctx): Screen { | |
| 57 | const state = new ListState(); | |
| 58 | const lines = orgTeamLines(f); | |
| 59 | const rows = lines.map( | |
| 60 | (l): ListRow => ({ | |
| 61 | title: l.team.name, | |
| 62 | secondary: `@${l.org.username}`, | |
| 63 | meta: `${l.members} ${l.members === 1 ? "member" : "members"} · ${l.team.permission}`, | |
| 64 | }), | |
| 65 | ); | |
| 66 | return { | |
| 67 | title: "Organization teams", | |
| 68 | keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "members")], | |
| 69 | render(area, s) { | |
| 70 | drawList(s, area, ctx.theme.c, state, rows); | |
| 71 | }, | |
| 72 | key(e) { | |
| 73 | if (state.key(e, lines.length)) return true; | |
| 74 | if (e.ctrl || e.alt || e.key !== "enter") return false; | |
| 75 | const line = lines[state.index]; | |
| 76 | if (line === undefined) return false; | |
| 77 | ctx.push(teamMembersScreen(ctx, f, line.org, line.team)); | |
| 78 | return true; | |
| 79 | }, | |
| 80 | }; | |
| 81 | } | |
| 82 | ||
| 83 | /** Organization teams, as /<org>/-/teams has them: anyone signed in who belongs to an organization. */ | |
| 84 | export const entry: PaletteEntry = { | |
| 85 | id: "org-teams", | |
| 86 | title: "Organization teams", | |
| 87 | hint: "who is in each team of your organizations", | |
| 88 | after: ["teams", "limits", "quotas", "people"], | |
| 89 | offered: (session) => | |
| 90 | session.user !== undefined && listOrgsForUser(session.app.db, session.user.id).length > 0, | |
| 91 | run: (session, ctx) => ctx.push(orgTeamsScreen(session, ctx)), | |
| 92 | }; |
test/tui-org-teams.test.ts added +143
| @@ -0,0 +1,143 @@ | ||
| 1 | /** | |
| 2 | * Organization teams in the terminal (#7997): an org admin sees every team of | |
| 3 | * the organization, a member only the teams they are in (`listTeamsVisibleTo`, | |
| 4 | * the Teams page's rule); Enter lists the team's members; a person in no | |
| 5 | * organization is not offered the entry. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 12 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 13 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 14 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 15 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | import { settleApp } from "./helpers/tui.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | describe("organization teams in the terminal", () => { | |
| 22 | let dataDir: string; | |
| 23 | let forge: Forge; | |
| 24 | ||
| 25 | function boot(username: string) { | |
| 26 | const terminal = headlessTerminal(140, 32); | |
| 27 | const user = getUserByName(forge.db, username)!; | |
| 28 | const { app } = createForgeApp( | |
| 29 | forge, | |
| 30 | { sshUsername: "t", remoteAddress: "127.0.0.1", user }, | |
| 31 | { terminal, exitHooks: false }, | |
| 32 | ); | |
| 33 | app.start(); | |
| 34 | const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 35 | return { | |
| 36 | text: () => app.screen.toText(), | |
| 37 | press: async (...keys: string[]) => { | |
| 38 | keys.forEach((k) => key(k)); | |
| 39 | await settleApp(app); | |
| 40 | }, | |
| 41 | ctrl: async (k: string) => { | |
| 42 | key(k, true); | |
| 43 | await settleApp(app); | |
| 44 | }, | |
| 45 | type: async (t: string) => { | |
| 46 | for (const ch of t) key(ch); | |
| 47 | await settleApp(app); | |
| 48 | }, | |
| 49 | stop: () => app.stop(), | |
| 50 | }; | |
| 51 | } | |
| 52 | ||
| 53 | const open = async (h: ReturnType<typeof boot>) => { | |
| 54 | await h.ctrl("k"); | |
| 55 | await h.type("Organization teams"); | |
| 56 | await h.press("enter"); | |
| 57 | }; | |
| 58 | ||
| 59 | beforeAll(async () => { | |
| 60 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-org-teams-")); | |
| 61 | forge = createApp({ dataDir, port: 0 }); | |
| 62 | for (const username of ["rooty", "olive", "mark", "dana", "finn", "erin"]) { | |
| 63 | const made = await createUser(forge.db, { | |
| 64 | origin: "seed", | |
| 65 | trusted: true, | |
| 66 | username, | |
| 67 | email: `${username}@forge.local`, | |
| 68 | password: "a-good-passphrase", | |
| 69 | adminIfFirst: username === "rooty", | |
| 70 | ...(username === "mark" ? { displayName: "Mark Member" } : {}), | |
| 71 | }); | |
| 72 | if (!made.ok) throw new Error(made.error); | |
| 73 | } | |
| 74 | const id = (n: string) => getUserByName(forge.db, n)!.id; | |
| 75 | const org = createOrg(forge.db, "acme", id("olive")); | |
| 76 | if (!org.ok) throw new Error(org.error); | |
| 77 | const writers = createTeam(forge.db, org.value.id, "Writers", "write"); | |
| 78 | const readers = createTeam(forge.db, org.value.id, "Readers", "read"); | |
| 79 | if (!writers.ok || !readers.ok) throw new Error("team"); | |
| 80 | addTeamMember(forge.db, writers.value, id("mark")); | |
| 81 | addTeamMember(forge.db, readers.value, id("dana")); | |
| 82 | addTeamMember(forge.db, readers.value, id("finn")); | |
| 83 | }); | |
| 84 | afterAll(async () => { | |
| 85 | forge.close(); | |
| 86 | await rm(dataDir, { recursive: true, force: true }); | |
| 87 | }); | |
| 88 | ||
| 89 | test("an org admin sees every team with its member count", async () => { | |
| 90 | const h = boot("olive"); | |
| 91 | await open(h); | |
| 92 | const text = h.text(); | |
| 93 | expect(text).toContain("Organization teams"); | |
| 94 | expect(text).toContain("Owners"); | |
| 95 | expect(text).toContain("Writers"); | |
| 96 | expect(text).toContain("Readers"); | |
| 97 | expect(text).toContain("@acme"); | |
| 98 | const row = (team: string) => text.split("\n").find((l) => l.includes(team)) ?? ""; | |
| 99 | expect(row("Writers")).toContain("1 member · write"); | |
| 100 | expect(row("Readers")).toContain("2 members · read"); | |
| 101 | h.stop(); | |
| 102 | }); | |
| 103 | ||
| 104 | test("a member sees only the teams they are in", async () => { | |
| 105 | const h = boot("mark"); | |
| 106 | await open(h); | |
| 107 | const text = h.text(); | |
| 108 | expect(text.split("\n").find((l) => l.includes("Writers")) ?? "").toContain("1 member · write"); | |
| 109 | expect(text).not.toContain("Readers"); | |
| 110 | expect(text).not.toContain("Owners"); | |
| 111 | h.stop(); | |
| 112 | }); | |
| 113 | ||
| 114 | test("Enter lists the team's members, and esc goes back", async () => { | |
| 115 | const h = boot("mark"); | |
| 116 | await open(h); | |
| 117 | await h.press("enter"); | |
| 118 | let text = h.text(); | |
| 119 | expect(text).toContain("acme / Writers"); | |
| 120 | expect(text).toContain("@mark"); | |
| 121 | expect(text).toContain("Mark Member"); | |
| 122 | expect(text).not.toContain("@dana"); | |
| 123 | expect(text).not.toContain("@olive"); | |
| 124 | await h.press("esc"); | |
| 125 | text = h.text(); | |
| 126 | expect(text).not.toContain("acme / Writers"); | |
| 127 | expect(text).toContain("Organization teams"); | |
| 128 | h.stop(); | |
| 129 | }); | |
| 130 | ||
| 131 | test("a person in no organization is not offered the entry", async () => { | |
| 132 | const h = boot("erin"); | |
| 133 | await h.ctrl("k"); | |
| 134 | await h.type("Organization teams"); | |
| 135 | expect(h.text()).not.toContain("who is in each team of your organizations"); | |
| 136 | h.stop(); | |
| 137 | const m = boot("mark"); | |
| 138 | await m.ctrl("k"); | |
| 139 | await m.type("Organization teams"); | |
| 140 | expect(m.text()).toContain("who is in each team of your organizations"); | |
| 141 | m.stop(); | |
| 142 | }); | |
| 143 | }); |
new above
offline