From b712a9b39ecd4613de212a5a0cec946301a81b9c Mon Sep 17 00:00:00 2001 From: Dylan Trotter Date: Wed, 28 Oct 2026 18:00:12 +0000 Subject: [PATCH 1/3] stewards: split per-person roles into roles.json (BB-105) --- plugins/stewards/DESIGN.md | 61 +++++- plugins/stewards/README.md | 66 ++++--- plugins/stewards/config.test.ts | 104 +++++++++- plugins/stewards/config.ts | 224 +++++++++++++++++++++- plugins/stewards/server.test.ts | 192 ++++++++++++++++++- plugins/stewards/server.ts | 220 +++++++++++++++++---- plugins/stewards/skills/stewards/SKILL.md | 20 +- plugins/stewards/templates.ts | 4 +- 8 files changed, 805 insertions(+), 86 deletions(-) diff --git a/plugins/stewards/DESIGN.md b/plugins/stewards/DESIGN.md index 23c94f9..62a82ff 100644 --- a/plugins/stewards/DESIGN.md +++ b/plugins/stewards/DESIGN.md @@ -42,7 +42,8 @@ existing bb-gate-hooks pipeline; workers for bounded tasks. | **Project steward** | One per bb project. Reads every binding's status record, sets priorities and holds, routes escalations, writes the morning digest. | | **Worker** | A short-lived child thread a steward starts for one bounded task: implement, fix CI, review a round. Archived with its steward. | | **Playbook** | A file in `.bb/stewards/playbooks/`: adoption, phases keyed by entity state, the ready gate, allowed actions, models, terminal states. | -| **Project file** | `.bb/stewards/project.json`: Linear teams, excluded repos, adoption label, nightly schedule, the project steward's model. | +| **Project file** | `.bb/stewards/project.json`: the project's conventions — Linear teams, excluded repos, adoption label, nightly schedule, the project steward's model, `deps`, `bigDecisions`. | +| **Roles file** | `.bb/stewards/roles.json`: per-person watches keyed by GitHub login — project steward, ops namespaces, Sentry projects, Dependabot repos (BB-105). | | **Spec** | What the entity should become: the playbook's terminal state, the ticket's description and acceptance criteria, its spec comments, and standing guidance. | | **Snapshot** | The observed state, built by plugin code. | | **Status record** | What a steward writes at the end of every judgment turn. | @@ -76,7 +77,9 @@ These were settled while designing; each changes what gets built. truth, so compaction loses nothing important. - **Opt-in by label** for tickets and pull requests: `bb-managed`. - **Every pull request in the project's repos gets a review steward.** Not - only the ones your review is requested on. + only the ones your review is requested on — until a `roles.json` names a + project steward, when a review steward is made only where your review is + requested (BB-105). - **Comments, not description edits.** A steward adds to a ticket with comments. It edits the description only when the scope changes. - **Approval before substantial implementation.** A ticket must be fully @@ -123,12 +126,15 @@ These were settled while designing; each changes what gets built. repos.json owned by multi-repo; the GitHub repos stewards/ project.json Linear teams, excluded repos, label, nightly, - project steward + project steward, deps, bigDecisions + roles.json per-person watches keyed by GitHub login: + projectSteward, ops, Sentry, Dependabot (BB-105) playbooks/ linear-tickets.md github-prs.md github-reviews.md - ops-watch.md only read when ops.targets lists something (§18) + ops-watch.md only read when a role watches a namespace or + Sentry project (§18) ``` ### `project.json` @@ -166,6 +172,49 @@ These were settled while designing; each changes what gets built. - `bb stewards check` validates the file and every playbook against Linear and GitHub. +### `roles.json` + +The watches with no natural owner are assigned here, keyed by the signed-in +GitHub login the github plugin reads. Every server runs only its own entry, so +two developers never watch the same namespace, Sentry project or Dependabot +repo, and a project has one project steward. Ticket stewards follow the Linear +assignee and review stewards follow the GitHub requested reviewer; neither +needs an entry. + +```jsonc +{ + "version": 1, + "roles": { + "jane": { + "projectSteward": true, + "ops": { + "k8sNamespaces": ["bb-gate-production"], + "context": "bc-prod", + "team": "OPS", + "autoAdopt": ["urgent"], + "sentry": { "projects": [{ "org": "acme", "project": "web" }], "resolveOnDone": false }, + "dependabot": "all" + } + }, + "sam": { "ops": { "k8sNamespaces": ["devbox-production"], "dependabot": ["acme/web"] } } + } +} +``` + +- A login with no entry runs no watches and is not the project steward for + the project; the home page says `No role for in roles.json`. A file + that is present but whose login cannot be read runs no roles — never every + role — and says so. +- The project steward's server reports cross-server problems: a watch nobody + runs, a watch two logins run, and no or two project stewards. A role whose + machine lacks a credential (no kubectl, no Sentry token) is that server's + own `bb stewards check` line. +- A `project.json` that still carries `ops` runs exactly as before on every + server and adds one problem line; once `roles.json` is present it wins, and + the migration line stays until the `ops` block is removed. +- A parse error keeps the last good copy in the plugin's own store; nothing + per-server is ever written under `.bb`. + ### Reading `.bb` `.bb` here is the multi-repo project's own repo — the one holding `repos.json`, @@ -197,8 +246,8 @@ HEAD; a checkout with local commits is left as it is and read as it is. |---|---|---| | `ticket` | `bb-managed` added to an issue in a listed Linear team | `linear-tickets` | | `pr` | `bb-managed` added to a pull request in a repo from `repos.json` not in `github.exclude` | `github-prs` | -| `review` | a non-draft pull request, authored by someone other than you, opened or marked ready in a repo from `repos.json` not in `github.exclude` | `github-reviews` | -| `ops` | a target listed in the project file's `ops.targets` (§18) | `ops-watch` | +| `review` | a non-draft pull request, authored by someone other than you, opened or marked ready in a repo from `repos.json` not in `github.exclude`; once `roles.json` names a project steward, only where your review is requested | `github-reviews` | +| `ops` | a namespace, Sentry project or Dependabot repo in this server's `roles.json` entry (§18) | `ops-watch`, `deps-alerts` | - The plugin hears triggers through bb-gate-hooks `team` and `repo` leases, which already exist. The nightly pass re-lists labelled tickets and open diff --git a/plugins/stewards/README.md b/plugins/stewards/README.md index 7a54d34..08f075e 100644 --- a/plugins/stewards/README.md +++ b/plugins/stewards/README.md @@ -20,8 +20,8 @@ bb. [DESIGN.md](DESIGN.md) is the design; this is how it is built and run. of its first 96 turns on bithub, so stewards were top-level.) A project's stewards share one sidebar section, named after the project; see DESIGN.md §15. -- **Ops stewards.** Each target in the project file's `ops.targets` (a - Kubernetes namespace or a Sentry project) gets an `ops` steward, reporting to the project +- **Ops stewards.** Each namespace and Sentry project in a login's + `roles.json` `ops` entry gets an `ops` steward, reporting to the project steward, for as long as it is listed. The plugin looks at the target with `kubectl` on the project's machine, the developer's own, as them (through its host entry, [host.ts](host.ts), and [kubectl.ts](kubectl.ts)): pods, @@ -43,9 +43,9 @@ bb. [DESIGN.md](DESIGN.md) is the design; this is how it is built and run. wall. Until kubectl can read a target, its binding waits and `bb stewards check` says "cannot read the target". DESIGN.md §18. - **Dependency stewards.** GitHub scans; the stewards turn Dependabot alerts - into tickets. Every repo in `repos.json` not in `github.exclude` (or the - explicit `{ kind: "dependabot", repo }` targets in `ops.targets`) gets an - `ops` binding that runs `deps-alerts` and is woken by the + into tickets. A login whose role lists `dependabot` (or `dependabot: "all"`) + gets an `ops` binding for each named repo, or every repo in `repos.json` not + in `github.exclude` for `"all"`; each runs `deps-alerts` and is woken by the `dependabot_alert` doorbell; the github plugin reads the alerts with the developer's token and leases each repo as a `dependabot_repo` (a `repo` lease does not receive alert doorbells). Severity sets the Linear priority @@ -508,6 +508,7 @@ HEAD through multi-repo's `readProjectFiles` and `repoSet`: ``` stewards/ project.json Linear teams, excluded repos, label, nightly, project steward model + roles.json per-person watches keyed by GitHub login (ops, Sentry, Dependabot) playbooks/ linear-tickets.md front matter + one ## section per phase github-prs.md @@ -523,25 +524,42 @@ fails to parse is reported and bindings keep running under the last good version. Project file fields beyond the design: `nightly.until`, `nightly.max`, `playbooks` (which playbook governs each kind), `reviews.enabled`, `adoption.perPass`, `bigDecisions` (what reaches you; -defaults in [config.ts](config.ts)), and `ops`: `targets` (`{ kind: -"k8s-namespace", name }`, at most ten), `team` (where tickets are filed; the -first of `linear.teams` by default, and it must be one of them) and -`context` (the kubeconfig context kubectl uses; the machine's current one -when left out) and `autoAdopt` (Linear priorities, `urgent`, `high`, `medium`, `low`, whose -tickets the ops steward labels for a steward itself when it files them). -A `{ kind: "sentry-project", org, project }` target watches a Sentry -project with the plugin's **Sentry token** setting (a user auth token with -`project:read`, `event:read` and `event:write`; `Sentry API` for a -self-hosted server), rung by bb-gate-hooks the moment an issue is created -or regresses and backed by the same periodic look; the Sentry issue id is -the fingerprint, and the wake carries the issue's title, culprit, -permalink, counts and latest stack trace ([sentry.ts](sentry.ts), DESIGN.md -§18). With `ops.sentry.resolveOnDone` on (it defaults off), the look -resolves a mapped issue whose ticket is Done while Sentry still shows it -unresolved — the plugin's only Sentry write — and notes it in the look; -`event:write` is the scope that write needs. -`ops-watch.md` is read only when `ops.targets` lists a k8s namespace or a -Sentry project. +defaults in [config.ts](config.ts)) and `deps` (below). `project.json` holds +conventions only; the watches that need an owner live in `roles.json`. + +`roles.json` (BB-105) assigns what each person's server runs. It is keyed by +the signed-in GitHub login the github plugin reads, and every server runs only +its own entry: no entry means no watches and no project steward for that +server, and its home page says `No role for in roles.json`. An entry +is `{ projectSteward?: boolean, ops?: { k8sNamespaces, context?, team?, +autoAdopt?, sentry?: { projects: [{ org, project }], resolveOnDone? }, +dependabot?: [repo, …] | "all" } }`. `k8sNamespaces` are watched with +kubectl on that person's machine (their kubeconfig `context` when named); +`team` is where their ops tickets are filed (the first of `linear.teams` by +default, and it must be one of them); `autoAdopt` names the Linear priorities +whose tickets the ops steward labels for a steward itself when it files them. +`sentry.projects` watches Sentry projects with the plugin's **Sentry token** +setting (a user auth token with `project:read`, `event:read` and +`event:write`; `Sentry API` for a self-hosted server), rung by bb-gate-hooks +the moment an issue is created or regresses and backed by the same periodic +look; the Sentry issue id is the fingerprint, and the wake carries the issue's +title, culprit, permalink, counts and latest stack trace +([sentry.ts](sentry.ts), DESIGN.md §18). With `sentry.resolveOnDone` on (it +defaults off), the look resolves a mapped issue whose ticket is Done while +Sentry still shows it unresolved — the plugin's only Sentry write — and notes +it in the look; `event:write` is the scope that write needs. `dependabot` +lists the repos whose alerts that person watches, or `"all"` for every repo in +`repos.json` not in `github.exclude`. `ops-watch.md` is read only when a role +watches a namespace or a Sentry project. + +The project steward's server reports cross-server configuration problems: a +watch nobody runs, a watch two logins run, and no or two project stewards. A +role whose credential is missing on a machine (no kubectl, no Sentry token) +is that server's own `bb stewards check` line, not a project-wide problem. +A `project.json` that still carries `ops` runs exactly as before on every +server and adds one problem line saying the watches now belong in +`roles.json`; once `roles.json` is present it wins, and the migration line +stays. The `deps` object is the dependency watch: `sla` (a duration or `asap` per severity; critical→urgent, high→high, medium→medium, low→low), `batch` (the diff --git a/plugins/stewards/config.test.ts b/plugins/stewards/config.test.ts index a3a38a5..8597b8f 100644 --- a/plugins/stewards/config.test.ts +++ b/plugins/stewards/config.test.ts @@ -1,5 +1,28 @@ import { describe, expect, it } from "vitest"; -import { depsTargets, isTerminal, k8sTargets, opsTeam, sentryTargets, usesKind, modelSpecSchema, parsePlaybook, parseProjectFile, phaseInstructions, phaseOf, playbookStates } from "./config.js"; +import { + depsTargets, + isTerminal, + k8sTargets, + legacyOpsPresent, + legacyRole, + modelSpecSchema, + opsTeam, + parsePlaybook, + parseProjectFile, + parseRolesFile, + phaseInstructions, + phaseOf, + playbookStates, + projectStewardProblem, + roleDepsTargets, + roleK8sTargets, + roleOpsTeam, + roleSentryTargets, + roleUsesOps, + sentryTargets, + usesKind, + watchProblems, +} from "./config.js"; import { DEPS_ALERTS, DEPS_PRS, GITHUB_PRS, GITHUB_REVIEWS, LINEAR_TICKETS, OPS_WATCH, projectFileTemplate } from "./templates.js"; describe("the project file", () => { @@ -301,3 +324,82 @@ describe("model specs", () => { for (const bad of ["/x", "claude/", "claude//high", "pi/a//b"]) expect(modelSpecSchema.safeParse(bad).success, bad).toBe(false); }); }); + +describe("roles.json (BB-105)", () => { + const roles = (entries: Record, extra: Record = {}) => parseRolesFile(JSON.stringify({ version: 1, roles: entries, ...extra })); + + it("parses roles, folds logins to lower case and fills the defaults", () => { + const parsed = roles({ "Jane-Doe": { projectSteward: true, ops: { k8sNamespaces: ["prod"], team: "eng", sentry: { projects: [{ org: " Acme ", project: "Web-API" }] }, dependabot: ["acme/web"] } }, Bob: {} }); + if (!parsed.ok) throw new Error(parsed.error); + expect(Object.keys(parsed.value.roles)).toEqual(["jane-doe", "bob"]); + const role = parsed.value.roles["jane-doe"]!; + expect(role.projectSteward).toBe(true); + expect(role.ops).toMatchObject({ k8sNamespaces: ["prod"], team: "ENG", autoAdopt: [], sentry: { projects: [{ org: "acme", project: "web-api" }], resolveOnDone: false }, dependabot: ["acme/web"] }); + expect(parsed.value.roles["bob"]).toEqual({ projectSteward: false, ops: undefined }); + // The role getters read the entry, not the legacy project.ops. + expect(roleK8sTargets(role)).toEqual([{ kind: "k8s-namespace", name: "prod" }]); + expect(roleSentryTargets(role)).toEqual([{ kind: "sentry-project", org: "acme", project: "web-api" }]); + expect(roleDepsTargets(role, ["acme/web", "acme/other"])).toEqual([{ kind: "dependabot", repo: "acme/web" }]); + expect(roleDepsTargets(parsed.value.roles["bob"]!, ["acme/web"])).toEqual([]); + expect(roleUsesOps(role)).toBe(true); + expect(roleUsesOps(parsed.value.roles["bob"]!)).toBe(false); + }); + + it("takes dependabot: all, and names the file in a parse error", () => { + const parsed = roles({ jane: { ops: { dependabot: "all" } } }); + if (!parsed.ok) throw new Error(parsed.error); + expect(roleDepsTargets(parsed.value.roles["jane"]!, ["acme/web", "acme/other"])).toEqual([{ kind: "dependabot", repo: "acme/web" }, { kind: "dependabot", repo: "acme/other" }]); + const bad = roles({ jane: { ops: { k8sNamespaces: ["Prod_1"] } } }); + expect(bad).toMatchObject({ ok: false }); + if (!bad.ok) expect(bad.error).toMatch(/stewards\/roles\.json: roles\.jane\.ops\.k8sNamespaces\.0: a namespace name/u); + }); + + it("rejects unknown keys, a bad version, and two keys naming one login ignoring case", () => { + expect(roles({ jane: { projectSteward: true } }, { extra: true })).toMatchObject({ ok: false }); + expect(roles({ jane: { unknown: true } })).toMatchObject({ ok: false }); + expect(parseRolesFile(JSON.stringify({ version: 2, roles: {} }))).toMatchObject({ ok: false }); + expect(roles({ Jane: {}, jane: {} })).toMatchObject({ ok: false, error: expect.stringMatching(/same login as another entry/u) }); + expect(parseRolesFile("{")).toMatchObject({ ok: false }); + }); + + it("keeps the project file strict, so an unknown key is still refused", () => { + expect(parseProjectFile(JSON.stringify({ version: 1, ops: { targets: [], watch: true } }))).toMatchObject({ ok: false }); + expect(parseProjectFile(JSON.stringify({ version: 1, roles: {} }))).toMatchObject({ ok: false }); + }); + + it("builds a legacy role from project.ops, and flags only non-empty ops as migration", () => { + const empty = parseProjectFile(JSON.stringify({ version: 1, ops: { targets: [], autoAdopt: [] } })); + if (!empty.ok) throw new Error(empty.error); + expect(legacyOpsPresent(empty.value)).toBe(false); + + const legacy = parseProjectFile(JSON.stringify({ version: 1, linear: { teams: ["ENG"] }, ops: { targets: [{ kind: "sentry-project", org: "acme", project: "web" }], team: "eng", context: "bc-prod", autoAdopt: ["urgent"], sentry: { resolveOnDone: true } } })); + if (!legacy.ok) throw new Error(legacy.error); + expect(legacyOpsPresent(legacy.value)).toBe(true); + const role = legacyRole(legacy.value); + expect(role.projectSteward).toBe(true); + expect(role.ops).toMatchObject({ team: "ENG", context: "bc-prod", autoAdopt: ["urgent"], sentry: { projects: [{ org: "acme", project: "web" }], resolveOnDone: true }, dependabot: "all" }); + expect(roleOpsTeam(role, legacy.value)).toBe("ENG"); + expect(usesKind(legacy.value, "ops", role)).toBe(true); + }); + + it("counts the project stewards and reports an unowned or doubly owned watch", () => { + const project = parseProjectFile(JSON.stringify({ version: 1, linear: { teams: ["ENG"] }, ops: { targets: [{ kind: "k8s-namespace", name: "web-production" }, { kind: "sentry-project", org: "acme", project: "web" }] } })); + if (!project.ok) throw new Error(project.error); + const none = roles({ jane: {}, bob: {} }); + if (!none.ok) throw new Error(none.error); + expect(projectStewardProblem(none.value)).toMatch(/no one is the project steward/u); + + const two = roles({ jane: { projectSteward: true }, bob: { projectSteward: true } }); + if (!two.ok) throw new Error(two.error); + expect(projectStewardProblem(two.value)).toMatch(/two people are the project steward: jane, bob/u); + + const doubled = roles({ jane: { projectSteward: true, ops: { k8sNamespaces: ["web-production"] } }, bob: { ops: { k8sNamespaces: ["web-production"] } } }); + if (!doubled.ok) throw new Error(doubled.error); + // No explicit dependabot target: the default (every repo) is expected. + const problems = watchProblems(project.value, doubled.value, ["acme/web", "acme/other"], true); + expect(problems).toContain("two people run the web-production watch: jane, bob"); + expect(problems).toContain("nobody runs the Sentry watch for acme/web"); + expect(problems).toContain("nobody runs the Dependabot watch for acme/web"); + expect(problems).toContain("nobody runs the Dependabot watch for acme/other"); + }); +}); diff --git a/plugins/stewards/config.ts b/plugins/stewards/config.ts index 78511cf..fb0714a 100644 --- a/plugins/stewards/config.ts +++ b/plugins/stewards/config.ts @@ -7,11 +7,13 @@ import { createHash } from "node:crypto"; import { parse as parseYaml } from "yaml"; import { z } from "zod"; -import { SENTRY_SLUG, type DependabotTarget, type K8sTarget, type OpsTarget, type SentryTarget } from "./ops.js"; +import { SENTRY_SLUG, opsTargetKey, type DependabotTarget, type K8sTarget, type OpsTarget, type SentryTarget } from "./ops.js"; import { DEPENDABOT_SEVERITIES } from "./source-contract.js"; export const STEWARDS_DIR = "stewards"; export const PROJECT_FILE = `${STEWARDS_DIR}/project.json`; +/** The per-person roles: which watches and the project stewardship a GitHub login's server runs. */ +export const ROLES_FILE = `${STEWARDS_DIR}/roles.json`; export const playbookPath = (name: string) => `${STEWARDS_DIR}/playbooks/${name}.md`; export const DEFAULT_LABEL = "bb-managed"; @@ -378,10 +380,129 @@ export function parseProjectFile(text: string): Parsed { return parsed.success ? { ok: true, value: parsed.data } : { ok: false, error: `${PROJECT_FILE}: ${zodMessage(parsed.error)}` }; } -export const playbookNameFor = (project: ProjectFile, kind: BindingKind): string => project.playbooks[kind] ?? DEFAULT_PLAYBOOKS[kind]; +// ---- roles: per-person watches, keyed by GitHub login ------------------------------ + +/** + * The per-person roles file (BB-105). It is read like the project file, from + * the canonical checkout's committed HEAD, and each server runs only the entry + * whose key is its own signed-in GitHub login. The watches with no natural + * owner (namespace, Sentry project, Dependabot repo) are assigned here; the + * ticket and review stewards are dispatched by the Linear assignee and the + * GitHub requested reviewer, and need no entry. + */ + +/** A GitHub login, folded to lower case by the parser. */ +const githubLogin = z.string().trim().regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/u, "a GitHub login such as jane-doe"); + +const sentryProjectSchema = z.object({ org: sentrySlug, project: sentrySlug }).strict(); + +const roleSentrySchema = z + .object({ + projects: z.array(sentryProjectSchema).max(50).default([]), + /** On, the Sentry look resolves a mapped issue whose ticket is Done (the project file's `ops.sentry.resolveOnDone`). */ + resolveOnDone: z.boolean().default(false), + }) + .strict() + .default({ projects: [], resolveOnDone: false }); + +/** A role's watches: namespaces, Sentry projects, Dependabot repos, and where its tickets file. */ +export const roleOpsSchema = z + .object({ + k8sNamespaces: z.array(z.string().trim().regex(/^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/u, "a namespace name such as bb-gate-production")).max(50).default([]), + /** The kubeconfig context kubectl uses on this person's machine; its current context when left out. */ + context: z.string().trim().min(1).max(200).regex(/^[^\s-][^\s]*$/u, "a kubeconfig context name").optional(), + /** Where this person's ops tickets are filed (the first of linear.teams when left out). */ + team: teamKey.optional(), + autoAdopt: z.array(z.enum(LINEAR_PRIORITIES)).max(4).default([]), + sentry: roleSentrySchema, + /** Repos whose Dependabot alerts this person watches; `"all"` is every repo in repos.json less github.exclude. */ + dependabot: z.union([z.literal("all"), z.array(dependabotRepoSchema).max(200)]).optional(), + }) + .strict(); +export type RoleOps = z.infer; + +/** One person's role: whether their server is the project steward, and the watches it runs. */ +export const roleSchema = z.object({ projectSteward: z.boolean().default(false), ops: roleOpsSchema.optional() }).strict(); +export type Role = z.infer; + +export type RolesFile = { version: 1; roles: Record }; + +const rolesFileRawSchema = z.object({ version: z.literal(1), roles: z.record(z.string(), roleSchema) }).strict(); + +/** + * Folds every login to lower case and refuses two keys that name the same + * login ignoring case. Duplicate JSON keys are collapsed by `JSON.parse` + * before this runs; the check catches `Jane` and `jane` in one file. + */ +export const rolesFileSchema = rolesFileRawSchema.transform((file, ctx): RolesFile => { + const roles: Record = {}; + for (const [key, role] of Object.entries(file.roles)) { + const login = key.trim().toLowerCase(); + if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(login)) { + ctx.addIssue({ code: "custom", path: ["roles", key], message: `${JSON.stringify(key)} is not a GitHub login` }); + return z.NEVER; + } + if (roles[login] !== undefined) { + ctx.addIssue({ code: "custom", path: ["roles", key], message: `${key} names the same login as another entry, ignoring case` }); + return z.NEVER; + } + roles[login] = role; + } + return { version: 1, roles }; +}); + +export function parseRolesFile(text: string): Parsed { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch (error) { + return { ok: false, error: `${ROLES_FILE} is not JSON: ${error instanceof Error ? error.message : String(error)}` }; + } + const parsed = rolesFileSchema.safeParse(raw); + return parsed.success ? { ok: true, value: parsed.data } : { ok: false, error: `${ROLES_FILE}: ${zodMessage(parsed.error)}` }; +} + +// ---- the role one server runs -------------------------------------------------------- + +/** The k8s namespaces a role watches, which are read with kubectl. */ +export const roleK8sTargets = (role: Role | null): K8sTarget[] => (role?.ops?.k8sNamespaces ?? []).map((name) => ({ kind: "k8s-namespace", name })); + +/** The Sentry projects a role watches, which are read with the plugin's Sentry token and rung by bb-gate-hooks. */ +export const roleSentryTargets = (role: Role | null): SentryTarget[] => (role?.ops?.sentry?.projects ?? []).map((p) => ({ kind: "sentry-project", org: p.org, project: p.project })); + +/** The namespace and Sentry targets a role watches; the Dependabot watch is separate because it defaults to every repo. */ +export const roleOpsTargets = (role: Role | null): OpsTarget[] => [...roleK8sTargets(role), ...roleSentryTargets(role)]; + +/** + * The Dependabot alert targets a role watches: `"all"` is every repo in the + * project's derived set (`repos.json` less `github.exclude`), an explicit list + * is exactly those, and no `dependabot` key is none. + */ +export function roleDepsTargets(role: Role | null, repos: readonly string[]): DependabotTarget[] { + const dep = role?.ops?.dependabot; + return (dep === "all" ? [...repos] : dep ?? []).map((repo) => ({ kind: "dependabot" as const, repo })); +} -/** Whether a project uses a kind of binding at all: reviews can be turned off, and ops needs a target. */ -export const usesKind = (project: ProjectFile, kind: BindingKind): boolean => (kind === "review" ? project.reviews.enabled : kind === "ops" ? project.ops.targets.length > 0 : true); +/** The Linear team a role's ops steward files in (the first of linear.teams when the role names none). */ +export const roleOpsTeam = (role: Role | null, project: ProjectFile): string | null => role?.ops?.team ?? project.linear.teams[0] ?? null; + +/** + * Whether a role watches a namespace or Sentry project, so the ops-watch + * playbook is needed. The Dependabot watch runs the separate deps-alerts + * playbook, so it does not count here. + */ +export const roleUsesOps = (role: Role | null): boolean => roleK8sTargets(role).length > 0 || roleSentryTargets(role).length > 0; + +/** + * Whether a project uses a kind of binding at all: reviews can be turned off, + * and ops needs a target. `role` is the server's resolved role: omitted, the + * legacy `project.ops` is read (a project without a roles.json); null, the + * server has no role and runs no ops. + */ +export const usesKind = (project: ProjectFile, kind: BindingKind, role?: Role | null): boolean => + kind === "review" ? project.reviews.enabled : kind === "ops" ? (role === undefined ? project.ops.targets.length > 0 : roleUsesOps(role)) : true; + +export const playbookNameFor = (project: ProjectFile, kind: BindingKind): string => project.playbooks[kind] ?? DEFAULT_PLAYBOOKS[kind]; export const opsTargets = (project: ProjectFile): OpsTarget[] => project.ops.targets; @@ -408,6 +529,101 @@ export const depsPrsPlaybookName = (project: ProjectFile): string => project.pla /** The Linear team an ops steward files in. */ export const opsTeam = (project: ProjectFile): string | null => project.ops.team ?? project.linear.teams[0] ?? null; +// ---- migration and the project steward's role problems --------------------------------- + +/** + * Whether a project file still carries watches that now belong in roles.json. + * An empty `ops` block (the init template's) is not carrying anything. + */ +export const legacyOpsPresent = (project: ProjectFile): boolean => + project.ops.targets.length > 0 || project.ops.team !== undefined || project.ops.context !== undefined || project.ops.autoAdopt.length > 0 || project.ops.sentry.resolveOnDone; + +/** + * A project file's legacy `ops` as a role, so a project with no roles.json runs + * exactly as before: every server is the project steward and runs every watch. + * The Dependabot default (every repo) becomes `"all"`. + */ +export function legacyRole(project: ProjectFile): Role { + const explicit = project.ops.targets.filter((t): t is DependabotTarget => t.kind === "dependabot").map((t) => t.repo); + return { + projectSteward: true, + ops: { + k8sNamespaces: k8sTargets(project).map((t) => t.name), + ...(project.ops.team === undefined ? {} : { team: project.ops.team }), + ...(project.ops.context === undefined ? {} : { context: project.ops.context }), + autoAdopt: [...project.ops.autoAdopt], + sentry: { projects: sentryTargets(project).map((t) => ({ org: t.org, project: t.project })), resolveOnDone: project.ops.sentry.resolveOnDone }, + dependabot: explicit.length > 0 ? explicit : "all", + }, + }; +} + +/** The words the project steward's problem lines use for one watch. */ +function watchLabel(key: string): string { + if (key.startsWith("k8s-namespace/")) return `the ${key.slice("k8s-namespace/".length)} watch`; + if (key.startsWith("sentry-project/")) return `the Sentry watch for ${key.slice("sentry-project/".length)}`; + return `the Dependabot watch for ${key.slice("dependabot/".length)}`; +} + +/** Every watch a role runs, keyed as `opsTargetKey` keys the same target. */ +function roleWatchKeys(role: Role, repos: readonly string[]): string[] { + return [...roleOpsTargets(role), ...roleDepsTargets(role, repos)].map(opsTargetKey); +} + +/** + * The watches the project expects someone to run: the legacy `ops` targets, + * and the Dependabot default (every repo) while the dependency watch is on. A + * watch in no role is unowned; one in two roles is doubly owned. + */ +export function expectedWatches(project: ProjectFile, repos: readonly string[], depsEnabled: boolean): { key: string; label: string }[] { + const out: { key: string; label: string }[] = []; + const seen = new Set(); + const add = (key: string) => { + if (seen.has(key)) return; + seen.add(key); + out.push({ key, label: watchLabel(key) }); + }; + for (const t of k8sTargets(project)) add(opsTargetKey(t)); + for (const t of sentryTargets(project)) add(opsTargetKey(t)); + const explicit = project.ops.targets.filter((t): t is DependabotTarget => t.kind === "dependabot").map((t) => t.repo); + if (explicit.length > 0) for (const repo of explicit) add(opsTargetKey({ kind: "dependabot", repo })); + else if (depsEnabled) for (const repo of repos) add(opsTargetKey({ kind: "dependabot", repo })); + return out; +} + +/** + * The project steward's problem lines about who owns what: a watch nobody + * runs, and a watch two people run. Credentials are checked on each server, + * never here: a role whose machine lacks kubectl or a Sentry token is that + * server's own problem line. + */ +export function watchProblems(project: ProjectFile, roles: RolesFile, repos: readonly string[], depsEnabled: boolean): string[] { + const problems: string[] = []; + const owners = new Map(); + for (const [login, role] of Object.entries(roles.roles)) { + for (const key of roleWatchKeys(role, repos)) owners.set(key, [...(owners.get(key) ?? []), login]); + } + const expected = expectedWatches(project, repos, depsEnabled); + for (const watch of expected) { + const list = owners.get(watch.key) ?? []; + if (list.length === 0) problems.push(`nobody runs ${watch.label}`); + else if (list.length > 1) problems.push(`two people run ${watch.label}: ${list.join(", ")}`); + } + // A watch two roles name that the project does not otherwise expect: a namespace, say. + for (const [key, list] of owners) { + if (list.length > 1 && !expected.some((w) => w.key === key)) problems.push(`two people run ${watchLabel(key)}: ${list.join(", ")}`); + } + return problems; +} + +/** The project steward count problem, or null when exactly one login holds it. */ +export function projectStewardProblem(roles: RolesFile): string | null { + const stewards = Object.entries(roles.roles).filter(([, role]) => role.projectSteward).map(([login]) => login); + if (stewards.length === 0) return "no one is the project steward: set projectSteward: true on one login in roles.json"; + if (stewards.length > 1) return `two people are the project steward: ${stewards.join(", ")}`; + return null; +} + // ---- playbooks --------------------------------------------------------------------- export const STAGES = ["spec", "build", "review"] as const; diff --git a/plugins/stewards/server.test.ts b/plugins/stewards/server.test.ts index a7e0532..f699f14 100644 --- a/plugins/stewards/server.test.ts +++ b/plugins/stewards/server.test.ts @@ -2,7 +2,7 @@ import Database from "better-sqlite3"; import { createFakePluginHost, makePluginAgentConfigurationContext, makeThreadResponse } from "@get-bb/plugin-sdk/testing"; import { describe, expect, it } from "vitest"; import { PROJECT_CHANGED } from "./channels.js"; -import { PROJECT_FILE, playbookPath } from "./config.js"; +import { PROJECT_FILE, ROLES_FILE, playbookPath } from "./config.js"; import { comment, crashingPod, issue, pull, warningEvent } from "./fixtures.js"; import { kubectlRefusal, type KubectlResult } from "./kubectl.js"; import { retiredLessonAnchor, withLessonAnchor } from "./lessons.js"; @@ -28,6 +28,8 @@ function world() { const entities = new Map(); /** Dependabot alerts the fake github serves, by lowercased repo. */ const dependabotAlerts = new Map(); + /** The login `sourceValidate` reports, per source; tests override one to make it unreadable (BB-105). */ + const viewers: Partial> = {}; /** Deployments the fake github serves, by `lowercased repo lowercased environment`. */ const sourceDeployments = new Map(); const rpcCalls: Call[] = []; @@ -90,7 +92,11 @@ function world() { .map((e) => ({ id: e.id, key: e.key, unit: e.unit, kind: e.kind, state: e.state, labels: e.labels, author: e.author, draft: e.pull?.draft ?? false, createdAt: e.createdAt })), errors: [], }; - } else if (args.method === "sourceValidate") out = { viewer: "Jane", units: (input.units as string[]).map((unit) => ({ unit, found: true, labelFound: true, missingStates: [] })), mine: [{ unit: "ENG", name: "Engineering" }], error: null }; + } else if (args.method === "sourceValidate") { + const source = args.pluginId === SOURCE_PLUGIN_IDS.github ? "github" : "linear"; + const viewer = source in viewers ? viewers[source]! : "Jane"; + out = { viewer, units: (input.units as string[]).map((unit) => ({ unit, found: true, labelFound: true, missingStates: [] })), mine: [{ unit: "ENG", name: "Engineering" }], error: null }; + } else if (args.method === "sourceDependabotAlerts") { const units = input.units as string[]; out = { viewer: "jane", alerts: units.flatMap((u) => dependabotAlerts.get(u.toLowerCase()) ?? []), errors: [] }; @@ -278,7 +284,7 @@ function world() { }, }; let now = Date.parse("2026-10-14T15:00:00Z"); - return { files, writes, fileContents, repos, entities, dependabotAlerts, sourceDeployments, rpcCalls, mergeGuidanceCalls, setMergeGuidanceResult: (value: typeof mergeGuidanceResult) => { mergeGuidanceResult = value; }, threads, deletes, sections, usage, metadata, spawns, sends, userTurns, githubComments, githubMerges, githubFiles, github, sdk, uiPreferences, uiPreferencesWrites, now: () => now, advance: (ms: number) => (now += ms) }; + return { files, writes, fileContents, repos, entities, dependabotAlerts, sourceDeployments, viewers, rpcCalls, mergeGuidanceCalls, setMergeGuidanceResult: (value: typeof mergeGuidanceResult) => { mergeGuidanceResult = value; }, threads, deletes, sections, usage, metadata, spawns, sends, userTurns, githubComments, githubMerges, githubFiles, github, sdk, uiPreferences, uiPreferencesWrites, now: () => now, advance: (ms: number) => (now += ms) }; } async function setup(extra: Partial = {}, settings: Record = {}, seedProvider?: string | null | "fail-list" | "fail-set") { @@ -5793,15 +5799,19 @@ describe("ops stewards", () => { c.state.logs = false; c.state.deployments = false; const readable = await check(); - expect(readable.stdout).not.toMatch(/problem:/u); - expect(readable.exitCode).toBe(0); + // The only problem is the migration line: the legacy ops still runs, and + // says it now belongs in roles.json. + expect(readable.stdout).toMatch(/problem: ops now belongs in stewards\/roles\.json/u); + expect(readable.stdout.split("\n").filter((l) => l.startsWith("problem:") && !l.includes("ops now belongs"))).toEqual([]); + expect(readable.exitCode).toBe(1); expect(readable.stdout).toMatch(/ops: k8s-namespace\/bb-gate-production is readable \(get pods, get events\); logs not readable, so diagnosis is from status and events/u); expect(readable.stdout).toMatch(/cannot get deployments \(Error from server \(Forbidden\): deployments\.apps is forbidden.*\); looks go without it/u); // kubectl acts as the developer, whose rights are broad: said, not a problem. c.state.writes = true; const writable = await check(); - expect(writable.exitCode).toBe(0); + expect(writable.exitCode).toBe(1); + expect(writable.stdout.split("\n").filter((l) => l.startsWith("problem:") && !l.includes("ops now belongs"))).toEqual([]); expect(writable.stdout).toMatch(/this identity may also delete pods, create pods\/exec, patch deployments; the plugin and the playbook keep to get, describe, logs, auth can-i/u); expect(c.calls.every((x) => kubectlRefusal(x.args) === null)).toBe(true); @@ -7075,7 +7085,10 @@ describe("Sentry stewards", () => { it("with a token, check reads the project and says how doorbells arrive; a refused project is a problem", async () => { const { t, sentry } = await watching({ env: { BB_HOOKS_URL: "http://hooks.test:8081" } }); const ok = (await t.harness.callRpc("stewardsCheck", { projectId: "p1" })) as { ok: boolean; lines: string[] }; - expect(ok.ok).toBe(true); + // The legacy ops still runs; the migration line is the one problem. + expect(ok.ok).toBe(false); + expect(ok.lines).toContain("problem: ops now belongs in stewards/roles.json"); + expect(ok.lines.filter((l) => l.startsWith("problem:") && !l.includes("ops now belongs"))).toEqual([]); expect(ok.lines).toContain("sentry: sentry-project/acme/web is readable (Web)"); expect(ok.lines).toContain("sentry: doorbells from bb-gate-hooks: stopped"); sentry.setStatus(403); @@ -7896,3 +7909,168 @@ describe("a steward idle in an active stage (BB-83)", () => { expect(toSteward().at(-1)!.text).toMatch(idleLine); }); }); + +// BB-105: per-person roles in .bb/stewards/roles.json. Each server runs only +// its own login's entry; watches are assigned there, ticket and review +// stewards follow the Linear assignee and the GitHub requested reviewer. +describe("roles.json (BB-105)", () => { + const roles = (entries: Record) => JSON.stringify({ version: 1, roles: entries }); + const problemsOf = async (t: Awaited>): Promise => { + const overview = (await t.harness.callRpc("stewardsOverview", null)) as { projects: Array<{ problems: string[] }> }; + return overview.projects[0]!.problems; + }; + + it("starts no ops binding and no project steward for a login with no entry, and says so", async () => { + const t = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + t.files[ROLES_FILE] = roles({ boss: { projectSteward: true, ops: { k8sNamespaces: ["bb-gate-production"] } } }); + t.files[playbookPath("ops-watch")] = OPS_WATCH; + await t.hooks.sweep(); + await t.flush(); + expect(await t.list()).toEqual([]); + expect(t.spawns).toEqual([]); + expect(await problemsOf(t)).toContain("No role for Jane in stewards/roles.json"); + }); + + it("reports two logins naming one namespace on the project steward's server only", async () => { + const t = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + t.files[ROLES_FILE] = roles({ jane: { projectSteward: true }, bob: { ops: { k8sNamespaces: ["web-production"] } }, sue: { ops: { k8sNamespaces: ["web-production"] } } }); + t.files[playbookPath("ops-watch")] = OPS_WATCH; + await t.hooks.sweep(); + await t.flush(); + expect((await problemsOf(t)).some((p) => p.includes("two people run the web-production watch"))).toBe(true); + + const other = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + other.viewers.github = "bob"; + other.files[ROLES_FILE] = roles({ jane: { projectSteward: true }, bob: { ops: { k8sNamespaces: ["web-production"] } }, sue: { ops: { k8sNamespaces: ["web-production"] } } }); + other.files[playbookPath("ops-watch")] = OPS_WATCH; + await other.hooks.sweep(); + await other.flush(); + expect((await problemsOf(other)).some((p) => p.includes("two people run"))).toBe(false); + }); + + it("reports a Sentry project and the Dependabot watch nobody runs", async () => { + const t = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + t.files[PROJECT_FILE] = JSON.stringify({ version: 1, linear: { teams: ["ENG"] }, ops: { targets: [{ kind: "sentry-project", org: "acme", project: "web" }] } }); + t.files[ROLES_FILE] = roles({ jane: { projectSteward: true } }); + t.files[playbookPath("deps-alerts")] = DEPS_ALERTS; + await t.hooks.sweep(); + await t.flush(); + const problems = await problemsOf(t); + expect(problems).toContain("nobody runs the Sentry watch for acme/web"); + expect(problems).toContain("nobody runs the Dependabot watch for acme/web"); + }); + + it("adopts only a ticket assigned to this server's Linear user", async () => { + const t = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + t.files[ROLES_FILE] = roles({ jane: {}, boss: { projectSteward: true } }); + t.entities.set("uuid-12", issue({ assignees: ["Sam"] })); + await t.ring("linear", "uuid-12"); + expect(await t.list()).toEqual([]); + t.entities.set("uuid-13", issue({ id: "uuid-13", key: "ENG-13", assignees: ["Jane"] })); + await t.ring("linear", "uuid-13"); + expect(await t.list()).toMatchObject([{ key: "ENG-13", kind: "ticket" }]); + }); + + it("adopts an unassigned ticket on the project steward's server only", async () => { + const t = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + t.files[ROLES_FILE] = roles({ jane: { projectSteward: true } }); + t.entities.set("uuid-12", issue({ assignees: [] })); + await t.ring("linear", "uuid-12"); + expect(await t.list()).toMatchObject([{ key: "ENG-12", kind: "ticket" }]); + + const other = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + other.files[ROLES_FILE] = roles({ jane: {}, boss: { projectSteward: true } }); + other.entities.set("uuid-12", issue({ assignees: [] })); + await other.ring("linear", "uuid-12"); + expect(await other.list()).toEqual([]); + }); + + it("makes a review steward only when the viewer is a requested reviewer", async () => { + const t = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + t.files[ROLES_FILE] = roles({ jane: {}, boss: { projectSteward: true } }); + t.entities.set("acme/web#7", pull()); + await t.ring("github", "acme/web#7"); + expect(await t.list()).toEqual([]); + t.entities.set("acme/web#8", pull({ id: "acme/web#8", key: "acme/web#8" }, { requestedReviewers: ["jane"] })); + await t.ring("github", "acme/web#8"); + expect(await t.list()).toMatchObject([{ key: "acme/web#8", kind: "review" }]); + }); + + it("still adopts a legacy project's unassigned ticket and says ops belongs in roles.json", async () => { + const t = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + t.files[PROJECT_FILE] = JSON.stringify({ version: 1, linear: { teams: ["ENG"] }, ops: { targets: [{ kind: "k8s-namespace", name: "bb-gate-production" }] } }); + t.files[playbookPath("ops-watch")] = OPS_WATCH; + t.entities.set("uuid-12", issue({ assignees: [] })); + await t.hooks.sweep(); + await t.flush(); + await t.ring("linear", "uuid-12"); + expect(await t.list()).toEqual(expect.arrayContaining([expect.objectContaining({ key: "ENG-12", kind: "ticket" })])); + expect(await problemsOf(t)).toContain("ops now belongs in stewards/roles.json"); + }); + + it("runs no roles and says so when roles.json is present but the login cannot be read", async () => { + const t = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + t.viewers.github = null; + t.files[ROLES_FILE] = roles({ jane: { projectSteward: true, ops: { k8sNamespaces: ["bb-gate-production"] } } }); + t.files[playbookPath("ops-watch")] = OPS_WATCH; + await t.hooks.sweep(); + await t.flush(); + expect(await t.list()).toEqual([]); + expect(t.spawns).toEqual([]); + const problems = await problemsOf(t); + expect(problems.some((p) => p.includes("GitHub login cannot be read"))).toBe(true); + }); + + it("keeps the last good roles.json when the new copy does not parse", async () => { + const t = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + t.files[ROLES_FILE] = roles({ jane: { projectSteward: true, ops: { k8sNamespaces: ["bb-gate-production"] } } }); + t.files[playbookPath("ops-watch")] = OPS_WATCH; + await t.hooks.sweep(); + await t.flush(); + expect(await t.list()).toMatchObject([{ key: "k8s-namespace/bb-gate-production", kind: "ops" }]); + t.files[ROLES_FILE] = "{ not json"; + const check = await t.harness.behavior.runCli(["check", "--project", "p1"]); + expect(check.stdout).toMatch(/roles\.json is not JSON/u); + expect(check.stdout).toMatch(/keeps running under the last good copy/u); + expect(await t.list()).toMatchObject([{ key: "k8s-namespace/bb-gate-production", kind: "ops" }]); + }); + + it("writes nothing under .bb for a per-server role", async () => { + const t = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + t.files[ROLES_FILE] = roles({ jane: { projectSteward: true, ops: { k8sNamespaces: ["bb-gate-production"] } } }); + t.files[playbookPath("ops-watch")] = OPS_WATCH; + await t.hooks.sweep(); + await t.flush(); + expect(t.writes).toEqual([]); + }); + + it("runs the watches in this server's role", async () => { + const kubectl = async (args: string[]): Promise => { + const a = args.filter((x) => !x.startsWith("--")); + if (a[0] === "auth") return { exitCode: 1, stdout: "no\n", stderr: "" }; + if (a[0] === "get") return { exitCode: 0, stdout: a.includes("json") ? JSON.stringify({ items: [] }) : "", stderr: "" }; + return { exitCode: 0, stdout: "", stderr: "" }; + }; + const t = await setup({ kubectl, timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + t.files[ROLES_FILE] = roles({ jane: { projectSteward: true, ops: { k8sNamespaces: ["bb-gate-production"] } } }); + t.files[playbookPath("ops-watch")] = OPS_WATCH; + await t.hooks.sweep(); + await t.flush(); + expect(await t.list()).toMatchObject([{ key: "k8s-namespace/bb-gate-production", kind: "ops" }]); + expect(t.spawns.map((s) => String(s.title))).toEqual(expect.arrayContaining(["Acme project steward", "Ops watch: bb-gate-production"])); + }); + + it("reports no project steward, and two, where the roles name them", async () => { + const two = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + two.files[ROLES_FILE] = roles({ jane: { projectSteward: true }, bob: { projectSteward: true } }); + await two.hooks.sweep(); + await two.flush(); + expect((await problemsOf(two)).some((p) => p.includes("two people are the project steward: jane, bob"))).toBe(true); + + const none = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + none.files[ROLES_FILE] = roles({ jane: {}, bob: {} }); + await none.hooks.sweep(); + await none.flush(); + expect((await problemsOf(none)).some((p) => p.includes("no one is the project steward"))).toBe(true); + }); +}); diff --git a/plugins/stewards/server.ts b/plugins/stewards/server.ts index 3c7d57d..bdeaf72 100644 --- a/plugins/stewards/server.ts +++ b/plugins/stewards/server.ts @@ -23,30 +23,40 @@ import { ESCALATION_KINDS, KINDS, PROJECT_FILE, + ROLES_FILE, depsAlertsPlaybookName, depsPrsPlaybookName, - depsTargets, durationSchema, formatDuration, isTerminal, - k8sTargets, + legacyOpsPresent, + legacyRole, modelSpecSchema, - opsTeam, parsePlaybook, parseProjectFile, + parseRolesFile, phaseOf, playbookNameFor, playbookPath, playbookStates, - sentryTargets, + projectStewardProblem, + roleDepsTargets, + roleK8sTargets, + roleOpsTargets, + roleOpsTeam, + roleSentryTargets, + roleUsesOps, STAGES, usesKind, + watchProblems, type BindingKind, type EscalationKind, type LessonRules, type ModelSpec, type Playbook, type ProjectFile, + type Role as StewardRole, + type RolesFile, } from "./config.js"; import { decideFor, decideForPull, decideForRefusal } from "./decisions.js"; import { alertFingerprint, batchAlert, deadlineAt, depsLookSchema, isBatchable, priorityFor, repoOfFingerprint, sightingOf, type DepsFinding, type DepsLook, type DepsTicket } from "./deps.js"; @@ -575,6 +585,18 @@ const ENDED_ON_BOARD_MS = 14 * 86_400_000; type ProjectConfig = { projectId: string; file: ProjectFile; + /** The roles file, when one is present and parses (or its last good copy). */ + roles: RolesFile | null; + /** Whether roles.json is present, so role dispatch is on rather than the legacy project.ops. */ + rolesPresent: boolean; + /** Whether roles.json is present but this server's GitHub or Linear login could not be read. */ + rolesUnreadable: boolean; + /** This server's signed-in GitHub login, as the github plugin reads it, or null. */ + githubLogin: string | null; + /** This server's connected Linear user, as the linear plugin reads it, or null. */ + linearLogin: string | null; + /** The role this server runs: its login's entry, a legacy project.ops role, or null. */ + role: StewardRole | null; playbooks: Partial>; /** The dependency watch's alert playbook, when it parses. */ depsAlerts: Playbook | null; @@ -837,6 +859,22 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P const sourceRpc = (source: Source, method: string, input: unknown, outputSchema: z.ZodType): Promise => rpc(SOURCE_PLUGIN_IDS[source], method, input, outputSchema); + /** + * The login a source acts as: the gh login github-watcher reads, or the + * connected Linear user's name. A source that is not connected, or cannot + * be asked, answers null; roles.json then runs no roles rather than every + * role (BB-105). + */ + async function readViewer(source: Source): Promise { + try { + const out = await sourceRpc(source, "sourceValidate", { units: [], label: "", states: [] }, sourceContract.sourceValidate.output); + return out.viewer; + } catch (error) { + bb.log.warn(`stewards: could not read the ${source} login: ${errorMessage(error)}`); + return null; + } + } + /** The plugin's own project-steward approval records for a binding; the gate matches a posted marker to one by comment id. */ const projectApprovalsFor = (b: Binding) => store.projectApprovals(b.id); @@ -955,7 +993,8 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P async function readConfig(projectId: string): Promise { const problems: string[] = []; - const projectText = (await readFiles(projectId, [PROJECT_FILE])).get(PROJECT_FILE) ?? null; + const configFiles = await readFiles(projectId, [PROJECT_FILE, ROLES_FILE]); + const projectText = configFiles.get(PROJECT_FILE) ?? null; if (projectText === null) return null; let file: ProjectFile; const parsed = parseProjectFile(projectText); @@ -973,7 +1012,60 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P file = lastParsed.value; } - const kinds = KINDS.filter((k) => usesKind(file, k)); + /** + * The per-person roles file (BB-105). It is optional: without it a + * project runs the legacy `project.ops` on every server, exactly as + * before. A file that fails to parse falls back to the last good copy, + * kept in kv, and the failure is a problem. A file present with no good + * copy runs no roles rather than falling back to the legacy ops. + */ + const rolesText = configFiles.get(ROLES_FILE) ?? null; + const rolesPresent = rolesText !== null; + let roles: RolesFile | null = null; + if (rolesText !== null) { + const rolesParsed = parseRolesFile(rolesText); + if (rolesParsed.ok) { + roles = rolesParsed.value; + await bb.storage.kv.set(`good/${projectId}/roles`, rolesText); + } else { + problems.push(rolesParsed.error); + const last = await bb.storage.kv.get(`good/${projectId}/roles`); + const lastParsed = typeof last === "string" ? parseRolesFile(last) : null; + if (lastParsed !== null && lastParsed.ok) { + roles = lastParsed.value; + problems.push(`${ROLES_FILE} keeps running under the last good copy.`); + } + } + } + + /** + * The role this server runs. With no roles.json, the legacy project.ops + * is every server's role, as before. With one, the entry keyed by this + * server's GitHub login; no entry runs no watches and no project + * steward, and the home page says so. A login that cannot be read runs + * no roles and never falls back to running every role. + */ + let githubLogin: string | null = null; + let linearLogin: string | null = null; + let role: StewardRole | null; + let rolesUnreadable = false; + if (rolesPresent) { + githubLogin = await readViewer("github"); + linearLogin = await readViewer("linear"); + if (githubLogin === null || linearLogin === null) { + rolesUnreadable = true; + role = null; + problems.push(`${ROLES_FILE} is present but this server's ${githubLogin === null ? "GitHub" : "Linear"} login cannot be read; running no roles`); + } else { + role = roles?.roles[lc(githubLogin)] ?? null; + if (roles !== null && role === null) problems.push(`No role for ${githubLogin} in ${ROLES_FILE}`); + } + } else { + role = legacyRole(file); + if (legacyOpsPresent(file)) problems.push(`ops now belongs in ${ROLES_FILE}`); + } + + const kinds = KINDS.filter((k) => usesKind(file, k, role)); const names = new Map(kinds.map((k) => [k, playbookNameFor(file, k)] as const)); const depsAlertsName = depsAlertsPlaybookName(file); const depsPrsName = depsPrsPlaybookName(file); @@ -1038,7 +1130,24 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P } catch (error) { problems.push(`could not read the repo set: ${errorMessage(error)}`); } - return { projectId, file, playbooks, depsAlerts, depsPrs, repos, problems, loadedAt: now() }; + + /** + * The project steward's cross-server problems (BB-105): no or two + * project stewards, a watch nobody runs, a watch two people run. They + * are computed here and shown on the project steward's server. With no + * project steward at all there is no such server, so every server with a + * role shows the "no one is the project steward" line instead. + */ + if (roles !== null) { + const stewardProblem = projectStewardProblem(roles); + if (role?.projectSteward === true) { + if (stewardProblem !== null) problems.push(stewardProblem); + problems.push(...watchProblems(file, roles, repos, depsAlerts !== null)); + } else if (role !== null && stewardProblem !== null && stewardProblem.startsWith("no one")) { + problems.push(stewardProblem); + } + } + return { projectId, file, roles, rolesPresent, rolesUnreadable, githubLogin, linearLogin, role, playbooks, depsAlerts, depsPrs, repos, problems, loadedAt: now() }; } // ---- the hand-written guidance's meter (BB-35 step 6) ------------------------------ @@ -1333,7 +1442,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P // Every repo the project watches for Dependabot alerts. A server // whose gh login cannot read alerts has each lease refused as // `forbidden`, which it skips; stewards check says so. - dependabot: [...new Set(projects.flatMap((p) => (p.depsAlerts === null ? [] : depsTargets(p.file, p.repos).map((t) => t.repo))))].sort(), + dependabot: [...new Set(projects.flatMap((p) => (p.depsAlerts === null ? [] : roleDepsTargets(p.role, p.repos).map((t) => t.repo))))].sort(), // Every repo a playbook names as a deployment target. A login // without `deployments:read` has each lease refused as `forbidden`, // which it skips; stewards check says so. @@ -1807,6 +1916,9 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P } async function ensureProjectSteward(config: ProjectConfig): Promise { + // Only the login roles.json names as project steward runs it. Without a + // roles.json the legacy role is every server's, so this is unchanged. + if (config.role?.projectSteward !== true) return null; let threadId: string | null = null; await serial(`project:${config.projectId}`, async () => { const row = store.project(config.projectId); @@ -2509,8 +2621,10 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P return hostClient.call("opsKubectl", { args, timeoutMs: KUBECTL_TIMEOUT_MS }, { hostId, timeoutMs: KUBECTL_TIMEOUT_MS + 10_000 }); } - const opsReaderFor = (config: ProjectConfig): OpsReader => - createKubectlReader((args) => runKubectl(config.projectId, args), config.file.ops.context === undefined ? {} : { context: config.file.ops.context }); + const opsReaderFor = (config: ProjectConfig): OpsReader => { + const context = config.role?.ops?.context; + return createKubectlReader((args) => runKubectl(config.projectId, args), context === undefined ? {} : { context }); + }; /** The states of the tickets a look's problems are filed under, read through linear; null for one that cannot be read. */ async function ticketStates(keys: string[]): Promise> { @@ -2523,7 +2637,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P } /** Every ops target a project watches: the k8s namespaces, the Sentry projects and the Dependabot repositories. */ - const allOpsTargets = (config: ProjectConfig): OpsTarget[] => [...k8sTargets(config.file), ...sentryTargets(config.file), ...depsTargets(config.file, config.repos)]; + const allOpsTargets = (config: ProjectConfig): OpsTarget[] => [...roleK8sTargets(config.role), ...roleSentryTargets(config.role), ...roleDepsTargets(config.role, config.repos)]; /** One look at an ops binding's target, its problems entered in the fingerprint map and classified against it. */ async function lookAt(b: Binding, config: ProjectConfig, target: K8sTarget, playbook: Playbook): Promise { @@ -2701,9 +2815,9 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P binding: b, playbook, look, - team: opsTeam(config.file), + team: roleOpsTeam(config.role, config.file), label: config.file.label, - autoAdopt: config.file.ops.autoAdopt, + autoAdopt: config.role?.ops?.autoAdopt ?? [], escalations: store.openEscalations(b.id), changes: [...new Set(changes.map((c) => c.text))], }); @@ -2758,7 +2872,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P binding: b, playbook, look, - team: opsTeam(config.file), + team: roleOpsTeam(config.role, config.file), label: config.file.label, autoAdopt: config.file.deps.autoAdopt, timeZone: config.file.nightly.timezone, @@ -2914,7 +3028,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P */ async function reconcileSentry(b: Binding, config: ProjectConfig, playbook: Playbook, target: SentryTarget, force: WakeReason | null): Promise { const before = b.sentryLook; - const look = await lookAtSentry(b, target, playbook, config.file.ops.sentry.resolveOnDone); + const look = await lookAtSentry(b, target, playbook, config.role?.ops?.sentry?.resolveOnDone ?? false); const requeueCame = b.requeueAt !== null && b.requeueAt <= now(); const requeueAt = requeueCame || b.requeueAt === null ? now() + playbook.requeueDefaultMs : b.requeueAt; b = store.patch(b.id, { sentryLook: look, requeueAt }); @@ -2953,9 +3067,9 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P binding: b, playbook, look, - team: opsTeam(config.file), + team: roleOpsTeam(config.role, config.file), label: config.file.label, - autoAdopt: config.file.ops.autoAdopt, + autoAdopt: config.role?.ops?.autoAdopt ?? [], escalations: store.openEscalations(b.id), changes: [...new Set(changes.map((c) => c.text))], }); @@ -2988,7 +3102,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P }; for (const c of configs.values()) { if (c.value === null) continue; - for (const t of sentryTargets(c.value.file)) add("project", `${t.org}/${t.project}`); + for (const t of roleSentryTargets(c.value.role)) add("project", `${t.org}/${t.project}`); } const recent = now() - ISSUE_LEASE_WINDOW_MS; for (const { binding } of openSentryBindings()) { @@ -3029,7 +3143,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P for (const { binding } of openSentryBindings()) scheduleReconcile(binding.id); }, }); - const sentryWanted = () => [...configs.values()].some((c) => c.value !== null && sentryTargets(c.value.file).length > 0); + const sentryWanted = () => [...configs.values()].some((c) => c.value !== null && roleSentryTargets(c.value.role).length > 0); /** * One ops binding per configured target. A target taken out of the @@ -3042,8 +3156,8 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P async function syncOpsBindings(config: ProjectConfig): Promise { const wanted = new Map(); const opsPlaybook = playbookFor(config, "ops"); - if (opsPlaybook !== null) for (const target of [...k8sTargets(config.file), ...sentryTargets(config.file)]) wanted.set(opsTargetKey(target), { target, playbook: opsPlaybook }); - if (config.depsAlerts !== null) for (const target of depsTargets(config.file, config.repos)) wanted.set(opsTargetKey(target), { target, playbook: config.depsAlerts }); + if (opsPlaybook !== null) for (const target of roleOpsTargets(config.role)) wanted.set(opsTargetKey(target), { target, playbook: opsPlaybook }); + if (config.depsAlerts !== null) for (const target of roleDepsTargets(config.role, config.repos)) wanted.set(opsTargetKey(target), { target, playbook: config.depsAlerts }); const existing = store.all(config.projectId).filter((b) => b.kind === "ops"); for (const b of existing) { if (wanted.has(b.entityId)) continue; @@ -3209,14 +3323,40 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P const projectFor = (configs: ProjectConfig[], entity: Pick) => configs.find((c) => (entity.source === "linear" ? c.file.linear.teams.includes(entity.unit.toUpperCase()) : c.repos.some((r) => lc(r) === lc(entity.unit)))) ?? null; - /** Which kind of binding an entity wants in this project, or null for none. */ - function adoptionKind(config: ProjectConfig, e: { source: Source; kind: "issue" | "pull"; state: string; labels: string[]; author: string | null; draft: boolean; unit: string }, viewer: string | null): BindingKind | null { + /** + * Whether a roles.json names a project steward, so ticket and review + * adoption narrows to this server's owner (BB-105, addendum 1). Without a + * roles.json adoption is exactly as before: every managed ticket and + * teammate pull request is adopted. + */ + const rolesNameProjectSteward = (config: ProjectConfig): boolean => config.roles !== null && Object.values(config.roles.roles).some((r) => r.projectSteward); + + /** + * Which kind of binding an entity wants in this project, or null for none. + * `assignees` and `requestedReviewers` are absent on the nightly pass's + * list, which is permissive: `maybeAdopt` re-checks with the full snapshot. + */ + function adoptionKind( + config: ProjectConfig, + e: { source: Source; kind: "issue" | "pull"; state: string; labels: string[]; author: string | null; draft: boolean; unit: string; assignees?: string[]; requestedReviewers?: string[] }, + viewer: string | null, + ): BindingKind | null { const labelled = e.labels.some((l) => lc(l) === lc(config.file.label)); if (e.source === "linear") { const playbook = playbookFor(config, "ticket"); - return labelled && playbook !== null && !isTerminal(playbook, e.state) ? "ticket" : null; + if (!labelled || playbook === null || isTerminal(playbook, e.state)) return null; + // roles.json present but this server's login unreadable: run no roles. + if (config.rolesUnreadable) return null; + if (rolesNameProjectSteward(config) && e.assignees !== undefined) { + // The Linear assignee decides; an unassigned ticket belongs to the + // project steward's server, so none is orphaned. + const mine = config.linearLogin !== null && e.assignees.some((a) => lc(a) === lc(config.linearLogin)); + if (!mine && !(e.assignees.length === 0 && config.role?.projectSteward === true)) return null; + } + return "ticket"; } if (e.kind !== "pull") return null; + // A labelled pull request is the project's own work, whatever the login. if (labelled) { const playbook = playbookFor(config, "pr"); return playbook !== null && !isTerminal(playbook, e.state) ? "pr" : null; @@ -3224,11 +3364,15 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P // The dependency watch adopts the bot's version-update pull requests in // the repos it watches, whether or not they are labelled. deps-prs // reviews them and escalates for a human merge; it never merges. - if (config.depsPrs !== null && e.author !== null && DEPENDENCY_BOTS.has(lc(e.author)) && depsTargets(config.file, config.repos).some((t) => lc(t.repo) === lc(e.unit))) return "pr"; + if (config.depsPrs !== null && e.author !== null && DEPENDENCY_BOTS.has(lc(e.author)) && roleDepsTargets(config.role, config.repos).some((t) => lc(t.repo) === lc(e.unit))) return "pr"; const review = playbookFor(config, "review"); if (review === null || !config.file.reviews.enabled || e.draft || e.state !== "open") return null; if (e.author === null || viewer === null || lc(e.author) === lc(viewer)) return null; if (review.skipAuthors.some((a) => lc(a) === lc(e.author))) return null; + if (config.rolesUnreadable) return null; + // Once a project steward is named, a review steward is made only where + // the viewer is a requested reviewer. + if (rolesNameProjectSteward(config) && e.requestedReviewers !== undefined && !e.requestedReviewers.some((r) => lc(r) === lc(viewer))) return null; return "review"; } @@ -3250,7 +3394,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P const config = projectFor(projects, entity); if (config === null) return null; if (store.byEntity(entity.source, entity.id) !== null) return null; - const kind = adoptionKind(config, { ...entity, draft: entity.pull?.draft ?? false }, entity.viewer); + const kind = adoptionKind(config, { ...entity, draft: entity.pull?.draft ?? false, requestedReviewers: entity.pull?.requestedReviewers ?? [] }, entity.viewer); if (kind === null) return null; const depBot = kind === "pr" && entity.author !== null && DEPENDENCY_BOTS.has(lc(entity.author)); const playbook = (depBot ? config.depsPrs : null) ?? playbookFor(config, kind); @@ -4934,7 +5078,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P playbooks: config === null ? [] - : KINDS.filter((k) => usesKind(config.file, k)).map((kind) => ({ kind, name: playbookNameFor(config.file, kind), version: config.playbooks[kind]?.version ?? null })), + : KINDS.filter((k) => usesKind(config.file, k, config.role)).map((kind) => ({ kind, name: playbookNameFor(config.file, kind), version: config.playbooks[kind]?.version ?? null })), problems: config?.problems ?? [], nightly: config === null ? null : { at: config.file.nightly.at, timezone: config.file.nightly.timezone }, lastNight: row.nightlyDate, @@ -6915,7 +7059,12 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P const lines: string[] = [...config.problems.map((p) => `problem: ${p}`)]; const ticket = config.playbooks.ticket; lines.push(`Linear teams: ${config.file.linear.teams.join(", ") || "(none)"}; repos: ${config.repos.join(", ") || "(none)"}${config.file.github.exclude.length > 0 ? ` (excluded: ${config.file.github.exclude.join(", ")})` : ""}; label ${config.file.label}`); - for (const kind of KINDS.filter((k) => usesKind(config.file, k))) { + lines.push( + config.rolesPresent + ? `roles: ${config.githubLogin ?? "(login unreadable)"}${config.role === null ? " has no entry" : config.role.projectSteward ? " (project steward)" : ""}` + : "roles: none (legacy project.ops, run on every server)", + ); + for (const kind of KINDS.filter((k) => usesKind(config.file, k, config.role))) { const p = config.playbooks[kind]; lines.push(`${kind}: ${p === undefined ? `no usable playbook (${playbookNameFor(config.file, kind)})` : `${p.name}@${p.version}`}`); } @@ -6937,14 +7086,15 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P }; await validate("linear", config.file.linear.teams, ticket === undefined ? [] : playbookStates(ticket)); await validate("github", config.repos, []); - if (usesKind(config.file, "ops")) { - const auto = config.file.ops.autoAdopt; - lines.push(`ops: tickets filed in ${opsTeam(config.file) ?? "(no team)"}${auto.length > 0 ? `, adopted when filed at ${auto.join(", ")}` : ""}`); + if (usesKind(config.file, "ops", config.role)) { + const auto = config.role?.ops?.autoAdopt ?? []; + const context = config.role?.ops?.context; + lines.push(`ops: tickets filed in ${roleOpsTeam(config.role, config.file) ?? "(no team)"}${auto.length > 0 ? `, adopted when filed at ${auto.join(", ")}` : ""}`); const host = await projectHost(projectId); - lines.push(`ops: kubectl runs on ${host ?? "no machine"}${config.file.ops.context === undefined ? ", in its current context" : `, in context ${config.file.ops.context}`}`); + lines.push(`ops: kubectl runs on ${host ?? "no machine"}${context === undefined ? ", in its current context" : `, in context ${context}`}`); const reader = opsReaderFor(config); - for (const target of k8sTargets(config.file)) lines.push(...opsAccessLines(opsTargetKey(target), await reader.access(target).catch((error: unknown): OpsAccess => ({ error: errorMessage(error), reads: [], logs: null, writes: [] })))); - const sentry = sentryTargets(config.file); + for (const target of roleK8sTargets(config.role)) lines.push(...opsAccessLines(opsTargetKey(target), await reader.access(target).catch((error: unknown): OpsAccess => ({ error: errorMessage(error), reads: [], logs: null, writes: [] })))); + const sentry = roleSentryTargets(config.role); if (sentry.length > 0) { if ((await sentryToken()) === null) lines.push(`problem: sentry: ${NO_TOKEN}: set the Sentry token in the stewards plugin's settings (a user auth token with ${TOKEN_SCOPES.join(" and ")})`); const sentryApi = await sentryReader(); @@ -6964,7 +7114,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P lines.push(`deps: sla critical=${slaText(deps.sla.critical)} high=${slaText(deps.sla.high)} medium=${slaText(deps.sla.medium)} low=${slaText(deps.sla.low)}; batch medium=${formatDuration(deps.batch.medium)} low=${formatDuration(deps.batch.low)}; autoAdopt ${deps.autoAdopt.join(", ") || "(none)"}`); const deployTargets = deploymentTargets(config); if (deployTargets.length > 0) lines.push(`deployment: ${deployTargets.map((t) => `${t.repo} ${t.environment} (timeout ${formatDuration(t.timeout)})`).join(", ")}`); - const depsRepos = depsTargets(config.file, config.repos); + const depsRepos = roleDepsTargets(config.role, config.repos); if (config.depsAlerts !== null && depsRepos.length > 0) { lines.push(`deps: watching ${depsRepos.length} repo${depsRepos.length === 1 ? "" : "s"} for alerts: ${depsRepos.map((t) => t.repo).join(", ")}`); try { diff --git a/plugins/stewards/skills/stewards/SKILL.md b/plugins/stewards/skills/stewards/SKILL.md index adc5305..7bfc401 100644 --- a/plugins/stewards/skills/stewards/SKILL.md +++ b/plugins/stewards/skills/stewards/SKILL.md @@ -14,8 +14,8 @@ steward** per project sits above all of them. |---|---|---| | `ticket` | the project's label (default `bb-managed`) is on a Linear issue in a listed team | `linear-tickets` | | `pr` | the label is on a pull request in one of the project's repos | `github-prs` | -| `review` | a teammate opens, or marks ready, a pull request in one of the project's repos | `github-reviews` | -| `ops` | `project.json` lists a target in `ops.targets` (a Kubernetes namespace or a Sentry project) | `ops-watch` | +| `review` | a teammate opens, or marks ready, a pull request in one of the project's repos; once `roles.json` names a project steward, only where your review is requested | `github-reviews` | +| `ops` | this server's `roles.json` entry watches a namespace or Sentry project | `ops-watch` | ## If you are a steward @@ -375,10 +375,16 @@ The project must be a multi-repo project: configuration is read from its 1. From a thread in the project: `bb stewards init` writes `.bb/stewards/project.json` (Linear teams you belong to, or `--teams ENG,OPS`) and the four default playbooks into the workspace's `.bb`. - To watch production, list targets in `ops.targets`, such as - `{ "kind": "k8s-namespace", "name": "bb-gate-production" }` or - `{ "kind": "sentry-project", "org": "acme", "project": "web" }` (the - latter needs the plugin's Sentry token setting). + The project file holds conventions only. Each person's watches go in + `.bb/stewards/roles.json`, keyed by GitHub login: `projectSteward: true` + on one login, and `ops.k8sNamespaces` / `ops.sentry.projects` / + `ops.dependabot` for the namespaces, Sentry projects and Dependabot repos + that server watches (`ops.sentry` needs the plugin's Sentry token + setting). A login with no entry runs no watches and is not the project + steward; its home page says so. Until `roles.json` exists, the legacy + `ops.targets` in `project.json` still runs on every server, with one + problem line saying it now belongs in `roles.json`. Nothing per-server is + written under `.bb`. 2. Review them, commit inside `.bb`, publish with `workspace_publish_guidance`, and merge the `guidance-` branch. Edits take effect only once merged. @@ -420,7 +426,7 @@ above which a steward is compacted after its turn. target` and `bb stewards check` says why: kubectl on the project's machine cannot reach the cluster (not installed, the machine not on the tailnet as the developer or a tagged node, no kubeconfig or context for - it; `ops.context` picks one), or it may not list pods there. For a Sentry + it; the role's `ops.context` picks one), or it may not list pods there. For a Sentry project: no token in the plugin's settings (the steward is woken once to ask for it), or the token cannot read the project. Otherwise it takes a turn every `requeue-default`, healthy or not. diff --git a/plugins/stewards/templates.ts b/plugins/stewards/templates.ts index 9a7a4b5..b54408e 100644 --- a/plugins/stewards/templates.ts +++ b/plugins/stewards/templates.ts @@ -27,8 +27,8 @@ export function projectFileTemplate(teams: string[]): string { nightly: { at: "01:00", timezone: "America/New_York", until: "02:45", max: 100 }, projectSteward: { model: "claude/claude-opus-5-5/high" }, bigDecisions: defaultDecideList(), - // Add a target, such as { "kind": "k8s-namespace", "name": "bb-gate-production" }, to watch it. - ops: { targets: [], autoAdopt: [] }, + // Watches are per person now: put namespaces, Sentry projects and + // Dependabot repos in stewards/roles.json, keyed by GitHub login. // Dependabot alerts: a per-severity SLA, the batch window a medium or low // alert may wait for company, and the severities adopted when filed. deps: { sla: { critical: "asap", high: "1d", medium: "7d", low: "30d" }, batch: { medium: "7d", low: "30d" }, autoAdopt: ["critical", "high"] }, From f4ab5e7a3349400cb3c335c6c235bb9380e28855 Mon Sep 17 00:00:00 2001 From: Dylan Trotter Date: Wed, 28 Oct 2026 18:22:44 +0000 Subject: [PATCH 2/3] stewards: address review round 1 (BB-105) --- plugins/stewards/DESIGN.md | 5 +++ plugins/stewards/README.md | 7 ++++ plugins/stewards/app.tsx | 3 ++ plugins/stewards/config.ts | 9 +++-- plugins/stewards/server.test.ts | 67 +++++++++++++++++++++++++++++++++ plugins/stewards/server.ts | 41 ++++++++++++++++++-- 6 files changed, 124 insertions(+), 8 deletions(-) diff --git a/plugins/stewards/DESIGN.md b/plugins/stewards/DESIGN.md index 62a82ff..dfedd75 100644 --- a/plugins/stewards/DESIGN.md +++ b/plugins/stewards/DESIGN.md @@ -214,6 +214,11 @@ needs an entry. the migration line stays until the `ops` block is removed. - A parse error keeps the last good copy in the plugin's own store; nothing per-server is ever written under `.bb`. +- Each server keeps its own bindings, so the project steward's server cannot + see another server's. An escalation routed to `project` on a server that is + not the project steward waits on its own binding; cross-server routing is a + later change. A review binding adopted before `roles.json` landed keeps + reviewing until its pull request closes, and is detached by hand to stop. ### Reading `.bb` diff --git a/plugins/stewards/README.md b/plugins/stewards/README.md index 08f075e..0bc06cb 100644 --- a/plugins/stewards/README.md +++ b/plugins/stewards/README.md @@ -561,6 +561,13 @@ server and adds one problem line saying the watches now belong in `roles.json`; once `roles.json` is present it wins, and the migration line stays. +Each server keeps its own bindings, so the project steward's server does not +see another server's. An escalation routed to `project` on a server that is +not the project steward therefore waits on its own binding; cross-server +routing is a later change. A review binding adopted before `roles.json` +landed keeps reviewing until its pull request closes — detach one by hand to +stop it. + The `deps` object is the dependency watch: `sla` (a duration or `asap` per severity; critical→urgent, high→high, medium→medium, low→low), `batch` (the window a medium or low alert may wait for company), and `autoAdopt` (the diff --git a/plugins/stewards/app.tsx b/plugins/stewards/app.tsx index 537c625..24e2b19 100644 --- a/plugins/stewards/app.tsx +++ b/plugins/stewards/app.tsx @@ -1981,6 +1981,9 @@ function StewardsSidebarCore({

