Files
gitops/ts/classes/syncmanager.ts
Juergen Kunz 44ac2e430f feat(sync): add parallel sync, fix default branch and protected branch issues, add group avatars option
- Set sync concurrency to 10 for faster parallel repo syncing
- Three-phase push: push refs, sync default_branch via API, then push with --prune
- Unprotect stale protected branches on target before pruning
- Handle group visibility 400 errors gracefully (skip visibility, sync description only)
- Add useGroupAvatarsForProjects option: projects without avatars inherit group avatar
- Upgrade @apiclient.xyz/gitlab to v2.3.0 (getProtectedBranches, unprotectBranch)
2026-02-28 17:39:28 +00:00

1723 lines
65 KiB
TypeScript

import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import type * as interfaces from '../../ts_interfaces/index.ts';
import type { ConnectionManager } from './connectionmanager.ts';
import type { ActionLog } from './actionlog.ts';
import type { StorageManager } from '../storage/index.ts';
const SYNC_PREFIX = '/sync/';
const SYNC_STATUS_PREFIX = '/sync-status/';
/**
* Manages sync configurations and executes periodic git mirror operations.
* Each sync config defines a source → target connection mapping.
* Repos are mirrored using bare git repos stored on disk.
*/
export class SyncManager {
private configs: interfaces.data.ISyncConfig[] = [];
private timers: Map<string, number> = new Map();
private runningSync: Set<string> = new Set();
private syncedGroupMeta: Set<string> = new Set();
private currentSyncConfig: interfaces.data.ISyncConfig | null = null;
constructor(
private storageManager: StorageManager,
private connectionManager: ConnectionManager,
private actionLog: ActionLog,
private mirrorsPath: string,
) {}
async init(): Promise<void> {
await this.loadConfigs();
for (const config of this.configs) {
if (config.status === 'active') {
this.startTimer(config);
}
}
if (this.configs.length > 0) {
logger.info(`SyncManager loaded ${this.configs.length} sync config(s)`);
}
}
async stop(): Promise<void> {
for (const [_id, timer] of this.timers) {
clearInterval(timer);
}
this.timers.clear();
}
// ============================================================================
// CRUD
// ============================================================================
getConfigs(): interfaces.data.ISyncConfig[] {
return [...this.configs];
}
getConfig(id: string): interfaces.data.ISyncConfig | undefined {
return this.configs.find((c) => c.id === id);
}
async createConfig(data: {
name: string;
sourceConnectionId: string;
targetConnectionId: string;
targetGroupOffset?: string;
intervalMinutes?: number;
enforceDelete?: boolean;
enforceGroupDelete?: boolean;
addMirrorHint?: boolean;
useGroupAvatarsForProjects?: boolean;
}): Promise<interfaces.data.ISyncConfig> {
const config: interfaces.data.ISyncConfig = {
id: crypto.randomUUID(),
name: data.name,
sourceConnectionId: data.sourceConnectionId,
targetConnectionId: data.targetConnectionId,
targetGroupOffset: data.targetGroupOffset,
intervalMinutes: data.intervalMinutes || 5,
status: 'paused',
lastSyncAt: 0,
reposSynced: 0,
enforceDelete: data.enforceDelete ?? false,
enforceGroupDelete: data.enforceGroupDelete ?? false,
addMirrorHint: data.addMirrorHint ?? false,
useGroupAvatarsForProjects: data.useGroupAvatarsForProjects ?? false,
createdAt: Date.now(),
};
this.validateSyncConfig(config);
this.configs.push(config);
await this.persistConfig(config);
logger.success(`Sync config created (paused): ${config.name}`);
return config;
}
async updateConfig(
id: string,
updates: { name?: string; targetGroupOffset?: string; intervalMinutes?: number; enforceDelete?: boolean; enforceGroupDelete?: boolean; addMirrorHint?: boolean; useGroupAvatarsForProjects?: boolean },
): Promise<interfaces.data.ISyncConfig> {
const config = this.configs.find((c) => c.id === id);
if (!config) throw new Error(`Sync config not found: ${id}`);
if (updates.name) config.name = updates.name;
if (updates.intervalMinutes) config.intervalMinutes = updates.intervalMinutes;
if (updates.enforceDelete !== undefined) config.enforceDelete = updates.enforceDelete;
if (updates.enforceGroupDelete !== undefined) config.enforceGroupDelete = updates.enforceGroupDelete;
if (updates.addMirrorHint !== undefined) config.addMirrorHint = updates.addMirrorHint;
if (updates.useGroupAvatarsForProjects !== undefined) config.useGroupAvatarsForProjects = updates.useGroupAvatarsForProjects;
if (updates.targetGroupOffset !== undefined) config.targetGroupOffset = updates.targetGroupOffset;
this.validateSyncConfig(config);
await this.persistConfig(config);
// Restart timer with new interval
if (config.status === 'active') {
this.startTimer(config);
}
return config;
}
async deleteConfig(id: string): Promise<void> {
const idx = this.configs.findIndex((c) => c.id === id);
if (idx === -1) throw new Error(`Sync config not found: ${id}`);
this.stopTimer(id);
this.configs.splice(idx, 1);
await this.storageManager.delete(`${SYNC_PREFIX}${id}.json`);
// Clean up repo statuses
const statusKeys = await this.storageManager.list(`${SYNC_STATUS_PREFIX}${id}/`);
for (const key of statusKeys) {
await this.storageManager.delete(key);
}
// Clean up mirror directory
const mirrorDir = plugins.path.join(this.mirrorsPath, id);
try {
await Deno.remove(mirrorDir, { recursive: true });
} catch {
// Directory may not exist
}
logger.info(`Sync config deleted: ${id}`);
}
async pauseConfig(id: string, paused: boolean): Promise<interfaces.data.ISyncConfig> {
const config = this.configs.find((c) => c.id === id);
if (!config) throw new Error(`Sync config not found: ${id}`);
config.status = paused ? 'paused' : 'active';
await this.persistConfig(config);
if (paused) {
this.stopTimer(id);
} else {
this.startTimer(config);
}
logger.info(`Sync config ${paused ? 'paused' : 'resumed'}: ${config.name}`);
return config;
}
async getRepoStatuses(syncConfigId: string): Promise<interfaces.data.ISyncRepoStatus[]> {
const keys = await this.storageManager.list(`${SYNC_STATUS_PREFIX}${syncConfigId}/`);
const statuses: interfaces.data.ISyncRepoStatus[] = [];
for (const key of keys) {
const status = await this.storageManager.getJSON<interfaces.data.ISyncRepoStatus>(key);
if (status) statuses.push(status);
}
return statuses;
}
// ============================================================================
// Preview
// ============================================================================
async previewSync(configId: string): Promise<{
mappings: Array<{ sourceFullPath: string; targetFullPath: string }>;
deletions: string[];
groupDeletions: string[];
}> {
const config = this.configs.find((c) => c.id === configId);
if (!config) throw new Error(`Sync config not found: ${configId}`);
logger.syncLog('info', `Preview started for "${config.name}"`, 'preview');
const sourceConn = this.connectionManager.getConnection(config.sourceConnectionId);
const targetConn = this.connectionManager.getConnection(config.targetConnectionId);
if (!sourceConn) throw new Error(`Source connection not found: ${config.sourceConnectionId}`);
if (!targetConn) throw new Error(`Target connection not found: ${config.targetConnectionId}`);
const sourceProvider = this.connectionManager.getProvider(config.sourceConnectionId);
const targetProvider = this.connectionManager.getProvider(config.targetConnectionId);
logger.syncLog('info', `Fetching source projects from "${sourceConn.name}"...`, 'preview');
const allProjects = await sourceProvider.getProjects();
const projects = allProjects.filter(p => !this.isObsoletePath(p.fullPath));
logger.syncLog('info', `Found ${projects.length} source projects (${allProjects.length - projects.length} obsolete excluded)`, 'preview');
const mappings = projects.map((project) => {
const targetFullPath = this.computeTargetFullPath(
project.fullPath, sourceConn.groupFilter, config.targetGroupOffset,
);
return { sourceFullPath: project.fullPath, targetFullPath };
});
// Compute repo deletions when enforce-delete is enabled
let deletions: string[] = [];
if (config.enforceDelete) {
logger.syncLog('info', 'Computing repo deletions (enforce-delete enabled)...', 'preview');
const expectedTargetPaths = new Set(
mappings.map((m) => m.targetFullPath.toLowerCase()),
);
const scopePrefix = config.targetGroupOffset;
const targetProjects = await targetProvider.getProjects();
for (const tp of targetProjects) {
if (this.isObsoletePath(tp.fullPath)) continue;
if (scopePrefix && !tp.fullPath.toLowerCase().startsWith(scopePrefix.toLowerCase() + '/')) {
continue;
}
if (!expectedTargetPaths.has(tp.fullPath.toLowerCase())) {
deletions.push(tp.fullPath);
}
}
}
// Compute group deletions when enforce-group-delete is enabled
let groupDeletions: string[] = [];
if (config.enforceGroupDelete) {
logger.syncLog('info', 'Computing group deletions (enforce-group-delete enabled)...', 'preview');
const sourceGroups = await sourceProvider.getGroups();
const expectedTargetGroups = new Set<string>();
for (const sg of sourceGroups) {
const targetPath = this.computeTargetFullPath(
sg.fullPath, sourceConn.groupFilter, config.targetGroupOffset,
);
expectedTargetGroups.add(targetPath.toLowerCase());
}
// Also include the offset itself and the obsolete group
if (config.targetGroupOffset) {
expectedTargetGroups.add(config.targetGroupOffset.toLowerCase());
expectedTargetGroups.add(`${config.targetGroupOffset}/obsolete`.toLowerCase());
}
const targetGroups = await targetProvider.getGroups();
const scopePrefix = config.targetGroupOffset;
for (const tg of targetGroups) {
if (this.isObsoletePath(tg.fullPath)) continue;
if (scopePrefix && !tg.fullPath.toLowerCase().startsWith(scopePrefix.toLowerCase() + '/')) {
continue;
}
if (!expectedTargetGroups.has(tg.fullPath.toLowerCase())) {
groupDeletions.push(tg.fullPath);
}
}
}
logger.syncLog('success', `Preview complete: ${mappings.length} mappings, ${deletions.length} deletions, ${groupDeletions.length} group deletions`, 'preview');
return { mappings, deletions, groupDeletions };
}
// ============================================================================
// Sync Engine
// ============================================================================
async executeSync(configId: string, force = false): Promise<void> {
if (this.runningSync.has(configId)) {
logger.warn(`Sync ${configId} already running, skipping`);
return;
}
const config = this.configs.find((c) => c.id === configId);
if (!config) return;
if (config.status === 'paused' && !force) return;
this.runningSync.add(configId);
this.syncedGroupMeta.clear();
this.currentSyncConfig = config;
const startTime = Date.now();
logger.syncLog('info', `Sync started for "${config.name}"`, 'sync');
try {
const sourceConn = this.connectionManager.getConnection(config.sourceConnectionId);
const targetConn = this.connectionManager.getConnection(config.targetConnectionId);
if (!sourceConn) throw new Error(`Source connection not found: ${config.sourceConnectionId}`);
if (!targetConn) throw new Error(`Target connection not found: ${config.targetConnectionId}`);
this.validateSyncConfig(config);
// Get all projects from source
const sourceProvider = this.connectionManager.getProvider(config.sourceConnectionId);
logger.syncLog('info', `Fetching source projects from "${sourceConn.name}"...`, 'api');
const allProjects = await sourceProvider.getProjects();
const projects = allProjects.filter(p => !this.isObsoletePath(p.fullPath));
logger.syncLog('info', `Found ${projects.length} source projects (${allProjects.length - projects.length} obsolete excluded)`, 'api');
let synced = 0;
const CONCURRENCY = 10;
for (let i = 0; i < projects.length; i += CONCURRENCY) {
const batch = projects.slice(i, i + CONCURRENCY);
await Promise.all(batch.map(async (project) => {
try {
logger.syncLog('info', `Syncing ${project.fullPath}...`, 'git');
await this.syncRepo(config, project, sourceConn, targetConn);
synced++;
await this.updateRepoStatus(config.id, project.fullPath, {
status: 'synced',
lastSyncAt: Date.now(),
lastSyncError: undefined,
});
logger.syncLog('success', `Synced ${project.fullPath}`, 'git');
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
await this.updateRepoStatus(config.id, project.fullPath, {
status: 'error',
lastSyncError: errMsg,
lastSyncAt: Date.now(),
});
logger.syncLog('error', `Sync failed for ${project.fullPath}: ${errMsg}`, 'git');
}
}));
}
// Enforce deletion: move stale target repos to obsolete
if (config.enforceDelete) {
logger.syncLog('info', 'Checking for stale target repos...', 'sync');
await this.enforceDeleteStaleRepos(config, projects, sourceConn, targetConn);
}
// Enforce group deletion: move stale target groups to obsolete
if (config.enforceGroupDelete) {
logger.syncLog('info', 'Checking for stale target groups...', 'sync');
await this.enforceDeleteStaleGroups(config, sourceConn, targetConn);
}
config.lastSyncAt = Date.now();
config.reposSynced = synced;
config.lastSyncDurationMs = Date.now() - startTime;
config.lastSyncError = undefined;
if (config.status === 'error') config.status = 'active';
await this.persistConfig(config);
logger.syncLog('success', `Sync complete for "${config.name}": ${synced}/${projects.length} repos in ${config.lastSyncDurationMs}ms`, 'sync');
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
config.lastSyncError = errMsg;
config.lastSyncAt = Date.now();
config.lastSyncDurationMs = Date.now() - startTime;
config.status = 'error';
await this.persistConfig(config);
logger.syncLog('error', `Sync config "${config.name}" failed: ${errMsg}`, 'sync');
} finally {
this.runningSync.delete(configId);
}
}
// ============================================================================
// Single Repo Sync
// ============================================================================
private async syncRepo(
config: interfaces.data.ISyncConfig,
project: interfaces.data.IProject,
sourceConn: interfaces.data.IProviderConnection,
targetConn: interfaces.data.IProviderConnection,
): Promise<void> {
const targetFullPath = this.computeTargetFullPath(
project.fullPath, sourceConn.groupFilter, config.targetGroupOffset,
);
// Build authenticated git URLs
const sourceUrl = this.buildAuthUrl(sourceConn, project.fullPath);
const targetUrl = this.buildAuthUrl(targetConn, targetFullPath);
// Mirror directory for this repo
const mirrorDir = plugins.path.join(
this.mirrorsPath,
config.id,
this.sanitizePath(project.fullPath),
);
// Ensure target group/project hierarchy exists
await this.ensureTargetExists(targetConn, targetFullPath, project, sourceConn, sourceConn.groupFilter, config.targetGroupOffset);
// Clone or fetch from source
try {
const exists = await this.dirExists(mirrorDir);
if (!exists) {
await Deno.mkdir(mirrorDir, { recursive: true });
await this.runGit(['clone', '--bare', sourceUrl, '.'], mirrorDir);
} else {
// Update source remote URL in case it changed
try {
await this.runGit(['remote', 'set-url', 'origin', sourceUrl], mirrorDir);
} catch {
// Ignore errors
}
await this.runGit(['fetch', '--prune', 'origin'], mirrorDir);
}
} catch (err: any) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("couldn't find remote ref HEAD")) {
logger.syncLog('warn', `Skipping empty repo ${project.fullPath} (no HEAD ref)`, 'git');
return;
}
throw err;
}
// Set up target remote and push
const remotes = await this.runGit(['remote'], mirrorDir);
if (!remotes.includes('target')) {
await this.runGit(['remote', 'add', 'target', targetUrl], mirrorDir);
} else {
await this.runGit(['remote', 'set-url', 'target', targetUrl], mirrorDir);
}
// Check for unrelated history before mirror-pushing
const isUnrelated = await this.checkUnrelatedHistory(mirrorDir);
if (isUnrelated) {
logger.syncLog('warn', `Target "${targetFullPath}" has unrelated history — moving to obsolete`, 'git');
await this.moveToObsolete(targetConn, targetFullPath, config.targetGroupOffset);
// Re-create fresh target
await this.ensureTargetExists(targetConn, targetFullPath, project, sourceConn, sourceConn.groupFilter, config.targetGroupOffset);
this.actionLog.append({
actionType: 'obsolete',
entityType: 'sync',
entityId: config.id,
entityName: config.name,
details: `Moved unrelated repo "${targetFullPath}" to obsolete`,
username: 'system',
});
}
// Phase 1: push all refs without pruning (ensures target has all source branches)
await this.runGit([
'push', 'target',
'+refs/heads/*:refs/heads/*',
'+refs/tags/*:refs/tags/*',
], mirrorDir);
// Phase 2: sync default_branch now that all branches exist on target
await this.syncDefaultBranchBeforePush(sourceConn, targetConn, project.fullPath, targetFullPath);
// Phase 2b: unprotect stale branches on target so --prune can delete them
await this.unprotectStaleBranches(targetConn, targetFullPath, mirrorDir);
// Phase 3: push with --prune to remove stale branches (safe now that default_branch is correct)
await this.runGit([
'push', 'target',
'+refs/heads/*:refs/heads/*',
'+refs/tags/*:refs/tags/*',
'--prune',
], mirrorDir);
// Sync project metadata (description, visibility, topics, default_branch, avatar)
await this.syncProjectMetadata(config, sourceConn, targetConn, project.fullPath, targetFullPath);
}
// ============================================================================
// Target Hierarchy Creation
// ============================================================================
private async ensureTargetExists(
targetConn: interfaces.data.IProviderConnection,
targetFullPath: string,
sourceProject: interfaces.data.IProject,
sourceConn?: interfaces.data.IProviderConnection,
sourceGroupFilter?: string,
targetGroupOffset?: string,
): Promise<void> {
const segments = targetFullPath.split('/');
const projectName = segments.pop()!;
const groupSegments = segments;
if (targetConn.providerType === 'gitlab') {
await this.ensureGitLabTarget(targetConn, groupSegments, projectName, sourceProject, sourceConn, sourceGroupFilter, targetGroupOffset);
} else {
await this.ensureGiteaTarget(targetConn, groupSegments, projectName, sourceProject, sourceConn, sourceGroupFilter, targetGroupOffset);
}
}
private async ensureGitLabTarget(
conn: interfaces.data.IProviderConnection,
groupSegments: string[],
projectName: string,
sourceProject: interfaces.data.IProject,
sourceConn?: interfaces.data.IProviderConnection,
sourceGroupFilter?: string,
targetGroupOffset?: string,
): Promise<void> {
const client = new plugins.gitlabClient.GitLabClient(conn.baseUrl, conn.token);
// Walk group hierarchy top-down, creating each if needed
let parentId: number | undefined = undefined;
let currentPath = '';
for (const segment of groupSegments) {
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
try {
const group = await client.getGroupByPath(currentPath);
parentId = group.id;
} catch {
// Group doesn't exist — create it
try {
const newGroup = await client.createGroup(segment, segment, parentId);
parentId = newGroup.id;
logger.info(`Created GitLab group: ${currentPath}`);
} catch (createErr: any) {
// 409 = already exists (race condition), try fetching again
if (String(createErr).includes('409') || String(createErr).includes('already')) {
const group = await client.getGroupByPath(currentPath);
parentId = group.id;
} else {
throw createErr;
}
}
}
// Sync group metadata from source (once per group per sync cycle)
if (sourceConn && !this.syncedGroupMeta.has(currentPath)) {
const sourceGroupPath = this.reverseTargetGroupPath(currentPath, sourceGroupFilter, targetGroupOffset);
if (sourceGroupPath) {
this.syncedGroupMeta.add(currentPath);
await this.syncGroupMetadata(sourceConn, conn, sourceGroupPath, currentPath);
}
}
}
// Create the project if it doesn't exist
const projectPath = groupSegments.length > 0
? `${groupSegments.join('/')}/${projectName}`
: projectName;
try {
// Check if project exists by path
await client.getGroupByPath(projectPath);
// If this succeeds, it's actually a group, not a project... unlikely but handle
} catch {
// Project doesn't exist as a group path; try creating it
try {
await client.createProject(projectName, {
path: projectName,
namespaceId: parentId,
description: sourceProject.description,
visibility: sourceProject.visibility || 'private',
});
logger.info(`Created GitLab project: ${projectPath}`);
} catch (createErr: any) {
// Already exists is fine
if (!String(createErr).includes('409') && !String(createErr).includes('already been taken')) {
throw createErr;
}
}
}
}
private async ensureGiteaTarget(
conn: interfaces.data.IProviderConnection,
groupSegments: string[],
projectName: string,
sourceProject: interfaces.data.IProject,
sourceConn?: interfaces.data.IProviderConnection,
sourceGroupFilter?: string,
targetGroupOffset?: string,
): Promise<void> {
const client = new plugins.giteaClient.GiteaClient(conn.baseUrl, conn.token);
// Gitea has flat orgs (no nesting). Use the first segment as org name.
// If there are nested segments, join them into the repo name.
const orgName = groupSegments[0] || conn.groupFilter || 'default';
const repoName = groupSegments.length > 1
? [...groupSegments.slice(1), projectName].join('-')
: projectName;
// Ensure org exists
try {
await client.getOrg(orgName);
} catch {
try {
await client.createOrg(orgName, { visibility: 'public' });
logger.info(`Created Gitea org: ${orgName}`);
} catch (createErr: any) {
if (!String(createErr).includes('409') && !String(createErr).includes('already')) {
throw createErr;
}
}
}
// Sync org metadata from source if we have the source connection
if (sourceConn && groupSegments[0] && !this.syncedGroupMeta.has(groupSegments[0])) {
const sourceGroupPath = this.reverseTargetGroupPath(groupSegments[0], sourceGroupFilter, targetGroupOffset);
if (sourceGroupPath) {
this.syncedGroupMeta.add(groupSegments[0]);
await this.syncGroupMetadata(sourceConn, conn, sourceGroupPath, groupSegments[0]);
}
}
// Ensure repo exists in org
try {
await client.createOrgRepo(orgName, repoName, {
description: sourceProject.description,
private: sourceProject.visibility !== 'public',
});
logger.info(`Created Gitea repo: ${orgName}/${repoName}`);
} catch (createErr: any) {
// Already exists is fine
if (!String(createErr).includes('409') && !String(createErr).includes('already')) {
throw createErr;
}
}
}
// ============================================================================
// Enforce Delete — remove stale target repos
// ============================================================================
private async enforceDeleteStaleRepos(
config: interfaces.data.ISyncConfig,
sourceProjects: interfaces.data.IProject[],
sourceConn: interfaces.data.IProviderConnection,
targetConn: interfaces.data.IProviderConnection,
): Promise<void> {
// Build set of expected target fullPaths from source
const expectedTargetPaths = new Set<string>();
for (const project of sourceProjects) {
const targetFullPath = this.computeTargetFullPath(
project.fullPath, sourceConn.groupFilter, config.targetGroupOffset,
);
expectedTargetPaths.add(targetFullPath.toLowerCase());
}
// Scope prefix — only delete repos under this prefix
const scopePrefix = config.targetGroupOffset;
// List all target projects (filtered by connection's groupFilter)
const targetProvider = this.connectionManager.getProvider(config.targetConnectionId);
const targetProjects = await targetProvider.getProjects();
// Delete target projects not in the expected set, scoped to the prefix
for (const targetProject of targetProjects) {
if (this.isObsoletePath(targetProject.fullPath)) continue;
// Skip repos outside our managed prefix
if (scopePrefix && !targetProject.fullPath.toLowerCase().startsWith(scopePrefix.toLowerCase() + '/')) {
continue;
}
if (!expectedTargetPaths.has(targetProject.fullPath.toLowerCase())) {
try {
await this.moveToObsolete(targetConn, targetProject.fullPath, config.targetGroupOffset);
logger.syncLog('warn', `Moved stale target repo "${targetProject.fullPath}" to obsolete`, 'sync');
this.actionLog.append({
actionType: 'obsolete',
entityType: 'sync',
entityId: config.id,
entityName: config.name,
details: `Moved stale target repo "${targetProject.fullPath}" to obsolete`,
username: 'system',
});
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
logger.syncLog('error', `Enforce-delete failed for "${targetProject.fullPath}": ${errMsg}`, 'sync');
}
}
}
}
// ============================================================================
// Enforce Delete — remove stale target groups/orgs
// ============================================================================
private async enforceDeleteStaleGroups(
config: interfaces.data.ISyncConfig,
sourceConn: interfaces.data.IProviderConnection,
targetConn: interfaces.data.IProviderConnection,
): Promise<void> {
const sourceProvider = this.connectionManager.getProvider(config.sourceConnectionId);
const targetProvider = this.connectionManager.getProvider(config.targetConnectionId);
// Build expected target group paths from source groups
const sourceGroups = await sourceProvider.getGroups();
const expectedTargetGroups = new Set<string>();
for (const sg of sourceGroups) {
const targetPath = this.computeTargetFullPath(
sg.fullPath, sourceConn.groupFilter, config.targetGroupOffset,
);
expectedTargetGroups.add(targetPath.toLowerCase());
}
// Always keep the offset itself and the obsolete group
if (config.targetGroupOffset) {
expectedTargetGroups.add(config.targetGroupOffset.toLowerCase());
expectedTargetGroups.add(`${config.targetGroupOffset}/obsolete`.toLowerCase());
}
// Find stale groups
const targetGroups = await targetProvider.getGroups();
const scopePrefix = config.targetGroupOffset;
const staleGroups: interfaces.data.IGroup[] = [];
for (const tg of targetGroups) {
if (this.isObsoletePath(tg.fullPath)) continue;
if (scopePrefix && !tg.fullPath.toLowerCase().startsWith(scopePrefix.toLowerCase() + '/')) {
continue;
}
if (!expectedTargetGroups.has(tg.fullPath.toLowerCase())) {
staleGroups.push(tg);
}
}
if (staleGroups.length === 0) return;
// Sort by path depth (shallowest first) to move top-level groups first
staleGroups.sort((a, b) => {
const depthA = a.fullPath.split('/').length;
const depthB = b.fullPath.split('/').length;
return depthA - depthB;
});
// Track moved prefixes to skip children already moved with their parent
const movedPrefixes: string[] = [];
for (const group of staleGroups) {
// Skip if a parent was already moved (GitLab moves children along)
const isChildOfMoved = movedPrefixes.some(
(prefix) => group.fullPath.toLowerCase().startsWith(prefix.toLowerCase() + '/'),
);
if (isChildOfMoved && targetConn.providerType === 'gitlab') continue;
try {
if (targetConn.providerType === 'gitlab') {
await this.moveGroupToObsolete(targetConn, group.fullPath, config.targetGroupOffset);
} else {
await this.moveGiteaOrgToObsolete(targetConn, group.fullPath, config.targetGroupOffset);
}
movedPrefixes.push(group.fullPath);
logger.syncLog('warn', `Moved stale group "${group.fullPath}" to obsolete`, 'sync');
this.actionLog.append({
actionType: 'obsolete',
entityType: 'sync',
entityId: config.id,
entityName: config.name,
details: `Moved stale target group "${group.fullPath}" to obsolete`,
username: 'system',
});
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
logger.syncLog('error', `Enforce-group-delete failed for "${group.fullPath}": ${errMsg}`, 'sync');
}
}
}
/**
* Move a GitLab group to the obsolete group with a unique suffix.
* Transfers the entire group (including subgroups and projects).
*/
private async moveGroupToObsolete(
targetConn: interfaces.data.IProviderConnection,
groupFullPath: string,
basePath?: string,
): Promise<void> {
const suffix = this.generateSuffix();
const obsoleteTarget = await this.ensureObsoleteGroup(targetConn, basePath);
if (obsoleteTarget.type !== 'gitlab') return;
// Get group by path
const group = await this.rawApiCall(
targetConn, 'GET',
`/api/v4/groups/${encodeURIComponent(groupFullPath)}`,
);
// Transfer group to be a child of the obsolete group
await this.rawApiCall(
targetConn, 'POST',
`/api/v4/groups/${group.id}/transfer`,
{ group_id: obsoleteTarget.groupId },
);
// Rename with suffix + set private
const originalPath = groupFullPath.split('/').pop()!;
await this.rawApiCall(
targetConn, 'PUT',
`/api/v4/groups/${group.id}`,
{ name: `${originalPath}-${suffix}`, path: `${originalPath}-${suffix}`, visibility: 'private' },
);
logger.info(`Moved GitLab group "${groupFullPath}" to obsolete as "${originalPath}-${suffix}"`);
}
/**
* Move all repos in a Gitea org to obsolete, then delete the empty org.
* Gitea orgs can't be transferred, so we move repos individually.
*/
private async moveGiteaOrgToObsolete(
targetConn: interfaces.data.IProviderConnection,
orgName: string,
basePath?: string,
): Promise<void> {
// List all repos in the stale org (auto-paginate)
const allRepos: any[] = [];
let page = 1;
const perPage = 50;
while (true) {
const repos = await this.rawApiCall(
targetConn, 'GET',
`/api/v1/orgs/${encodeURIComponent(orgName)}/repos?page=${page}&limit=${perPage}`,
);
const repoList = Array.isArray(repos) ? repos : [];
allRepos.push(...repoList);
if (repoList.length < perPage) break;
page++;
}
// Move each repo to obsolete
for (const repo of allRepos) {
try {
await this.moveToObsolete(targetConn, `${orgName}/${repo.name}`, basePath);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
logger.error(`Failed to move repo "${orgName}/${repo.name}" to obsolete: ${errMsg}`);
}
}
// Delete the now-empty org
try {
await this.rawApiCall(
targetConn, 'DELETE',
`/api/v1/orgs/${encodeURIComponent(orgName)}`,
);
logger.info(`Deleted empty Gitea org: ${orgName}`);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
logger.error(`Failed to delete empty Gitea org "${orgName}": ${errMsg}`);
}
}
// ============================================================================
// Helpers
// ============================================================================
/**
* Returns true if the given full path belongs to an obsolete namespace.
* Matches path segments named "obsolete" or ending with "-obsolete".
*/
private isObsoletePath(fullPath: string): boolean {
const segments = fullPath.toLowerCase().split('/');
return segments.some(s => s === 'obsolete' || s.endsWith('-obsolete'));
}
private buildAuthUrl(conn: interfaces.data.IProviderConnection, repoPath: string): string {
const url = new URL(conn.baseUrl);
if (conn.providerType === 'gitlab') {
return `${url.protocol}//oauth2:${conn.token}@${url.host}/${repoPath}.git`;
} else {
return `${url.protocol}//gitea-token:${conn.token}@${url.host}/${repoPath}.git`;
}
}
private computeRelativePath(fullPath: string, groupFilter?: string): string {
if (!groupFilter) return fullPath;
if (fullPath.startsWith(groupFilter + '/')) {
return fullPath.substring(groupFilter.length + 1);
}
return fullPath;
}
/**
* Reverse-map a target group path to the corresponding source group path.
* Returns null if the target path is part of the offset itself (not a source-derived group).
*/
private reverseTargetGroupPath(
targetGroupPath: string,
sourceGroupFilter?: string,
targetGroupOffset?: string,
): string | null {
let relativePath = targetGroupPath;
// Strip the target offset prefix
if (targetGroupOffset) {
if (targetGroupPath === targetGroupOffset) {
// This IS the offset group itself, not a source-derived group
return null;
}
if (targetGroupPath.startsWith(targetGroupOffset + '/')) {
relativePath = targetGroupPath.substring(targetGroupOffset.length + 1);
} else {
// Target path is not under the offset — can't reverse-map
return null;
}
}
// Re-add the source group filter prefix
if (sourceGroupFilter) {
return `${sourceGroupFilter}/${relativePath}`;
}
return relativePath;
}
private computeTargetFullPath(
sourceFullPath: string,
sourceGroupFilter?: string,
targetGroupOffset?: string,
): string {
const relativePath = this.computeRelativePath(sourceFullPath, sourceGroupFilter);
return targetGroupOffset ? `${targetGroupOffset}/${relativePath}` : relativePath;
}
/**
* Validates that the sync config's targetGroupOffset is reachable
* by the target connection's groupFilter. Throws when enforceDelete
* is on and the offset is outside the filter scope.
*/
private validateSyncConfig(config: {
targetConnectionId: string;
targetGroupOffset?: string;
enforceDelete: boolean;
}): void {
if (!config.targetGroupOffset) return;
const targetConn = this.connectionManager.getConnection(config.targetConnectionId);
if (!targetConn?.groupFilter) return;
const offset = config.targetGroupOffset.toLowerCase();
const filter = targetConn.groupFilter.toLowerCase();
const inScope = offset === filter || offset.startsWith(filter + '/');
if (!inScope && config.enforceDelete) {
throw new Error(
`Target group offset "${config.targetGroupOffset}" is outside target connection's ` +
`group filter "${targetConn.groupFilter}". With enforce-delete enabled, the sync ` +
`engine cannot list repos under the offset. Either change the offset to be within ` +
`"${targetConn.groupFilter}/...", remove the target's group filter, or disable enforce-delete.`
);
}
if (!inScope) {
logger.warn(
`Target group offset "${config.targetGroupOffset}" is outside target connection's ` +
`group filter "${targetConn.groupFilter}". Git pushes will work but repos won't ` +
`appear in the target connection's listings.`
);
}
}
// ============================================================================
// Metadata Sync
// ============================================================================
/**
* Download binary data (e.g. avatar image) with provider auth headers.
* Returns null on 404 or error.
*/
private async rawBinaryFetch(
conn: interfaces.data.IProviderConnection,
url: string,
): Promise<Uint8Array | null> {
try {
const headers: Record<string, string> = {};
if (conn.providerType === 'gitlab') {
headers['PRIVATE-TOKEN'] = conn.token;
} else {
headers['Authorization'] = `token ${conn.token}`;
}
const resp = await fetch(url, { headers });
if (!resp.ok) {
await resp.body?.cancel();
return null;
}
return new Uint8Array(await resp.arrayBuffer());
} catch {
return null;
}
}
/**
* Raw multipart call for avatar uploads (GitLab requires multipart FormData).
*/
private async rawMultipartCall(
conn: interfaces.data.IProviderConnection,
method: string,
apiPath: string,
formData: FormData,
): Promise<any> {
const baseUrl = conn.baseUrl.replace(/\/+$/, '');
const url = `${baseUrl}${apiPath}`;
const headers: Record<string, string> = {};
if (conn.providerType === 'gitlab') {
headers['PRIVATE-TOKEN'] = conn.token;
} else {
headers['Authorization'] = `token ${conn.token}`;
}
// Do NOT set Content-Type — let fetch set the multipart boundary
const resp = await fetch(url, { method, headers, body: formData });
if (!resp.ok) {
const text = await resp.text();
throw new Error(`${method} ${apiPath}: ${resp.status} - ${text}`);
}
try {
return await resp.json();
} catch {
return undefined;
}
}
/**
* Normalize visibility values between GitLab and Gitea.
* GitLab: "public" | "internal" | "private"
* Gitea: "public" | "limited" | "private"
*/
private normalizeVisibility(visibility: string): string {
const v = visibility?.toLowerCase() || 'private';
if (v === 'limited') return 'internal';
return v;
}
/**
* Guess MIME type from binary content or URL for avatar uploads.
*/
private guessAvatarMimeType(data: Uint8Array, url: string): string {
// Check magic bytes
if (data[0] === 0x89 && data[1] === 0x50) return 'image/png';
if (data[0] === 0xFF && data[1] === 0xD8) return 'image/jpeg';
if (data[0] === 0x47 && data[1] === 0x49) return 'image/gif';
// Fallback: check URL extension
if (url.includes('.png')) return 'image/png';
if (url.includes('.jpg') || url.includes('.jpeg')) return 'image/jpeg';
if (url.includes('.gif')) return 'image/gif';
return 'image/png'; // default
}
/**
* Pre-push: ensure target's default_branch matches source so --prune won't delete it.
*/
private async syncDefaultBranchBeforePush(
sourceConn: interfaces.data.IProviderConnection,
targetConn: interfaces.data.IProviderConnection,
sourceFullPath: string,
targetFullPath: string,
): Promise<void> {
try {
const sourceProject = await this.fetchProjectRaw(sourceConn, sourceFullPath);
if (!sourceProject) return;
const targetProject = await this.fetchProjectRaw(targetConn, targetFullPath);
if (!targetProject) return;
const sourceBranch = sourceProject.default_branch || 'main';
const targetBranch = targetProject.default_branch || 'main';
if (sourceBranch !== targetBranch) {
logger.syncLog('info', `Updating default branch for ${targetFullPath}: ${targetBranch} -> ${sourceBranch}`, 'api');
if (targetConn.providerType === 'gitlab') {
await this.rawApiCall(targetConn, 'PUT', `/api/v4/projects/${targetProject.id}`, {
default_branch: sourceBranch,
});
} else {
const segments = targetFullPath.split('/');
const repo = segments.pop()!;
const owner = segments[0] || '';
await this.rawApiCall(targetConn, 'PATCH', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, {
default_branch: sourceBranch,
});
}
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
logger.syncLog('warn', `Pre-push default_branch sync failed for ${targetFullPath}: ${errMsg}`, 'api');
}
}
/**
* Unprotect branches on the target that no longer exist in the source,
* so that git push --prune can delete them.
*/
private async unprotectStaleBranches(
targetConn: interfaces.data.IProviderConnection,
targetFullPath: string,
mirrorDir: string,
): Promise<void> {
if (targetConn.providerType !== 'gitlab') return;
try {
const targetProject = await this.fetchProjectRaw(targetConn, targetFullPath);
if (!targetProject) return;
const client = new plugins.gitlabClient.GitLabClient(targetConn.baseUrl, targetConn.token);
const protectedBranches = await client.getProtectedBranches(targetProject.id);
if (protectedBranches.length === 0) return;
// Get list of branches in the local mirror (= source branches)
const localBranchOutput = await this.runGit(['branch', '--list'], mirrorDir);
const localBranches = new Set(
localBranchOutput.split('\n').map(b => b.trim().replace(/^\* /, '')).filter(Boolean),
);
for (const pb of protectedBranches) {
if (!localBranches.has(pb.name)) {
logger.syncLog('info', `Unprotecting stale branch "${pb.name}" on ${targetFullPath}`, 'api');
await client.unprotectBranch(targetProject.id, pb.name);
}
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
logger.syncLog('warn', `Failed to unprotect stale branches for ${targetFullPath}: ${errMsg}`, 'api');
}
}
/**
* Sync project metadata (description, visibility, topics, default_branch, avatar)
* from source to target after the git push.
*/
private async syncProjectMetadata(
config: interfaces.data.ISyncConfig,
sourceConn: interfaces.data.IProviderConnection,
targetConn: interfaces.data.IProviderConnection,
sourceFullPath: string,
targetFullPath: string,
): Promise<void> {
try {
// Fetch source project raw JSON
const sourceProject = await this.fetchProjectRaw(sourceConn, sourceFullPath);
if (!sourceProject) return;
// Fetch target project raw JSON
const targetProject = await this.fetchProjectRaw(targetConn, targetFullPath);
if (!targetProject) return;
// Extract normalized metadata from both
const sourceMeta = this.extractProjectMeta(sourceConn, sourceProject);
const targetMeta = this.extractProjectMeta(targetConn, targetProject);
// Append mirror hint to description if enabled
if (config.addMirrorHint) {
const mirrorUrl = `${sourceConn.baseUrl.replace(/\/+$/, '')}/${sourceFullPath}`;
sourceMeta.description = `${sourceMeta.description}\n\n(This is a mirror of ${mirrorUrl})`.trim();
}
// Diff and update text metadata
const changes: string[] = [];
if (sourceMeta.description !== targetMeta.description) changes.push('description');
if (this.normalizeVisibility(sourceMeta.visibility) !== this.normalizeVisibility(targetMeta.visibility)) changes.push('visibility');
if (JSON.stringify([...sourceMeta.topics].sort()) !== JSON.stringify([...targetMeta.topics].sort())) changes.push('topics');
if (sourceMeta.defaultBranch !== targetMeta.defaultBranch) changes.push('default_branch');
if (changes.length > 0) {
logger.syncLog('info', `Syncing metadata for ${targetFullPath}: ${changes.join(', ')}`, 'api');
await this.updateProjectMeta(targetConn, targetFullPath, targetProject, sourceMeta);
logger.syncLog('success', `Updated metadata for ${targetFullPath}: ${changes.join(', ')}`, 'api');
}
// Sync avatar
if (sourceMeta.avatarUrl) {
await this.syncProjectAvatar(sourceConn, targetConn, sourceFullPath, targetFullPath, sourceMeta.avatarUrl, targetProject);
} else if (config.useGroupAvatarsForProjects) {
// Project has no avatar — inherit from parent group
const groupPath = sourceFullPath.substring(0, sourceFullPath.lastIndexOf('/'));
if (groupPath) {
try {
const sourceGroup = await this.fetchGroupRaw(sourceConn, groupPath);
if (sourceGroup) {
const groupMeta = this.extractGroupMeta(sourceConn, sourceGroup);
if (groupMeta.avatarUrl) {
logger.syncLog('info', `Applying group avatar to ${targetFullPath}`, 'api');
await this.syncProjectAvatar(sourceConn, targetConn, sourceFullPath, targetFullPath, groupMeta.avatarUrl, targetProject);
}
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
logger.syncLog('warn', `Group avatar sync failed for ${targetFullPath}: ${errMsg}`, 'api');
}
}
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
logger.syncLog('error', `Metadata sync failed for ${targetFullPath}: ${errMsg}`, 'api');
}
}
/**
* Sync group/org metadata (description, visibility, avatar) from source to target.
*/
private async syncGroupMetadata(
sourceConn: interfaces.data.IProviderConnection,
targetConn: interfaces.data.IProviderConnection,
sourceGroupPath: string,
targetGroupPath: string,
): Promise<void> {
try {
const sourceGroup = await this.fetchGroupRaw(sourceConn, sourceGroupPath);
if (!sourceGroup) return;
const targetGroup = await this.fetchGroupRaw(targetConn, targetGroupPath);
if (!targetGroup) return;
const sourceMeta = this.extractGroupMeta(sourceConn, sourceGroup);
const targetMeta = this.extractGroupMeta(targetConn, targetGroup);
// Append mirror hint to description if enabled
if (this.currentSyncConfig?.addMirrorHint) {
const mirrorUrl = `${sourceConn.baseUrl.replace(/\/+$/, '')}/${sourceGroupPath}`;
sourceMeta.description = `${sourceMeta.description}\n\n(This is a mirror of ${mirrorUrl})`.trim();
}
const changes: string[] = [];
if (sourceMeta.description !== targetMeta.description) changes.push('description');
if (this.normalizeVisibility(sourceMeta.visibility) !== this.normalizeVisibility(targetMeta.visibility)) changes.push('visibility');
if (changes.length > 0) {
logger.syncLog('info', `Syncing group metadata for ${targetGroupPath}: ${changes.join(', ')}`, 'api');
await this.updateGroupMeta(targetConn, targetGroupPath, targetGroup, sourceMeta);
logger.syncLog('success', `Updated group metadata for ${targetGroupPath}: ${changes.join(', ')}`, 'api');
}
// Sync avatar
if (sourceMeta.avatarUrl) {
await this.syncGroupAvatar(sourceConn, targetConn, sourceGroupPath, targetGroupPath, sourceMeta.avatarUrl, targetGroup);
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
logger.syncLog('error', `Group metadata sync failed for ${targetGroupPath}: ${errMsg}`, 'api');
}
}
// ---- Raw metadata fetchers ----
private async fetchProjectRaw(conn: interfaces.data.IProviderConnection, fullPath: string): Promise<any> {
if (conn.providerType === 'gitlab') {
return await this.rawApiCall(conn, 'GET', `/api/v4/projects/${encodeURIComponent(fullPath)}`);
} else {
const segments = fullPath.split('/');
const repo = segments.pop()!;
const owner = segments[0] || '';
return await this.rawApiCall(conn, 'GET', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`);
}
}
private async fetchGroupRaw(conn: interfaces.data.IProviderConnection, groupPath: string): Promise<any> {
if (conn.providerType === 'gitlab') {
return await this.rawApiCall(conn, 'GET', `/api/v4/groups/${encodeURIComponent(groupPath)}`);
} else {
// Gitea orgs are flat — the group path IS the org name
const orgName = groupPath.split('/')[0] || groupPath;
return await this.rawApiCall(conn, 'GET', `/api/v1/orgs/${encodeURIComponent(orgName)}`);
}
}
// ---- Metadata extractors ----
private extractProjectMeta(conn: interfaces.data.IProviderConnection, raw: any): {
description: string; visibility: string; topics: string[]; defaultBranch: string; avatarUrl: string;
} {
if (conn.providerType === 'gitlab') {
return {
description: raw.description || '',
visibility: raw.visibility || 'private',
topics: raw.topics || [],
defaultBranch: raw.default_branch || 'main',
avatarUrl: raw.avatar_url || '',
};
} else {
return {
description: raw.description || '',
visibility: raw.private ? 'private' : 'public',
topics: raw.topics || [],
defaultBranch: raw.default_branch || 'main',
avatarUrl: raw.avatar_url || '',
};
}
}
private extractGroupMeta(conn: interfaces.data.IProviderConnection, raw: any): {
description: string; visibility: string; avatarUrl: string;
} {
if (conn.providerType === 'gitlab') {
return {
description: raw.description || '',
visibility: raw.visibility || 'private',
avatarUrl: raw.avatar_url || '',
};
} else {
return {
description: raw.description || '',
visibility: raw.visibility || 'public',
avatarUrl: raw.avatar_url || '',
};
}
}
// ---- Metadata updaters ----
private async updateProjectMeta(
conn: interfaces.data.IProviderConnection,
fullPath: string,
rawProject: any,
meta: { description: string; visibility: string; topics: string[]; defaultBranch: string },
): Promise<void> {
if (conn.providerType === 'gitlab') {
await this.rawApiCall(conn, 'PUT', `/api/v4/projects/${rawProject.id}`, {
description: meta.description,
visibility: this.normalizeVisibility(meta.visibility),
topics: meta.topics,
default_branch: meta.defaultBranch,
});
} else {
const segments = fullPath.split('/');
const repo = segments.pop()!;
const owner = segments[0] || '';
const encodedOwner = encodeURIComponent(owner);
const encodedRepo = encodeURIComponent(repo);
// Update description, visibility, default_branch
await this.rawApiCall(conn, 'PATCH', `/api/v1/repos/${encodedOwner}/${encodedRepo}`, {
description: meta.description,
private: this.normalizeVisibility(meta.visibility) === 'private',
default_branch: meta.defaultBranch,
});
// Topics are a separate endpoint in Gitea
await this.rawApiCall(conn, 'PUT', `/api/v1/repos/${encodedOwner}/${encodedRepo}/topics`, {
topics: meta.topics,
});
}
}
private async updateGroupMeta(
conn: interfaces.data.IProviderConnection,
groupPath: string,
rawGroup: any,
meta: { description: string; visibility: string },
): Promise<void> {
if (conn.providerType === 'gitlab') {
try {
await this.rawApiCall(conn, 'PUT', `/api/v4/groups/${rawGroup.id}`, {
description: meta.description,
visibility: this.normalizeVisibility(meta.visibility),
});
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
if (errMsg.includes('visibility_level') || errMsg.includes('visibility')) {
logger.syncLog('warn', `Cannot sync visibility for group ${groupPath} (contains projects with higher visibility), syncing description only`, 'api');
await this.rawApiCall(conn, 'PUT', `/api/v4/groups/${rawGroup.id}`, {
description: meta.description,
});
} else {
throw err;
}
}
} else {
const orgName = groupPath.split('/')[0] || groupPath;
await this.rawApiCall(conn, 'PATCH', `/api/v1/orgs/${encodeURIComponent(orgName)}`, {
description: meta.description,
visibility: this.normalizeVisibility(meta.visibility) === 'private' ? 'private' : 'public',
});
}
}
// ---- Avatar sync ----
private async syncProjectAvatar(
sourceConn: interfaces.data.IProviderConnection,
targetConn: interfaces.data.IProviderConnection,
sourceFullPath: string,
targetFullPath: string,
sourceAvatarUrl: string,
targetRawProject: any,
): Promise<void> {
// Resolve relative avatar URLs
const resolvedUrl = sourceAvatarUrl.startsWith('http')
? sourceAvatarUrl
: `${sourceConn.baseUrl.replace(/\/+$/, '')}${sourceAvatarUrl}`;
const avatarData = await this.rawBinaryFetch(sourceConn, resolvedUrl);
if (!avatarData || avatarData.length === 0) return;
logger.syncLog('info', `Syncing avatar for ${targetFullPath}...`, 'api');
if (targetConn.providerType === 'gitlab') {
// GitLab: multipart upload
const mimeType = this.guessAvatarMimeType(avatarData, resolvedUrl);
const blob = new Blob([avatarData.buffer as ArrayBuffer], { type: mimeType });
const ext = mimeType.split('/')[1] || 'png';
const formData = new FormData();
formData.append('avatar', blob, `avatar.${ext}`);
await this.rawMultipartCall(
targetConn, 'PUT',
`/api/v4/projects/${targetRawProject.id}`,
formData,
);
} else {
// Gitea: base64 JSON upload
const segments = targetFullPath.split('/');
const repo = segments.pop()!;
const owner = segments[0] || '';
const base64Image = this.uint8ArrayToBase64(avatarData);
await this.rawApiCall(
targetConn, 'POST',
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/avatar`,
{ image: base64Image },
);
}
}
private async syncGroupAvatar(
sourceConn: interfaces.data.IProviderConnection,
targetConn: interfaces.data.IProviderConnection,
_sourceGroupPath: string,
targetGroupPath: string,
sourceAvatarUrl: string,
targetRawGroup: any,
): Promise<void> {
const resolvedUrl = sourceAvatarUrl.startsWith('http')
? sourceAvatarUrl
: `${sourceConn.baseUrl.replace(/\/+$/, '')}${sourceAvatarUrl}`;
const avatarData = await this.rawBinaryFetch(sourceConn, resolvedUrl);
if (!avatarData || avatarData.length === 0) return;
logger.syncLog('info', `Syncing avatar for group ${targetGroupPath}...`, 'api');
if (targetConn.providerType === 'gitlab') {
const mimeType = this.guessAvatarMimeType(avatarData, resolvedUrl);
const blob = new Blob([avatarData.buffer as ArrayBuffer], { type: mimeType });
const ext = mimeType.split('/')[1] || 'png';
const formData = new FormData();
formData.append('avatar', blob, `avatar.${ext}`);
await this.rawMultipartCall(
targetConn, 'PUT',
`/api/v4/groups/${targetRawGroup.id}`,
formData,
);
} else {
const orgName = targetGroupPath.split('/')[0] || targetGroupPath;
const base64Image = this.uint8ArrayToBase64(avatarData);
await this.rawApiCall(
targetConn, 'POST',
`/api/v1/orgs/${encodeURIComponent(orgName)}/avatar`,
{ image: base64Image },
);
}
}
private uint8ArrayToBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// ============================================================================
// Obsolete — move repos instead of deleting/overwriting
// ============================================================================
/**
* Raw HTTP call for API endpoints not supported by the client libraries.
*/
private async rawApiCall(
conn: interfaces.data.IProviderConnection,
method: string,
apiPath: string,
body?: any,
): Promise<any> {
const baseUrl = conn.baseUrl.replace(/\/+$/, '');
const url = `${baseUrl}${apiPath}`;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (conn.providerType === 'gitlab') {
headers['PRIVATE-TOKEN'] = conn.token;
} else {
headers['Authorization'] = `token ${conn.token}`;
}
const resp = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`${method} ${apiPath}: ${resp.status} - ${text}`);
}
try {
return await resp.json();
} catch {
return undefined;
}
}
private generateSuffix(): string {
return crypto.randomUUID().substring(0, 6);
}
/**
* Ensure an "obsolete" group/org exists under the target base path.
* Returns the obsolete group/org identifier needed for transfers.
*/
private async ensureObsoleteGroup(
targetConn: interfaces.data.IProviderConnection,
basePath?: string,
): Promise<{ type: 'gitlab'; groupId: number } | { type: 'gitea'; orgName: string }> {
if (targetConn.providerType === 'gitlab') {
const client = new plugins.gitlabClient.GitLabClient(targetConn.baseUrl, targetConn.token);
// Walk the basePath to find the parent group, then create "obsolete" subgroup
let parentId: number | undefined;
if (basePath) {
const parentGroup = await client.getGroupByPath(basePath);
parentId = parentGroup.id;
}
// Try to get existing obsolete group
const obsoletePath = basePath ? `${basePath}/obsolete` : 'obsolete';
try {
const group = await client.getGroupByPath(obsoletePath);
return { type: 'gitlab', groupId: group.id };
} catch {
// Doesn't exist — create it
try {
const newGroup = await client.createGroup('obsolete', 'obsolete', parentId);
// Set to private via raw API (createGroup defaults to private already)
logger.info(`Created GitLab obsolete group: ${obsoletePath}`);
return { type: 'gitlab', groupId: newGroup.id };
} catch (createErr: any) {
if (String(createErr).includes('409') || String(createErr).includes('already')) {
const group = await client.getGroupByPath(obsoletePath);
return { type: 'gitlab', groupId: group.id };
}
throw createErr;
}
}
} else {
// Gitea: flat orgs — create "{org}-obsolete"
const client = new plugins.giteaClient.GiteaClient(targetConn.baseUrl, targetConn.token);
const segments = basePath ? basePath.split('/') : [];
const orgName = segments[0] || targetConn.groupFilter || 'default';
const obsoleteOrg = `${orgName}-obsolete`;
try {
await client.getOrg(obsoleteOrg);
} catch {
try {
await client.createOrg(obsoleteOrg, { visibility: 'private' });
logger.info(`Created Gitea obsolete org: ${obsoleteOrg}`);
} catch (createErr: any) {
if (!String(createErr).includes('409') && !String(createErr).includes('already')) {
throw createErr;
}
}
}
return { type: 'gitea', orgName: obsoleteOrg };
}
}
/**
* Move a target repo to the "obsolete" group with a unique suffix.
* The project is also set to private.
*/
private async moveToObsolete(
targetConn: interfaces.data.IProviderConnection,
targetFullPath: string,
basePath?: string,
): Promise<void> {
const suffix = this.generateSuffix();
const obsoleteTarget = await this.ensureObsoleteGroup(targetConn, basePath);
if (obsoleteTarget.type === 'gitlab') {
// 1. Get project by path
const project = await this.rawApiCall(
targetConn, 'GET',
`/api/v4/projects/${encodeURIComponent(targetFullPath)}`,
);
const projectId = project.id;
// 2. Transfer to obsolete group
await this.rawApiCall(
targetConn, 'PUT',
`/api/v4/projects/${projectId}/transfer`,
{ namespace: obsoleteTarget.groupId },
);
// 3. Rename with suffix + set private
const originalPath = targetFullPath.split('/').pop()!;
await this.rawApiCall(
targetConn, 'PUT',
`/api/v4/projects/${projectId}`,
{ name: `${originalPath}-${suffix}`, path: `${originalPath}-${suffix}`, visibility: 'private' },
);
logger.info(`Moved GitLab project "${targetFullPath}" to obsolete as "${originalPath}-${suffix}"`);
} else {
// Gitea: parse owner/repo
const segments = targetFullPath.split('/');
const repo = segments.pop()!;
const owner = segments[0] || '';
// 1. Transfer to obsolete org
await this.rawApiCall(
targetConn, 'POST',
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/transfer`,
{ new_owner: obsoleteTarget.orgName },
);
// 2. Rename with suffix + set private
await this.rawApiCall(
targetConn, 'PATCH',
`/api/v1/repos/${encodeURIComponent(obsoleteTarget.orgName)}/${encodeURIComponent(repo)}`,
{ name: `${repo}-${suffix}`, private: true },
);
logger.info(`Moved Gitea repo "${targetFullPath}" to obsolete as "${repo}-${suffix}"`);
}
}
/**
* Check whether the target remote in a bare mirror dir has unrelated history
* compared to the source (origin). Returns true if histories are completely disjoint.
*/
private async checkUnrelatedHistory(mirrorDir: string): Promise<boolean> {
// Fetch target refs into the bare mirror
try {
await this.runGit(['fetch', 'target'], mirrorDir);
} catch {
// Target is empty or unreachable — not unrelated
return false;
}
// Get any source ref
const sourceRefs = await this.runGit(
['for-each-ref', '--format=%(objectname)', 'refs/heads/'], mirrorDir,
);
const sourceRef = sourceRefs.trim().split('\n')[0];
if (!sourceRef) return false; // Source is empty
// Get any target ref
const targetRefs = await this.runGit(
['for-each-ref', '--format=%(objectname)', 'refs/remotes/target/'], mirrorDir,
);
const targetRef = targetRefs.trim().split('\n')[0];
if (!targetRef) return false; // Target is empty
// Check for common ancestor
try {
await this.runGit(['merge-base', sourceRef, targetRef], mirrorDir);
return false; // Common ancestor found — related
} catch {
return true; // No common ancestor — unrelated
}
}
private sanitizePath(fullPath: string): string {
return fullPath.replace(/[^a-zA-Z0-9._/-]/g, '_');
}
private async dirExists(dirPath: string): Promise<boolean> {
try {
const stat = await Deno.stat(dirPath);
return stat.isDirectory;
} catch {
return false;
}
}
private async runGit(args: string[], cwd?: string): Promise<string> {
const cmd = new Deno.Command('git', {
args,
cwd,
stdout: 'piped',
stderr: 'piped',
env: { ...Deno.env.toObject(), GIT_TERMINAL_PROMPT: '0' },
});
const output = await cmd.output();
if (!output.success) {
const stderr = new TextDecoder().decode(output.stderr);
throw new Error(`git ${args[0]} failed: ${stderr.trim()}`);
}
return new TextDecoder().decode(output.stdout);
}
// ============================================================================
// Persistence
// ============================================================================
private async loadConfigs(): Promise<void> {
const keys = await this.storageManager.list(SYNC_PREFIX);
this.configs = [];
for (const key of keys) {
const config = await this.storageManager.getJSON<interfaces.data.ISyncConfig>(key);
if (config) this.configs.push(config);
}
}
private async persistConfig(config: interfaces.data.ISyncConfig): Promise<void> {
await this.storageManager.setJSON(`${SYNC_PREFIX}${config.id}.json`, config);
}
private async updateRepoStatus(
syncConfigId: string,
sourceFullPath: string,
updates: Partial<interfaces.data.ISyncRepoStatus>,
): Promise<void> {
const hash = this.sanitizePath(sourceFullPath).replace(/\//g, '__');
const key = `${SYNC_STATUS_PREFIX}${syncConfigId}/${hash}.json`;
let status = await this.storageManager.getJSON<interfaces.data.ISyncRepoStatus>(key);
if (!status) {
status = {
id: hash,
syncConfigId,
sourceFullPath,
targetFullPath: '',
lastSyncAt: 0,
status: 'pending',
};
}
Object.assign(status, updates);
await this.storageManager.setJSON(key, status);
}
// ============================================================================
// Timer Management
// ============================================================================
private startTimer(config: interfaces.data.ISyncConfig): void {
this.stopTimer(config.id);
const intervalMs = config.intervalMinutes * 60 * 1000;
const timerId = setInterval(() => {
this.executeSync(config.id).catch((err) =>
logger.error(`Scheduled sync for ${config.name} failed: ${err}`)
);
}, intervalMs);
Deno.unrefTimer(timerId);
this.timers.set(config.id, timerId);
}
private stopTimer(configId: string): void {
const timerId = this.timers.get(configId);
if (timerId !== undefined) {
clearInterval(timerId);
this.timers.delete(configId);
}
}
}