@@ -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" ;
1618import { logger } from "./logger.server" ;
1719import { getCurrentPlan , getLimit } from "./platform.v3.server" ;
1820import { type z } from "zod" ;
@@ -24,6 +26,12 @@ import {
2426} from "~/v3/services/billingLimit/getInitialEnvPauseStateForBillingLimit.server" ;
2527
2628type 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
2836export 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+
237342export async function checkBranchLimit ( {
238343 prisma,
239344 organizationId,
0 commit comments