No projects yet.

) : (
+ {data !== null && data.roleProblem !== null ? ( +

{data.roleProblem}

+ ) : null} {active.blocked.length > 0 ? (
    {rows(active.blocked)}
diff --git a/plugins/stewards/config.ts b/plugins/stewards/config.ts index fb0714a..5777240 100644 --- a/plugins/stewards/config.ts +++ b/plugins/stewards/config.ts @@ -391,8 +391,8 @@ export function parseProjectFile(text: string): Parsed { * GitHub requested reviewer, and need no entry. */ -/** A GitHub login, folded to lower case by the parser. */ -const githubLogin = z.string().trim().regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/u, "a GitHub login such as jane-doe"); +/** A GitHub login, folded to lower case by the parser. GitHub caps logins at 39 characters. */ +const githubLogin = z.string().trim().min(1).max(39).regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/u, "a GitHub login such as jane-doe"); const sentryProjectSchema = z.object({ org: sentrySlug, project: sentrySlug }).strict(); @@ -437,11 +437,12 @@ const rolesFileRawSchema = z.object({ version: z.literal(1), roles: z.record(z.s export const rolesFileSchema = rolesFileRawSchema.transform((file, ctx): RolesFile => { const roles: Record = {}; for (const [key, role] of Object.entries(file.roles)) { - const login = key.trim().toLowerCase(); - if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(login)) { + const parsed = githubLogin.safeParse(key); + if (!parsed.success) { ctx.addIssue({ code: "custom", path: ["roles", key], message: `${JSON.stringify(key)} is not a GitHub login` }); return z.NEVER; } + const login = parsed.data.toLowerCase(); if (roles[login] !== undefined) { ctx.addIssue({ code: "custom", path: ["roles", key], message: `${key} names the same login as another entry, ignoring case` }); return z.NEVER; diff --git a/plugins/stewards/server.test.ts b/plugins/stewards/server.test.ts index f699f14..9c7bf32 100644 --- a/plugins/stewards/server.test.ts +++ b/plugins/stewards/server.test.ts @@ -7931,6 +7931,52 @@ describe("roles.json (BB-105)", () => { expect(await problemsOf(t)).toContain("No role for Jane in stewards/roles.json"); }); + it("says the no-role line in the sidebar too", async () => { + const t = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + t.files[ROLES_FILE] = roles({ boss: { projectSteward: true } }); + await t.hooks.sweep(); + await t.flush(); + const sidebar = (await t.harness.callRpc("stewardsSidebar", { projectId: "p1" })) as { roleProblem: string | null }; + expect(sidebar.roleProblem).toBe("No role for Jane in stewards/roles.json"); + + await t.flush(); + t.viewers.github = "boss"; + await t.hooks.loadConfig("p1", true); + const withRole = (await t.harness.callRpc("stewardsSidebar", { projectId: "p1" })) as { roleProblem: string | null }; + expect(withRole.roleProblem).toBeNull(); + }); + + it("keeps the migration line and lets roles.json win when both files carry ops", async () => { + const t = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + t.files[PROJECT_FILE] = JSON.stringify({ version: 1, linear: { teams: ["ENG"] }, ops: { targets: [{ kind: "k8s-namespace", name: "legacy-ns" }] } }); + t.files[ROLES_FILE] = roles({ jane: { projectSteward: true } }); + t.files[playbookPath("ops-watch")] = OPS_WATCH; + await t.hooks.sweep(); + await t.flush(); + expect(await problemsOf(t)).toContain("ops now belongs in stewards/roles.json"); + // roles.json wins for what runs: jane watches nothing, so no legacy-ns binding. + expect(await t.list()).toEqual([]); + }); + + it("reports no project steward even when no login has a role", async () => { + const t = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + t.files[ROLES_FILE] = roles({}); + await t.hooks.sweep(); + await t.flush(); + expect((await problemsOf(t)).some((p) => p.includes("no one is the project steward"))).toBe(true); + }); + + it("runs no roles when only the Linear login cannot be read", async () => { + const t = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + t.viewers.linear = null; + t.files[ROLES_FILE] = roles({ jane: { projectSteward: true, ops: { k8sNamespaces: ["bb-gate-production"] } } }); + t.files[playbookPath("ops-watch")] = OPS_WATCH; + await t.hooks.sweep(); + await t.flush(); + expect(await t.list()).toEqual([]); + expect((await problemsOf(t)).some((p) => p.includes("Linear login cannot be read"))).toBe(true); + }); + it("reports two logins naming one namespace on the project steward's server only", async () => { const t = await setup({ timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); t.files[ROLES_FILE] = roles({ jane: { projectSteward: true }, bob: { ops: { k8sNamespaces: ["web-production"] } }, sue: { ops: { k8sNamespaces: ["web-production"] } } }); @@ -8044,6 +8090,27 @@ describe("roles.json (BB-105)", () => { expect(t.writes).toEqual([]); }); + it("retires the project steward thread when roles.json gives the role to another login", async () => { + const kubectl = async (args: string[]): Promise => { + const a = args.filter((x) => !x.startsWith("--")); + if (a[0] === "auth") return { exitCode: 1, stdout: "no\n", stderr: "" }; + if (a[0] === "get") return { exitCode: 0, stdout: a.includes("json") ? JSON.stringify({ items: [] }) : "", stderr: "" }; + return { exitCode: 0, stdout: "", stderr: "" }; + }; + const t = await setup({ kubectl, timing: { debounceMs: 1, nightlyStaggerMs: 0, configTtlMs: 0 } }); + t.files[ROLES_FILE] = roles({ jane: { projectSteward: true, ops: { k8sNamespaces: ["bb-gate-production"] } } }); + t.files[playbookPath("ops-watch")] = OPS_WATCH; + await t.hooks.sweep(); + await t.flush(); + const project = t.spawns.find((s) => String(s.title) === "Acme project steward")!.id; + expect(t.hooks.store.project("p1").threadId).toBe(project); + t.files[ROLES_FILE] = roles({ boss: { projectSteward: true } }); + await t.hooks.sweep(); + await t.flush(); + expect(t.hooks.store.project("p1").threadId).toBeNull(); + expect(t.threads.get(project)!.archivedAt).not.toBeNull(); + }); + it("runs the watches in this server's role", async () => { const kubectl = async (args: string[]): Promise => { const a = args.filter((x) => !x.startsWith("--")); diff --git a/plugins/stewards/server.ts b/plugins/stewards/server.ts index bdeaf72..83dff42 100644 --- a/plugins/stewards/server.ts +++ b/plugins/stewards/server.ts @@ -507,6 +507,13 @@ export const rpcContract = defineRpcContract({ /** The project the rest of the payload is for; null when nothing is stewarded. */ projectId: z.string().nullable(), projectStewardThreadId: z.string().nullable(), + /** + * The picked project's role problem for this server (BB-105), e.g. `No + * role for in stewards/roles.json`; null when it has a role (or + * no roles.json). The sidebar shows it where the home page shows the + * same line. + */ + roleProblem: z.string().nullable(), /** * The board's "waiting on you" items, in the sidebar's own shape: the * same questions, with-user escalations and actionable needs, tagged @@ -1062,8 +1069,11 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P } } else { role = legacyRole(file); - if (legacyOpsPresent(file)) problems.push(`ops now belongs in ${ROLES_FILE}`); } + // The migration line stays whether or not roles.json is present: the + // legacy ops no longer runs once roles.json wins, but the watch is not + // moved until the project file drops it. + if (legacyOpsPresent(file)) problems.push(`ops now belongs in ${ROLES_FILE}`); const kinds = KINDS.filter((k) => usesKind(file, k, role)); const names = new Map(kinds.map((k) => [k, playbookNameFor(file, k)] as const)); @@ -1143,7 +1153,9 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P if (role?.projectSteward === true) { if (stewardProblem !== null) problems.push(stewardProblem); problems.push(...watchProblems(file, roles, repos, depsAlerts !== null)); - } else if (role !== null && stewardProblem !== null && stewardProblem.startsWith("no one")) { + } else if (stewardProblem !== null && stewardProblem.startsWith("no one")) { + // No server has the role, so every server that reads roles.json shows + // the missing project steward rather than only the "No role" line. problems.push(stewardProblem); } } @@ -1915,6 +1927,23 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P if (row.title !== title) store.patchProject(projectId, { title }); } + /** + * Retire a project steward whose server roles.json no longer names for it + * (BB-105): archive the thread and clear the row, so it does not stay in + * the sidebar and the board while never being woken again. A login that + * could not be read is left alone: that is a transient read failure, not a + * role change. + */ + async function retireProjectSteward(config: ProjectConfig): Promise { + if (!config.rolesPresent || config.rolesUnreadable || config.role?.projectSteward === true) return; + const row = store.project(config.projectId); + if (row.threadId === null) return; + const threadId = row.threadId; + store.patchProject(config.projectId, { threadId: null, title: null }); + await quietly(`archive the retired project steward ${threadId}`, () => bb.sdk.threads.archive({ threadId })); + bb.log.info(`stewards: ${config.projectId} is no longer this server's project steward; archived ${threadId}`); + } + async function ensureProjectSteward(config: ProjectConfig): Promise { // Only the login roles.json names as project steward runs it. Without a // roles.json the legacy role is every server's, so this is unchanged. @@ -5200,7 +5229,8 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P // instead of `bb.sdk.projects.list()` twice. const projectList = await bb.sdk.projects.list(); const named = new Map(projectList.map((p) => [p.id, p.name] as const)); - const projects = projectIds((await stewardedProjects(false, projectList)).map((c) => c.projectId)) + const configs = await stewardedProjects(false, projectList); + const projects = projectIds(configs.map((c) => c.projectId)) .map((projectId) => ({ projectId, name: named.get(projectId) ?? projectId })) .sort((a, b) => a.name.localeCompare(b.name)); // The picker's count is the server's own: the open bindings that need @@ -5213,9 +5243,10 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P })); const picked = projects.find((p) => p.projectId === wanted) ?? (wanted === null ? projects[0] : undefined); if (picked === undefined) { - return { projects: picker, projectId: null, projectStewardThreadId: null, needs: { count: 0, items: [] }, bindings: [], ended: [] }; + return { projects: picker, projectId: null, projectStewardThreadId: null, roleProblem: null, needs: { count: 0, items: [] }, bindings: [], ended: [] }; } const { projectId } = picked; + const roleProblem = configs.find((c) => c.projectId === projectId)?.problems.find((p) => p.startsWith("No role for ")) ?? null; const all = store.all(projectId); const ended = all .filter((b) => b.endedAt !== null && b.endedAt >= started - ENDED_ON_BOARD_MS) @@ -5257,6 +5288,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P projects: picker, projectId, projectStewardThreadId: store.project(projectId).threadId, + roleProblem, needs: { count: questions.length + withUser.length + needs.length, items: [ @@ -6880,6 +6912,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P pushInterest(); for (const config of projects) { if (!(await settings.get()).paused) await adoptionPass(config); + await retireProjectSteward(config); await keepProjectSteward(config.projectId); await syncOpsBindings(config); await reconcileLessons(config); From 414dc231143ddc233522c21cdaf76bafade68738 Mon Sep 17 00:00:00 2001 From: Dylan Trotter Date: Wed, 28 Oct 2026 18:27:49 +0000 Subject: [PATCH 3/3] stewards: sidebar no-role UI test (BB-105) --- plugins/stewards/sidebar-app.test.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/plugins/stewards/sidebar-app.test.tsx b/plugins/stewards/sidebar-app.test.tsx index e4c4f2a..9f7bf57 100644 --- a/plugins/stewards/sidebar-app.test.tsx +++ b/plugins/stewards/sidebar-app.test.tsx @@ -51,10 +51,11 @@ const projects = [ { id: "personal", name: "Personal", isPersonal: true, href: "/projects/personal", settingsHref: "/projects/personal/settings" }, ]; -const sidebarData = (over: { bindings?: unknown[]; ended?: unknown[]; needs?: unknown; projectStewardThreadId?: string | null; projectId?: string; needsCount?: number } = {}) => ({ +const sidebarData = (over: { bindings?: unknown[]; ended?: unknown[]; needs?: unknown; projectStewardThreadId?: string | null; projectId?: string; needsCount?: number; roleProblem?: string | null } = {}) => ({ projects: [{ projectId: "p1", name: "Acme", needsCount: over.needsCount ?? 0 }], projectId: over.projectId ?? "p1", projectStewardThreadId: over.projectStewardThreadId ?? null, + roleProblem: over.roleProblem ?? null, needs: over.needs ?? { count: 0, items: [] }, bindings: over.bindings ?? [], ended: over.ended ?? [], @@ -167,6 +168,17 @@ describe("the stewards sidebar list (BB-44)", () => { expect(captured.threadLists[0]).toMatchObject({ id: "sidebar", title: "Stewards" }); }); + // BB-105: the sidebar carries the home page's no-role line for a server + // whose roles.json has no entry for its login. + it("shows the no-role line when this server has no roles.json entry", async () => { + const slot = await renderSidebar({ + sidebarThreads: { projects, threads: [] }, + rpc: { stewardsSidebar: () => sidebarData({ roleProblem: "No role for jane in stewards/roles.json" }) } as never, + }); + await waitFor(() => expect(slot.getByText("No role for jane in stewards/roles.json")).toBeTruthy()); + slot.lifecycle.unmount(); + }); + it("shows a tab per live project and a two-line effort row with key, title, kind, phase and phrase", async () => { const slot = await renderSidebar({ sidebarThreads: { projects, threads: [thread({ id: "thr_steward", displayTitle: "ENG-1: Fix login" })] },