feat(sync): remove target avatar when source has none

Add removeProjectAvatar and removeGroupAvatar methods to keep avatars
fully in sync by clearing the target avatar when the source has none.
This commit is contained in:
2026-02-28 17:57:00 +00:00
parent 44ac2e430f
commit 78247c1d41
4 changed files with 59 additions and 2 deletions

View File

@@ -1,5 +1,13 @@
# Changelog
## 2026-02-28 - 2.9.0 - feat(sync)
remove target avatar when source has none to keep avatars fully in sync
- Add removeProjectAvatar and removeGroupAvatar methods for GitLab and Gitea APIs
- In syncProjectMetadata, remove target project avatar when source has no avatar and no group fallback applies
- When useGroupAvatarsForProjects is enabled but the group also has no avatar, remove the target avatar
- In syncGroupMetadata, remove target group avatar when source group has no avatar
## 2026-02-28 - 2.8.0 - feat(sync)
add sync subsystem: SyncManager, OpsServer sync handlers, Sync UI and state, provider groupFilter support, and realtime sync log streaming via TypedSocket

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/gitops",
"version": "2.8.0",
"version": "2.9.0",
"description": "GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs",
"main": "mod.ts",
"type": "module",

View File

@@ -1139,6 +1139,7 @@ export class SyncManager {
} else if (config.useGroupAvatarsForProjects) {
// Project has no avatar — inherit from parent group
const groupPath = sourceFullPath.substring(0, sourceFullPath.lastIndexOf('/'));
let groupAvatarApplied = false;
if (groupPath) {
try {
const sourceGroup = await this.fetchGroupRaw(sourceConn, groupPath);
@@ -1147,6 +1148,7 @@ export class SyncManager {
if (groupMeta.avatarUrl) {
logger.syncLog('info', `Applying group avatar to ${targetFullPath}`, 'api');
await this.syncProjectAvatar(sourceConn, targetConn, sourceFullPath, targetFullPath, groupMeta.avatarUrl, targetProject);
groupAvatarApplied = true;
}
}
} catch (err) {
@@ -1154,6 +1156,15 @@ export class SyncManager {
logger.syncLog('warn', `Group avatar sync failed for ${targetFullPath}: ${errMsg}`, 'api');
}
}
// If group also has no avatar, remove target avatar
if (!groupAvatarApplied && targetMeta.avatarUrl) {
await this.removeProjectAvatar(targetConn, targetFullPath, targetProject);
}
} else {
// No source avatar, no group fallback — remove target avatar if present
if (targetMeta.avatarUrl) {
await this.removeProjectAvatar(targetConn, targetFullPath, targetProject);
}
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
@@ -1199,6 +1210,8 @@ export class SyncManager {
// Sync avatar
if (sourceMeta.avatarUrl) {
await this.syncGroupAvatar(sourceConn, targetConn, sourceGroupPath, targetGroupPath, sourceMeta.avatarUrl, targetGroup);
} else if (targetMeta.avatarUrl) {
await this.removeGroupAvatar(targetConn, targetGroupPath, targetGroup);
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
@@ -1383,6 +1396,25 @@ export class SyncManager {
}
}
private async removeProjectAvatar(
targetConn: interfaces.data.IProviderConnection,
targetFullPath: string,
targetRawProject: any,
): Promise<void> {
logger.syncLog('info', `Removing avatar from ${targetFullPath}`, 'api');
if (targetConn.providerType === 'gitlab') {
await this.rawApiCall(targetConn, 'PUT', `/api/v4/projects/${targetRawProject.id}`, {
avatar: '',
});
} else {
const segments = targetFullPath.split('/');
const repo = segments.pop()!;
const owner = segments[0] || '';
await this.rawApiCall(targetConn, 'DELETE',
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/avatar`);
}
}
private async syncGroupAvatar(
sourceConn: interfaces.data.IProviderConnection,
targetConn: interfaces.data.IProviderConnection,
@@ -1422,6 +1454,23 @@ export class SyncManager {
}
}
private async removeGroupAvatar(
targetConn: interfaces.data.IProviderConnection,
targetGroupPath: string,
targetRawGroup: any,
): Promise<void> {
logger.syncLog('info', `Removing avatar from group ${targetGroupPath}`, 'api');
if (targetConn.providerType === 'gitlab') {
await this.rawApiCall(targetConn, 'PUT', `/api/v4/groups/${targetRawGroup.id}`, {
avatar: '',
});
} else {
const orgName = targetGroupPath.split('/')[0] || targetGroupPath;
await this.rawApiCall(targetConn, 'DELETE',
`/api/v1/orgs/${encodeURIComponent(orgName)}/avatar`);
}
}
private uint8ArrayToBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {

File diff suppressed because one or more lines are too long