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)
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
"@api.global/typedserver": "8.4.0",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@apiclient.xyz/gitea": "1.2.0",
|
||||
"@apiclient.xyz/gitlab": "2.2.0",
|
||||
"@apiclient.xyz/gitlab": "2.3.0",
|
||||
"@design.estate/dees-catalog": "^3.43.3",
|
||||
"@design.estate/dees-element": "^2.1.6"
|
||||
},
|
||||
|
||||
@@ -67,6 +67,7 @@ export class SyncManager {
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
useGroupAvatarsForProjects?: boolean;
|
||||
}): Promise<interfaces.data.ISyncConfig> {
|
||||
const config: interfaces.data.ISyncConfig = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -81,6 +82,7 @@ export class SyncManager {
|
||||
enforceDelete: data.enforceDelete ?? false,
|
||||
enforceGroupDelete: data.enforceGroupDelete ?? false,
|
||||
addMirrorHint: data.addMirrorHint ?? false,
|
||||
useGroupAvatarsForProjects: data.useGroupAvatarsForProjects ?? false,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.validateSyncConfig(config);
|
||||
@@ -92,7 +94,7 @@ export class SyncManager {
|
||||
|
||||
async updateConfig(
|
||||
id: string,
|
||||
updates: { name?: string; targetGroupOffset?: string; intervalMinutes?: number; enforceDelete?: boolean; enforceGroupDelete?: boolean; addMirrorHint?: boolean },
|
||||
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}`);
|
||||
@@ -101,6 +103,7 @@ export class SyncManager {
|
||||
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);
|
||||
@@ -282,7 +285,7 @@ export class SyncManager {
|
||||
logger.syncLog('info', `Found ${projects.length} source projects (${allProjects.length - projects.length} obsolete excluded)`, 'api');
|
||||
|
||||
let synced = 0;
|
||||
const CONCURRENCY = 4;
|
||||
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) => {
|
||||
@@ -418,6 +421,20 @@ export class SyncManager {
|
||||
});
|
||||
}
|
||||
|
||||
// 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/*',
|
||||
@@ -997,6 +1014,81 @@ export class SyncManager {
|
||||
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.
|
||||
@@ -1044,6 +1136,24 @@ export class SyncManager {
|
||||
// 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);
|
||||
@@ -1202,10 +1312,22 @@ export class SyncManager {
|
||||
meta: { description: string; visibility: string },
|
||||
): Promise<void> {
|
||||
if (conn.providerType === 'gitlab') {
|
||||
await this.rawApiCall(conn, 'PUT', `/api/v4/groups/${rawGroup.id}`, {
|
||||
description: meta.description,
|
||||
visibility: this.normalizeVisibility(meta.visibility),
|
||||
});
|
||||
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)}`, {
|
||||
|
||||
@@ -71,6 +71,7 @@ export class SyncHandler {
|
||||
enforceDelete: dataArg.enforceDelete,
|
||||
enforceGroupDelete: dataArg.enforceGroupDelete,
|
||||
addMirrorHint: dataArg.addMirrorHint,
|
||||
useGroupAvatarsForProjects: dataArg.useGroupAvatarsForProjects,
|
||||
});
|
||||
this.actionLog.append({
|
||||
actionType: 'create',
|
||||
@@ -98,6 +99,7 @@ export class SyncHandler {
|
||||
enforceDelete: dataArg.enforceDelete,
|
||||
enforceGroupDelete: dataArg.enforceGroupDelete,
|
||||
addMirrorHint: dataArg.addMirrorHint,
|
||||
useGroupAvatarsForProjects: dataArg.useGroupAvatarsForProjects,
|
||||
});
|
||||
this.actionLog.append({
|
||||
actionType: 'update',
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -15,6 +15,7 @@ export interface ISyncConfig {
|
||||
enforceDelete: boolean; // When true, stale target repos are moved to obsolete
|
||||
enforceGroupDelete: boolean; // When true, stale target groups/orgs are moved to obsolete
|
||||
addMirrorHint?: boolean; // When true, target descriptions get "(This is a mirror of ...)" appended
|
||||
useGroupAvatarsForProjects?: boolean; // When true, projects without avatars inherit the group avatar
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface IReq_CreateSyncConfig extends plugins.typedrequestInterfaces.im
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
useGroupAvatarsForProjects?: boolean;
|
||||
};
|
||||
response: {
|
||||
config: data.ISyncConfig;
|
||||
@@ -49,6 +50,7 @@ export interface IReq_UpdateSyncConfig extends plugins.typedrequestInterfaces.im
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
useGroupAvatarsForProjects?: boolean;
|
||||
};
|
||||
response: {
|
||||
config: data.ISyncConfig;
|
||||
|
||||
@@ -742,6 +742,7 @@ export const createSyncConfigAction = syncStatePart.createAction<{
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
useGroupAvatarsForProjects?: boolean;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
@@ -769,6 +770,7 @@ export const updateSyncConfigAction = syncStatePart.createAction<{
|
||||
enforceDelete?: boolean;
|
||||
enforceGroupDelete?: boolean;
|
||||
addMirrorHint?: boolean;
|
||||
useGroupAvatarsForProjects?: boolean;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
|
||||
@@ -104,6 +104,7 @@ export class GitopsViewSync extends DeesElement {
|
||||
'Enforce Delete': item.enforceDelete ? 'Yes' : 'No',
|
||||
'Enforce Group Delete': item.enforceGroupDelete ? 'Yes' : 'No',
|
||||
'Mirror Hint': item.addMirrorHint ? 'Yes' : 'No',
|
||||
'Group Avatars': item.useGroupAvatarsForProjects ? 'Yes' : 'No',
|
||||
'Last Sync': item.lastSyncAt ? new Date(item.lastSyncAt).toLocaleString() : 'Never',
|
||||
Repos: String(item.reposSynced),
|
||||
};
|
||||
@@ -288,6 +289,9 @@ export class GitopsViewSync extends DeesElement {
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Add Mirror Hint'} .key=${'addMirrorHint'} .value=${false} .description=${'When enabled, target descriptions get "(This is a mirror of ...)" appended.'}></dees-input-checkbox>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Group Avatars for Projects'} .key=${'useGroupAvatarsForProjects'} .value=${false} .description=${'When enabled, projects without their own avatar inherit the group avatar.'}></dees-input-checkbox>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
@@ -299,7 +303,7 @@ export class GitopsViewSync extends DeesElement {
|
||||
for (const input of inputs) {
|
||||
if (input.key === 'sourceConnectionId' || input.key === 'targetConnectionId') {
|
||||
data[input.key] = input.selectedOption?.key || '';
|
||||
} else if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'addMirrorHint') {
|
||||
} else if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'addMirrorHint' || input.key === 'useGroupAvatarsForProjects') {
|
||||
data[input.key] = input.getValue();
|
||||
} else {
|
||||
data[input.key] = input.value || '';
|
||||
@@ -314,6 +318,7 @@ export class GitopsViewSync extends DeesElement {
|
||||
enforceDelete: !!data.enforceDelete,
|
||||
enforceGroupDelete: !!data.enforceGroupDelete,
|
||||
addMirrorHint: !!data.addMirrorHint,
|
||||
useGroupAvatarsForProjects: !!data.useGroupAvatarsForProjects,
|
||||
});
|
||||
modal.destroy();
|
||||
},
|
||||
@@ -345,6 +350,9 @@ export class GitopsViewSync extends DeesElement {
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Add Mirror Hint'} .key=${'addMirrorHint'} .value=${!!item.addMirrorHint} .description=${'When enabled, target descriptions get "(This is a mirror of ...)" appended.'}></dees-input-checkbox>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<dees-input-checkbox .label=${'Group Avatars for Projects'} .key=${'useGroupAvatarsForProjects'} .value=${!!item.useGroupAvatarsForProjects} .description=${'When enabled, projects without their own avatar inherit the group avatar.'}></dees-input-checkbox>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modal: any) => { modal.destroy(); } },
|
||||
@@ -354,7 +362,7 @@ export class GitopsViewSync extends DeesElement {
|
||||
const inputs = modal.shadowRoot.querySelectorAll('dees-input-text, dees-input-checkbox');
|
||||
const data: any = {};
|
||||
for (const input of inputs) {
|
||||
if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'addMirrorHint') {
|
||||
if (input.key === 'enforceDelete' || input.key === 'enforceGroupDelete' || input.key === 'addMirrorHint' || input.key === 'useGroupAvatarsForProjects') {
|
||||
data[input.key] = input.getValue();
|
||||
} else {
|
||||
data[input.key] = input.value || '';
|
||||
@@ -368,6 +376,7 @@ export class GitopsViewSync extends DeesElement {
|
||||
enforceDelete: !!data.enforceDelete,
|
||||
enforceGroupDelete: !!data.enforceGroupDelete,
|
||||
addMirrorHint: !!data.addMirrorHint,
|
||||
useGroupAvatarsForProjects: !!data.useGroupAvatarsForProjects,
|
||||
});
|
||||
modal.destroy();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user