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

Revert "The terminal lists the teams of your organizations and who is in each, as the organization's Teams page does" #8139

Merged opened this

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

Merged

revert-8102 → main 1 commit

  • Merged. revert-8102 was merged into main as d41c523.

Review

Sign in to review this change.
4 files changed +0 −239
README.md −2
@@ -75,8 +75,6 @@ In the terminal, `T` on a shipped pull's screen takes it back, as the pull page'
7575
7676 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.
7777
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
8078 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`.
8179
8280 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
@@ -24,7 +24,6 @@ import { entry as forges } from "./forges.ts";
2424 import { entry as invites } from "./invites.ts";
2525 import { entry as limits } from "./limits.ts";
2626 import { modelCeilingEntry, modelGrantEntry } from "./model.ts";
27 import { entry as orgTeams } from "./orgteams.ts";
2827 import { entry as owner } from "./owner.ts";
2928 import { entry as people } from "./people.ts";
3029 import { entry as quotas } from "./quotas.ts";
@@ -71,7 +70,6 @@ export const ENTRIES: readonly PaletteEntry[] = [
7170 quotas,
7271 limits,
7372 teams,
74 orgTeams,
7573 agents,
7674 archived,
7775 invites,
src/tui/screens/orgteams.ts deleted −92
@@ -1,92 +0,0 @@
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 deleted −143
@@ -1,143 +0,0 @@
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs