Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/dev-branch-member-scoping.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 31 additions & 8 deletions apps/webapp/app/services/upsertBranch.server.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -141,20 +145,39 @@ 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
);

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,
Expand Down
62 changes: 61 additions & 1 deletion apps/webapp/test/devBranchServices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down Expand Up @@ -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 }) => {
Expand Down