From 1372d8fc0e9c09928e4faa967c7c9547c8fa7fb4 Mon Sep 17 00:00:00 2001 From: Mathieu LAUDE Date: Fri, 9 Jan 2026 17:02:58 +0100 Subject: [PATCH 1/4] refactor: split Gitlab classes in separate files --- plugins/argocd/src/functions.ts | 2 +- plugins/gitlab/package.json | 2 +- plugins/gitlab/src/gitlab-api.ts | 168 +++++++++++++ .../src/{class.ts => gitlab-project-api.ts} | 229 +----------------- plugins/gitlab/src/gitlab-zone-api.ts | 54 +++++ plugins/gitlab/src/index.ts | 3 +- plugins/gitlab/src/members.ts | 2 +- plugins/gitlab/src/repositories.ts | 2 +- 8 files changed, 233 insertions(+), 229 deletions(-) create mode 100644 plugins/gitlab/src/gitlab-api.ts rename plugins/gitlab/src/{class.ts => gitlab-project-api.ts} (62%) create mode 100644 plugins/gitlab/src/gitlab-zone-api.ts diff --git a/plugins/argocd/src/functions.ts b/plugins/argocd/src/functions.ts index 0c7357106..86b4c21aa 100644 --- a/plugins/argocd/src/functions.ts +++ b/plugins/argocd/src/functions.ts @@ -1,7 +1,7 @@ import type { ClusterObject, Environment, ListMinimumResources, Project, Repository, StepCall } from '@cpn-console/hooks' import { parseError, uniqueResource } from '@cpn-console/hooks' import { dump } from 'js-yaml' -import type { GitlabProjectApi } from '@cpn-console/gitlab-plugin/types/class.js' +import type { GitlabProjectApi } from '@cpn-console/gitlab-plugin/types/gitlab-project-api.js' import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' import { PatchUtils } from '@kubernetes/client-node' import { inClusterLabel } from '@cpn-console/shared' diff --git a/plugins/gitlab/package.json b/plugins/gitlab/package.json index 2863ff8c5..0933bea97 100644 --- a/plugins/gitlab/package.json +++ b/plugins/gitlab/package.json @@ -1,7 +1,7 @@ { "name": "@cpn-console/gitlab-plugin", "type": "module", - "version": "3.3.1", + "version": "3.4.0", "private": false, "description": "", "main": "dist/index.js", diff --git a/plugins/gitlab/src/gitlab-api.ts b/plugins/gitlab/src/gitlab-api.ts new file mode 100644 index 000000000..edb3daf7b --- /dev/null +++ b/plugins/gitlab/src/gitlab-api.ts @@ -0,0 +1,168 @@ +import { createHash } from 'node:crypto' +import { PluginApi } from '@cpn-console/hooks' +import type { CommitAction } from '@gitbeaker/rest' +import type { AllRepositoryTreesOptions, CondensedProjectSchema, Gitlab, PaginationRequestOptions, RepositoryFileExpandedSchema, RepositoryTreeSchema } from '@gitbeaker/core' +import { objectEntries } from '@cpn-console/shared' +import type { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { getApi } from './utils.js' + +export interface GitlabMirrorSecret { + MIRROR_USER: string + MIRROR_TOKEN: string +} + +export interface RepoSelect { + mirror?: CondensedProjectSchema + target?: CondensedProjectSchema +} +type PendingCommits = Record +}> + +export interface CreateEmptyRepositoryArgs { + repoName: string + description?: string +} + +/** Abstract class providing functions to interact with Gitlab API */ +export abstract class GitlabApi extends PluginApi { + protected api: Gitlab + private pendingCommits: PendingCommits = {} + + constructor() { + super() + this.api = getApi() + } + + public async createEmptyRepository({ createFirstCommit, groupId, repoName, description }: CreateEmptyRepositoryArgs & { + createFirstCommit: boolean + groupId: number + }) { + const project = await this.api.Projects.create({ + name: repoName, + path: repoName, + namespaceId: groupId, + description, + }) + // Dépôt tout juste créé, zéro branche => pas d'erreur (filesTree undefined) + if (createFirstCommit) { + await this.api.Commits.create(project.id, 'main', 'ci: 🌱 First commit', []) + } + return project + } + + public async commitCreateOrUpdate( + repoId: number, + fileContent: string, + filePath: string, + branch: string = 'main', + comment: string = 'ci: :robot_face: Update file content', + ): Promise { + let action: CommitAction['action'] = 'create' + + const branches = await this.api.Branches.all(repoId) + if (branches.some(b => b.name === branch)) { + let actualFile: RepositoryFileExpandedSchema | undefined + try { + actualFile = await this.api.RepositoryFiles.show(repoId, filePath, branch) + } catch (_) {} + if (actualFile) { + const newContentDigest = createHash('sha256').update(fileContent).digest('hex') + if (actualFile.content_sha256 === newContentDigest) { + // Already up-to-date + return false + } + // Update needed + action = 'update' + } + } + + const commitAction: CommitAction = { + action, + filePath, + content: fileContent, + } + this.addActions(repoId, branch, comment, [commitAction]) + + return true + } + + /** + * Fonction pour supprimer une liste de fichiers d'un repo + * @param repoId + * @param files + * @param branch + * @param comment + */ + public async commitDelete( + repoId: number, + files: string[], + branch: string = 'main', + comment: string = 'ci: :robot_face: Delete files', + ): Promise { + if (files.length) { + const commitActions: CommitAction[] = files.map((filePath) => { + return { + action: 'delete', + filePath, + } + }) + this.addActions(repoId, branch, comment, commitActions) + return true + } + return false + } + + private addActions(repoId: number, branch: string, comment: string, commitActions: CommitAction[]) { + if (!this.pendingCommits[repoId]) { + this.pendingCommits[repoId] = { branches: {} } + } + if (this.pendingCommits[repoId].branches[branch]) { + this.pendingCommits[repoId].branches[branch].actions.push(...commitActions) + this.pendingCommits[repoId].branches[branch].messages.push(comment) + } else { + this.pendingCommits[repoId].branches[branch] = { + actions: commitActions, + messages: [comment], + } + } + } + + public async commitFiles() { + let filesUpdated: number = 0 + for (const [id, repo] of objectEntries(this.pendingCommits)) { + for (const [branch, details] of objectEntries(repo.branches)) { + const filesNumber = details.actions.length + if (filesNumber) { + filesUpdated += filesNumber + const message = [`ci: :robot_face: Update ${filesNumber} file${filesNumber > 1 ? 's' : ''}`, ...details.messages.filter(m => m)].join('\n') + await this.api.Commits.create(id, branch, message, details.actions) + } + } + } + return filesUpdated + } + + public async listFiles(repoId: number, options: AllRepositoryTreesOptions & PaginationRequestOptions<'keyset'> = {}) { + options.path = options?.path ?? '/' + options.ref = options?.ref ?? 'main' + options.recursive = options?.recursive ?? false + try { + const files: RepositoryTreeSchema[] = await this.api.Repositories.allRepositoryTrees(repoId, options) + return files + } catch (error) { + const { cause } = error as GitbeakerRequestError + if (cause?.description.includes('Not Found')) { + // Empty repository, with zero commit ==> Zero files + return [] + } else { + throw error + } + } + } + + public async deleteRepository(repoId: number, fullPath: string) { + await this.api.Projects.remove(repoId) // Marks for deletion + return this.api.Projects.remove(repoId, { permanentlyRemove: true, fullPath: `${fullPath}-deletion_scheduled-${repoId}` }) // Effective deletion + } +} diff --git a/plugins/gitlab/src/class.ts b/plugins/gitlab/src/gitlab-project-api.ts similarity index 62% rename from plugins/gitlab/src/class.ts rename to plugins/gitlab/src/gitlab-project-api.ts index d76e03397..076776888 100644 --- a/plugins/gitlab/src/class.ts +++ b/plugins/gitlab/src/gitlab-project-api.ts @@ -1,236 +1,17 @@ -import { createHash } from 'node:crypto' -import { PluginApi, type Project, type UniqueRepo } from '@cpn-console/hooks' -import type { AccessTokenScopes, CommitAction, GroupSchema, GroupStatisticsSchema, MemberSchema, ProjectVariableSchema, VariableSchema } from '@gitbeaker/rest' -import type { AllRepositoryTreesOptions, CondensedProjectSchema, Gitlab, PaginationRequestOptions, ProjectSchema, RepositoryFileExpandedSchema, RepositoryTreeSchema } from '@gitbeaker/core' +import type { AccessTokenScopes, GroupSchema, GroupStatisticsSchema, MemberSchema, ProjectVariableSchema, VariableSchema } from '@gitbeaker/rest' import { AccessLevel } from '@gitbeaker/core' import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' -import { objectEntries } from '@cpn-console/shared' -import type { GitbeakerRequestError } from '@gitbeaker/requester-utils' import { getApi, getGroupRootId, infraAppsRepoName, internalMirrorRepoName } from './utils.js' import config from './config.js' +import { type CreateEmptyRepositoryArgs, GitlabApi, type GitlabMirrorSecret, type RepoSelect } from './gitlab-api.js' +import type { Project, UniqueRepo } from '@cpn-console/hooks' +import { GitlabZoneApi } from './gitlab-zone-api.js' type setVariableResult = 'created' | 'updated' | 'already up-to-date' type AccessLevelAllowed = AccessLevel.NO_ACCESS | AccessLevel.MINIMAL_ACCESS | AccessLevel.GUEST | AccessLevel.REPORTER | AccessLevel.DEVELOPER | AccessLevel.MAINTAINER | AccessLevel.OWNER -const infraGroupName = 'Infra' -const infraGroupPath = 'infra' export const pluginManagedTopic = 'plugin-managed' -interface GitlabMirrorSecret { - MIRROR_USER: string - MIRROR_TOKEN: string -} - -interface RepoSelect { - mirror?: CondensedProjectSchema - target?: CondensedProjectSchema -} -type PendingCommits = Record -}> - -interface CreateEmptyRepositoryArgs { - repoName: string - description?: string -} - -export class GitlabApi extends PluginApi { - protected api: Gitlab - private pendingCommits: PendingCommits = {} - - constructor() { - super() - this.api = getApi() - } - - public async createEmptyRepository({ createFirstCommit, groupId, repoName, description }: CreateEmptyRepositoryArgs & { - createFirstCommit: boolean - groupId: number - }) { - const project = await this.api.Projects.create({ - name: repoName, - path: repoName, - namespaceId: groupId, - description, - }) - // Dépôt tout juste créé, zéro branche => pas d'erreur (filesTree undefined) - if (createFirstCommit) { - await this.api.Commits.create(project.id, 'main', 'ci: 🌱 First commit', []) - } - return project - } - - public async commitCreateOrUpdate( - repoId: number, - fileContent: string, - filePath: string, - branch: string = 'main', - comment: string = 'ci: :robot_face: Update file content', - ): Promise { - let action: CommitAction['action'] = 'create' - - const branches = await this.api.Branches.all(repoId) - if (branches.some(b => b.name === branch)) { - let actualFile: RepositoryFileExpandedSchema | undefined - try { - actualFile = await this.api.RepositoryFiles.show(repoId, filePath, branch) - } catch (_) {} - if (actualFile) { - const newContentDigest = createHash('sha256').update(fileContent).digest('hex') - if (actualFile.content_sha256 === newContentDigest) { - // Already up-to-date - return false - } - // Update needed - action = 'update' - } - } - - const commitAction: CommitAction = { - action, - filePath, - content: fileContent, - } - this.addActions(repoId, branch, comment, [commitAction]) - - return true - } - - /** - * Fonction pour supprimer une liste de fichiers d'un repo - * @param repoId - * @param files - * @param branch - * @param comment - */ - public async commitDelete( - repoId: number, - files: string[], - branch: string = 'main', - comment: string = 'ci: :robot_face: Delete files', - ): Promise { - if (files.length) { - const commitActions: CommitAction[] = files.map((filePath) => { - return { - action: 'delete', - filePath, - } - }) - this.addActions(repoId, branch, comment, commitActions) - return true - } - return false - } - - private addActions(repoId: number, branch: string, comment: string, commitActions: CommitAction[]) { - if (!this.pendingCommits[repoId]) { - this.pendingCommits[repoId] = { branches: {} } - } - if (this.pendingCommits[repoId].branches[branch]) { - this.pendingCommits[repoId].branches[branch].actions.push(...commitActions) - this.pendingCommits[repoId].branches[branch].messages.push(comment) - } else { - this.pendingCommits[repoId].branches[branch] = { - actions: commitActions, - messages: [comment], - } - } - } - - public async commitFiles() { - let filesUpdated: number = 0 - for (const [id, repo] of objectEntries(this.pendingCommits)) { - for (const [branch, details] of objectEntries(repo.branches)) { - const filesNumber = details.actions.length - if (filesNumber) { - filesUpdated += filesNumber - const message = [`ci: :robot_face: Update ${filesNumber} file${filesNumber > 1 ? 's' : ''}`, ...details.messages.filter(m => m)].join('\n') - await this.api.Commits.create(id, branch, message, details.actions) - } - } - } - return filesUpdated - } - - public async listFiles(repoId: number, options: AllRepositoryTreesOptions & PaginationRequestOptions<'keyset'> = {}) { - options.path = options?.path ?? '/' - options.ref = options?.ref ?? 'main' - options.recursive = options?.recursive ?? false - try { - const files: RepositoryTreeSchema[] = await this.api.Repositories.allRepositoryTrees(repoId, options) - // if (depth >= 0) { - // for (const file of files) { - // if (file.type !== 'tree') { - // return [] - // } - // const childrenFiles = await this.listFiles(repoId, { depth: depth - 1, ...options, path: file.path }) - // console.trace({ file, childrenFiles }) - - // files.push(...childrenFiles) - // } - // } - return files - } catch (error) { - const { cause } = error as GitbeakerRequestError - if (cause?.description.includes('Not Found')) { - // Empty repository, with zero commit ==> Zero files - return [] - } else { - throw error - } - } - } - - public async deleteRepository(repoId: number, fullPath: string) { - await this.api.Projects.remove(repoId) // Marks for deletion - return this.api.Projects.remove(repoId, { permanentlyRemove: true, fullPath: `${fullPath}-deletion_scheduled-${repoId}` }) // Effective deletion - } -} - -export class GitlabZoneApi extends GitlabApi { - private infraProjectsByZoneSlug: Map - - constructor() { - super() - this.infraProjectsByZoneSlug = new Map() - } - - // Group Infra - public async getOrCreateInfraGroup(): Promise { - const rootId = await getGroupRootId() - // Get or create projects_root_dir/infra group - const searchResult = await this.api.Groups.search(infraGroupName) - const existingParentGroup = searchResult.find(group => group.parent_id === rootId && group.name === infraGroupName) - return existingParentGroup || await this.api.Groups.create(infraGroupName, infraGroupPath, { - parentId: rootId, - projectCreationLevel: 'maintainer', - subgroupCreationLevel: 'owner', - defaultBranchProtection: 0, - description: 'Group that hosts infrastructure-as-code repositories for all zones (ArgoCD pull targets).', - }) - } - - public async getOrCreateInfraProject(zone: string): Promise { - if (this.infraProjectsByZoneSlug.has(zone)) { - return this.infraProjectsByZoneSlug.get(zone)! - } - const infraGroup = await this.getOrCreateInfraGroup() - // Get or create projects_root_dir/infra/zone - const infraProjects = await this.api.Groups.allProjects(infraGroup.id, { - search: zone, - simple: true, - perPage: 100, - }) - const project: ProjectSchema = infraProjects.find(repo => repo.name === zone) ?? await this.createEmptyRepository({ - repoName: zone, - groupId: infraGroup.id, - description: 'Repository hosting deployment files for this zone.', - createFirstCommit: true, - }, - ) - this.infraProjectsByZoneSlug.set(zone, project) - return project - } -} +/** Class providing project-specific functions to interact with Gitlab API */ export class GitlabProjectApi extends GitlabApi { private project: Project | UniqueRepo private gitlabGroup: GroupSchema & { statistics: GroupStatisticsSchema } | undefined diff --git a/plugins/gitlab/src/gitlab-zone-api.ts b/plugins/gitlab/src/gitlab-zone-api.ts new file mode 100644 index 000000000..40b8493da --- /dev/null +++ b/plugins/gitlab/src/gitlab-zone-api.ts @@ -0,0 +1,54 @@ +import type { GroupSchema } from '@gitbeaker/rest' +import type { ProjectSchema } from '@gitbeaker/core' +import { getGroupRootId } from './utils.js' +import { GitlabApi } from './gitlab-api.js' + +const infraGroupName = 'Infra' +const infraGroupPath = 'infra' + +/** Class providing zone-specific functions to interact with Gitlab API */ +export class GitlabZoneApi extends GitlabApi { + private infraProjectsByZoneSlug: Map + + constructor() { + super() + this.infraProjectsByZoneSlug = new Map() + } + + // Group Infra + public async getOrCreateInfraGroup(): Promise { + const rootId = await getGroupRootId() + // Get or create projects_root_dir/infra group + const searchResult = await this.api.Groups.search(infraGroupName) + const existingParentGroup = searchResult.find(group => group.parent_id === rootId && group.name === infraGroupName) + return existingParentGroup || await this.api.Groups.create(infraGroupName, infraGroupPath, { + parentId: rootId, + projectCreationLevel: 'maintainer', + subgroupCreationLevel: 'owner', + defaultBranchProtection: 0, + description: 'Group that hosts infrastructure-as-code repositories for all zones (ArgoCD pull targets).', + }) + } + + public async getOrCreateInfraProject(zone: string): Promise { + if (this.infraProjectsByZoneSlug.has(zone)) { + return this.infraProjectsByZoneSlug.get(zone)! + } + const infraGroup = await this.getOrCreateInfraGroup() + // Get or create projects_root_dir/infra/zone + const infraProjects = await this.api.Groups.allProjects(infraGroup.id, { + search: zone, + simple: true, + perPage: 100, + }) + const project: ProjectSchema = infraProjects.find(repo => repo.name === zone) ?? await this.createEmptyRepository({ + repoName: zone, + groupId: infraGroup.id, + description: 'Repository hosting deployment files for this zone.', + createFirstCommit: true, + }, + ) + this.infraProjectsByZoneSlug.set(zone, project) + return project + } +} diff --git a/plugins/gitlab/src/index.ts b/plugins/gitlab/src/index.ts index ef72f7dd5..2a0c119a6 100644 --- a/plugins/gitlab/src/index.ts +++ b/plugins/gitlab/src/index.ts @@ -12,7 +12,8 @@ import { import { getOrCreateGroupRoot } from './utils.js' import infos from './infos.js' import monitor from './monitor.js' -import { GitlabProjectApi, GitlabZoneApi } from './class.js' +import { GitlabProjectApi } from './gitlab-project-api.js' +import { GitlabZoneApi } from './gitlab-zone-api.js' const onlyApi = { api: (project: Project | UniqueRepo) => new GitlabProjectApi(project) } diff --git a/plugins/gitlab/src/members.ts b/plugins/gitlab/src/members.ts index ec77ee45d..33dd1c035 100644 --- a/plugins/gitlab/src/members.ts +++ b/plugins/gitlab/src/members.ts @@ -1,6 +1,6 @@ import type { Project } from '@cpn-console/hooks' import type { UserSchema } from '@gitbeaker/core' -import type { GitlabProjectApi } from './class.js' +import type { GitlabProjectApi } from './gitlab-project-api.js' import { upsertUser } from './user.js' export async function ensureMembers(gitlabApi: GitlabProjectApi, project: Project) { diff --git a/plugins/gitlab/src/repositories.ts b/plugins/gitlab/src/repositories.ts index 3217eafb9..b94656186 100644 --- a/plugins/gitlab/src/repositories.ts +++ b/plugins/gitlab/src/repositories.ts @@ -2,7 +2,7 @@ import type { Project, Repository } from '@cpn-console/hooks' import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' import type { CondensedProjectSchema, ProjectSchema } from '@gitbeaker/rest' import { shallowEqual } from '@cpn-console/shared' -import { type GitlabProjectApi, pluginManagedTopic } from './class.js' +import { type GitlabProjectApi, pluginManagedTopic } from './gitlab-project-api.js' import { provisionMirror } from './project.js' import { infraAppsRepoName, internalMirrorRepoName } from './utils.js' From 134638fdf3306fb522f611a3b3f2e69a525316b8 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Tue, 20 Jan 2026 14:49:48 +0100 Subject: [PATCH 2/4] Update [ghstack-poisoned] --- apps/server/prisma.config.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/server/prisma.config.ts b/apps/server/prisma.config.ts index 057121c97..bc2998ea8 100644 --- a/apps/server/prisma.config.ts +++ b/apps/server/prisma.config.ts @@ -1,5 +1,18 @@ import path from 'node:path' import { defineConfig } from 'prisma/config' +import * as dotenv from 'dotenv' + +if (process.env.DOCKER !== 'true') { + dotenv.config({ path: '.env' }) +} + +if (process.env.INTEGRATION === 'true') { + const envInteg = dotenv.config({ path: '.env.integ' }) + process.env = { + ...process.env, + ...(envInteg?.parsed ?? {}), + } +} export default defineConfig({ schema: path.join('src', 'prisma', 'schema'), From 5e28bea97451292ff930c01d3d287626924836fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20TR=C3=89BEL=20=28Perso=29?= Date: Tue, 20 Jan 2026 16:34:11 +0100 Subject: [PATCH 3/4] Revert "refactor: split Gitlab classes in separate files" This reverts commit 1372d8fc0e9c09928e4faa967c7c9547c8fa7fb4. --- plugins/argocd/src/functions.ts | 2 +- plugins/gitlab/package.json | 2 +- .../src/{gitlab-project-api.ts => class.ts} | 229 +++++++++++++++++- plugins/gitlab/src/gitlab-api.ts | 168 ------------- plugins/gitlab/src/gitlab-zone-api.ts | 54 ----- plugins/gitlab/src/index.ts | 3 +- plugins/gitlab/src/members.ts | 2 +- plugins/gitlab/src/repositories.ts | 2 +- 8 files changed, 229 insertions(+), 233 deletions(-) rename plugins/gitlab/src/{gitlab-project-api.ts => class.ts} (62%) delete mode 100644 plugins/gitlab/src/gitlab-api.ts delete mode 100644 plugins/gitlab/src/gitlab-zone-api.ts diff --git a/plugins/argocd/src/functions.ts b/plugins/argocd/src/functions.ts index 86b4c21aa..0c7357106 100644 --- a/plugins/argocd/src/functions.ts +++ b/plugins/argocd/src/functions.ts @@ -1,7 +1,7 @@ import type { ClusterObject, Environment, ListMinimumResources, Project, Repository, StepCall } from '@cpn-console/hooks' import { parseError, uniqueResource } from '@cpn-console/hooks' import { dump } from 'js-yaml' -import type { GitlabProjectApi } from '@cpn-console/gitlab-plugin/types/gitlab-project-api.js' +import type { GitlabProjectApi } from '@cpn-console/gitlab-plugin/types/class.js' import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' import { PatchUtils } from '@kubernetes/client-node' import { inClusterLabel } from '@cpn-console/shared' diff --git a/plugins/gitlab/package.json b/plugins/gitlab/package.json index 0933bea97..2863ff8c5 100644 --- a/plugins/gitlab/package.json +++ b/plugins/gitlab/package.json @@ -1,7 +1,7 @@ { "name": "@cpn-console/gitlab-plugin", "type": "module", - "version": "3.4.0", + "version": "3.3.1", "private": false, "description": "", "main": "dist/index.js", diff --git a/plugins/gitlab/src/gitlab-project-api.ts b/plugins/gitlab/src/class.ts similarity index 62% rename from plugins/gitlab/src/gitlab-project-api.ts rename to plugins/gitlab/src/class.ts index 076776888..d76e03397 100644 --- a/plugins/gitlab/src/gitlab-project-api.ts +++ b/plugins/gitlab/src/class.ts @@ -1,17 +1,236 @@ -import type { AccessTokenScopes, GroupSchema, GroupStatisticsSchema, MemberSchema, ProjectVariableSchema, VariableSchema } from '@gitbeaker/rest' +import { createHash } from 'node:crypto' +import { PluginApi, type Project, type UniqueRepo } from '@cpn-console/hooks' +import type { AccessTokenScopes, CommitAction, GroupSchema, GroupStatisticsSchema, MemberSchema, ProjectVariableSchema, VariableSchema } from '@gitbeaker/rest' +import type { AllRepositoryTreesOptions, CondensedProjectSchema, Gitlab, PaginationRequestOptions, ProjectSchema, RepositoryFileExpandedSchema, RepositoryTreeSchema } from '@gitbeaker/core' import { AccessLevel } from '@gitbeaker/core' import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import { objectEntries } from '@cpn-console/shared' +import type { GitbeakerRequestError } from '@gitbeaker/requester-utils' import { getApi, getGroupRootId, infraAppsRepoName, internalMirrorRepoName } from './utils.js' import config from './config.js' -import { type CreateEmptyRepositoryArgs, GitlabApi, type GitlabMirrorSecret, type RepoSelect } from './gitlab-api.js' -import type { Project, UniqueRepo } from '@cpn-console/hooks' -import { GitlabZoneApi } from './gitlab-zone-api.js' type setVariableResult = 'created' | 'updated' | 'already up-to-date' type AccessLevelAllowed = AccessLevel.NO_ACCESS | AccessLevel.MINIMAL_ACCESS | AccessLevel.GUEST | AccessLevel.REPORTER | AccessLevel.DEVELOPER | AccessLevel.MAINTAINER | AccessLevel.OWNER +const infraGroupName = 'Infra' +const infraGroupPath = 'infra' export const pluginManagedTopic = 'plugin-managed' +interface GitlabMirrorSecret { + MIRROR_USER: string + MIRROR_TOKEN: string +} + +interface RepoSelect { + mirror?: CondensedProjectSchema + target?: CondensedProjectSchema +} +type PendingCommits = Record +}> + +interface CreateEmptyRepositoryArgs { + repoName: string + description?: string +} + +export class GitlabApi extends PluginApi { + protected api: Gitlab + private pendingCommits: PendingCommits = {} + + constructor() { + super() + this.api = getApi() + } + + public async createEmptyRepository({ createFirstCommit, groupId, repoName, description }: CreateEmptyRepositoryArgs & { + createFirstCommit: boolean + groupId: number + }) { + const project = await this.api.Projects.create({ + name: repoName, + path: repoName, + namespaceId: groupId, + description, + }) + // Dépôt tout juste créé, zéro branche => pas d'erreur (filesTree undefined) + if (createFirstCommit) { + await this.api.Commits.create(project.id, 'main', 'ci: 🌱 First commit', []) + } + return project + } + + public async commitCreateOrUpdate( + repoId: number, + fileContent: string, + filePath: string, + branch: string = 'main', + comment: string = 'ci: :robot_face: Update file content', + ): Promise { + let action: CommitAction['action'] = 'create' + + const branches = await this.api.Branches.all(repoId) + if (branches.some(b => b.name === branch)) { + let actualFile: RepositoryFileExpandedSchema | undefined + try { + actualFile = await this.api.RepositoryFiles.show(repoId, filePath, branch) + } catch (_) {} + if (actualFile) { + const newContentDigest = createHash('sha256').update(fileContent).digest('hex') + if (actualFile.content_sha256 === newContentDigest) { + // Already up-to-date + return false + } + // Update needed + action = 'update' + } + } + + const commitAction: CommitAction = { + action, + filePath, + content: fileContent, + } + this.addActions(repoId, branch, comment, [commitAction]) + + return true + } + + /** + * Fonction pour supprimer une liste de fichiers d'un repo + * @param repoId + * @param files + * @param branch + * @param comment + */ + public async commitDelete( + repoId: number, + files: string[], + branch: string = 'main', + comment: string = 'ci: :robot_face: Delete files', + ): Promise { + if (files.length) { + const commitActions: CommitAction[] = files.map((filePath) => { + return { + action: 'delete', + filePath, + } + }) + this.addActions(repoId, branch, comment, commitActions) + return true + } + return false + } + + private addActions(repoId: number, branch: string, comment: string, commitActions: CommitAction[]) { + if (!this.pendingCommits[repoId]) { + this.pendingCommits[repoId] = { branches: {} } + } + if (this.pendingCommits[repoId].branches[branch]) { + this.pendingCommits[repoId].branches[branch].actions.push(...commitActions) + this.pendingCommits[repoId].branches[branch].messages.push(comment) + } else { + this.pendingCommits[repoId].branches[branch] = { + actions: commitActions, + messages: [comment], + } + } + } + + public async commitFiles() { + let filesUpdated: number = 0 + for (const [id, repo] of objectEntries(this.pendingCommits)) { + for (const [branch, details] of objectEntries(repo.branches)) { + const filesNumber = details.actions.length + if (filesNumber) { + filesUpdated += filesNumber + const message = [`ci: :robot_face: Update ${filesNumber} file${filesNumber > 1 ? 's' : ''}`, ...details.messages.filter(m => m)].join('\n') + await this.api.Commits.create(id, branch, message, details.actions) + } + } + } + return filesUpdated + } + + public async listFiles(repoId: number, options: AllRepositoryTreesOptions & PaginationRequestOptions<'keyset'> = {}) { + options.path = options?.path ?? '/' + options.ref = options?.ref ?? 'main' + options.recursive = options?.recursive ?? false + try { + const files: RepositoryTreeSchema[] = await this.api.Repositories.allRepositoryTrees(repoId, options) + // if (depth >= 0) { + // for (const file of files) { + // if (file.type !== 'tree') { + // return [] + // } + // const childrenFiles = await this.listFiles(repoId, { depth: depth - 1, ...options, path: file.path }) + // console.trace({ file, childrenFiles }) + + // files.push(...childrenFiles) + // } + // } + return files + } catch (error) { + const { cause } = error as GitbeakerRequestError + if (cause?.description.includes('Not Found')) { + // Empty repository, with zero commit ==> Zero files + return [] + } else { + throw error + } + } + } + + public async deleteRepository(repoId: number, fullPath: string) { + await this.api.Projects.remove(repoId) // Marks for deletion + return this.api.Projects.remove(repoId, { permanentlyRemove: true, fullPath: `${fullPath}-deletion_scheduled-${repoId}` }) // Effective deletion + } +} + +export class GitlabZoneApi extends GitlabApi { + private infraProjectsByZoneSlug: Map + + constructor() { + super() + this.infraProjectsByZoneSlug = new Map() + } + + // Group Infra + public async getOrCreateInfraGroup(): Promise { + const rootId = await getGroupRootId() + // Get or create projects_root_dir/infra group + const searchResult = await this.api.Groups.search(infraGroupName) + const existingParentGroup = searchResult.find(group => group.parent_id === rootId && group.name === infraGroupName) + return existingParentGroup || await this.api.Groups.create(infraGroupName, infraGroupPath, { + parentId: rootId, + projectCreationLevel: 'maintainer', + subgroupCreationLevel: 'owner', + defaultBranchProtection: 0, + description: 'Group that hosts infrastructure-as-code repositories for all zones (ArgoCD pull targets).', + }) + } + + public async getOrCreateInfraProject(zone: string): Promise { + if (this.infraProjectsByZoneSlug.has(zone)) { + return this.infraProjectsByZoneSlug.get(zone)! + } + const infraGroup = await this.getOrCreateInfraGroup() + // Get or create projects_root_dir/infra/zone + const infraProjects = await this.api.Groups.allProjects(infraGroup.id, { + search: zone, + simple: true, + perPage: 100, + }) + const project: ProjectSchema = infraProjects.find(repo => repo.name === zone) ?? await this.createEmptyRepository({ + repoName: zone, + groupId: infraGroup.id, + description: 'Repository hosting deployment files for this zone.', + createFirstCommit: true, + }, + ) + this.infraProjectsByZoneSlug.set(zone, project) + return project + } +} -/** Class providing project-specific functions to interact with Gitlab API */ export class GitlabProjectApi extends GitlabApi { private project: Project | UniqueRepo private gitlabGroup: GroupSchema & { statistics: GroupStatisticsSchema } | undefined diff --git a/plugins/gitlab/src/gitlab-api.ts b/plugins/gitlab/src/gitlab-api.ts deleted file mode 100644 index edb3daf7b..000000000 --- a/plugins/gitlab/src/gitlab-api.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { createHash } from 'node:crypto' -import { PluginApi } from '@cpn-console/hooks' -import type { CommitAction } from '@gitbeaker/rest' -import type { AllRepositoryTreesOptions, CondensedProjectSchema, Gitlab, PaginationRequestOptions, RepositoryFileExpandedSchema, RepositoryTreeSchema } from '@gitbeaker/core' -import { objectEntries } from '@cpn-console/shared' -import type { GitbeakerRequestError } from '@gitbeaker/requester-utils' -import { getApi } from './utils.js' - -export interface GitlabMirrorSecret { - MIRROR_USER: string - MIRROR_TOKEN: string -} - -export interface RepoSelect { - mirror?: CondensedProjectSchema - target?: CondensedProjectSchema -} -type PendingCommits = Record -}> - -export interface CreateEmptyRepositoryArgs { - repoName: string - description?: string -} - -/** Abstract class providing functions to interact with Gitlab API */ -export abstract class GitlabApi extends PluginApi { - protected api: Gitlab - private pendingCommits: PendingCommits = {} - - constructor() { - super() - this.api = getApi() - } - - public async createEmptyRepository({ createFirstCommit, groupId, repoName, description }: CreateEmptyRepositoryArgs & { - createFirstCommit: boolean - groupId: number - }) { - const project = await this.api.Projects.create({ - name: repoName, - path: repoName, - namespaceId: groupId, - description, - }) - // Dépôt tout juste créé, zéro branche => pas d'erreur (filesTree undefined) - if (createFirstCommit) { - await this.api.Commits.create(project.id, 'main', 'ci: 🌱 First commit', []) - } - return project - } - - public async commitCreateOrUpdate( - repoId: number, - fileContent: string, - filePath: string, - branch: string = 'main', - comment: string = 'ci: :robot_face: Update file content', - ): Promise { - let action: CommitAction['action'] = 'create' - - const branches = await this.api.Branches.all(repoId) - if (branches.some(b => b.name === branch)) { - let actualFile: RepositoryFileExpandedSchema | undefined - try { - actualFile = await this.api.RepositoryFiles.show(repoId, filePath, branch) - } catch (_) {} - if (actualFile) { - const newContentDigest = createHash('sha256').update(fileContent).digest('hex') - if (actualFile.content_sha256 === newContentDigest) { - // Already up-to-date - return false - } - // Update needed - action = 'update' - } - } - - const commitAction: CommitAction = { - action, - filePath, - content: fileContent, - } - this.addActions(repoId, branch, comment, [commitAction]) - - return true - } - - /** - * Fonction pour supprimer une liste de fichiers d'un repo - * @param repoId - * @param files - * @param branch - * @param comment - */ - public async commitDelete( - repoId: number, - files: string[], - branch: string = 'main', - comment: string = 'ci: :robot_face: Delete files', - ): Promise { - if (files.length) { - const commitActions: CommitAction[] = files.map((filePath) => { - return { - action: 'delete', - filePath, - } - }) - this.addActions(repoId, branch, comment, commitActions) - return true - } - return false - } - - private addActions(repoId: number, branch: string, comment: string, commitActions: CommitAction[]) { - if (!this.pendingCommits[repoId]) { - this.pendingCommits[repoId] = { branches: {} } - } - if (this.pendingCommits[repoId].branches[branch]) { - this.pendingCommits[repoId].branches[branch].actions.push(...commitActions) - this.pendingCommits[repoId].branches[branch].messages.push(comment) - } else { - this.pendingCommits[repoId].branches[branch] = { - actions: commitActions, - messages: [comment], - } - } - } - - public async commitFiles() { - let filesUpdated: number = 0 - for (const [id, repo] of objectEntries(this.pendingCommits)) { - for (const [branch, details] of objectEntries(repo.branches)) { - const filesNumber = details.actions.length - if (filesNumber) { - filesUpdated += filesNumber - const message = [`ci: :robot_face: Update ${filesNumber} file${filesNumber > 1 ? 's' : ''}`, ...details.messages.filter(m => m)].join('\n') - await this.api.Commits.create(id, branch, message, details.actions) - } - } - } - return filesUpdated - } - - public async listFiles(repoId: number, options: AllRepositoryTreesOptions & PaginationRequestOptions<'keyset'> = {}) { - options.path = options?.path ?? '/' - options.ref = options?.ref ?? 'main' - options.recursive = options?.recursive ?? false - try { - const files: RepositoryTreeSchema[] = await this.api.Repositories.allRepositoryTrees(repoId, options) - return files - } catch (error) { - const { cause } = error as GitbeakerRequestError - if (cause?.description.includes('Not Found')) { - // Empty repository, with zero commit ==> Zero files - return [] - } else { - throw error - } - } - } - - public async deleteRepository(repoId: number, fullPath: string) { - await this.api.Projects.remove(repoId) // Marks for deletion - return this.api.Projects.remove(repoId, { permanentlyRemove: true, fullPath: `${fullPath}-deletion_scheduled-${repoId}` }) // Effective deletion - } -} diff --git a/plugins/gitlab/src/gitlab-zone-api.ts b/plugins/gitlab/src/gitlab-zone-api.ts deleted file mode 100644 index 40b8493da..000000000 --- a/plugins/gitlab/src/gitlab-zone-api.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { GroupSchema } from '@gitbeaker/rest' -import type { ProjectSchema } from '@gitbeaker/core' -import { getGroupRootId } from './utils.js' -import { GitlabApi } from './gitlab-api.js' - -const infraGroupName = 'Infra' -const infraGroupPath = 'infra' - -/** Class providing zone-specific functions to interact with Gitlab API */ -export class GitlabZoneApi extends GitlabApi { - private infraProjectsByZoneSlug: Map - - constructor() { - super() - this.infraProjectsByZoneSlug = new Map() - } - - // Group Infra - public async getOrCreateInfraGroup(): Promise { - const rootId = await getGroupRootId() - // Get or create projects_root_dir/infra group - const searchResult = await this.api.Groups.search(infraGroupName) - const existingParentGroup = searchResult.find(group => group.parent_id === rootId && group.name === infraGroupName) - return existingParentGroup || await this.api.Groups.create(infraGroupName, infraGroupPath, { - parentId: rootId, - projectCreationLevel: 'maintainer', - subgroupCreationLevel: 'owner', - defaultBranchProtection: 0, - description: 'Group that hosts infrastructure-as-code repositories for all zones (ArgoCD pull targets).', - }) - } - - public async getOrCreateInfraProject(zone: string): Promise { - if (this.infraProjectsByZoneSlug.has(zone)) { - return this.infraProjectsByZoneSlug.get(zone)! - } - const infraGroup = await this.getOrCreateInfraGroup() - // Get or create projects_root_dir/infra/zone - const infraProjects = await this.api.Groups.allProjects(infraGroup.id, { - search: zone, - simple: true, - perPage: 100, - }) - const project: ProjectSchema = infraProjects.find(repo => repo.name === zone) ?? await this.createEmptyRepository({ - repoName: zone, - groupId: infraGroup.id, - description: 'Repository hosting deployment files for this zone.', - createFirstCommit: true, - }, - ) - this.infraProjectsByZoneSlug.set(zone, project) - return project - } -} diff --git a/plugins/gitlab/src/index.ts b/plugins/gitlab/src/index.ts index 2a0c119a6..ef72f7dd5 100644 --- a/plugins/gitlab/src/index.ts +++ b/plugins/gitlab/src/index.ts @@ -12,8 +12,7 @@ import { import { getOrCreateGroupRoot } from './utils.js' import infos from './infos.js' import monitor from './monitor.js' -import { GitlabProjectApi } from './gitlab-project-api.js' -import { GitlabZoneApi } from './gitlab-zone-api.js' +import { GitlabProjectApi, GitlabZoneApi } from './class.js' const onlyApi = { api: (project: Project | UniqueRepo) => new GitlabProjectApi(project) } diff --git a/plugins/gitlab/src/members.ts b/plugins/gitlab/src/members.ts index 33dd1c035..ec77ee45d 100644 --- a/plugins/gitlab/src/members.ts +++ b/plugins/gitlab/src/members.ts @@ -1,6 +1,6 @@ import type { Project } from '@cpn-console/hooks' import type { UserSchema } from '@gitbeaker/core' -import type { GitlabProjectApi } from './gitlab-project-api.js' +import type { GitlabProjectApi } from './class.js' import { upsertUser } from './user.js' export async function ensureMembers(gitlabApi: GitlabProjectApi, project: Project) { diff --git a/plugins/gitlab/src/repositories.ts b/plugins/gitlab/src/repositories.ts index b94656186..3217eafb9 100644 --- a/plugins/gitlab/src/repositories.ts +++ b/plugins/gitlab/src/repositories.ts @@ -2,7 +2,7 @@ import type { Project, Repository } from '@cpn-console/hooks' import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' import type { CondensedProjectSchema, ProjectSchema } from '@gitbeaker/rest' import { shallowEqual } from '@cpn-console/shared' -import { type GitlabProjectApi, pluginManagedTopic } from './gitlab-project-api.js' +import { type GitlabProjectApi, pluginManagedTopic } from './class.js' import { provisionMirror } from './project.js' import { infraAppsRepoName, internalMirrorRepoName } from './utils.js' From dbc4f5df57fa2b63eb37f3efffd4c0b8c49d9f79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20TR=C3=89BEL=20=28Perso=29?= Date: Tue, 20 Jan 2026 16:30:46 +0100 Subject: [PATCH 4/4] Revert "chore(gitlab): purge useless file" This reverts commit 263715641844bb7d766730a3de9240531c9f26e3. --- plugins/gitlab/src/class.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/gitlab/src/class.ts b/plugins/gitlab/src/class.ts index d76e03397..ce8d9e808 100644 --- a/plugins/gitlab/src/class.ts +++ b/plugins/gitlab/src/class.ts @@ -41,13 +41,15 @@ export class GitlabApi extends PluginApi { this.api = getApi() } - public async createEmptyRepository({ createFirstCommit, groupId, repoName, description }: CreateEmptyRepositoryArgs & { + public async createEmptyRepository({ createFirstCommit, groupId, repoName, description, ciConfigPath }: CreateEmptyRepositoryArgs & { createFirstCommit: boolean groupId: number + ciConfigPath?: string }) { const project = await this.api.Projects.create({ name: repoName, path: repoName, + ciConfigPath, namespaceId: groupId, description, }) @@ -411,6 +413,7 @@ export class GitlabProjectApi extends GitlabApi { repoName, groupId: namespaceId, description, + ciConfigPath: clone ? '.gitlab-ci-dso.yml' : undefined, createFirstCommit: !clone, }) }