diff --git a/.server-changes/dev-branch-member-scoping.md b/.server-changes/dev-branch-member-scoping.md new file mode 100644 index 0000000000..d836c6c835 --- /dev/null +++ b/.server-changes/dev-branch-member-scoping.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Allow different organization members to use the same development branch name without sharing or colliding with each other's branch environments. diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index fa18101b47..3364f3f560 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -1,4 +1,8 @@ -import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/database"; +import { + type Prisma, + type PrismaClient, + type PrismaClientOrTransaction, +} from "@trigger.dev/database"; import slug from "slug"; import { prisma } from "~/db.server"; import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.server"; @@ -141,7 +145,31 @@ export class UpsertBranchService { const branchSlug = `${slug(`${parentEnvironment.slug}-${sanitizedBranchName}`)}`; const apiKey = createApiKeyForEnv(parentEnvironment.type); const pkApiKey = createPkApiKeyForEnv(parentEnvironment.type); - const shortcode = branchSlug; + const isDevelopmentBranch = parentEnvironment.type === "DEVELOPMENT"; + // Dev branches can share a slug across members, so their identity is scoped by + // orgMemberId. Shortcodes remain project-scoped and use the parent's unique + // shortcode to distinguish otherwise identical branch slugs. + const shortcode = isDevelopmentBranch + ? `${branchSlug}-${parentEnvironment.shortcode}` + : branchSlug; + let branchWhere: Prisma.RuntimeEnvironmentWhereUniqueInput; + if (isDevelopmentBranch) { + invariant(parentEnvironment.orgMemberId, "Development branches require an org member"); + branchWhere = { + projectId_slug_orgMemberId: { + projectId: parentEnvironment.project.id, + slug: branchSlug, + orgMemberId: parentEnvironment.orgMemberId, + }, + }; + } else { + branchWhere = { + projectId_shortcode: { + projectId: parentEnvironment.project.id, + shortcode, + }, + }; + } const billingPause = await getInitialEnvPauseStateForBillingLimit( parentEnvironment.organization.id, parentEnvironment.type @@ -149,12 +177,7 @@ export class UpsertBranchService { const now = new Date(); const branch = await this.#prismaClient.runtimeEnvironment.upsert({ - where: { - projectId_shortcode: { - projectId: parentEnvironment.project.id, - shortcode: shortcode, - }, - }, + where: branchWhere, create: { slug: branchSlug, apiKey, diff --git a/apps/webapp/test/devBranchServices.test.ts b/apps/webapp/test/devBranchServices.test.ts index 06c06f7c7e..55cf4fcbbc 100644 --- a/apps/webapp/test/devBranchServices.test.ts +++ b/apps/webapp/test/devBranchServices.test.ts @@ -4,7 +4,11 @@ import slug from "slug"; import { describe, expect, vi } from "vitest"; import { ArchiveBranchService } from "~/services/archiveBranch.server"; import { UpsertBranchService } from "~/services/upsertBranch.server"; -import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures"; +import { + createTestOrgProjectWithMember, + createTestUser, + uniqueId, +} from "./fixtures/environmentVariablesFixtures"; vi.setConfig({ testTimeout: 60_000 }); @@ -77,6 +81,62 @@ describe("UpsertBranchService — DEVELOPMENT parent", () => { } ); + postgresTest("allows different members to use the same branch name", async ({ prisma }) => { + const { + organization, + project, + user: firstUser, + orgMember: firstMember, + } = await createTestOrgProjectWithMember(prisma); + const secondUser = await createTestUser(prisma); + const secondMember = await prisma.orgMember.create({ + data: { + organizationId: organization.id, + userId: secondUser.id, + role: "MEMBER", + }, + }); + + const [firstRoot, secondRoot] = await Promise.all([ + createDevRoot(prisma, project.id, organization.id, firstMember.id), + createDevRoot(prisma, project.id, organization.id, secondMember.id), + ]); + + const options = { + projectId: project.id, + env: "development" as const, + branchName: "shared-name", + }; + const [firstResult, secondResult] = await Promise.all([ + new UpsertBranchService(prisma).call( + { type: "userMembership", userId: firstUser.id }, + options + ), + new UpsertBranchService(prisma).call( + { type: "userMembership", userId: secondUser.id }, + options + ), + ]); + + expect(firstResult.success && secondResult.success).toBe(true); + if (!firstResult.success || !secondResult.success) return; + expect(firstResult.branch.id).not.toBe(secondResult.branch.id); + expect(firstResult.branch.slug).toBe(secondResult.branch.slug); + expect(firstResult.branch.shortcode).toBe(`dev-shared-name-${firstRoot.shortcode}`); + expect(secondResult.branch.shortcode).toBe(`dev-shared-name-${secondRoot.shortcode}`); + expect(firstResult.branch.orgMemberId).toBe(firstMember.id); + expect(secondResult.branch.orgMemberId).toBe(secondMember.id); + + const firstRetry = await new UpsertBranchService(prisma).call( + { type: "userMembership", userId: firstUser.id }, + options + ); + expect(firstRetry.success).toBe(true); + if (!firstRetry.success) return; + expect(firstRetry.alreadyExisted).toBe(true); + expect(firstRetry.branch.id).toBe(firstResult.branch.id); + }); + postgresTest( "rejects an invalid branch name without touching the database", async ({ prisma }) => {