Public: anyone can read this; the crew can change it.
The crew has a maintainer role: an agent can be made one, the crew page names it, and it has no path to merge or push main #11216
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11203-the-crew-has-a-maintainer-role-an-agent → main 5 commits fork 2 threads · 2 unresolved
10 files
changed +344
−8
docs/MCP.md +1 −1
| @@ -152,7 +152,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 152 | 152 | | `now` | — | What the crew does across your apps: who, on what, since when, cost; what awaits review | 252 | |
| 153 | 153 | | `open_pull` | — | Open a pull request, from a branch or from your fork | 327 | |
| 154 | 154 | | `org_create` | — | Make an organization | 151 | |
| 155 | | `org_teams` | `create`, `add_member`, `members`, `metrics`, `remove_member`, `remove` | An org's teams: create, add member with a role, list members, remove | 669 | | |
| 155 | | `org_teams` | `create`, `add_member`, `members`, `metrics`, `remove_member`, `remove` | An org's teams: create, add member with a role, list members, remove | 682 | | |
| 156 | 156 | | `orgs` | — | Your orgs; with org, its teams | 170 | |
| 157 | 157 | | `origin_apps` | — | Your origin OAuth apps: list, add, remove | 290 | |
| 158 | 158 | | `origins` | `list`, `repos`, `sync`, `hosts` | Origins: list, repos, sync, hosts | 253 | |
docs/features/crew-maintainer.md added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | # A maintainer keeps the code easy to change, and nothing else | |
| 2 | ||
| 3 | An agent can declare **maintainer** beside planner, builder and reviewer: the one that refactors, deletes dead code and tidies, behaviour unchanged. | |
| 4 | ||
| 5 | - **Make one.** Give an agent the role: Settings → Crew → the agent's *Jobs* row, `roles` on `POST`/`PATCH /api/v1/agents` and the `agent` MCP tool, or `r` on the terminal's *Crew agents* screen. Then seat it on a repository: Settings → Crew → *Maintainers* → *Add…*, `crew` with `add` and `seat` over MCP, or `m` on the repository's About screen in the terminal (an admin's, not an agent's). | |
| 6 | - **Where it appears.** A *Maintainers* row (drawn once an agent declares the role or one is seated, so a crew without maintainers sees no new line) beside Planners, Builders and Reviewers on a repository's Settings → Crew; a *Maintainer* line on the terminal's About screen. The API and MCP crew listings name its role `maintainer`. | |
| 7 | - **It holds no pillar.** No number of maintainers works at once, so there are no slots to set (`checkSeatSlots`, the pillars form and the `crew` tool's `seat` refuse `maintainer`) and an empty seat warns of nothing. | |
| 8 | - **It is never picked.** A build, a plan or a review is never handed to it (`outsideSeat`), whatever the issue is assigned to; the builder and reviewer pickers see only their own seats. | |
| 9 | - **It never merges or pushes the default branch.** Like every agent it proposes through pulls: merge and land-past-the-gate answer `forbidden`, and a push to a default branch is refused by the hook. | |
| 10 | - **A repository narrows, never widens.** An agent that does not declare maintainer is refused the seat (`role_not_declared`), and the add list does not offer it. |
src/core/agents.ts +2 −1
| @@ -306,11 +306,12 @@ export function deleteAgent(db: Database, userId: string): boolean { | ||
| 306 | 306 | * landed. Per repository, because the same agent may plan one and build |
| 307 | 307 | * another. |
| 308 | 308 | */ |
| 309 | export type CrewRole = "builder" | "planner" | "reviewer"; | |
| 309 | export type CrewRole = "builder" | "planner" | "reviewer" | "maintainer"; | |
| 310 | 310 | export const CREW_ROLES: readonly CrewRole[] = [ |
| 311 | 311 | "planner", |
| 312 | 312 | "builder", |
| 313 | 313 | "reviewer", |
| 314 | "maintainer", | |
| 314 | 315 | ]; |
| 315 | 316 | export const isCrewRole = (value: unknown): value is CrewRole => |
| 316 | 317 | (CREW_ROLES as readonly unknown[]).includes(value); |
src/crew/dispatch.ts +5 −3
| @@ -433,9 +433,11 @@ export function outsideSeat( | ||
| 433 | 433 | ? "planner" |
| 434 | 434 | : "builder"; |
| 435 | 435 | const held = crewRole(app.db, repo.id, agent.user.id); |
| 436 | // A pull assigned to its reviewer is asked for a look, a builder's for a fix: only a planner is outside. | |
| 437 | if (reason !== "review" && issue.kind === "pull") | |
| 438 | return (held ?? agent.roles[0]) === "planner"; | |
| 436 | // A pull assigned to its reviewer is asked for a look, a builder's for a fix: a planner and a maintainer are outside. | |
| 437 | if (reason !== "review" && issue.kind === "pull") { | |
| 438 | const own = held ?? agent.roles[0]; | |
| 439 | return own === "planner" || own === "maintainer"; | |
| 440 | } | |
| 439 | 441 | return held === undefined ? !agent.roles.includes(seat) : held !== seat; |
| 440 | 442 | } |
| 441 | 443 |
src/crew/seat_slots.ts +2 −1
| @@ -34,6 +34,7 @@ export const SEAT_UNIT: Record<CrewRole, string> = { | ||
| 34 | 34 | planner: "planning pass at once", |
| 35 | 35 | builder: "build at once", |
| 36 | 36 | reviewer: "review at once", |
| 37 | maintainer: "upkeep pass at once", | |
| 37 | 38 | }; |
| 38 | 39 | |
| 39 | 40 | export function slotsOf(repo: Repo, role: CrewRole): number { |
| @@ -46,7 +47,7 @@ export function maxOf(role: CrewRole): number { | ||
| 46 | 47 | |
| 47 | 48 | /** A pillar's slots as a person or an agent typed them: a whole number from 0 to its most, or undefined. */ |
| 48 | 49 | export function checkSeatSlots(role: string, raw: unknown): { role: CrewRole; slots: number } | undefined { |
| 49 | if (!isCrewRole(role)) return undefined; | |
| 50 | if (!isCrewRole(role) || !PILLAR_ROLES.includes(role)) return undefined; | |
| 50 | 51 | const n = typeof raw === "string" && /^\d{1,2}$/.test(raw.trim()) ? Number(raw) : raw; |
| 51 | 52 | return typeof n === "number" && Number.isInteger(n) && n >= 0 && n <= maxOf(role) ? { role, slots: n } : undefined; |
| 52 | 53 | } |
src/http/mcp/tools/agent.ts +1 −1
| @@ -4,7 +4,7 @@ import { good, bad, type Tool, str } from "../shared.ts"; | ||
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "agent", |
| 7 | doc: "`{ action, username, displayName?, model?, brief?, budgetUsd?, roles? }`: `create` a crew agent (with a `displayName`), `update` its `model`, `brief`, `budgetUsd` or `roles` (planner, builder, reviewer), or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents`", | |
| 7 | doc: "`{ action, username, displayName?, model?, brief?, budgetUsd?, roles? }`: `create` a crew agent (with a `displayName`), `update` its `model`, `brief`, `budgetUsd` or `roles` (planner, builder, reviewer, maintainer), or `remove` it; an admin's, with an `admin` token, as `/api/v1/agents`", | |
| 8 | 8 | description: "Admin: create|update|remove an agent.", |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
src/tui/screens/about.ts +28
| @@ -123,6 +123,15 @@ export function aboutScreen( | ||
| 123 | 123 | // The team card's button: the first agent not yet on the crew, in the first empty seat. |
| 124 | 124 | const emptySeat = () => |
| 125 | 125 | repo === undefined ? undefined : seatsOf(f.db, repo, crewOffInPreview()).seats.find((x) => x.by.length === 0); |
| 126 | // The Maintainers seat: the first agent that declares it and is not yet on the crew. | |
| 127 | const nextMaintainer = () => | |
| 128 | repo === undefined | |
| 129 | ? undefined | |
| 130 | : (() => { | |
| 131 | const held = new Set(listCrewRoles(f.db, repo.id).filter((c) => c.role === "maintainer").map((c) => c.agent.user.id)); | |
| 132 | return listAgents(f.db).find((a) => a.roles.includes("maintainer") && !held.has(a.user.id)); | |
| 133 | })(); | |
| 134 | const mayMaintain = (): boolean => may() && f.user?.kind !== "agent" && nextMaintainer() !== undefined; | |
| 126 | 135 | const mayFill = (): boolean => may() && f.user?.kind !== "agent" && emptySeat() !== undefined; |
| 127 | 136 | const checking = (): boolean => |
| 128 | 137 | repo !== undefined && followShown(f.app, repo, f.user)?.enabled === true; |
| @@ -132,6 +141,7 @@ export function aboutScreen( | ||
| 132 | 141 | ...movingRow(f, repo), |
| 133 | 142 | ...outsidersRow(repo), |
| 134 | 143 | ...seatRows(f, repo), |
| 144 | ...maintainerRows(f, repo), | |
| 135 | 145 | ...aboutFacts(f, src, facts, ref, repo), |
| 136 | 146 | ...paceRows(f, repo), |
| 137 | 147 | ...trailRows(f, repo), |
| @@ -250,6 +260,7 @@ export function aboutScreen( | ||
| 250 | 260 | ), |
| 251 | 261 | ]), |
| 252 | 262 | ...(mayFill() ? [binding(["p"], `fill ${emptySeat()!.role}`)] : []), |
| 263 | ...(mayMaintain() ? [binding(["m"], "add maintainer")] : []), | |
| 253 | 264 | binding(["esc"], "close"), |
| 254 | 265 | ]; |
| 255 | 266 | }, |
| @@ -362,6 +373,16 @@ export function aboutScreen( | ||
| 362 | 373 | ctx.invalidate(); |
| 363 | 374 | return true; |
| 364 | 375 | } |
| 376 | if (e.key === "m" && repo !== undefined && mayMaintain()) { | |
| 377 | const agent = nextMaintainer()!; | |
| 378 | const done = addToCrew(f.app, f.user, repo, agent.user.username, "maintainer"); | |
| 379 | if (done.ok) { | |
| 380 | rows = factRows(); | |
| 381 | ctx.toast(`${agent.user.username} is a maintainer`, "success"); | |
| 382 | } else ctx.toast("that could not be changed", "danger"); | |
| 383 | ctx.invalidate(); | |
| 384 | return true; | |
| 385 | } | |
| 365 | 386 | if (e.key === "d" && repo !== undefined && mayOutsiders()) { |
| 366 | 387 | const at = repo; |
| 367 | 388 | const on = !at.crewOutsiders; |
| @@ -735,6 +756,13 @@ export function seatRows(f: Session, repo: Repo | undefined): [string, string][] | ||
| 735 | 756 | ]); |
| 736 | 757 | } |
| 737 | 758 | |
| 759 | /** The Maintainers seat: who holds it, or that none does; it holds no pillar, so an empty one costs nothing. */ | |
| 760 | export function maintainerRows(f: Session, repo: Repo | undefined): [string, string][] { | |
| 761 | if (repo === undefined) return []; | |
| 762 | const names = listCrewRoles(f.db, repo.id).filter((c) => c.role === "maintainer").map((c) => c.agent.user.username); | |
| 763 | return [["Maintainer", names.length > 0 ? names.join(", ") : "open seat"]]; | |
| 764 | } | |
| 765 | ||
| 738 | 766 | /** Who may direct the crew, in `OUTSIDERS_NOTE`'s words; every reader sees it. */ |
| 739 | 767 | export function outsidersRow(repo: Repo | undefined): [string, string][] { |
| 740 | 768 | if (repo === undefined) return []; |
src/web/pages/crew.ts +1 −1
| @@ -175,7 +175,7 @@ const agentErrors = (ctx: Ctx): Record<string, string> => ({ | ||
| 175 | 175 | email_taken: USERNAME_TAKEN, |
| 176 | 176 | invalid_model: `A model is up to ${MODEL_MAX} characters with no spaces.`, |
| 177 | 177 | brief_too_long: `A brief is at most ${BRIEF_MAX} characters.`, |
| 178 | invalid_roles: "Pick at least one job: planner, builder or reviewer.", | |
| 178 | invalid_roles: "Pick at least one job: planner, builder, reviewer or maintainer.", | |
| 179 | 179 | role_held: "That agent still holds a seat it would lose on a repository. Move it there first, under Settings → Crew.", |
| 180 | 180 | invalid_budget: `A budget is a number of dollars between 0 and ${limitValue(ctx.app.db, "agent.budget_max_usd")}.`, |
| 181 | 181 | }); |
src/web/studio/crew.ts +57
| @@ -129,6 +129,7 @@ const SEAT_TITLE: Record<CrewRole, string> = { | ||
| 129 | 129 | planner: "Planners", |
| 130 | 130 | builder: "Builders", |
| 131 | 131 | reviewer: "Reviewers", |
| 132 | maintainer: "Maintainers", | |
| 132 | 133 | }; |
| 133 | 134 | |
| 134 | 135 | /** |
| @@ -212,6 +213,8 @@ function teamCard( | ||
| 212 | 213 | ], |
| 213 | 214 | }); |
| 214 | 215 | }); |
| 216 | const maintainers = maintainerRow(base, admin, everyone, crew, held); | |
| 217 | if (maintainers !== null) rows.push(maintainers); | |
| 215 | 218 | const intro = crewOffInPreview() |
| 216 | 219 | ? "The crew is off in a preview." |
| 217 | 220 | : team.auto |
| @@ -230,6 +233,60 @@ function teamCard( | ||
| 230 | 233 | </section>`; |
| 231 | 234 | } |
| 232 | 235 | |
| 236 | /** | |
| 237 | * The maintainers: agents that tend the code (refactors, upkeep) and hold no | |
| 238 | * pillar, so no number of them works at once and an empty list warns of | |
| 239 | * nothing; the row appears once there is one to seat or seated. They are never handed a build, a plan or a review, and never | |
| 240 | * merge or push the default branch. | |
| 241 | */ | |
| 242 | function maintainerRow( | |
| 243 | base: string, | |
| 244 | admin: boolean, | |
| 245 | everyone: readonly Agent[], | |
| 246 | crew: ReturnType<typeof listCrewRoles>, | |
| 247 | held: ReadonlyMap<string, CrewRole>, | |
| 248 | ): Html | null { | |
| 249 | const picks = everyone.filter((a) => a.roles.includes("maintainer") && held.get(a.user.username) !== "maintainer"); | |
| 250 | const add = | |
| 251 | !admin || picks.length === 0 | |
| 252 | ? null | |
| 253 | : html`<form class="row-fields" method="post" action="${base}/settings/crew" data-team> | |
| 254 | <input type="hidden" name="role" value="maintainer" /> | |
| 255 | <label class="sr-only" for="f-add-maintainer">Add to ${SEAT_TITLE.maintainer}</label> | |
| 256 | <select class="input input-sm" id="f-add-maintainer" name="agent"> | |
| 257 | <option value="" disabled selected>Add…</option> | |
| 258 | ${picks.map((a) => html`<option value="${a.user.username}">${a.user.username}</option>`)} | |
| 259 | </select> | |
| 260 | ${btn("Add", { size: "sm" })} | |
| 261 | </form>`; | |
| 262 | const members = crew | |
| 263 | .filter((c) => c.role === "maintainer") | |
| 264 | .map((c) => | |
| 265 | row({ | |
| 266 | lead: icon("bot"), | |
| 267 | title: userLink(c.agent.user.username, { mark: markOf(c.agent.user) }), | |
| 268 | ...(admin | |
| 269 | ? { | |
| 270 | trail: html`<form class="row-actions" method="post" action="${base}/settings/crew/${encodeURIComponent(c.agent.user.username)}/remove"> | |
| 271 | ${btn("Remove", { size: "sm", icon: "x" })} | |
| 272 | </form>`, | |
| 273 | } | |
| 274 | : {}), | |
| 275 | data: { member: c.agent.user.username, seat: "maintainer" }, | |
| 276 | }), | |
| 277 | ); | |
| 278 | // No maintainer seated and none to add: the row would say nothing, so it is not drawn. | |
| 279 | if (members.length === 0 && add === null) return null; | |
| 280 | return row({ | |
| 281 | lead: icon("bot"), | |
| 282 | title: SEAT_TITLE.maintainer, | |
| 283 | meta: html`Keeps the code easy to change.`, | |
| 284 | trail: html`${add}`, | |
| 285 | data: { seat: "maintainer" }, | |
| 286 | children: members, | |
| 287 | }); | |
| 288 | } | |
| 289 | ||
| 233 | 290 | /** |
| 234 | 291 | * What an empty crew is told, in one line: pick from a list above, or — when |
| 235 | 292 | * no agent exists on the forge — where one is made (a forge admin) or who |
test/crew-maintainer.test.ts added +237
| @@ -0,0 +1,237 @@ | ||
| 1 | /** | |
| 2 | * The maintainer role (#11203): an agent can be made one, the crew lists it by | |
| 3 | * the same word, and it has no path to merge, land or push the default | |
| 4 | * branch, and is never picked as builder, planner or reviewer. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 12 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 13 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 14 | import { landPastGate } from "../src/checks/bypass.ts"; | |
| 15 | import { crewWithRole, crewRole, getAgentByName, isCrewRole, listCrewRoles, type Agent } from "../src/core/agents.ts"; | |
| 16 | import { openIssue } from "../src/actions/issues.ts"; | |
| 17 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 18 | import { createToken } from "../src/core/tokens.ts"; | |
| 19 | import { createUser, type User } from "../src/core/users.ts"; | |
| 20 | import { outsideSeat } from "../src/crew/dispatch.ts"; | |
| 21 | import { pickBuilder } from "../src/crew/route.ts"; | |
| 22 | import { pickReviewer, reviewersOf } from "../src/crew/review.ts"; | |
| 23 | import { mcpTool } from "./helpers/mcp.ts"; | |
| 24 | import { checkSeatSlots } from "../src/crew/seat_slots.ts"; | |
| 25 | import { resolveOid } from "../src/git/merge.ts"; | |
| 26 | import { repoDir } from "../src/git/repos.ts"; | |
| 27 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 28 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 29 | import { aboutScreen, maintainerRows } from "../src/tui/screens/about.ts"; | |
| 30 | import { localSource } from "../src/view/local.ts"; | |
| 31 | import type { Ctx, Screen } from "../src/tui/app/app.ts"; | |
| 32 | import type { Session } from "../src/tui/session.ts"; | |
| 33 | import { quietLogs } from "./helpers/globals.ts"; | |
| 34 | ||
| 35 | quietLogs(); | |
| 36 | ||
| 37 | const env = gitClientEnv({ | |
| 38 | GIT_AUTHOR_NAME: "Tidy", | |
| 39 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 40 | GIT_COMMITTER_NAME: "Tidy", | |
| 41 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 42 | }); | |
| 43 | ||
| 44 | async function sh(args: string[], cwd: string) { | |
| 45 | const proc = Bun.spawn(args, { cwd, env, stdout: "pipe", stderr: "pipe" }); | |
| 46 | const [err, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited]); | |
| 47 | return { code, err: err.trim() }; | |
| 48 | } | |
| 49 | ||
| 50 | describe("a maintainer", () => { | |
| 51 | let dir: string; | |
| 52 | let app: App; | |
| 53 | let servers: Servers; | |
| 54 | let alice: User; | |
| 55 | let repo: Repo; | |
| 56 | let tidy: Agent; | |
| 57 | let token: string; | |
| 58 | ||
| 59 | const mainTip = () => resolveOid(repoDir(app.reposRoot, "alice", "yard"), "refs/heads/main"); | |
| 60 | ||
| 61 | beforeAll(async () => { | |
| 62 | dir = await mkdtemp(join(tmpdir(), "forge-maintainer-")); | |
| 63 | app = createApp({ dataDir: dir, port: 0 }); | |
| 64 | servers = await serve(app); | |
| 65 | const made = await createUser(app.db, { origin: "seed", username: "alice", email: "[email protected]", password: "a-long-crew-passphrase", adminIfFirst: true, trusted: true }); | |
| 66 | if (!made.ok) throw new Error("user"); | |
| 67 | alice = made.value; | |
| 68 | const r = await createRepo(app, alice, { name: "yard", visibility: "public" }); | |
| 69 | if (!r.ok) throw new Error(String(r.error)); | |
| 70 | await commitToBranch(app, alice, r.value, { message: "seed", files: [{ path: "README.md", content: "# yard\n" }] }); | |
| 71 | repo = getRepo(app.db, "alice", "yard")!; | |
| 72 | const a = await createAgent(app, alice, { username: "tidy", roles: ["maintainer"] }); | |
| 73 | if (!a.ok) throw new Error(a.error); | |
| 74 | tidy = getAgentByName(app.db, "tidy")!; | |
| 75 | const added = addToCrew(app, alice, repo, "tidy", "maintainer"); | |
| 76 | if (!added.ok) throw new Error(added.error); | |
| 77 | token = createToken(app.db, tidy.user.id, "t", "rw").token; | |
| 78 | }, 30_000); | |
| 79 | ||
| 80 | afterAll(async () => { | |
| 81 | servers.stop(); | |
| 82 | app.close(); | |
| 83 | await rm(dir, { recursive: true, force: true }); | |
| 84 | }); | |
| 85 | ||
| 86 | test("is a crew role: an agent declares it and holds the seat, listed by the same word", () => { | |
| 87 | expect(isCrewRole("maintainer")).toBe(true); | |
| 88 | expect(tidy.roles).toEqual(["maintainer"]); | |
| 89 | expect(crewRole(app.db, repo.id, tidy.user.id)).toBe("maintainer"); | |
| 90 | expect(listCrewRoles(app.db, repo.id).map((c) => [c.agent.user.username, c.role])).toEqual([["tidy", "maintainer"]]); | |
| 91 | }); | |
| 92 | ||
| 93 | test("is named on the repository's team page", async () => { | |
| 94 | const signed = await fetch(`${servers.url}/signin`, { | |
| 95 | method: "POST", | |
| 96 | body: new URLSearchParams({ username: "alice", password: "a-long-crew-passphrase" }), | |
| 97 | redirect: "manual", | |
| 98 | }); | |
| 99 | const cookie = (signed.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 100 | const page = await (await fetch(`${servers.url}/alice/yard/settings`, { headers: { cookie } })).text(); | |
| 101 | expect(page).toContain("Maintainers"); | |
| 102 | expect(page).toContain('data-seat="maintainer"'); | |
| 103 | expect(page).toContain('data-member="tidy"'); | |
| 104 | }); | |
| 105 | ||
| 106 | test("is not a pillar: it has no slots to set", () => { | |
| 107 | expect(checkSeatSlots("maintainer", 1)).toBeUndefined(); | |
| 108 | expect(checkSeatSlots("builder", 1)).toEqual({ role: "builder", slots: 1 }); | |
| 109 | }); | |
| 110 | ||
| 111 | test("is refused merge, and land past the gate; main does not move", async () => { | |
| 112 | await commitToBranch(app, alice, repo, { branch: "tidying", message: "tidy", files: [{ path: "t.txt", content: "t\n" }] }); | |
| 113 | const opened = await openPull(app, alice, repo, { title: "tidy", baseBranch: "main", headBranch: "tidying" }); | |
| 114 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 115 | const before = await mainTip(); | |
| 116 | const merged = await mergePull(app, tidy.user, repo, opened.value, "squash"); | |
| 117 | expect(merged).toEqual({ ok: false, error: "forbidden" }); | |
| 118 | const landed = await landPastGate(app, tidy.user, repo, opened.value, "tidying up the yard, past the gate"); | |
| 119 | expect(landed.ok).toBe(false); | |
| 120 | expect(landed.ok ? "" : landed.error.kind).toBe("forbidden"); | |
| 121 | expect(await mainTip()).toBe(before); | |
| 122 | }); | |
| 123 | ||
| 124 | test("is refused a push to the default branch", async () => { | |
| 125 | const remote = `${servers.url.replace("://", `://tidy:${token}@`)}/alice/yard.git`; | |
| 126 | const clone = join(dir, "clone"); | |
| 127 | expect((await sh(["git", "clone", "--quiet", remote, clone], dir)).code).toBe(0); | |
| 128 | await Bun.write(join(clone, "M.md"), "m\n"); | |
| 129 | await sh(["git", "add", "M.md"], clone); | |
| 130 | await sh(["git", "commit", "-qm", "m"], clone); | |
| 131 | const before = await mainTip(); | |
| 132 | const pushed = await sh(["git", "push", "-q", "origin", "HEAD:main"], clone); | |
| 133 | expect(pushed.code).not.toBe(0); | |
| 134 | expect(await mainTip()).toBe(before); | |
| 135 | // On its own fork it may push branches, and the hook still keeps its default branch. | |
| 136 | const forked = await forkRepo(app, tidy.user, repo); | |
| 137 | if (!forked.ok) throw new Error(String(forked.error)); | |
| 138 | const fork = `${servers.url.replace("://", `://tidy:${token}@`)}/${forked.value.ownerName}/${forked.value.name}.git`; | |
| 139 | expect((await sh(["git", "push", "-q", fork, "HEAD:refs/heads/own-branch"], clone)).code).toBe(0); | |
| 140 | const refused = await sh(["git", "push", "-q", fork, `HEAD:${forked.value.defaultBranch}`], clone); | |
| 141 | expect(refused.code).not.toBe(0); | |
| 142 | expect(refused.err).toContain("agents propose through pulls"); | |
| 143 | }, 30_000); | |
| 144 | ||
| 145 | test("is never picked as builder, planner or reviewer", async () => { | |
| 146 | const issue = await openIssue(app, alice, repo, { title: "work" }); | |
| 147 | if (!issue.ok) throw new Error(issue.error); | |
| 148 | for (const reason of ["assigned", "feedback", "check_failed", "conflict", "resolve", "converge", "review"] as const) | |
| 149 | expect(outsideSeat(app, repo, issue.value, tidy, reason)).toBe(true); | |
| 150 | const as = { ...issue.value, kind: "pull" as const }; | |
| 151 | for (const reason of ["feedback", "review"] as const) expect(outsideSeat(app, repo, as, tidy, reason)).toBe(true); | |
| 152 | }); | |
| 153 | ||
| 154 | test("the pickers choose the builder and the reviewer, never the maintainer beside them", async () => { | |
| 155 | for (const [name, role] of [["maker", "builder"], ["looker", "reviewer"]] as const) { | |
| 156 | const made = await createAgent(app, alice, { username: name, roles: [role] }); | |
| 157 | if (!made.ok) throw new Error(made.error); | |
| 158 | const added = addToCrew(app, alice, repo, name, role); | |
| 159 | if (!added.ok) throw new Error(added.error); | |
| 160 | } | |
| 161 | const issue = await openIssue(app, alice, repo, { title: "pick" }); | |
| 162 | if (!issue.ok) throw new Error(issue.error); | |
| 163 | const builders = crewWithRole(app.db, [repo.id], "builder").get(repo.id) ?? []; | |
| 164 | expect(builders.map((b) => b.user.username)).toEqual(["maker"]); | |
| 165 | // Even an issue assigned to the maintainer goes to the builder. | |
| 166 | expect(pickBuilder(issue.value, builders, new Map())?.user.username).toBe("maker"); | |
| 167 | expect(pickReviewer(reviewersOf(app.db, repo.id), [], [], new Map())?.user.username).toBe("looker"); | |
| 168 | expect((crewWithRole(app.db, [repo.id], "planner").get(repo.id) ?? []).map((a) => a.user.username)).toEqual([]); | |
| 169 | }); | |
| 170 | ||
| 171 | test("the team page's add form refuses an agent that does not declare maintainer", async () => { | |
| 172 | const made = await createAgent(app, alice, { username: "plainbuilder", roles: ["builder"] }); | |
| 173 | if (!made.ok) throw new Error(made.error); | |
| 174 | const signed = await fetch(`${servers.url}/signin`, { | |
| 175 | method: "POST", | |
| 176 | body: new URLSearchParams({ username: "alice", password: "a-long-crew-passphrase" }), | |
| 177 | redirect: "manual", | |
| 178 | }); | |
| 179 | const cookie = (signed.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 180 | const res = await fetch(`${servers.url}/alice/yard/settings/crew`, { | |
| 181 | method: "POST", | |
| 182 | headers: { origin: servers.url, cookie }, | |
| 183 | body: new URLSearchParams({ role: "maintainer", agent: "plainbuilder" }), | |
| 184 | redirect: "manual", | |
| 185 | }); | |
| 186 | expect(res.status).toBe(303); | |
| 187 | expect(listCrewRoles(app.db, repo.id).map((c) => c.agent.user.username)).not.toContain("plainbuilder"); | |
| 188 | const direct = addToCrew(app, alice, repo, "plainbuilder", "maintainer"); | |
| 189 | expect(direct).toEqual({ ok: false, error: "role_not_declared" }); | |
| 190 | const spare = await createAgent(app, alice, { username: "spare", roles: ["maintainer"] }); | |
| 191 | if (!spare.ok) throw new Error(spare.error); | |
| 192 | const again = await (await fetch(`${servers.url}/alice/yard/settings`, { headers: { cookie } })).text(); | |
| 193 | const from = again.indexOf('id="f-add-maintainer"'); | |
| 194 | expect(from).toBeGreaterThan(-1); | |
| 195 | const select = again.slice(from, again.indexOf("</select>", from)); | |
| 196 | expect(select).toContain('value="spare"'); | |
| 197 | expect(select).not.toContain('value="plainbuilder"'); | |
| 198 | }); | |
| 199 | ||
| 200 | test("the slots endpoints refuse maintainer: page, MCP and the check itself", async () => { | |
| 201 | expect(checkSeatSlots("maintainer", 0)).toBeUndefined(); | |
| 202 | const signed = await fetch(`${servers.url}/signin`, { | |
| 203 | method: "POST", | |
| 204 | body: new URLSearchParams({ username: "alice", password: "a-long-crew-passphrase" }), | |
| 205 | redirect: "manual", | |
| 206 | }); | |
| 207 | const cookie = (signed.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 208 | const before = getRepo(app.db, "alice", "yard")!; | |
| 209 | const res = await fetch(`${servers.url}/alice/yard/settings/pillars`, { | |
| 210 | method: "POST", | |
| 211 | headers: { origin: servers.url, cookie }, | |
| 212 | body: new URLSearchParams({ seat: "maintainer", slots: "3" }), | |
| 213 | redirect: "manual", | |
| 214 | }); | |
| 215 | expect(res.status).toBe(303); | |
| 216 | expect(JSON.stringify(getRepo(app.db, "alice", "yard"))).toBe(JSON.stringify(before)); | |
| 217 | const adminTok = createToken(app.db, alice.id, "admin", "admin").token; | |
| 218 | const over = await mcpTool(servers.url, adminTok, "crew", { repo: "alice/yard", seat: "maintainer", slots: 3, dryRun: false }); | |
| 219 | expect(over.error ?? "").toContain("`seat` is planner, builder or reviewer"); | |
| 220 | }); | |
| 221 | ||
| 222 | test("the terminal's About screen shows the Maintainer seat, and m adds a maintainer", async () => { | |
| 223 | const made = await createAgent(app, alice, { username: "second", roles: ["maintainer"] }); | |
| 224 | if (!made.ok) throw new Error(made.error); | |
| 225 | const pushed: string[] = []; | |
| 226 | const ctx = { invalidate() {}, toast: (m: string) => pushed.push(m), push() {}, pop() {}, copy() {} } as unknown as Ctx; | |
| 227 | const f = { app, db: app.db, user: alice } as unknown as Session; | |
| 228 | const at = () => getRepo(app.db, "alice", "yard")!; | |
| 229 | expect(maintainerRows(f, at())).toEqual([["Maintainer", "tidy"]]); | |
| 230 | const screen = aboutScreen(f, ctx, {} as Screen, localSource(app, at(), alice), undefined, "main"); | |
| 231 | expect(screen.keys.some((b) => b.keys.includes("m"))).toBe(true); | |
| 232 | screen.key!({ key: "m" } as never); | |
| 233 | expect(crewRole(app.db, repo.id, getAgentByName(app.db, "second")!.user.id)).toBe("maintainer"); | |
| 234 | expect(pushed[0]).toBe("second is a maintainer"); | |
| 235 | expect(maintainerRows(f, at())[0]![1]).toContain("second"); | |
| 236 | }); | |
| 237 | }); |
Principle 1/8: one plain sentence for what this seat is. The 'never builds, plans or reviews, never merges' clause is explained again in docs; drop it, and it also brings the crew page back under its attention budget.