diff --git a/apps/docs/docs/concepts/agents-as-code.md b/apps/docs/docs/concepts/agents-as-code.md index 09213be2..20b54814 100644 --- a/apps/docs/docs/concepts/agents-as-code.md +++ b/apps/docs/docs/concepts/agents-as-code.md @@ -11,6 +11,12 @@ are explicit, and at least one trigger is required. The Markdown body is the agent prompt. The frontmatter is execution configuration. Keeping both in one reviewed file makes a model change, schedule change, or prompt change visible in Git history. +Discovery uses the primary repository's optional project `developmentBranch`, with the connected +GitHub default as fallback. The same source supplies skills and `.facility.yml`, new story bases, +and configuration PR bases. See the [development source contract](../reference/project-manifest.md#development-source). +Changing this setting does not replace queued/running turns' captured agent manifests or move +existing story branches. + Supported triggers are: - `manual`, used by trusted internal or direct API dispatch; diff --git a/apps/docs/docs/reference/project-manifest.md b/apps/docs/docs/reference/project-manifest.md index 090ae897..05c529ba 100644 --- a/apps/docs/docs/reference/project-manifest.md +++ b/apps/docs/docs/reference/project-manifest.md @@ -5,10 +5,38 @@ title: Project manifest # `.facility.yml` reference The primary repository's `.facility.yml` is the complete workspace environment contract. Facility -loads it from the repository default branch when it starts or prepares a story. The schema is +loads it from the project's selected development branch when it starts or prepares a story, falling +back to the GitHub default branch when no override is set. The schema is strict: unknown keys, duplicate YAML keys, aliases, invalid names, and wrong value types fail the operation. +## Development source + +In project settings, select an existing primary-repository branch, or leave the field empty to +use the connected GitHub default. The API accepts `PATCH /v1/projects/:projectId` with +`{ "developmentBranch": "develop" }`; use `null` to clear it. Updating this preference requires +both `projects:write` and `repos:write`. Facility checks the branch using the project's active, +organization-scoped GitHub App installation before saving. Missing or malformed branches are +rejected, and unavailable or suspended installations cannot validate changes. Other project +settings are preserved by this narrow update. + +The option is stored as `settings.developmentBranch`, outside this manifest: Facility must choose +a ref before reading `.facility.yml`. It does not change GitHub's default branch or reinterpret +legacy `default_branch` settings. Related repositories continue to use their own defaults. + +Agents, skills, the workspace manifest, new story branches, and agent-edit/kickstart configuration +PRs all use the selected source. Proposal branches for an override are scoped to that source, +so changing it does not rewrite a configuration PR opened against the previous default. GitHub's default and the selected development branch are protected +from direct configuration writes and story-branch adoption. Existing story branches and worktrees +are retained when the setting or branch tip changes; no reset or automatic rebase occurs. Queued +and running turns retain their agent-manifest snapshots. Subsequent catalog refreshes and workspace +preparations read the current selected source. A branch removed after selection fails source +refresh/preparation rather than silently reading another branch. + +After saving, refresh the catalog and verify its source commit. A source change is not a deployment, +merge, workspace wake, or approval to modify production. GitHub protections and human PR review +remain the merge boundary. + ## Complete example ```yaml diff --git a/apps/web/app/(app)/projects/[projectId]/settings/page.tsx b/apps/web/app/(app)/projects/[projectId]/settings/page.tsx index 47394592..5f3c8050 100644 --- a/apps/web/app/(app)/projects/[projectId]/settings/page.tsx +++ b/apps/web/app/(app)/projects/[projectId]/settings/page.tsx @@ -1,6 +1,7 @@ import { Divider, Eyebrow, PillTag } from "@facility/ui"; import Link from "next/link"; import { ErrorNotice, Offline } from "@/components/offline"; +import { DevelopmentBranch } from "@/components/project/development-branch"; import { DisconnectRepository } from "@/components/project/disconnect-repository"; import { NativePreviews } from "@/components/project/native-previews"; import { WorkspaceVariables } from "@/components/story/workspace-variables"; @@ -41,6 +42,25 @@ export default async function ProjectSettingsPage({

