Skip to content

Commit bb34a2e

Browse files
authored
fix(webapp): scope development branches to each member (#4323)
## Summary Allow each organization member to use the same development branch name without colliding with another member's environment. Fixes #4320. ## Fix Development branches now use the existing member-scoped project, slug, and organization-member key for upserts. Preview branches retain their project-wide shortcode behavior. New development branches receive distinct shortcodes while keeping their readable, member-scoped slugs. Existing branches continue to resolve through the member-scoped key, so this requires no migration or backfill.
1 parent 95307ba commit bb34a2e

3 files changed

Lines changed: 98 additions & 9 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Allow different organization members to use the same development branch name without sharing or colliding with each other's branch environments.

apps/webapp/app/services/upsertBranch.server.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/database";
1+
import {
2+
type Prisma,
3+
type PrismaClient,
4+
type PrismaClientOrTransaction,
5+
} from "@trigger.dev/database";
26
import slug from "slug";
37
import { prisma } from "~/db.server";
48
import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.server";
@@ -141,20 +145,39 @@ export class UpsertBranchService {
141145
const branchSlug = `${slug(`${parentEnvironment.slug}-${sanitizedBranchName}`)}`;
142146
const apiKey = createApiKeyForEnv(parentEnvironment.type);
143147
const pkApiKey = createPkApiKeyForEnv(parentEnvironment.type);
144-
const shortcode = branchSlug;
148+
const isDevelopmentBranch = parentEnvironment.type === "DEVELOPMENT";
149+
// Dev branches can share a slug across members, so their identity is scoped by
150+
// orgMemberId. Shortcodes remain project-scoped and use the parent's unique
151+
// shortcode to distinguish otherwise identical branch slugs.
152+
const shortcode = isDevelopmentBranch
153+
? `${branchSlug}-${parentEnvironment.shortcode}`
154+
: branchSlug;
155+
let branchWhere: Prisma.RuntimeEnvironmentWhereUniqueInput;
156+
if (isDevelopmentBranch) {
157+
invariant(parentEnvironment.orgMemberId, "Development branches require an org member");
158+
branchWhere = {
159+
projectId_slug_orgMemberId: {
160+
projectId: parentEnvironment.project.id,
161+
slug: branchSlug,
162+
orgMemberId: parentEnvironment.orgMemberId,
163+
},
164+
};
165+
} else {
166+
branchWhere = {
167+
projectId_shortcode: {
168+
projectId: parentEnvironment.project.id,
169+
shortcode,
170+
},
171+
};
172+
}
145173
const billingPause = await getInitialEnvPauseStateForBillingLimit(
146174
parentEnvironment.organization.id,
147175
parentEnvironment.type
148176
);
149177

150178
const now = new Date();
151179
const branch = await this.#prismaClient.runtimeEnvironment.upsert({
152-
where: {
153-
projectId_shortcode: {
154-
projectId: parentEnvironment.project.id,
155-
shortcode: shortcode,
156-
},
157-
},
180+
where: branchWhere,
158181
create: {
159182
slug: branchSlug,
160183
apiKey,

apps/webapp/test/devBranchServices.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import slug from "slug";
44
import { describe, expect, vi } from "vitest";
55
import { ArchiveBranchService } from "~/services/archiveBranch.server";
66
import { UpsertBranchService } from "~/services/upsertBranch.server";
7-
import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures";
7+
import {
8+
createTestOrgProjectWithMember,
9+
createTestUser,
10+
uniqueId,
11+
} from "./fixtures/environmentVariablesFixtures";
812

913
vi.setConfig({ testTimeout: 60_000 });
1014

@@ -77,6 +81,62 @@ describe("UpsertBranchService — DEVELOPMENT parent", () => {
7781
}
7882
);
7983

84+
postgresTest("allows different members to use the same branch name", async ({ prisma }) => {
85+
const {
86+
organization,
87+
project,
88+
user: firstUser,
89+
orgMember: firstMember,
90+
} = await createTestOrgProjectWithMember(prisma);
91+
const secondUser = await createTestUser(prisma);
92+
const secondMember = await prisma.orgMember.create({
93+
data: {
94+
organizationId: organization.id,
95+
userId: secondUser.id,
96+
role: "MEMBER",
97+
},
98+
});
99+
100+
const [firstRoot, secondRoot] = await Promise.all([
101+
createDevRoot(prisma, project.id, organization.id, firstMember.id),
102+
createDevRoot(prisma, project.id, organization.id, secondMember.id),
103+
]);
104+
105+
const options = {
106+
projectId: project.id,
107+
env: "development" as const,
108+
branchName: "shared-name",
109+
};
110+
const [firstResult, secondResult] = await Promise.all([
111+
new UpsertBranchService(prisma).call(
112+
{ type: "userMembership", userId: firstUser.id },
113+
options
114+
),
115+
new UpsertBranchService(prisma).call(
116+
{ type: "userMembership", userId: secondUser.id },
117+
options
118+
),
119+
]);
120+
121+
expect(firstResult.success && secondResult.success).toBe(true);
122+
if (!firstResult.success || !secondResult.success) return;
123+
expect(firstResult.branch.id).not.toBe(secondResult.branch.id);
124+
expect(firstResult.branch.slug).toBe(secondResult.branch.slug);
125+
expect(firstResult.branch.shortcode).toBe(`dev-shared-name-${firstRoot.shortcode}`);
126+
expect(secondResult.branch.shortcode).toBe(`dev-shared-name-${secondRoot.shortcode}`);
127+
expect(firstResult.branch.orgMemberId).toBe(firstMember.id);
128+
expect(secondResult.branch.orgMemberId).toBe(secondMember.id);
129+
130+
const firstRetry = await new UpsertBranchService(prisma).call(
131+
{ type: "userMembership", userId: firstUser.id },
132+
options
133+
);
134+
expect(firstRetry.success).toBe(true);
135+
if (!firstRetry.success) return;
136+
expect(firstRetry.alreadyExisted).toBe(true);
137+
expect(firstRetry.branch.id).toBe(firstResult.branch.id);
138+
});
139+
80140
postgresTest(
81141
"rejects an invalid branch name without touching the database",
82142
async ({ prisma }) => {

0 commit comments

Comments
 (0)