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.
This commit is contained in:
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user