+ {repos.ok && repos.data.find((repo) => repo.role === "primary") && ( + repo.role === "primary")?.defaultBranch ?? "main" + } + canWrite={ + can(me.ok ? me.data.permissions : [], "projects:write") && + can(me.ok ? me.data.permissions : [], "repos:write") + } + /> + )} + ( + "PATCH", + `/v1/projects/${encodeURIComponent(projectId)}`, + { + developmentBranch, + idempotency_key: `ui-development-branch-${crypto.randomUUID()}`, + }, + ); + setPending(false); + if (!result.ok) { + setError(result.message); + return; + } + if ( + !result.data?.settings || + !("developmentBranch" in result.data.settings) || + result.data.settings.developmentBranch !== developmentBranch + ) { + setError( + "The server did not confirm this setting. Refresh after the API deployment completes.", + ); + return; + } + setValue(developmentBranch ?? ""); + setSaved(developmentBranch ?? ""); + setMessage(`Development source saved: ${developmentBranch ?? defaultBranch}.`); + router.refresh(); + } + return ( +
+ development branch +

+ Read agents, skills and .facility.yml from this branch of the primary repository. New + stories and configuration pull requests use the same base. Existing story branches and + queued or running turns keep their work. +

+

+ Leave empty to use the GitHub default: {defaultBranch}. +

+ + { + setValue(event.target.value); + setMessage(""); + }} + /> + {canWrite && ( +
+ +
+ )} + {error && ( +

+ {error} +

+ )} + {message && ( +

+ {message} +

+ )} +
+ ); +} diff --git a/apps/web/test/development-branch.test.tsx b/apps/web/test/development-branch.test.tsx new file mode 100644 index 00000000..44684af4 --- /dev/null +++ b/apps/web/test/development-branch.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { DevelopmentBranch } from "../components/project/development-branch"; + +const refresh = vi.hoisted(() => vi.fn()); +vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh }) })); +let container: HTMLDivElement, root: Root; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + refresh.mockClear(); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); +async function render(props = {}) { + await act(async () => + root.render( + , + ), + ); +} +const input = () => container.querySelector("input") as HTMLInputElement; +const button = () => container.querySelector("button") as HTMLButtonElement; +async function change(value: string) { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input(), value); + input().dispatchEvent(new Event("input", { bubbles: true })); + }); +} +it("shows fallback and sends only a scoped preference on save", async () => { + const fetch = vi + .fn() + .mockResolvedValue(Response.json({ settings: { developmentBranch: "develop" } })); + vi.stubGlobal("fetch", fetch); + await render(); + expect(container.textContent).toContain("GitHub default: main"); + expect(button().disabled).toBe(true); + await change("develop"); + expect(fetch).not.toHaveBeenCalled(); + await act(async () => button().click()); + expect(JSON.parse(fetch.mock.calls[0]?.[1].body)).toEqual({ + developmentBranch: "develop", + idempotency_key: expect.stringMatching(/^ui-development-branch-/), + }); + expect(fetch).toHaveBeenCalledWith( + "/api/v1/projects/proj_test", + expect.objectContaining({ method: "PATCH" }), + ); + expect(container.querySelector("[role=status]")?.textContent).toContain("saved: develop"); + expect(refresh).toHaveBeenCalledOnce(); +}); +it("clears the override and requires server confirmation", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ settings: {} })) + .mockResolvedValueOnce(Response.json({ settings: { developmentBranch: null } })); + vi.stubGlobal("fetch", fetch); + await render({ branch: "develop" }); + await change(""); + await act(async () => button().click()); + expect(container.querySelector("[role=alert]")?.textContent).toContain("did not confirm"); + expect(refresh).not.toHaveBeenCalled(); + await act(async () => button().click()); + expect(JSON.parse(fetch.mock.calls[1]?.[1].body).developmentBranch).toBeNull(); + expect(container.querySelector("[role=status]")?.textContent).toContain("saved: main"); +}); +it("shows denied or unavailable updates without claiming success", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + Response.json({ error: { message: "Branch unavailable" } }, { status: 503 }), + ), + ); + await render(); + await change("missing"); + await act(async () => button().click()); + expect(container.querySelector("[role=alert]")?.textContent).toBe("Branch unavailable"); + expect(container.querySelector("[role=status]")).toBeNull(); + expect(refresh).not.toHaveBeenCalled(); +}); +it("disables editing without permission", async () => { + await render({ canWrite: false, branch: "develop" }); + expect(input().disabled).toBe(true); + expect(button()).toBeNull(); +}); +it("disables controls while branch validation is pending", async () => { + let complete: (response: Response) => void = () => undefined; + vi.stubGlobal( + "fetch", + vi.fn().mockReturnValue( + new Promise((resolve) => { + complete = resolve; + }), + ), + ); + await render(); + await change("develop"); + await act(async () => button().click()); + expect(input().disabled).toBe(true); + expect(button().textContent).toBe("saving…"); + await act(async () => complete(Response.json({ settings: { developmentBranch: "develop" } }))); + expect(input().disabled).toBe(false); +}); diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 3e976a4b..798306b6 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3966,6 +3966,10 @@ "properties": { "nativePreviewsEnabled": { "type": "boolean" + }, + "developmentBranch": { + "nullable": true, + "type": "string" } }, "additionalProperties": {} @@ -4390,12 +4394,20 @@ "properties": { "nativePreviewsEnabled": { "type": "boolean" + }, + "developmentBranch": { + "nullable": true, + "type": "string" } }, "additionalProperties": {} }, "nativePreviewsEnabled": { "type": "boolean" + }, + "developmentBranch": { + "nullable": true, + "type": "string" } } } diff --git a/packages/sdk/src/schema.d.ts b/packages/sdk/src/schema.d.ts index 8e0c75a5..fe740811 100644 --- a/packages/sdk/src/schema.d.ts +++ b/packages/sdk/src/schema.d.ts @@ -3707,6 +3707,7 @@ export interface operations { description?: string; settings?: { nativePreviewsEnabled?: boolean; + developmentBranch?: string | null; } & { [key: string]: unknown; }; @@ -4054,10 +4055,12 @@ export interface operations { description?: string | null; settings?: { nativePreviewsEnabled?: boolean; + developmentBranch?: string | null; } & { [key: string]: unknown; }; nativePreviewsEnabled?: boolean; + developmentBranch?: string | null; }; }; }; diff --git a/services/api/package.json b/services/api/package.json index 657d2030..18919ff9 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -12,7 +12,7 @@ "build:runtime": "tsup src/index.ts src/dev.ts src/start.ts src/worker.ts src/openapi.ts --format esm", "typecheck": "tsc --noEmit", "test": "vitest run --fileParallelism=false", - "test:e2e-workspace": "vitest run --fileParallelism=false test/workspace-runtime.integration.test.ts test/facility-012.e2e.integration.test.ts", + "test:e2e-workspace": "vitest run --fileParallelism=false test/workspace-runtime.integration.test.ts test/facility-012.e2e.integration.test.ts test/development-branch-workspace.integration.test.ts", "dev": "tsx watch src/dev.ts", "dev:worker": "tsx watch src/worker.ts", "start": "node dist/start.js", diff --git a/services/api/src/agents/catalog.ts b/services/api/src/agents/catalog.ts index f3280eb7..a02d035d 100644 --- a/services/api/src/agents/catalog.ts +++ b/services/api/src/agents/catalog.ts @@ -17,6 +17,7 @@ import { } from "@facility/db"; import { and, asc, eq, notInArray, sql } from "drizzle-orm"; import { FacilityGithubClient, type GithubClientFactory } from "../github/client.js"; +import { configurationBranch, repositoryDevelopmentBranch } from "../github/development-branch.js"; import { readRepoFiles } from "../github/repo-files.js"; import { isAgentManifestPath, isProjectSkillPath, readAgentCatalogFiles } from "./catalog-files.js"; @@ -105,12 +106,23 @@ export class GithubAgentCatalogSource implements AgentCatalogSource { } try { + const developmentBranch = await repositoryDevelopmentBranch(this.db, repository); const client = new FacilityGithubClient(await this.factory(installation.installationId), { owner: repository.owner, repo: repository.name, defaultBranch: repository.defaultBranch, + developmentBranch, + }); + const commitSha = await client.getBranchSha(developmentBranch).catch((error: unknown) => { + if (developmentBranch !== repository.defaultBranch) { + throw new AgentCatalogError( + "agent_catalog_branch_unavailable", + "The selected development branch could not be read from GitHub", + 503, + ); + } + throw error; }); - const commitSha = await client.getDefaultBranchSha(); // Authorization and the current ref are checked before every cache read. A changed // repository, installation, or tenant can never reuse another scope's snapshot. const key = JSON.stringify([ @@ -121,6 +133,7 @@ export class GithubAgentCatalogSource implements AgentCatalogSource { repository.owner, repository.name, repository.defaultBranch, + developmentBranch, commitSha, ]); let snapshot = this.snapshots.get(key); @@ -203,12 +216,14 @@ export class GithubAgentCatalogSource implements AgentCatalogSource { const path = `.agents/${parsedName.data}.md`; const agent = parseAgentManifest(input.source, path); + const developmentBranch = await repositoryDevelopmentBranch(this.db, repository); const client = new FacilityGithubClient(await this.factory(installation.installationId), { owner: repository.owner, repo: repository.name, defaultBranch: repository.defaultBranch, + developmentBranch, }); - const baseCommitSha = await client.getDefaultBranchSha(); + const baseCommitSha = await client.getBranchSha(developmentBranch); if (baseCommitSha !== input.expectedCommitSha) { throw new AgentCatalogError( "agent_catalog_changed", @@ -217,10 +232,12 @@ export class GithubAgentCatalogSource implements AgentCatalogSource { } const current = (await readRepoFiles(client, baseCommitSha, [path])).get(path); if (!current) throw new AgentCatalogError("agent_not_found", "agent not found", 404); - const branch = `facility/agent-${parsedName.data}`; - const existing = ( - await client.listOpenPullRequestsForHead(branch, repository.defaultBranch) - )[0]; + const branch = configurationBranch( + `facility/agent-${parsedName.data}`, + developmentBranch, + repository.defaultBranch, + ); + const existing = (await client.listOpenPullRequestsForHead(branch, developmentBranch))[0]; if (!existing && parseAgentManifest(current, path).hash === agent.hash) { throw new AgentCatalogError("agent_manifest_unchanged", "The agent manifest is unchanged"); } @@ -275,6 +292,7 @@ export class GithubAgentCatalogSource implements AgentCatalogSource { "Facility does not merge this pull request automatically.", ].join("\n"), head: branch, + base: developmentBranch, })); return { agent, diff --git a/services/api/src/github/client.ts b/services/api/src/github/client.ts index 95114556..dd048fc7 100644 --- a/services/api/src/github/client.ts +++ b/services/api/src/github/client.ts @@ -111,6 +111,7 @@ export type RepoRef = { owner: string; repo: string; defaultBranch: string; + developmentBranch?: string; }; export type TreeItem = { @@ -138,10 +139,14 @@ export class FacilityGithubClient { } async getDefaultBranchSha(): Promise { + return this.getBranchSha(this.repo.defaultBranch); + } + + async getBranchSha(branch: string): Promise { const response = await this.octokit.rest.repos.getBranch({ owner: this.repo.owner, repo: this.repo.repo, - branch: this.repo.defaultBranch, + branch, }); return response.data.commit.sha; } @@ -273,5 +278,8 @@ export class FacilityGithubClient { if (normalized === this.repo.defaultBranch) { throw new Error(`Refusing to write to default branch ${this.repo.defaultBranch}`); } + if (normalized === this.repo.developmentBranch) { + throw new Error(`Refusing to write to development branch ${this.repo.developmentBranch}`); + } } } diff --git a/services/api/src/github/development-branch.ts b/services/api/src/github/development-branch.ts new file mode 100644 index 00000000..ed139afa --- /dev/null +++ b/services/api/src/github/development-branch.ts @@ -0,0 +1,54 @@ +import { createHash } from "node:crypto"; +import { type FacilityDb, projects } from "@facility/db"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { isSafeGitBranch } from "../workspaces/git-branch.js"; + +export const DevelopmentBranchSchema = z + .string() + .refine( + (branch) => + isSafeGitBranch(branch) && + branch !== "@" && + !branch.startsWith("refs/") && + branch.split("/").every((part) => part && !part.startsWith(".") && !part.endsWith(".lock")), + "Use an existing branch name, without a refs/ prefix", + ); + +/** Separate proposal heads prevent a source change from rewriting a PR against the old base. */ +export function configurationBranch( + prefix: string, + developmentBranch: string, + defaultBranch: string, +) { + return developmentBranch === defaultBranch + ? prefix + : `${prefix}-${createHash("sha256").update(developmentBranch).digest("hex").slice(0, 12)}`; +} + +export function projectDevelopmentBranch(settings: unknown, defaultBranch: string) { + const branch = + settings && typeof settings === "object" && !Array.isArray(settings) + ? (settings as Record).developmentBranch + : undefined; + return branch === undefined || branch === null + ? defaultBranch + : DevelopmentBranchSchema.parse(branch); +} + +/** The override belongs to the primary repository; related repositories retain their defaults. */ +export async function repositoryDevelopmentBranch( + db: FacilityDb, + repository: { orgId: string; projectId: string; defaultBranch: string; role?: string }, +) { + if (repository.role === "related") return repository.defaultBranch; + const project = ( + await db + .select({ settings: projects.settings }) + .from(projects) + .where(and(eq(projects.orgId, repository.orgId), eq(projects.id, repository.projectId))) + .limit(1) + )[0]; + if (!project) throw new Error("Project not found"); + return projectDevelopmentBranch(project.settings, repository.defaultBranch); +} diff --git a/services/api/src/github/kickstart.ts b/services/api/src/github/kickstart.ts index 50721f57..415b8455 100644 --- a/services/api/src/github/kickstart.ts +++ b/services/api/src/github/kickstart.ts @@ -3,6 +3,7 @@ import { type FacilityDb, githubInstallations } from "@facility/db"; import { eq } from "drizzle-orm"; import type { AppConfig, Principal } from "../types.js"; import { FacilityGithubClient, type GithubClientFactory, type TreeItem } from "./client.js"; +import { configurationBranch, repositoryDevelopmentBranch } from "./development-branch.js"; import { readRepoFiles } from "./repo-files.js"; export type KickstartAnswers = { @@ -28,12 +29,14 @@ export type GithubRepositoryRow = { owner: string; name: string; defaultBranch: string; + role?: string; }; export async function createGithubClientForRepo( db: FacilityDb, factory: GithubClientFactory, repository: GithubRepositoryRow, + developmentBranch?: string, ): Promise { if (!repository.installationId) throw new Error("Repository has no GitHub installation"); const installation = ( @@ -50,6 +53,7 @@ export async function createGithubClientForRepo( owner: repository.owner, repo: repository.name, defaultBranch: repository.defaultBranch, + developmentBranch: developmentBranch ?? (await repositoryDevelopmentBranch(db, repository)), }); } @@ -59,9 +63,11 @@ export async function kickstartPreview( repository: GithubRepositoryRow, answers: KickstartAnswers, ) { - const client = await createGithubClientForRepo(db, factory, repository); - const existing = await readRepoFiles(client, repository.defaultBranch); - const detection = detectWorkspace(existing, answers.defaultBranch ?? repository.defaultBranch); + const developmentBranch = await repositoryDevelopmentBranch(db, repository); + const client = await createGithubClientForRepo(db, factory, repository, developmentBranch); + const commitSha = await client.getBranchSha(developmentBranch); + const existing = await readRepoFiles(client, commitSha); + const detection = detectWorkspace(existing, developmentBranch); const workspaceAnswers = workspaceKickstartAnswers( repository, answers, @@ -97,12 +103,16 @@ export async function kickstartRepo(args: { repo: GithubRepositoryRow; answers: KickstartAnswers; }) { - const client = await createGithubClientForRepo(args.db, args.factory, args.repo); - const existing = await readRepoFiles(client, args.repo.defaultBranch); - const detection = detectWorkspace( - existing, - args.answers.defaultBranch ?? args.repo.defaultBranch, + const developmentBranch = await repositoryDevelopmentBranch(args.db, args.repo); + const client = await createGithubClientForRepo( + args.db, + args.factory, + args.repo, + developmentBranch, ); + const baseSha = await client.getBranchSha(developmentBranch); + const existing = await readRepoFiles(client, baseSha); + const detection = detectWorkspace(existing, developmentBranch); const rendered = renderWorkspaceKickstart( workspaceKickstartAnswers(args.repo, args.answers, existing, detection.packageManager), existing, @@ -111,8 +121,11 @@ export async function kickstartRepo(args: { throw new Error("Repository already contains the Facility 0.12 kickstart files"); } - const branch = "facility/kickstart-0.12"; - const baseSha = await client.getDefaultBranchSha(); + const branch = configurationBranch( + "facility/kickstart-0.12", + developmentBranch, + args.repo.defaultBranch, + ); const baseCommit = await client.getCommit(baseSha); const treeSha = await client.createTree( baseCommit.treeSha, @@ -130,13 +143,14 @@ export async function kickstartRepo(args: { await client.updateBranch(branch, commitSha); } const existingPullRequest = ( - await client.listOpenPullRequestsForHead(branch, args.repo.defaultBranch) + await client.listOpenPullRequestsForHead(branch, developmentBranch) )[0]; const pr = existingPullRequest ?? (await client.createPullRequest({ title: "feat!: configure Facility 0.12 story workspaces", head: branch, + base: developmentBranch, body: kickstartPrBody(rendered.files.map((file) => file.path)), })); return { diff --git a/services/api/src/github/workspace-credentials.ts b/services/api/src/github/workspace-credentials.ts index 90438b91..96219b9d 100644 --- a/services/api/src/github/workspace-credentials.ts +++ b/services/api/src/github/workspace-credentials.ts @@ -1,12 +1,14 @@ import { type FacilityDb, githubInstallations, projectRepositories } from "@facility/db"; import { and, asc, eq, inArray } from "drizzle-orm"; import type { GithubMaintainerTokenFactory } from "./client.js"; +import { repositoryDevelopmentBranch } from "./development-branch.js"; import type { GithubGitIdentity } from "./git-identity.js"; export type WorkspaceRepository = { owner: string; name: string; defaultBranch: string; + developmentBranch?: string; role: "primary" | "related"; }; @@ -136,12 +138,15 @@ export class GithubWorkspaceCredentialBroker { } return { gitIdentity: primaryCredential.gitIdentity, - repositories: repositories.map((repository) => ({ - owner: repository.owner, - name: repository.name, - defaultBranch: repository.defaultBranch, - role: repository.role as "primary" | "related", - })), + repositories: await Promise.all( + repositories.map(async (repository) => ({ + owner: repository.owner, + name: repository.name, + defaultBranch: repository.defaultBranch, + developmentBranch: await repositoryDevelopmentBranch(this.db, repository), + role: repository.role as "primary" | "related", + })), + ), environment: { GH_TOKEN: primaryToken, GITHUB_TOKEN: primaryToken, diff --git a/services/api/src/routes/v1/projects.ts b/services/api/src/routes/v1/projects.ts index c3add1ee..9b263200 100644 --- a/services/api/src/routes/v1/projects.ts +++ b/services/api/src/routes/v1/projects.ts @@ -1,10 +1,12 @@ -import { newId } from "@facility/core"; +import { can, newId } from "@facility/core"; import { type FacilityDb, githubInstallations, projectRepositories, projects } from "@facility/db"; import { and, asc, eq, isNull, sql } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { ApiError, notFound } from "../../errors.js"; import { createGithubClientFactory } from "../../github/client.js"; +import { DevelopmentBranchSchema } from "../../github/development-branch.js"; +import { createGithubClientForRepo } from "../../github/kickstart.js"; import { removeRepositoryConnection } from "../../github/repository-connections.js"; import { projectNativePreviewsEnabled } from "../../workspaces/project-native-previews.js"; import { @@ -33,7 +35,10 @@ const ProjectSchema = z.object({ }); const ProjectSettings = z - .object({ nativePreviewsEnabled: z.boolean().optional() }) + .object({ + nativePreviewsEnabled: z.boolean().optional(), + developmentBranch: DevelopmentBranchSchema.nullable().optional(), + }) .catchall(z.unknown()); const RepositorySchema = z.object({ @@ -95,7 +100,9 @@ export async function registerProjectRoutes(app: FastifyInstance, context: V1Rou name: z.string().min(1).max(160), slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), description: z.string().max(2_000).optional(), - settings: ProjectSettings.optional(), + settings: ProjectSettings.refine((settings) => settings.developmentBranch == null, { + message: "Connect a primary repository before selecting a development branch", + }).optional(), }), response: { 200: ProjectSchema }, }, @@ -148,11 +155,14 @@ export async function registerProjectRoutes(app: FastifyInstance, context: V1Rou description: z.string().max(2_000).nullable().optional(), settings: ProjectSettings.optional(), nativePreviewsEnabled: z.boolean().optional(), + developmentBranch: DevelopmentBranchSchema.nullable().optional(), }) .refine( - (body) => body.settings === undefined || body.nativePreviewsEnabled === undefined, + (body) => + body.settings === undefined || + (body.nativePreviewsEnabled === undefined && body.developmentBranch === undefined), { - message: "Use settings or nativePreviewsEnabled, not both", + message: "Use settings or individual preferences, not both", }, ), response: { 200: ProjectSchema }, @@ -166,28 +176,96 @@ export async function registerProjectRoutes(app: FastifyInstance, context: V1Rou description?: string | null; settings?: Record; nativePreviewsEnabled?: boolean; + developmentBranch?: string | null; }; - const { nativePreviewsEnabled, ...fields } = body; - const row = ( - await db - .update(projects) - .set( - definedFields({ - ...fields, - // The narrow UI update preserves unrelated settings, including concurrent writes. - ...(nativePreviewsEnabled === undefined - ? {} - : { - settings: sql`${projects.settings} || ${JSON.stringify({ nativePreviewsEnabled })}::jsonb`, - }), - updatedAt: new Date(), - }), - ) - .where(and(eq(projects.orgId, actor.orgId), eq(projects.id, id))) - .returning(projectColumns) - )[0]; - if (!row) throw notFound("Project not found"); - return presentProject(row); + return db.transaction(async (transaction) => { + const db = transaction as unknown as FacilityDb; + await db.execute(sql`select pg_advisory_xact_lock(hashtext(${`${actor.orgId}:${id}`}))`); + const current = await loadProject(db, actor.orgId, id); + const nextBranch = + body.settings === undefined + ? body.developmentBranch + : ((body.settings.developmentBranch as string | null | undefined) ?? null); + if ( + nextBranch !== undefined && + nextBranch !== ((current.settings as Record).developmentBranch ?? null) + ) { + if (!can(actor.permissions, "repos:write")) { + throw new ApiError( + 403, + "forbidden", + "Changing the development branch requires repos:write", + ); + } + if (nextBranch !== null) { + const repository = ( + await db + .select() + .from(projectRepositories) + .where( + and( + eq(projectRepositories.orgId, actor.orgId), + eq(projectRepositories.projectId, id), + eq(projectRepositories.role, "primary"), + ), + ) + .limit(1) + )[0]; + if (!repository) + throw new ApiError( + 400, + "primary_repository_required", + "Connect a primary repository first", + ); + try { + const client = await createGithubClientForRepo( + db, + app.githubClientFactory ?? createGithubClientFactory(context.config), + repository, + ); + await client.getBranchSha(nextBranch); + } catch (error) { + if ((error as { status?: number }).status === 404) { + throw new ApiError( + 400, + "development_branch_not_found", + "The development branch is not available to the GitHub App", + ); + } + throw new ApiError( + 503, + "development_branch_unavailable", + "Could not validate the branch with the GitHub installation", + ); + } + } + } + const { nativePreviewsEnabled, developmentBranch, ...fields } = body; + const preferences = { + ...(nativePreviewsEnabled === undefined ? {} : { nativePreviewsEnabled }), + ...(developmentBranch === undefined ? {} : { developmentBranch }), + }; + const row = ( + await db + .update(projects) + .set( + definedFields({ + ...fields, + // The narrow UI update preserves unrelated settings, including concurrent writes. + ...(Object.keys(preferences).length === 0 + ? {} + : { + settings: sql`${projects.settings} || ${JSON.stringify(preferences)}::jsonb`, + }), + updatedAt: new Date(), + }), + ) + .where(and(eq(projects.orgId, actor.orgId), eq(projects.id, id))) + .returning(projectColumns) + )[0]; + if (!row) throw notFound("Project not found"); + return presentProject(row); + }); }, ); diff --git a/services/api/src/stories/branch.ts b/services/api/src/stories/branch.ts index 65974971..9132e63d 100644 --- a/services/api/src/stories/branch.ts +++ b/services/api/src/stories/branch.ts @@ -7,6 +7,7 @@ import { workspaces, } from "@facility/db"; import { and, desc, eq, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm"; +import { repositoryDevelopmentBranch } from "../github/development-branch.js"; import { isSafeGitBranch } from "../workspaces/git-branch.js"; import { appendStoryEvidence } from "./evidence.js"; @@ -17,6 +18,7 @@ export function observedStoryBranch( story: Pick, evidence: Pick, defaultBranch: string | undefined, + developmentBranch?: string, ): string | null { const branch = evidence.finalBranch; if ( @@ -29,6 +31,7 @@ export function observedStoryBranch( !branch || branch === story.branch || branch === defaultBranch || + branch === developmentBranch || !isSafeGitBranch(branch) ) return null; @@ -88,6 +91,8 @@ export async function reconcileTurnBranch( await db .select({ id: projectRepositories.id, + orgId: projectRepositories.orgId, + projectId: projectRepositories.projectId, role: projectRepositories.role, defaultBranch: projectRepositories.defaultBranch, }) @@ -104,7 +109,8 @@ export async function reconcileTurnBranch( .limit(1) )[0]; if (!repository) return; - const branch = observedStoryBranch(story, observed, repository.defaultBranch); + const developmentBranch = await repositoryDevelopmentBranch(db, repository); + const branch = observedStoryBranch(story, observed, repository.defaultBranch, developmentBranch); if (!branch) return; await db.execute( sql`SELECT pg_advisory_xact_lock(hashtext(${`facility:story-branch:${story.orgId}:${story.projectId}:${repository.id}:${branch}`}))`, diff --git a/services/api/src/workspaces/project-environment.ts b/services/api/src/workspaces/project-environment.ts index 741fca8e..180793d4 100644 --- a/services/api/src/workspaces/project-environment.ts +++ b/services/api/src/workspaces/project-environment.ts @@ -6,6 +6,7 @@ import { and, eq } from "drizzle-orm"; import { parseDocument } from "yaml"; import { z } from "zod"; import { FacilityGithubClient, type GithubClientFactory } from "../github/client.js"; +import { repositoryDevelopmentBranch } from "../github/development-branch.js"; import { decodeContent } from "../github/repo-files.js"; import type { GithubWorkspaceCredentials, @@ -185,7 +186,9 @@ export class GithubProjectManifestSource implements ProjectManifestSource { repo: repository.name, defaultBranch: repository.defaultBranch, }); - const content = (await client.getContent(".facility.yml", repository.defaultBranch)) as { + const developmentBranch = await repositoryDevelopmentBranch(this.db, repository); + const commitSha = await client.getBranchSha(developmentBranch); + const content = (await client.getContent(".facility.yml", commitSha)) as { type?: string; content?: string; encoding?: string; @@ -557,6 +560,15 @@ export class ProjectEnvironmentService { if (!isSafeGitBranch(input.branch)) { throw new ProjectEnvironmentError("story_branch_invalid", "story branch is invalid"); } + if ( + input.branch === repository.defaultBranch || + input.branch === repository.developmentBranch + ) { + throw new ProjectEnvironmentError( + "story_branch_protected", + "Use a story branch, not a repository base branch", + ); + } const local = await this.runtime.exec(input.workspace, { command: "git", args: ["show-ref", "--verify", `refs/heads/${input.branch}`], @@ -576,7 +588,12 @@ export class ProjectEnvironmentService { ? ["switch", input.branch] : remote.exitCode === 0 ? ["switch", "-c", input.branch, "--track", `origin/${input.branch}`] - : ["switch", "-c", input.branch, `origin/${repository.defaultBranch}`], + : [ + "switch", + "-c", + input.branch, + `origin/${repository.developmentBranch ?? repository.defaultBranch}`, + ], cwd, "git branch", ); diff --git a/services/api/test/development-branch-workspace.integration.test.ts b/services/api/test/development-branch-workspace.integration.test.ts new file mode 100644 index 00000000..ee0d6002 --- /dev/null +++ b/services/api/test/development-branch-workspace.integration.test.ts @@ -0,0 +1,121 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { newId } from "@facility/core"; +import { createDb, migrate, orgs, projects, stories, workspaces } from "@facility/db"; +import Docker from "dockerode"; +import { expect, it } from "vitest"; +import type { GithubWorkspaceCredentials } from "../src/github/workspace-credentials.js"; +import { DockerWorkspaceRuntime } from "../src/workspaces/docker.js"; +import { FakeWorkspaceRuntime } from "../src/workspaces/fake.js"; +import { + ProjectEnvironmentService, + parseProjectManifest, +} from "../src/workspaces/project-environment.js"; + +it("creates work from development and retains commits and dirty files after the base advances and compute is replaced", async () => { + const databaseUrl = + process.env.DATABASE_URL ?? "postgres://facility:facility@127.0.0.1:5461/facility_test"; + const { db, client } = createDb(databaseUrl); + const root = await mkdtemp(join(tmpdir(), "facility-development-workspace-")); + const runtime = + process.env.FACILITY_E2E_DOCKER === "1" + ? new DockerWorkspaceRuntime(new Docker()) + : new FakeWorkspaceRuntime(root); + const orgId = newId("org"), + projectId = newId("proj"), + storyId = newId("story"), + workspaceId = newId("ws"); + const workspace = await runtime.create({ + id: workspaceId, + image: process.env.FACILITY_WORKSPACE_TEST_IMAGE ?? "facility-runner:dev", + }); + const command = async (script: string, cwd?: string) => { + const result = await runtime.exec(workspace, { command: "sh", args: ["-lc", script], cwd }); + expect(result.exitCode, result.stderr).toBe(0); + return result.stdout.trim(); + }; + try { + await migrate(databaseUrl); + await db.insert(orgs).values({ id: orgId, name: orgId, slug: orgId }); + await db.insert(projects).values({ id: projectId, orgId, name: projectId, slug: projectId }); + await db.insert(stories).values({ + id: storyId, + orgId, + projectId, + provider: "manual", + externalId: storyId, + title: "Development workspace", + branch: "feature/story", + createdBy: { type: "key", id: "test" }, + }); + await db.insert(workspaces).values({ + id: workspaceId, + orgId, + projectId, + storyId, + provider: "fake", + externalRef: workspace.externalRef, + volumeRef: workspace.volumeRef, + state: "running", + }); + const workspaceRoot = await command("pwd"); + await command( + `mkdir -p branch-remotes/acme seed; git init --bare --initial-branch=main branch-remotes/acme/app.git; git init --initial-branch=main seed; cd seed; git config user.name Test; git config user.email test@example.com; git config commit.gpgsign false; git config core.hooksPath /dev/null; printf stable > README.md; git add README.md; git commit -m 'initial'; git remote add origin '${workspaceRoot}/branch-remotes/acme/app.git'; git push origin main; git switch -c develop; printf development > development.txt; git add development.txt; git commit -m 'development'; git push origin develop`, + ); + const developmentSha = await command("git rev-parse develop", "seed"); + const manifest = parseProjectManifest( + "version: 1\nrepositories:\n primary: github.com/acme/app\nenvironment:\n setup: 'true'\n start: 'true'\n", + ); + const credentials: GithubWorkspaceCredentials = { + repositories: [ + { + owner: "acme", + name: "app", + defaultBranch: "main", + developmentBranch: "develop", + role: "primary", + }, + ], + environment: {}, + expiresAt: new Date(Date.now() + 60_000), + gitIdentity: { name: "Test", email: "test@example.com" }, + }; + const service = new ProjectEnvironmentService( + db, + runtime, + `file://${workspaceRoot}/branch-remotes`, + ); + const input = { orgId, projectId, workspace, manifest, credentials, branch: "feature/story" }; + for (const branch of ["main", "develop"]) + await expect(service.prepare({ ...input, branch })).rejects.toMatchObject({ + code: "story_branch_protected", + }); + await service.prepare(input); + expect(await command("git rev-parse HEAD", "repos/acme/app")).toBe(developmentSha); + expect(await command("cat development.txt", "repos/acme/app")).toBe("development"); + await command( + "git config commit.gpgsign false; git config core.hooksPath /dev/null; printf story > story.txt; git add story.txt; git commit -m 'story'; printf dirty > dirty.txt", + "repos/acme/app", + ); + const head = await command("git rev-parse HEAD", "repos/acme/app"); + await command( + "printf advanced > advanced.txt; git add advanced.txt; git commit -m 'advance'; git push origin develop", + "seed", + ); + await runtime.replaceCompute(workspace); + const resumed = await runtime.wake(workspace); + Object.assign(workspace, resumed); + await service.prepare(input); + expect(await command("git rev-parse HEAD", "repos/acme/app")).toBe(head); + expect(await command("git branch --show-current", "repos/acme/app")).toBe("feature/story"); + expect(await command("cat dirty.txt", "repos/acme/app")).toBe("dirty"); + expect(await command("test ! -f advanced.txt && echo preserved", "repos/acme/app")).toBe( + "preserved", + ); + } finally { + await runtime.destroy(workspace); + await client.end(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); diff --git a/services/api/test/development-branch.integration.test.ts b/services/api/test/development-branch.integration.test.ts new file mode 100644 index 00000000..7e6ac967 --- /dev/null +++ b/services/api/test/development-branch.integration.test.ts @@ -0,0 +1,376 @@ +import { randomUUID } from "node:crypto"; +import { generateApiKey, newId } from "@facility/core"; +import { + apiKeys, + createDb, + githubInstallations, + migrate, + orgs, + projectRepositories, + projects, + roles, + stories, + storyConversations, + turns, +} from "@facility/db"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, expect, it, vi } from "vitest"; +import { AgentCatalogService, GithubAgentCatalogSource } from "../src/agents/catalog.js"; +import { buildApp } from "../src/app.js"; +import type { GithubClientFactory } from "../src/github/client.js"; +import { repositoryDevelopmentBranch } from "../src/github/development-branch.js"; +import { kickstartRepo } from "../src/github/kickstart.js"; +import { GithubWorkspaceCredentialBroker } from "../src/github/workspace-credentials.js"; +import type { AppConfig, Principal } from "../src/types.js"; +import { GithubProjectManifestSource } from "../src/workspaces/project-environment.js"; + +const databaseUrl = + process.env.DATABASE_URL ?? "postgres://facility:facility@127.0.0.1:5461/facility_test"; +const { db, client } = createDb(databaseUrl); +const orgId = newId("org"), + foreignOrgId = newId("org"), + projectId = newId("proj"), + otherProjectId = newId("proj"), + foreignProjectId = newId("proj"); +const installationId = newId("ghi"), + repositoryId = newId("repo"), + storyId = newId("story"), + conversationId = newId("msg"), + turnId = newId("turn"); +const config: AppConfig = { + databaseUrl, + secretMasterKey: Buffer.alloc(32, 14).toString("base64"), + port: 4400, + publicUrl: "https://api.facility.test", + facilityInsecureDev: true, + workspaceDriver: "docker", + workspaceImage: "test", + logLevel: "silent", +}; +let app: Awaited>; +let writer: string, reader: string, projectWriter: string; +const mainSha = "a".repeat(40), + developSha = "b".repeat(40); +const source = (label: string) => + `---\nname: builder\ndescription: ${label}\nengine: codex\nmodel: gpt-5.5\nenabled: true\ntriggers:\n - type: manual\n---\nBuild ${label}.\n`; +const manifest = (label: string) => + `version: 1\nrepositories:\n primary: github.com/acme/app\nenvironment:\n setup: echo ${label}\n start: 'true'\n`; +const getBranch = vi.fn(async ({ branch }: Record) => { + if (branch === "develop" && developmentUnavailable) + throw Object.assign(new Error("missing"), { status: 404 }); + if (branch === "missing") throw Object.assign(new Error("missing"), { status: 404 }); + if (branch === "denied") throw Object.assign(new Error("denied"), { status: 403 }); + return { data: { commit: { sha: branch === "main" ? mainSha : developSha } } }; +}); +let kickstart = false; +let developmentUnavailable = false; +const getContent = vi.fn(async ({ path, ref }: Record) => { + const label = ref === mainSha ? "stable" : "development"; + let content: unknown; + if (kickstart) { + if (path === "package.json") content = { type: "file", content: '{"scripts":{"dev":"vite"}}' }; + else throw Object.assign(new Error("missing"), { status: 404 }); + } else if (path === ".agents") content = [{ type: "file", path: ".agents/builder.md" }]; + else if (path === ".agents/builder.md") content = { type: "file", content: source(label) }; + else if (path === ".facility.yml") content = { type: "file", content: manifest(label) }; + else if (path === ".claude/skills") content = [{ type: "dir", path: ".claude/skills/planning" }]; + else if (path === ".claude/skills/planning") + content = [{ type: "file", path: ".claude/skills/planning/SKILL.md" }]; + else if (path === ".claude/skills/planning/SKILL.md") + content = { type: "file", content: `---\nname: planning\ndescription: ${label}\n---\nPlan.` }; + else throw Object.assign(new Error("missing"), { status: 404 }); + return { data: content }; +}); +const createPullRequest = vi.fn(async () => ({ + data: { number: 42, html_url: "https://github.com/acme/app/pull/42" }, +})); +const createRef = vi.fn(async () => ({ data: {} })); +const createCommit = vi.fn(async () => ({ data: { sha: "c".repeat(40) } })); +const factory = (async () => ({ + rest: { + repos: { getBranch, getContent }, + git: { + getCommit: async () => ({ data: { sha: developSha, tree: { sha: "d".repeat(40) } } }), + createBlob: async () => ({ data: { sha: "e".repeat(40) } }), + createTree: async () => ({ data: { sha: "f".repeat(40) } }), + createCommit, + createRef, + updateRef: async () => ({ data: {} }), + }, + pulls: { list: async () => ({ data: [] }), create: createPullRequest }, + }, +})) as unknown as GithubClientFactory; +beforeAll(async () => { + await migrate(databaseUrl); + await db.insert(orgs).values([orgId, foreignOrgId].map((id) => ({ id, name: id, slug: id }))); + await db.insert(projects).values([ + { id: projectId, orgId, name: "Development", slug: projectId, settings: { retained: true } }, + { id: otherProjectId, orgId, name: "Other", slug: otherProjectId }, + { id: foreignProjectId, orgId: foreignOrgId, name: "Foreign", slug: foreignProjectId }, + ]); + await db.insert(githubInstallations).values({ + id: installationId, + orgId, + installationId: Math.floor(Math.random() * 1e9), + accountLogin: "acme", + accountId: 1234, + targetType: "Organization", + }); + await db.insert(projectRepositories).values({ + id: repositoryId, + orgId, + projectId, + installationId, + owner: "acme", + name: "app", + defaultBranch: "main", + role: "primary", + }); + for (const [permissions, assign] of [ + [ + ["projects:read", "projects:write", "repos:write"], + (token: string) => { + writer = token; + }, + ], + [ + ["projects:read"], + (token: string) => { + reader = token; + }, + ], + [ + ["projects:write"], + (token: string) => { + projectWriter = token; + }, + ], + ] as const) { + const roleId = newId("role"), + generated = await generateApiKey("fak"); + await db + .insert(roles) + .values({ id: roleId, orgId, name: roleId, permissions: [...permissions] }); + await db.insert(apiKeys).values({ + id: generated.id, + orgId, + roleId, + projectId, + scopeType: "project", + name: "branch-test", + prefix: generated.lookup, + last4: generated.last4, + hash: generated.hash, + }); + assign(generated.secret); + } + await db.insert(stories).values({ + id: storyId, + orgId, + projectId, + title: "Retained story", + provider: "manual", + externalId: storyId, + branch: "feature/retained", + createdBy: { type: "key", id: "test" }, + }); + await db.insert(storyConversations).values({ id: conversationId, orgId, projectId, storyId }); + await db.insert(turns).values({ + id: turnId, + orgId, + projectId, + storyId, + conversationId, + agentName: "builder", + manifestHash: "old-hash", + manifest: { prompt: "immutable" }, + engine: "codex", + model: "gpt-5.5", + state: "queued", + triggerType: "manual", + createdBy: { type: "key", id: "test" }, + }); + app = await buildApp(config); + app.githubClientFactory = factory; + await app.ready(); +}); +afterAll(async () => { + await app?.close(); + await client.end(); +}); +const patch = ( + payload: Record, + target = projectId, + token = writer, + key = randomUUID(), +) => + app.inject({ + method: "PATCH", + url: `/v1/projects/${target}`, + headers: { authorization: `Bearer ${token}`, "idempotency-key": key }, + payload, + }); +it("selects development sources and preserves metadata, settings, stories and queued/running snapshots", async () => { + const catalog = new GithubAgentCatalogSource(db, factory); + expect((await catalog.load(orgId, projectId)).sources[0]?.source).toContain("stable"); + const key = randomUUID(); + const response = await patch({ developmentBranch: "develop" }, projectId, writer, key); + expect(response.statusCode).toBe(200); + expect(response.json().settings).toEqual({ retained: true, developmentBranch: "develop" }); + expect((await patch({ developmentBranch: "develop" }, projectId, writer, key)).json()).toEqual( + response.json(), + ); + const snapshot = await catalog.load(orgId, projectId); + expect(snapshot.commitSha).toBe(developSha); + expect(snapshot.sources[0]?.source).toContain("development"); + expect(snapshot.skills?.[0]?.source).toContain("development"); + expect( + (await new GithubProjectManifestSource(db, factory).load(orgId, projectId)).environment.setup, + ).toBe("echo development"); + const tokenFactory = vi.fn(async () => ({ + token: "fake-installation-token", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + gitIdentity: { name: "test[bot]", email: "test@example.com" }, + })); + expect( + (await new GithubWorkspaceCredentialBroker(db, tokenFactory).issue(orgId, projectId)) + .repositories[0], + ).toMatchObject({ defaultBranch: "main", developmentBranch: "develop" }); + expect( + await repositoryDevelopmentBranch(db, { + orgId, + projectId, + role: "related", + defaultBranch: "stable", + }), + ).toBe("stable"); + expect( + (await db.select().from(projectRepositories).where(eq(projectRepositories.id, repositoryId)))[0] + ?.defaultBranch, + ).toBe("main"); + expect((await db.select().from(stories).where(eq(stories.id, storyId)))[0]?.branch).toBe( + "feature/retained", + ); + expect((await db.select().from(turns).where(eq(turns.id, turnId)))[0]).toMatchObject({ + manifestHash: "old-hash", + manifest: { prompt: "immutable" }, + state: "queued", + }); + await db.update(turns).set({ state: "running" }).where(eq(turns.id, turnId)); + expect((await patch({ developmentBranch: "main" })).statusCode).toBe(200); + expect((await db.select().from(turns).where(eq(turns.id, turnId)))[0]).toMatchObject({ + manifestHash: "old-hash", + manifest: { prompt: "immutable" }, + state: "running", + }); + expect((await patch({ developmentBranch: "develop" })).statusCode).toBe(200); +}); +it("targets agent and kickstart configuration PRs at the selected development base", async () => { + await patch({ developmentBranch: "develop" }); + const catalog = new GithubAgentCatalogSource(db, factory); + await catalog.proposeUpdate(orgId, projectId, { + name: "builder", + source: source("edited"), + expectedCommitSha: developSha, + }); + expect(createPullRequest).toHaveBeenLastCalledWith( + expect.objectContaining({ + base: "develop", + head: expect.stringMatching(/^facility\/agent-builder-[a-f0-9]{12}$/), + }), + ); + expect(createCommit).toHaveBeenLastCalledWith(expect.objectContaining({ parents: [developSha] })); + const repo = ( + await db.select().from(projectRepositories).where(eq(projectRepositories.id, repositoryId)) + )[0]; + if (!repo) throw Error("Missing fixture"); + kickstart = true; + try { + await kickstartRepo({ + db, + factory, + config, + principal: { orgId } as Principal, + projectId, + repo, + answers: {}, + }); + expect(createPullRequest).toHaveBeenLastCalledWith( + expect.objectContaining({ + base: "develop", + head: expect.stringMatching(/^facility\/kickstart-0\.12-[a-f0-9]{12}$/), + }), + ); + expect(createCommit).toHaveBeenLastCalledWith( + expect.objectContaining({ parents: [developSha] }), + ); + } finally { + kickstart = false; + } +}); +it("rejects unauthorized, cross-project, cross-tenant, malformed and unavailable branch changes", async () => { + for (const [target, token] of [ + [projectId, reader], + [projectId, projectWriter], + [otherProjectId, writer], + [foreignProjectId, writer], + ]) { + getBranch.mockClear(); + expect((await patch({ developmentBranch: "other" }, target, token)).statusCode).toBe( + target === projectId ? 403 : 404, + ); + expect(getBranch).not.toHaveBeenCalled(); + } + for (const branch of ["refs/heads/dev", "", "dev~1", "a//b", 1, true]) + expect((await patch({ developmentBranch: branch })).statusCode).toBe(400); + expect((await patch({ developmentBranch: "develop", settings: {} })).statusCode).toBe(400); + expect((await patch({ developmentBranch: "missing" })).statusCode).toBe(400); + expect((await patch({ developmentBranch: "denied" })).statusCode).toBe(503); + expect((await patch({ settings: {} }, projectId, projectWriter)).statusCode).toBe(403); + const previous = (await db.select().from(projects).where(eq(projects.id, projectId)))[0] + ?.settings; + expect(previous).toMatchObject({ developmentBranch: "develop" }); + await db + .update(githubInstallations) + .set({ suspendedAt: new Date() }) + .where(eq(githubInstallations.id, installationId)); + try { + expect((await patch({ developmentBranch: "other" })).statusCode).toBe(503); + await expect( + new GithubAgentCatalogSource(db, factory).load(orgId, projectId), + ).rejects.toMatchObject({ code: "github_installation_unavailable" }); + } finally { + await db + .update(githubInstallations) + .set({ suspendedAt: null }) + .where(eq(githubInstallations.id, installationId)); + } +}); +it("does not dispatch a cached catalog from another source when the selected branch is removed", async () => { + await patch({ developmentBranch: "develop" }); + const catalog = new AgentCatalogService(db, new GithubAgentCatalogSource(db, factory)); + await catalog.sync(orgId, projectId); + developmentUnavailable = true; + try { + await expect(catalog.get(orgId, projectId, "builder")).rejects.toMatchObject({ + code: "agent_catalog_branch_unavailable", + }); + await expect( + new GithubProjectManifestSource(db, factory).load(orgId, projectId), + ).rejects.toMatchObject({ status: 404 }); + } finally { + developmentUnavailable = false; + } +}); +it("clears the override atomically and restores default-branch discovery", async () => { + expect((await patch({ developmentBranch: null })).json().settings).toEqual({ + retained: true, + developmentBranch: null, + }); + expect((await new GithubAgentCatalogSource(db, factory).load(orgId, projectId)).commitSha).toBe( + mainSha, + ); + await expect( + new GithubAgentCatalogSource(db, factory).load(foreignOrgId, projectId), + ).rejects.toMatchObject({ code: "primary_repository_not_found" }); +}); diff --git a/services/api/test/development-branch.test.ts b/services/api/test/development-branch.test.ts new file mode 100644 index 00000000..137635cd --- /dev/null +++ b/services/api/test/development-branch.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + configurationBranch, + DevelopmentBranchSchema, + projectDevelopmentBranch, +} from "../src/github/development-branch.js"; + +describe("project development branch", () => { + it("retains legacy proposal heads and separates development sources, including names that slugify alike", () => { + const prefix = "facility/kickstart-0.12"; + expect(configurationBranch(prefix, "main", "main")).toBe(prefix); + const heads = ["develop", "team/dev", "team-dev"].map((branch) => + configurationBranch(prefix, branch, "main"), + ); + expect(new Set(heads).size).toBe(3); + expect(heads.every((head) => DevelopmentBranchSchema.safeParse(head).success)).toBe(true); + }); + it("falls back only when unset, including legacy settings", () => { + for (const settings of [{}, { developmentBranch: null }, { default_branch: "develop" }]) { + expect(projectDevelopmentBranch(settings, "main")).toBe("main"); + } + expect(projectDevelopmentBranch({ developmentBranch: "develop" }, "main")).toBe("develop"); + }); + it.each([ + "", + "-develop", + "refs/heads/develop", + "develop~1", + "a..b", + "a//b", + ".hidden", + "a/.hidden", + "a.lock", + "a/b.lock", + "@", + "dev\nmain", + "a\\b", + "a/", + "a.", + ])("rejects %j", (branch) => { + expect(DevelopmentBranchSchema.safeParse(branch).success).toBe(false); + expect(() => projectDevelopmentBranch({ developmentBranch: branch }, "main")).toThrow(); + }); + it.each([ + "main", + "dev", + "develop", + "release/next", + "team-1/integration", + ])("accepts %s", (branch) => { + expect(DevelopmentBranchSchema.parse(branch)).toBe(branch); + }); +}); diff --git a/services/api/test/github-client.test.ts b/services/api/test/github-client.test.ts index 89ed858f..6e6a8ab7 100644 --- a/services/api/test/github-client.test.ts +++ b/services/api/test/github-client.test.ts @@ -39,6 +39,7 @@ function fixture() { owner: "acme", repo: "app", defaultBranch: "main", + developmentBranch: "develop", }), createRef, updateRef, @@ -95,4 +96,19 @@ describe("FacilityGithubClient", () => { expect(updateRef).not.toHaveBeenCalled(); expect(createPullRequest).not.toHaveBeenCalled(); }); + it("also refuses writes and PR heads on the development branch", async () => { + const { client, createRef, updateRef, createPullRequest } = fixture(); + await expect(client.createBranch("develop", "a".repeat(40))).rejects.toThrow( + /development branch/, + ); + await expect(client.updateBranch("refs/heads/develop", "a".repeat(40))).rejects.toThrow( + /development branch/, + ); + await expect( + client.createPullRequest({ title: "Unsafe", body: "", head: "heads/develop" }), + ).rejects.toThrow(/development branch/); + expect(createRef).not.toHaveBeenCalled(); + expect(updateRef).not.toHaveBeenCalled(); + expect(createPullRequest).not.toHaveBeenCalled(); + }); }); diff --git a/services/api/test/story-branch.test.ts b/services/api/test/story-branch.test.ts index 665e0c2f..85f1ff19 100644 --- a/services/api/test/story-branch.test.ts +++ b/services/api/test/story-branch.test.ts @@ -12,6 +12,14 @@ describe("verified story branch changes", () => { it("follows the branch recorded after a completed workspace turn", () => { expect(observedStoryBranch(story, evidence, "main")).toBe("chore/story"); }); + it("protects the development branch as well as the GitHub default", () => { + for (const finalBranch of ["main", "develop"]) { + expect( + observedStoryBranch(story, { ...evidence, finalBranch }, "main", "develop"), + ).toBeNull(); + } + expect(observedStoryBranch(story, evidence, "main", "develop")).toBe("chore/story"); + }); it.each([ ["an existing PR", { ...story, pullRequestNumber: 42 }], ["a concurrent branch change", { ...story, branch: "fix/other" }],