From 8439b1a5c95cdd27d279afc0252adb2fe46c5d85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20TR=C3=89BEL=20=28Perso=29?= Date: Wed, 23 Sep 2026 16:24:04 +0200 Subject: [PATCH 1/5] feat(shared): expose repository sync branch Refs #1672 Generated with AI assistance, reviewed by @stephanetrebel --- packages/shared/src/contracts/repository.ts | 4 ++-- .../shared/src/schemas/repository.spec.ts | 21 +++++++++++++++++++ packages/shared/src/schemas/repository.ts | 4 +++- packages/shared/src/utils/schemas.spec.ts | 5 ++++- packages/test-utils/src/utils/random-utils.ts | 1 + packages/test-utils/src/utils/types.ts | 1 + 6 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 packages/shared/src/schemas/repository.spec.ts diff --git a/packages/shared/src/contracts/repository.ts b/packages/shared/src/contracts/repository.ts index 131deee00e..870c9632a4 100644 --- a/packages/shared/src/contracts/repository.ts +++ b/packages/shared/src/contracts/repository.ts @@ -12,7 +12,7 @@ export const repositoryContract = contractInstance.router({ contentType: 'application/json', summary: 'Create repo', description: 'Create new repo.', - body: RepoSchema.omit({ id: true, createdAt: true, updatedAt: true }), + body: RepoSchema.omit({ id: true, createdAt: true, updatedAt: true, branchName: true }), responses: { 201: RepoSchema, 400: ErrorSchema, @@ -71,7 +71,7 @@ export const repositoryContract = contractInstance.router({ repositoryId: z.string() .uuid(), }), - body: RepoSchema.omit({ createdAt: true, updatedAt: true }).partial(), + body: RepoSchema.omit({ createdAt: true, updatedAt: true, branchName: true }).partial(), responses: { 200: RepoSchema, 500: ErrorSchema, diff --git a/packages/shared/src/schemas/repository.spec.ts b/packages/shared/src/schemas/repository.spec.ts new file mode 100644 index 0000000000..bff90ea58f --- /dev/null +++ b/packages/shared/src/schemas/repository.spec.ts @@ -0,0 +1,21 @@ +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import { defaultBranchName } from '../utils/const.js' +import { RepoSchema } from './repository.js' + +describe('repoSchema', () => { + it('defaults a repository response without a branch to main', () => { + const repository = RepoSchema.parse({ + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: 'my-repo', + externalRepoUrl: '', + isPrivate: false, + isInfra: false, + createdAt: faker.date.past().toISOString(), + updatedAt: faker.date.past().toISOString(), + }) + + expect(repository.branchName).toBe(defaultBranchName) + }) +}) diff --git a/packages/shared/src/schemas/repository.ts b/packages/shared/src/schemas/repository.ts index 85675b9123..ecb9bc33c5 100644 --- a/packages/shared/src/schemas/repository.ts +++ b/packages/shared/src/schemas/repository.ts @@ -1,6 +1,6 @@ import type Zod from 'zod' import { z } from 'zod' -import { forbiddenRepoNames, invalidGitUrl, invalidInternalRepoName, missingCredentials } from '../utils/const.js' +import { defaultBranchName, forbiddenRepoNames, invalidGitUrl, invalidInternalRepoName, missingCredentials } from '../utils/const.js' import { AtDatesToStringExtend } from './_utils.js' export const RepoSchema = z.object({ @@ -26,6 +26,8 @@ export const RepoSchema = z.object({ .optional(), projectId: z.string() .uuid(), + branchName: z.string() + .default(defaultBranchName), // Optional deployment settings for infra repositories deployRevision: z.string().optional(), deployPath: z.string().optional(), diff --git a/packages/shared/src/utils/schemas.spec.ts b/packages/shared/src/utils/schemas.spec.ts index 6a1aac3a71..f1e3761f8d 100644 --- a/packages/shared/src/utils/schemas.spec.ts +++ b/packages/shared/src/utils/schemas.spec.ts @@ -2,7 +2,7 @@ import type { Log, ProjectV2 } from '../index.js' import { faker } from '@faker-js/faker' import { describe, expect, it } from 'vitest' import { ZodError } from 'zod' -import { ClusterDetailsSchema, ClusterPrivacy, ClusterUsageSchema, descriptionMaxLength, EnvironmentSchema, instanciateSchema, LogSchema, parseZodError, ProjectSchemaV2, RepoSchema, StageSchema, UserSchema } from '../index.js' +import { ClusterDetailsSchema, ClusterPrivacy, ClusterUsageSchema, defaultBranchName, descriptionMaxLength, EnvironmentSchema, instanciateSchema, LogSchema, parseZodError, ProjectSchemaV2, RepoSchema, StageSchema, UserSchema } from '../index.js' describe('schemas utils', () => { it('should delete config in log', () => { @@ -44,6 +44,7 @@ describe('schemas utils', () => { id: faker.string.uuid(), projectId: faker.string.uuid(), internalRepoName: 'candilib', + branchName: defaultBranchName, externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', externalToken: 'eddddsqsq-_', isPrivate: true, @@ -159,6 +160,7 @@ describe('schemas utils', () => { id: faker.string.uuid(), projectId: faker.string.uuid(), internalRepoName: 'candilib', + branchName: defaultBranchName, externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', externalUserName: 'clairenlet', externalToken: 'myToken', @@ -213,6 +215,7 @@ describe('schemas utils', () => { id: faker.string.uuid(), projectId: faker.string.uuid(), internalRepoName: '-candilib', + branchName: defaultBranchName, externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', isPrivate: false, isInfra: false, diff --git a/packages/test-utils/src/utils/random-utils.ts b/packages/test-utils/src/utils/random-utils.ts index 3f78ccf346..64a1f275ad 100644 --- a/packages/test-utils/src/utils/random-utils.ts +++ b/packages/test-utils/src/utils/random-utils.ts @@ -94,6 +94,7 @@ export function getRandomRepo(projectId = faker.string.uuid()): Repository { id: faker.string.uuid(), projectId, internalRepoName: faker.lorem.word(), + branchName: 'main', externalRepoUrl: getRandomGitUrl(), isPrivate: faker.datatype.boolean(), isInfra: faker.datatype.boolean(), diff --git a/packages/test-utils/src/utils/types.ts b/packages/test-utils/src/utils/types.ts index 14eaf73bb8..b4faafdc38 100644 --- a/packages/test-utils/src/utils/types.ts +++ b/packages/test-utils/src/utils/types.ts @@ -2,6 +2,7 @@ export interface Repository { id: string projectId: string internalRepoName: string + branchName: string externalRepoUrl: string isPrivate: boolean isInfra: boolean From eeb9aa62e567e05179e6aff45ce23a454b066b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20TR=C3=89BEL=20=28Perso=29?= Date: Wed, 23 Sep 2026 16:25:09 +0200 Subject: [PATCH 2/5] feat(server-nestjs): persist repository sync branch Refs #1672 Generated with AI assistance, reviewed by @stephanetrebel --- .../repository-datastore.service.spec.ts | 14 ++++++++++++++ .../repository/repository-datastore.service.ts | 7 +++++++ .../modules/repository/repository-testing.utils.ts | 1 + .../migration.sql | 1 + .../server-nestjs/src/prisma/schema/project.prisma | 1 + .../migration.sql | 1 + apps/server/src/prisma/schema/project.prisma | 1 + 7 files changed, 26 insertions(+) create mode 100644 apps/server-nestjs/src/prisma/migrations/20260923150000_add_repository_branch_name/migration.sql create mode 100644 apps/server/src/prisma/migrations/20260923150000_add_repository_branch_name/migration.sql diff --git a/apps/server-nestjs/src/modules/repository/repository-datastore.service.spec.ts b/apps/server-nestjs/src/modules/repository/repository-datastore.service.spec.ts index 351620b12a..d221cac15d 100644 --- a/apps/server-nestjs/src/modules/repository/repository-datastore.service.spec.ts +++ b/apps/server-nestjs/src/modules/repository/repository-datastore.service.spec.ts @@ -137,6 +137,20 @@ describe('repositoryDatastoreService', () => { }) }) + describe('updateBranchName', () => { + it('updates the persisted synchronization branch', async () => { + const repository = makeInternalRepository({ projectId }) + prisma.repository.update.mockResolvedValue(repository) + + await service.updateBranchName(repository.id, 'develop') + + expect(prisma.repository.update).toHaveBeenCalledWith({ + where: { id: repository.id }, + data: { branchName: 'develop' }, + }) + }) + }) + describe('deleteRepository', () => { it('returns the deleted repository', async () => { const repository = makeInternalRepository() diff --git a/apps/server-nestjs/src/modules/repository/repository-datastore.service.ts b/apps/server-nestjs/src/modules/repository/repository-datastore.service.ts index 8bfd8a8255..66758b5fac 100644 --- a/apps/server-nestjs/src/modules/repository/repository-datastore.service.ts +++ b/apps/server-nestjs/src/modules/repository/repository-datastore.service.ts @@ -37,6 +37,13 @@ export class RepositoryDatastoreService { }) } + updateBranchName(repositoryId: string, branchName: string): Promise { + return this.prisma.repository.update({ + where: { id: repositoryId }, + data: { branchName }, + }) + } + deleteRepository(repositoryId: string): Promise { return this.prisma.repository.delete({ where: { id: repositoryId }, diff --git a/apps/server-nestjs/src/modules/repository/repository-testing.utils.ts b/apps/server-nestjs/src/modules/repository/repository-testing.utils.ts index 5d5e008aae..5f4d16d271 100644 --- a/apps/server-nestjs/src/modules/repository/repository-testing.utils.ts +++ b/apps/server-nestjs/src/modules/repository/repository-testing.utils.ts @@ -8,6 +8,7 @@ export function makeRepository(overrides: Partial = {}): Repository id: faker.string.uuid(), projectId: faker.string.uuid(), internalRepoName: faker.string.alphanumeric(8).toLowerCase(), + branchName: 'main', externalRepoUrl: '', externalUserName: '', isInfra: false, diff --git a/apps/server-nestjs/src/prisma/migrations/20260923150000_add_repository_branch_name/migration.sql b/apps/server-nestjs/src/prisma/migrations/20260923150000_add_repository_branch_name/migration.sql new file mode 100644 index 0000000000..e306cf5252 --- /dev/null +++ b/apps/server-nestjs/src/prisma/migrations/20260923150000_add_repository_branch_name/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Repository" ADD COLUMN "branchName" TEXT NOT NULL DEFAULT 'main'; diff --git a/apps/server-nestjs/src/prisma/schema/project.prisma b/apps/server-nestjs/src/prisma/schema/project.prisma index 94d33c7894..f7db83366c 100644 --- a/apps/server-nestjs/src/prisma/schema/project.prisma +++ b/apps/server-nestjs/src/prisma/schema/project.prisma @@ -75,6 +75,7 @@ model Repository { id String @id @default(uuid()) @db.Uuid projectId String @db.Uuid internalRepoName String + branchName String @default("main") externalRepoUrl String @default("") externalUserName String @default("") isInfra Boolean @default(false) diff --git a/apps/server/src/prisma/migrations/20260923150000_add_repository_branch_name/migration.sql b/apps/server/src/prisma/migrations/20260923150000_add_repository_branch_name/migration.sql new file mode 100644 index 0000000000..e306cf5252 --- /dev/null +++ b/apps/server/src/prisma/migrations/20260923150000_add_repository_branch_name/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Repository" ADD COLUMN "branchName" TEXT NOT NULL DEFAULT 'main'; diff --git a/apps/server/src/prisma/schema/project.prisma b/apps/server/src/prisma/schema/project.prisma index 94d33c7894..f7db83366c 100644 --- a/apps/server/src/prisma/schema/project.prisma +++ b/apps/server/src/prisma/schema/project.prisma @@ -75,6 +75,7 @@ model Repository { id String @id @default(uuid()) @db.Uuid projectId String @db.Uuid internalRepoName String + branchName String @default("main") externalRepoUrl String @default("") externalUserName String @default("") isInfra Boolean @default(false) From 9a419e06abd8c6ddfbbf39982431ae8b9b65fd26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20TR=C3=89BEL=20=28Perso=29?= Date: Wed, 23 Sep 2026 16:26:41 +0200 Subject: [PATCH 3/5] feat(server-nestjs): save branch before repository sync Refs #1672 Generated with AI assistance, reviewed by @stephanetrebel --- .../repository/repository.service.spec.ts | 26 +++++++++++++++---- .../modules/repository/repository.service.ts | 4 +++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/server-nestjs/src/modules/repository/repository.service.spec.ts b/apps/server-nestjs/src/modules/repository/repository.service.spec.ts index f63441224f..ac1db17251 100644 --- a/apps/server-nestjs/src/modules/repository/repository.service.spec.ts +++ b/apps/server-nestjs/src/modules/repository/repository.service.spec.ts @@ -321,16 +321,27 @@ describe('repositoryService', () => { }, { action: 'Sync Repository', userId, requestId }, ) + expect(datastore.updateBranchName).not.toHaveBeenCalled() }) - it('forwards the requested branch when not syncing every branch', async () => { + it('persists the requested branch before triggering its mirror', async () => { const repository = makeRepository({ id: repositoryId, projectId }) const branchName = faker.git.branch() + const calls: string[] = [] datastore.getRepositoryById.mockResolvedValue(repository) - appEvents.emitRepositoryEvent.mockResolvedValue({}) + datastore.updateBranchName.mockImplementation(async () => { + calls.push('persist') + return repository + }) + appEvents.emitRepositoryEvent.mockImplementation(async () => { + calls.push('mirror') + return {} + }) await service.syncRepository(projectId, projectSlug, repositoryId, { syncAllBranches: false, branchName }, userId, requestId) + expect(datastore.updateBranchName).toHaveBeenCalledWith(repositoryId, branchName) + expect(calls).toEqual(['persist', 'mirror']) expect(appEvents.emitRepositoryEvent).toHaveBeenCalledWith( 'repository.sync', expect.objectContaining({ syncAllBranches: false, branchName }), @@ -338,14 +349,18 @@ describe('repositoryService', () => { ) }) - it('rejects with 422 when a plugin fails the synchronization', async () => { - datastore.getRepositoryById.mockResolvedValue(makeRepository({ id: repositoryId, projectId })) + it('keeps the requested branch when a plugin fails the synchronization', async () => { + const branchName = faker.git.branch() + const repository = makeRepository({ id: repositoryId, projectId }) + datastore.getRepositoryById.mockResolvedValue(repository) + datastore.updateBranchName.mockResolvedValue(repository) appEvents.emitRepositoryEvent.mockResolvedValue({ gitlab: { status: 'KO', message: 'Unable to find mirror repository', executionTime: 1, error: new Error('boom') }, }) - await expect(service.syncRepository(projectId, projectSlug, repositoryId, syncRequest, userId, requestId)) + await expect(service.syncRepository(projectId, projectSlug, repositoryId, { syncAllBranches: false, branchName }, userId, requestId)) .rejects.toThrow(UnprocessableEntityException) + expect(datastore.updateBranchName).toHaveBeenCalledWith(repositoryId, branchName) }) it('rejects when the repository belongs to another project', async () => { @@ -353,6 +368,7 @@ describe('repositoryService', () => { await expect(service.syncRepository(projectId, projectSlug, repositoryId, syncRequest, userId, requestId)) .rejects.toThrow(NotFoundException) + expect(datastore.updateBranchName).not.toHaveBeenCalled() expect(appEvents.emitRepositoryEvent).not.toHaveBeenCalled() }) }) diff --git a/apps/server-nestjs/src/modules/repository/repository.service.ts b/apps/server-nestjs/src/modules/repository/repository.service.ts index 256db880d0..c1f5bcbd06 100644 --- a/apps/server-nestjs/src/modules/repository/repository.service.ts +++ b/apps/server-nestjs/src/modules/repository/repository.service.ts @@ -85,6 +85,10 @@ export class RepositoryService { async syncRepository(projectId: string, projectSlug: string, repositoryId: string, syncRequest: SyncRepository, userId: string, requestId: string): Promise { const repository = await this.getProjectRepositoryOrThrow(projectId, repositoryId) + if (!syncRequest.syncAllBranches) { + await this.repositoryDatastoreService.updateBranchName(repositoryId, syncRequest.branchName) + } + const results = await this.syncRepositoryMirror( { projectId, From a7ba4e9bd78efc4e2f02d1bc775915aa3d633b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20TR=C3=89BEL=20=28Perso=29?= Date: Wed, 23 Sep 2026 16:46:40 +0200 Subject: [PATCH 4/5] test(server-nestjs): complete repository fixture Refs #1672 Generated with AI assistance, reviewed by @stephanetrebel --- .../src/modules/deployment/deployment-testing.utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts b/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts index d107a3138e..0c2280b62c 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts @@ -44,6 +44,7 @@ export function makeRepository(overrides: Partial = {}): Repository id: faker.string.uuid(), projectId: faker.string.uuid(), internalRepoName: faker.string.alphanumeric(8).toLowerCase(), + branchName: 'main', externalRepoUrl: '', externalUserName: '', isInfra: false, From 0aab87e4fdbe825100039d051c5ff10d32aaf68f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20TR=C3=89BEL=20=28Perso=29?= Date: Wed, 23 Sep 2026 16:46:46 +0200 Subject: [PATCH 5/5] feat(client): preload repository sync branch Refs #1672 Generated with AI assistance, reviewed by @stephanetrebel --- .../src/components/ProjectResources.vue | 23 +++++++++++-------- .../client/src/utils/repository-utils.spec.ts | 6 +++++ apps/client/src/utils/repository-utils.ts | 2 +- playwright/e2e-tests/repos.spec.ts | 17 ++++++++++++++ 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/apps/client/src/components/ProjectResources.vue b/apps/client/src/components/ProjectResources.vue index 68e074e684..4b6bba5cb4 100644 --- a/apps/client/src/components/ProjectResources.vue +++ b/apps/client/src/components/ProjectResources.vue @@ -1,5 +1,5 @@