Skip to content

Commit dd55fdb

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
feat(webapp,cli): archive inactive dev branches at the limit
Mono-RevId: 81e6ee9f8902eea13a49f3e491005b7e22abf7f5
1 parent 56823c3 commit dd55fdb

7 files changed

Lines changed: 188 additions & 6 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"trigger.dev": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
Automatically archive up to three inactive development branches when creating a branch at the plan limit. Connected and recently active branches remain protected, and the CLI reports which branches were archived.

apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,10 @@ export async function action({ request, params }: ActionFunctionArgs) {
163163
return json({ error: result.error }, { status: 400 });
164164
}
165165

166-
return json({ id: result.branch.id });
166+
return json({
167+
id: result.branch.id,
168+
autoArchivedBranches: result.autoArchivedBranches,
169+
});
167170
}
168171

169172
export async function loader({ request, params }: LoaderFunctionArgs) {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export class ArchiveBranchService {
8888
const shortcode = slug;
8989

9090
const updatedBranch = await this.#prismaClient.runtimeEnvironment.update({
91-
where: { id: environmentId },
91+
where: { id: environmentId, archivedAt: null },
9292
data: { archivedAt: new Date(), slug, shortcode },
9393
});
9494

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

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
rootEnvironmentWhere,
1414
toBranchableEnvironmentType,
1515
} from "~/utils/branchableEnvironment";
16+
import { devPresence } from "~/presenters/v3/DevPresence.server";
17+
import { ArchiveBranchService } from "./archiveBranch.server";
1618
import { logger } from "./logger.server";
1719
import { getCurrentPlan, getLimit } from "./platform.v3.server";
1820
import { type z } from "zod";
@@ -24,6 +26,12 @@ import {
2426
} from "~/v3/services/billingLimit/getInitialEnvPauseStateForBillingLimit.server";
2527

2628
type CreateBranchOptions = z.infer<typeof CreateBranchOptions>;
29+
type OrgFilter =
30+
| { type: "userMembership"; userId: string }
31+
| { type: "orgId"; organizationId: string };
32+
33+
const DEV_BRANCH_STALE_AFTER_MS = 60 * 60 * 1000;
34+
const DEV_BRANCH_AUTO_ARCHIVE_LIMIT = 3;
2735

2836
export class UpsertBranchService {
2937
#prismaClient: PrismaClient;
@@ -37,9 +45,7 @@ export class UpsertBranchService {
3745
// Currently authorization checks are spread across the controller/route layer and the service layer. Often we check in multiple places for org/project membership.
3846
// Ideally we would take care of both the authentication and authorization checks in the controllers and routes.
3947
// That would unify how we handle authorization and org/project membership checks. Also it would make the service layer queries simpler.
40-
orgFilter:
41-
| { type: "userMembership"; userId: string }
42-
| { type: "orgId"; organizationId: string },
48+
orgFilter: OrgFilter,
4349
{ projectId, env, branchName, git }: CreateBranchOptions
4450
) {
4551
const parentEnvType = toBranchableEnvironmentType(env);
@@ -120,14 +126,34 @@ export class UpsertBranchService {
120126
};
121127
}
122128

123-
const limits = await checkBranchLimit({
129+
let limits = await checkBranchLimit({
124130
prisma: this.#prismaClient,
125131
organizationId: parentEnvironment.organization.id,
126132
projectId: parentEnvironment.project.id,
127133
type: parentEnvType,
128134
userId,
129135
newBranchName: sanitizedBranchName,
130136
});
137+
const autoArchivedBranches = limits.isAtLimit
138+
? await autoArchiveStaleDevBranches({
139+
prisma: this.#prismaClient,
140+
orgFilter,
141+
parentEnvironment,
142+
userId,
143+
limit: limits.limit,
144+
})
145+
: [];
146+
147+
if (autoArchivedBranches.length > 0) {
148+
limits = await checkBranchLimit({
149+
prisma: this.#prismaClient,
150+
organizationId: parentEnvironment.organization.id,
151+
projectId: parentEnvironment.project.id,
152+
type: parentEnvType,
153+
userId,
154+
newBranchName: sanitizedBranchName,
155+
});
156+
}
131157

132158
if (limits.isAtLimit) {
133159
// DEVELOPMENT has no upgrade path, so only PREVIEW mentions upgrading.
@@ -223,6 +249,7 @@ export class UpsertBranchService {
223249
branch,
224250
organization: parentEnvironment.organization,
225251
project: parentEnvironment.project,
252+
autoArchivedBranches,
226253
};
227254
} catch (e) {
228255
logger.error("CreateBranchService error", { error: e });
@@ -234,6 +261,84 @@ export class UpsertBranchService {
234261
}
235262
}
236263

264+
async function autoArchiveStaleDevBranches({
265+
prisma,
266+
orgFilter,
267+
parentEnvironment,
268+
userId,
269+
limit,
270+
}: {
271+
prisma: PrismaClient;
272+
orgFilter: OrgFilter;
273+
parentEnvironment: {
274+
id: string;
275+
type: string;
276+
orgMemberId: string | null;
277+
project: { id: string };
278+
};
279+
userId?: string;
280+
limit: number;
281+
}) {
282+
if (parentEnvironment.type !== "DEVELOPMENT" || !userId) return [];
283+
284+
try {
285+
const branches = await prisma.runtimeEnvironment.findMany({
286+
where: {
287+
parentEnvironmentId: parentEnvironment.id,
288+
archivedAt: null,
289+
},
290+
select: { id: true, branchName: true, createdAt: true },
291+
});
292+
const recentBranches = await devPresence.getRecentBranchIds(
293+
userId,
294+
parentEnvironment.project.id
295+
);
296+
const connectedBranches = await devPresence.isConnectedMany(
297+
branches.map((branch) => branch.id)
298+
);
299+
const staleBefore = Date.now() - DEV_BRANCH_STALE_AFTER_MS;
300+
const candidates = branches
301+
.filter((branch) => {
302+
if (connectedBranches.get(branch.id)) return false;
303+
const lastActivity = recentBranches.get(branch.id)?.getTime() ?? branch.createdAt.getTime();
304+
return lastActivity <= staleBefore;
305+
})
306+
.sort((a, b) => {
307+
const aActivity = recentBranches.get(a.id)?.getTime() ?? a.createdAt.getTime();
308+
const bActivity = recentBranches.get(b.id)?.getTime() ?? b.createdAt.getTime();
309+
return aActivity - bActivity;
310+
})
311+
.slice(0, DEV_BRANCH_AUTO_ARCHIVE_LIMIT);
312+
313+
const used = await prisma.runtimeEnvironment.count({
314+
where: {
315+
projectId: parentEnvironment.project.id,
316+
orgMemberId: parentEnvironment.orgMemberId,
317+
type: "DEVELOPMENT",
318+
archivedAt: null,
319+
},
320+
});
321+
if (used < limit) return [];
322+
323+
const archiveService = new ArchiveBranchService(prisma);
324+
const results = await Promise.all(
325+
candidates.map((candidate) => archiveService.call(orgFilter, { environmentId: candidate.id }))
326+
);
327+
328+
return results.flatMap((result) =>
329+
result.success && result.branch.branchName
330+
? [{ id: result.branch.id, branchName: result.branch.branchName }]
331+
: []
332+
);
333+
} catch (error) {
334+
logger.warn("Failed to auto-archive stale development branches", {
335+
projectId: parentEnvironment.project.id,
336+
error,
337+
});
338+
return [];
339+
}
340+
}
341+
237342
export async function checkBranchLimit({
238343
prisma,
239344
organizationId,

apps/webapp/test/devBranchServices.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ import {
1010
uniqueId,
1111
} from "./fixtures/environmentVariablesFixtures";
1212

13+
const devPresence = vi.hoisted(() => ({
14+
getRecentBranchIds: vi.fn(),
15+
isConnectedMany: vi.fn(),
16+
}));
17+
18+
vi.mock("~/presenters/v3/DevPresence.server", () => ({ devPresence }));
19+
vi.mock("~/services/platform.v3.server", () => ({
20+
getCurrentPlan: vi.fn().mockResolvedValue(null),
21+
getLimit: vi.fn().mockResolvedValue(5),
22+
}));
23+
1324
vi.setConfig({ testTimeout: 60_000 });
1425

1526
async function createDevRoot(
@@ -137,6 +148,48 @@ describe("UpsertBranchService — DEVELOPMENT parent", () => {
137148
expect(firstRetry.branch.id).toBe(firstResult.branch.id);
138149
});
139150

151+
postgresTest(
152+
"archives up to three stale branches when creating at the limit",
153+
async ({ prisma }) => {
154+
const { organization, project, user, orgMember } =
155+
await createTestOrgProjectWithMember(prisma);
156+
const devRoot = await createDevRoot(prisma, project.id, organization.id, orgMember.id);
157+
const service = new UpsertBranchService(prisma);
158+
const orgFilter = { type: "userMembership" as const, userId: user.id };
159+
160+
for (const branchName of ["old-1", "old-2", "old-3", "old-4"]) {
161+
expect(
162+
(
163+
await service.call(orgFilter, {
164+
projectId: project.id,
165+
env: "development",
166+
branchName,
167+
})
168+
).success
169+
).toBe(true);
170+
}
171+
172+
await prisma.runtimeEnvironment.updateMany({
173+
where: { parentEnvironmentId: devRoot.id },
174+
data: { createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000) },
175+
});
176+
devPresence.getRecentBranchIds.mockResolvedValue(new Map());
177+
devPresence.isConnectedMany.mockImplementation(
178+
async (ids: string[]) => new Map(ids.map((id) => [id, false]))
179+
);
180+
181+
const result = await service.call(orgFilter, {
182+
projectId: project.id,
183+
env: "development",
184+
branchName: "new-branch",
185+
});
186+
187+
expect(result.success).toBe(true);
188+
if (!result.success) return;
189+
expect(result.autoArchivedBranches).toHaveLength(3);
190+
}
191+
);
192+
140193
postgresTest(
141194
"rejects an invalid branch name without touching the database",
142195
async ({ prisma }) => {

packages/cli-v3/src/commands/dev.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,13 @@ async function startDev(options: StartDevOptions) {
312312
logger.error(`Failed to use branch "${branch}": ${upsertResult.error}`);
313313
process.exit(1);
314314
}
315+
316+
if (upsertResult.data.autoArchivedBranches?.length) {
317+
const archivedNames = upsertResult.data.autoArchivedBranches
318+
.map((archivedBranch) => `"${archivedBranch.branchName}"`)
319+
.join(", ");
320+
log.warn(`Archived inactive dev branches to make room: ${archivedNames}`);
321+
}
315322
}
316323

317324
// eslint-disable-next-line no-inner-declarations

packages/core/src/v3/schemas/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,14 @@ export type UpsertBranchRequestBody = z.infer<typeof UpsertBranchRequestBody>;
751751

752752
export const UpsertBranchResponseBody = z.object({
753753
id: z.string(),
754+
autoArchivedBranches: z
755+
.array(
756+
z.object({
757+
id: z.string(),
758+
branchName: z.string(),
759+
})
760+
)
761+
.optional(),
754762
});
755763

756764
export type UpsertBranchResponseBody = z.infer<typeof UpsertBranchResponseBody>;

0 commit comments

Comments
 (0)