@serve.zone/gitops 2.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.smartconfig.json +114 -0
- package/binary/gitops.ts +4 -0
- package/changelog.md +185 -0
- package/cli.child.js +4 -0
- package/cli.js +4 -0
- package/cli.ts.js +5 -0
- package/deno.json +10 -0
- package/dist_serve/bundle.js +36362 -0
- package/dist_serve/index.html +33 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/cache/classes.cache.cleaner.d.ts +23 -0
- package/dist_ts/cache/classes.cache.cleaner.js +61 -0
- package/dist_ts/cache/classes.cached.document.d.ts +30 -0
- package/dist_ts/cache/classes.cached.document.js +101 -0
- package/dist_ts/cache/classes.cachedb.d.ts +22 -0
- package/dist_ts/cache/classes.cachedb.js +58 -0
- package/dist_ts/cache/classes.secrets.scan.service.d.ts +51 -0
- package/dist_ts/cache/classes.secrets.scan.service.js +237 -0
- package/dist_ts/cache/documents/classes.cached.project.d.ts +13 -0
- package/dist_ts/cache/documents/classes.cached.project.js +101 -0
- package/dist_ts/cache/documents/classes.cached.secret.d.ts +24 -0
- package/dist_ts/cache/documents/classes.cached.secret.js +158 -0
- package/dist_ts/cache/documents/index.d.ts +2 -0
- package/dist_ts/cache/documents/index.js +3 -0
- package/dist_ts/cache/index.d.ts +7 -0
- package/dist_ts/cache/index.js +6 -0
- package/dist_ts/classes/actionlog.d.ts +19 -0
- package/dist_ts/classes/actionlog.js +44 -0
- package/dist_ts/classes/connectionmanager.d.ts +57 -0
- package/dist_ts/classes/connectionmanager.js +247 -0
- package/dist_ts/classes/gitopsapp.d.ts +30 -0
- package/dist_ts/classes/gitopsapp.js +101 -0
- package/dist_ts/classes/jobmanager.d.ts +47 -0
- package/dist_ts/classes/jobmanager.js +301 -0
- package/dist_ts/classes/jobrunners/autobookstackdocs.runner.d.ts +29 -0
- package/dist_ts/classes/jobrunners/autobookstackdocs.runner.js +361 -0
- package/dist_ts/classes/jobrunners/base.jobrunner.d.ts +14 -0
- package/dist_ts/classes/jobrunners/base.jobrunner.js +3 -0
- package/dist_ts/classes/jobrunners/index.d.ts +5 -0
- package/dist_ts/classes/jobrunners/index.js +14 -0
- package/dist_ts/classes/managedsecrets.manager.d.ts +47 -0
- package/dist_ts/classes/managedsecrets.manager.js +247 -0
- package/dist_ts/classes/syncmanager.d.ts +189 -0
- package/dist_ts/classes/syncmanager.js +1787 -0
- package/dist_ts/index.d.ts +6 -0
- package/dist_ts/index.js +32 -0
- package/dist_ts/logging.d.ts +49 -0
- package/dist_ts/logging.js +134 -0
- package/dist_ts/opsserver/classes.opsserver.d.ts +25 -0
- package/dist_ts/opsserver/classes.opsserver.js +70 -0
- package/dist_ts/opsserver/handlers/actionlog.handler.d.ts +9 -0
- package/dist_ts/opsserver/handlers/actionlog.handler.js +24 -0
- package/dist_ts/opsserver/handlers/actions.handler.d.ts +9 -0
- package/dist_ts/opsserver/handlers/actions.handler.js +38 -0
- package/dist_ts/opsserver/handlers/admin.handler.d.ts +19 -0
- package/dist_ts/opsserver/handlers/admin.handler.js +96 -0
- package/dist_ts/opsserver/handlers/connections.handler.d.ts +10 -0
- package/dist_ts/opsserver/handlers/connections.handler.js +109 -0
- package/dist_ts/opsserver/handlers/groups.handler.d.ts +9 -0
- package/dist_ts/opsserver/handlers/groups.handler.js +24 -0
- package/dist_ts/opsserver/handlers/index.d.ts +13 -0
- package/dist_ts/opsserver/handlers/index.js +14 -0
- package/dist_ts/opsserver/handlers/jobs.handler.d.ts +16 -0
- package/dist_ts/opsserver/handlers/jobs.handler.js +146 -0
- package/dist_ts/opsserver/handlers/logs.handler.d.ts +9 -0
- package/dist_ts/opsserver/handlers/logs.handler.js +21 -0
- package/dist_ts/opsserver/handlers/managedsecrets.handler.d.ts +11 -0
- package/dist_ts/opsserver/handlers/managedsecrets.handler.js +110 -0
- package/dist_ts/opsserver/handlers/pipelines.handler.d.ts +31 -0
- package/dist_ts/opsserver/handlers/pipelines.handler.js +204 -0
- package/dist_ts/opsserver/handlers/projects.handler.d.ts +9 -0
- package/dist_ts/opsserver/handlers/projects.handler.js +24 -0
- package/dist_ts/opsserver/handlers/secrets.handler.d.ts +10 -0
- package/dist_ts/opsserver/handlers/secrets.handler.js +171 -0
- package/dist_ts/opsserver/handlers/sync.handler.d.ts +16 -0
- package/dist_ts/opsserver/handlers/sync.handler.js +166 -0
- package/dist_ts/opsserver/handlers/webhook.handler.d.ts +7 -0
- package/dist_ts/opsserver/handlers/webhook.handler.js +55 -0
- package/dist_ts/opsserver/helpers/guards.d.ts +5 -0
- package/dist_ts/opsserver/helpers/guards.js +12 -0
- package/dist_ts/opsserver/index.d.ts +1 -0
- package/dist_ts/opsserver/index.js +2 -0
- package/dist_ts/paths.d.ts +9 -0
- package/dist_ts/paths.js +13 -0
- package/dist_ts/plugins.d.ts +25 -0
- package/dist_ts/plugins.js +32 -0
- package/dist_ts/providers/classes.baseprovider.d.ts +51 -0
- package/dist_ts/providers/classes.baseprovider.js +17 -0
- package/dist_ts/providers/classes.giteaprovider.d.ts +40 -0
- package/dist_ts/providers/classes.giteaprovider.js +224 -0
- package/dist_ts/providers/classes.gitlabprovider.d.ts +39 -0
- package/dist_ts/providers/classes.gitlabprovider.js +207 -0
- package/dist_ts/providers/index.d.ts +3 -0
- package/dist_ts/providers/index.js +4 -0
- package/dist_ts/storage/classes.storagemanager.d.ts +33 -0
- package/dist_ts/storage/classes.storagemanager.js +135 -0
- package/dist_ts/storage/index.d.ts +2 -0
- package/dist_ts/storage/index.js +2 -0
- package/dist_ts/timers.d.ts +4 -0
- package/dist_ts/timers.js +24 -0
- package/dist_ts_bundled/bundle.d.ts +4 -0
- package/dist_ts_bundled/bundle.js +12 -0
- package/dist_ts_interfaces/data/actionlog.d.ts +12 -0
- package/dist_ts_interfaces/data/actionlog.js +2 -0
- package/dist_ts_interfaces/data/branch.d.ts +8 -0
- package/dist_ts_interfaces/data/branch.js +2 -0
- package/dist_ts_interfaces/data/connection.d.ts +12 -0
- package/dist_ts_interfaces/data/connection.js +2 -0
- package/dist_ts_interfaces/data/group.d.ts +10 -0
- package/dist_ts_interfaces/data/group.js +2 -0
- package/dist_ts_interfaces/data/identity.d.ts +7 -0
- package/dist_ts_interfaces/data/identity.js +2 -0
- package/dist_ts_interfaces/data/index.d.ts +11 -0
- package/dist_ts_interfaces/data/index.js +12 -0
- package/dist_ts_interfaces/data/job.d.ts +37 -0
- package/dist_ts_interfaces/data/job.js +2 -0
- package/dist_ts_interfaces/data/managedsecret.d.ts +37 -0
- package/dist_ts_interfaces/data/managedsecret.js +2 -0
- package/dist_ts_interfaces/data/pipeline.d.ts +22 -0
- package/dist_ts_interfaces/data/pipeline.js +2 -0
- package/dist_ts_interfaces/data/project.d.ts +12 -0
- package/dist_ts_interfaces/data/project.js +2 -0
- package/dist_ts_interfaces/data/secret.d.ts +11 -0
- package/dist_ts_interfaces/data/secret.js +2 -0
- package/dist_ts_interfaces/data/sync.d.ts +34 -0
- package/dist_ts_interfaces/data/sync.js +2 -0
- package/dist_ts_interfaces/index.d.ts +5 -0
- package/dist_ts_interfaces/index.js +8 -0
- package/dist_ts_interfaces/plugins.d.ts +2 -0
- package/dist_ts_interfaces/plugins.js +4 -0
- package/dist_ts_interfaces/requests/actionlog.d.ts +15 -0
- package/dist_ts_interfaces/requests/actionlog.js +3 -0
- package/dist_ts_interfaces/requests/actions.d.ts +31 -0
- package/dist_ts_interfaces/requests/actions.js +3 -0
- package/dist_ts_interfaces/requests/admin.d.ts +31 -0
- package/dist_ts_interfaces/requests/admin.js +3 -0
- package/dist_ts_interfaces/requests/connections.d.ts +71 -0
- package/dist_ts_interfaces/requests/connections.js +3 -0
- package/dist_ts_interfaces/requests/groups.d.ts +14 -0
- package/dist_ts_interfaces/requests/groups.js +3 -0
- package/dist_ts_interfaces/requests/index.d.ts +13 -0
- package/dist_ts_interfaces/requests/index.js +14 -0
- package/dist_ts_interfaces/requests/jobs.d.ts +86 -0
- package/dist_ts_interfaces/requests/jobs.js +3 -0
- package/dist_ts_interfaces/requests/logs.d.ts +14 -0
- package/dist_ts_interfaces/requests/logs.js +3 -0
- package/dist_ts_interfaces/requests/managedsecrets.d.ts +84 -0
- package/dist_ts_interfaces/requests/managedsecrets.js +3 -0
- package/dist_ts_interfaces/requests/pipelines.d.ts +55 -0
- package/dist_ts_interfaces/requests/pipelines.js +3 -0
- package/dist_ts_interfaces/requests/projects.d.ts +14 -0
- package/dist_ts_interfaces/requests/projects.js +3 -0
- package/dist_ts_interfaces/requests/secrets.d.ts +72 -0
- package/dist_ts_interfaces/requests/secrets.js +3 -0
- package/dist_ts_interfaces/requests/sync.d.ts +120 -0
- package/dist_ts_interfaces/requests/sync.js +3 -0
- package/dist_ts_interfaces/requests/webhook.d.ts +13 -0
- package/dist_ts_interfaces/requests/webhook.js +3 -0
- package/license +21 -0
- package/package.json +81 -0
- package/readme.md +177 -0
- package/readme.todo.md +3 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/cache/classes.cache.cleaner.ts +69 -0
- package/ts/cache/classes.cached.document.ts +57 -0
- package/ts/cache/classes.cachedb.ts +72 -0
- package/ts/cache/classes.secrets.scan.service.ts +267 -0
- package/ts/cache/documents/classes.cached.project.ts +32 -0
- package/ts/cache/documents/classes.cached.secret.ts +81 -0
- package/ts/cache/documents/index.ts +2 -0
- package/ts/cache/index.ts +7 -0
- package/ts/classes/actionlog.ts +57 -0
- package/ts/classes/connectionmanager.ts +263 -0
- package/ts/classes/gitopsapp.ts +128 -0
- package/ts/classes/jobmanager.ts +337 -0
- package/ts/classes/jobrunners/autobookstackdocs.runner.ts +435 -0
- package/ts/classes/jobrunners/base.jobrunner.ts +16 -0
- package/ts/classes/jobrunners/index.ts +17 -0
- package/ts/classes/managedsecrets.manager.ts +322 -0
- package/ts/classes/syncmanager.ts +2117 -0
- package/ts/index.ts +37 -0
- package/ts/logging.ts +162 -0
- package/ts/opsserver/classes.opsserver.ts +86 -0
- package/ts/opsserver/handlers/actionlog.handler.ts +30 -0
- package/ts/opsserver/handlers/actions.handler.ts +50 -0
- package/ts/opsserver/handlers/admin.handler.ts +122 -0
- package/ts/opsserver/handlers/connections.handler.ts +162 -0
- package/ts/opsserver/handlers/groups.handler.ts +32 -0
- package/ts/opsserver/handlers/index.ts +13 -0
- package/ts/opsserver/handlers/jobs.handler.ts +189 -0
- package/ts/opsserver/handlers/logs.handler.ts +29 -0
- package/ts/opsserver/handlers/managedsecrets.handler.ts +158 -0
- package/ts/opsserver/handlers/pipelines.handler.ts +281 -0
- package/ts/opsserver/handlers/projects.handler.ts +32 -0
- package/ts/opsserver/handlers/secrets.handler.ts +224 -0
- package/ts/opsserver/handlers/sync.handler.ts +224 -0
- package/ts/opsserver/handlers/webhook.handler.ts +62 -0
- package/ts/opsserver/helpers/guards.ts +16 -0
- package/ts/opsserver/index.ts +1 -0
- package/ts/paths.ts +19 -0
- package/ts/plugins.ts +38 -0
- package/ts/providers/classes.baseprovider.ts +99 -0
- package/ts/providers/classes.giteaprovider.ts +279 -0
- package/ts/providers/classes.gitlabprovider.ts +265 -0
- package/ts/providers/index.ts +3 -0
- package/ts/storage/classes.storagemanager.ts +144 -0
- package/ts/storage/index.ts +2 -0
- package/ts/timers.ts +34 -0
- package/ts_interfaces/data/actionlog.ts +13 -0
- package/ts_interfaces/data/branch.ts +9 -0
- package/ts_interfaces/data/connection.ts +13 -0
- package/ts_interfaces/data/group.ts +10 -0
- package/ts_interfaces/data/identity.ts +7 -0
- package/ts_interfaces/data/index.ts +11 -0
- package/ts_interfaces/data/job.ts +42 -0
- package/ts_interfaces/data/managedsecret.ts +41 -0
- package/ts_interfaces/data/pipeline.ts +32 -0
- package/ts_interfaces/data/project.ts +12 -0
- package/ts_interfaces/data/secret.ts +11 -0
- package/ts_interfaces/data/sync.ts +37 -0
- package/ts_interfaces/index.ts +9 -0
- package/ts_interfaces/plugins.ts +6 -0
- package/ts_interfaces/requests/actionlog.ts +19 -0
- package/ts_interfaces/requests/actions.ts +39 -0
- package/ts_interfaces/requests/admin.ts +43 -0
- package/ts_interfaces/requests/connections.ts +95 -0
- package/ts_interfaces/requests/groups.ts +18 -0
- package/ts_interfaces/requests/index.ts +13 -0
- package/ts_interfaces/requests/jobs.ts +118 -0
- package/ts_interfaces/requests/logs.ts +18 -0
- package/ts_interfaces/requests/managedsecrets.ts +112 -0
- package/ts_interfaces/requests/pipelines.ts +71 -0
- package/ts_interfaces/requests/projects.ts +18 -0
- package/ts_interfaces/requests/secrets.ts +92 -0
- package/ts_interfaces/requests/sync.ts +157 -0
- package/ts_interfaces/requests/webhook.ts +18 -0
- package/ts_web/00_commitinfo_data.ts +8 -0
- package/ts_web/appstate.ts +1251 -0
- package/ts_web/elements/gitops-dashboard.ts +350 -0
- package/ts_web/elements/index.ts +10 -0
- package/ts_web/elements/shared/css.ts +29 -0
- package/ts_web/elements/shared/index.ts +1 -0
- package/ts_web/elements/views/actionlog/index.ts +101 -0
- package/ts_web/elements/views/actions/index.ts +209 -0
- package/ts_web/elements/views/buildlog/index.ts +196 -0
- package/ts_web/elements/views/connections/index.ts +260 -0
- package/ts_web/elements/views/groups/index.ts +134 -0
- package/ts_web/elements/views/jobs/index.ts +424 -0
- package/ts_web/elements/views/managedsecrets/index.ts +502 -0
- package/ts_web/elements/views/overview/index.ts +86 -0
- package/ts_web/elements/views/pipelines/index.ts +561 -0
- package/ts_web/elements/views/projects/index.ts +149 -0
- package/ts_web/elements/views/secrets/index.ts +310 -0
- package/ts_web/elements/views/sync/index.ts +512 -0
- package/ts_web/index.ts +7 -0
- package/ts_web/plugins.ts +15 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,2117 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import { logger } from '../logging.js';
|
|
3
|
+
import { intervalMinutesToMs, unrefTimer, validateIntervalMinutes } from '../timers.js';
|
|
4
|
+
import type { ChildProcess } from 'node:child_process';
|
|
5
|
+
import type * as interfaces from '../../ts_interfaces/index.js';
|
|
6
|
+
import type { ConnectionManager } from './connectionmanager.js';
|
|
7
|
+
import type { ActionLog } from './actionlog.js';
|
|
8
|
+
import type { StorageManager } from '../storage/index.js';
|
|
9
|
+
import type { BaseProvider } from '../providers/classes.baseprovider.js';
|
|
10
|
+
|
|
11
|
+
const SYNC_PREFIX = '/sync/';
|
|
12
|
+
const SYNC_STATUS_PREFIX = '/sync-status/';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Manages sync configurations and executes periodic git mirror operations.
|
|
16
|
+
* Each sync config defines a source → target connection mapping.
|
|
17
|
+
* Repos are mirrored using bare git repos stored on disk.
|
|
18
|
+
*/
|
|
19
|
+
export class SyncManager {
|
|
20
|
+
private configs: interfaces.data.ISyncConfig[] = [];
|
|
21
|
+
private timers: Map<string, ReturnType<typeof setInterval>> = new Map();
|
|
22
|
+
private runningSync: Set<string> = new Set();
|
|
23
|
+
private syncedGroupMeta: Set<string> = new Set();
|
|
24
|
+
private currentSyncConfig: interfaces.data.ISyncConfig | null = null;
|
|
25
|
+
private avatarUploadCache: Map<string, string> = new Map();
|
|
26
|
+
private activeGitChildren = new Set<ChildProcess>();
|
|
27
|
+
private stopping = false;
|
|
28
|
+
|
|
29
|
+
private mirrorsPath = '';
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
private storageManager: StorageManager,
|
|
33
|
+
private connectionManager: ConnectionManager,
|
|
34
|
+
private actionLog: ActionLog,
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
async init(): Promise<void> {
|
|
38
|
+
this.stopping = false;
|
|
39
|
+
// Create temp directory for mirrors (RAM-backed on most Linux systems via tmpfs)
|
|
40
|
+
this.mirrorsPath = await plugins.fs.mkdtemp(plugins.path.join(plugins.os.tmpdir(), 'gitops-mirrors-'));
|
|
41
|
+
await this.loadConfigs();
|
|
42
|
+
for (const config of this.configs) {
|
|
43
|
+
if (config.status === 'active') {
|
|
44
|
+
this.startTimer(config);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (this.configs.length > 0) {
|
|
48
|
+
logger.info(`SyncManager loaded ${this.configs.length} sync config(s)`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async stop(): Promise<void> {
|
|
53
|
+
this.stopping = true;
|
|
54
|
+
for (const [_id, timer] of this.timers) {
|
|
55
|
+
clearInterval(timer);
|
|
56
|
+
}
|
|
57
|
+
this.timers.clear();
|
|
58
|
+
|
|
59
|
+
for (const child of this.activeGitChildren) {
|
|
60
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
61
|
+
child.kill('SIGTERM');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (this.activeGitChildren.size > 0) {
|
|
66
|
+
const forceKillTimer = setTimeout(() => {
|
|
67
|
+
for (const child of this.activeGitChildren) {
|
|
68
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
69
|
+
child.kill('SIGKILL');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}, 5000);
|
|
73
|
+
unrefTimer(forceKillTimer);
|
|
74
|
+
await this.waitForRunningSyncs();
|
|
75
|
+
clearTimeout(forceKillTimer);
|
|
76
|
+
} else {
|
|
77
|
+
await this.waitForRunningSyncs();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Clean up temp mirrors directory
|
|
81
|
+
if (this.mirrorsPath) {
|
|
82
|
+
try {
|
|
83
|
+
await plugins.fs.rm(this.mirrorsPath, { recursive: true, force: true });
|
|
84
|
+
} catch { /* may already be gone */ }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// CRUD
|
|
90
|
+
// ============================================================================
|
|
91
|
+
|
|
92
|
+
getConfigs(): interfaces.data.ISyncConfig[] {
|
|
93
|
+
return [...this.configs];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getConfig(id: string): interfaces.data.ISyncConfig | undefined {
|
|
97
|
+
return this.configs.find((c) => c.id === id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async createConfig(data: {
|
|
101
|
+
name: string;
|
|
102
|
+
sourceConnectionId: string;
|
|
103
|
+
targetConnectionId: string;
|
|
104
|
+
targetGroupOffset?: string;
|
|
105
|
+
intervalMinutes?: number;
|
|
106
|
+
enforceDelete?: boolean;
|
|
107
|
+
enforceGroupDelete?: boolean;
|
|
108
|
+
addMirrorHint?: boolean;
|
|
109
|
+
useGroupAvatarsForProjects?: boolean;
|
|
110
|
+
}): Promise<interfaces.data.ISyncConfig> {
|
|
111
|
+
const config: interfaces.data.ISyncConfig = {
|
|
112
|
+
id: crypto.randomUUID(),
|
|
113
|
+
name: data.name,
|
|
114
|
+
sourceConnectionId: data.sourceConnectionId,
|
|
115
|
+
targetConnectionId: data.targetConnectionId,
|
|
116
|
+
targetGroupOffset: data.targetGroupOffset,
|
|
117
|
+
intervalMinutes: validateIntervalMinutes(data.intervalMinutes, 'sync intervalMinutes', 5),
|
|
118
|
+
status: 'paused',
|
|
119
|
+
lastSyncAt: 0,
|
|
120
|
+
reposSynced: 0,
|
|
121
|
+
enforceDelete: data.enforceDelete ?? false,
|
|
122
|
+
enforceGroupDelete: data.enforceGroupDelete ?? false,
|
|
123
|
+
addMirrorHint: data.addMirrorHint ?? false,
|
|
124
|
+
useGroupAvatarsForProjects: data.useGroupAvatarsForProjects ?? false,
|
|
125
|
+
createdAt: Date.now(),
|
|
126
|
+
};
|
|
127
|
+
this.validateSyncConfig(config);
|
|
128
|
+
this.configs.push(config);
|
|
129
|
+
await this.persistConfig(config);
|
|
130
|
+
logger.success(`Sync config created (paused): ${config.name}`);
|
|
131
|
+
return config;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async updateConfig(
|
|
135
|
+
id: string,
|
|
136
|
+
updates: { name?: string; targetGroupOffset?: string; intervalMinutes?: number; enforceDelete?: boolean; enforceGroupDelete?: boolean; addMirrorHint?: boolean; useGroupAvatarsForProjects?: boolean },
|
|
137
|
+
): Promise<interfaces.data.ISyncConfig> {
|
|
138
|
+
const config = this.configs.find((c) => c.id === id);
|
|
139
|
+
if (!config) throw new Error(`Sync config not found: ${id}`);
|
|
140
|
+
if (updates.name) config.name = updates.name;
|
|
141
|
+
if (updates.intervalMinutes !== undefined) {
|
|
142
|
+
config.intervalMinutes = validateIntervalMinutes(updates.intervalMinutes, 'sync intervalMinutes');
|
|
143
|
+
}
|
|
144
|
+
if (updates.enforceDelete !== undefined) config.enforceDelete = updates.enforceDelete;
|
|
145
|
+
if (updates.enforceGroupDelete !== undefined) config.enforceGroupDelete = updates.enforceGroupDelete;
|
|
146
|
+
if (updates.addMirrorHint !== undefined) config.addMirrorHint = updates.addMirrorHint;
|
|
147
|
+
if (updates.useGroupAvatarsForProjects !== undefined) config.useGroupAvatarsForProjects = updates.useGroupAvatarsForProjects;
|
|
148
|
+
if (updates.targetGroupOffset !== undefined) config.targetGroupOffset = updates.targetGroupOffset;
|
|
149
|
+
this.validateSyncConfig(config);
|
|
150
|
+
await this.persistConfig(config);
|
|
151
|
+
// Restart timer with new interval
|
|
152
|
+
if (config.status === 'active') {
|
|
153
|
+
this.startTimer(config);
|
|
154
|
+
}
|
|
155
|
+
return config;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async deleteConfig(id: string): Promise<void> {
|
|
159
|
+
const idx = this.configs.findIndex((c) => c.id === id);
|
|
160
|
+
if (idx === -1) throw new Error(`Sync config not found: ${id}`);
|
|
161
|
+
this.stopTimer(id);
|
|
162
|
+
this.configs.splice(idx, 1);
|
|
163
|
+
await this.storageManager.delete(`${SYNC_PREFIX}${id}.json`);
|
|
164
|
+
// Clean up repo statuses
|
|
165
|
+
const statusKeys = await this.storageManager.list(`${SYNC_STATUS_PREFIX}${id}/`);
|
|
166
|
+
for (const key of statusKeys) {
|
|
167
|
+
await this.storageManager.delete(key);
|
|
168
|
+
}
|
|
169
|
+
// Clean up mirror directory
|
|
170
|
+
const mirrorDir = plugins.path.join(this.mirrorsPath, id);
|
|
171
|
+
try {
|
|
172
|
+
await plugins.fs.rm(mirrorDir, { recursive: true, force: true });
|
|
173
|
+
} catch {
|
|
174
|
+
// Directory may not exist
|
|
175
|
+
}
|
|
176
|
+
logger.info(`Sync config deleted: ${id}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async pauseConfig(id: string, paused: boolean): Promise<interfaces.data.ISyncConfig> {
|
|
180
|
+
const config = this.configs.find((c) => c.id === id);
|
|
181
|
+
if (!config) throw new Error(`Sync config not found: ${id}`);
|
|
182
|
+
config.status = paused ? 'paused' : 'active';
|
|
183
|
+
await this.persistConfig(config);
|
|
184
|
+
if (paused) {
|
|
185
|
+
this.stopTimer(id);
|
|
186
|
+
} else {
|
|
187
|
+
this.startTimer(config);
|
|
188
|
+
}
|
|
189
|
+
logger.info(`Sync config ${paused ? 'paused' : 'resumed'}: ${config.name}`);
|
|
190
|
+
return config;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async getRepoStatuses(syncConfigId: string): Promise<interfaces.data.ISyncRepoStatus[]> {
|
|
194
|
+
const keys = await this.storageManager.list(`${SYNC_STATUS_PREFIX}${syncConfigId}/`);
|
|
195
|
+
const statuses: interfaces.data.ISyncRepoStatus[] = [];
|
|
196
|
+
for (const key of keys) {
|
|
197
|
+
const status = await this.storageManager.getJSON<interfaces.data.ISyncRepoStatus>(key);
|
|
198
|
+
if (status) statuses.push(status);
|
|
199
|
+
}
|
|
200
|
+
return statuses;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ============================================================================
|
|
204
|
+
// Preview
|
|
205
|
+
// ============================================================================
|
|
206
|
+
|
|
207
|
+
async previewSync(configId: string): Promise<{
|
|
208
|
+
mappings: Array<{ sourceFullPath: string; targetFullPath: string }>;
|
|
209
|
+
deletions: string[];
|
|
210
|
+
groupDeletions: string[];
|
|
211
|
+
}> {
|
|
212
|
+
const config = this.configs.find((c) => c.id === configId);
|
|
213
|
+
if (!config) throw new Error(`Sync config not found: ${configId}`);
|
|
214
|
+
|
|
215
|
+
logger.syncLog('info', `Preview started for "${config.name}"`, 'preview');
|
|
216
|
+
|
|
217
|
+
const sourceConn = this.connectionManager.getConnection(config.sourceConnectionId);
|
|
218
|
+
const targetConn = this.connectionManager.getConnection(config.targetConnectionId);
|
|
219
|
+
if (!sourceConn) throw new Error(`Source connection not found: ${config.sourceConnectionId}`);
|
|
220
|
+
if (!targetConn) throw new Error(`Target connection not found: ${config.targetConnectionId}`);
|
|
221
|
+
|
|
222
|
+
const sourceProvider = this.connectionManager.getProvider(config.sourceConnectionId);
|
|
223
|
+
const targetProvider = this.connectionManager.getProvider(config.targetConnectionId);
|
|
224
|
+
logger.syncLog('info', `Fetching source projects from "${sourceConn.name}"...`, 'preview');
|
|
225
|
+
const allProjects = await sourceProvider.getProjects();
|
|
226
|
+
const projects = allProjects.filter(p => !this.isObsoletePath(p.fullPath));
|
|
227
|
+
logger.syncLog('info', `Found ${projects.length} source projects (${allProjects.length - projects.length} obsolete excluded)`, 'preview');
|
|
228
|
+
|
|
229
|
+
const mappings = projects.map((project) => {
|
|
230
|
+
const targetFullPath = this.computeTargetFullPath(
|
|
231
|
+
project.fullPath, sourceConn.groupFilter, config.targetGroupOffset,
|
|
232
|
+
);
|
|
233
|
+
return { sourceFullPath: project.fullPath, targetFullPath };
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Compute repo deletions when enforce-delete is enabled
|
|
237
|
+
let deletions: string[] = [];
|
|
238
|
+
if (config.enforceDelete) {
|
|
239
|
+
logger.syncLog('info', 'Computing repo deletions (enforce-delete enabled)...', 'preview');
|
|
240
|
+
const expectedTargetPaths = new Set(
|
|
241
|
+
mappings.map((m) => m.targetFullPath.toLowerCase()),
|
|
242
|
+
);
|
|
243
|
+
const scopePrefix = config.targetGroupOffset;
|
|
244
|
+
const targetProjects = await targetProvider.getProjects();
|
|
245
|
+
|
|
246
|
+
for (const tp of targetProjects) {
|
|
247
|
+
if (this.isObsoletePath(tp.fullPath)) continue;
|
|
248
|
+
if (scopePrefix && !tp.fullPath.toLowerCase().startsWith(scopePrefix.toLowerCase() + '/')) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (!expectedTargetPaths.has(tp.fullPath.toLowerCase())) {
|
|
252
|
+
deletions.push(tp.fullPath);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Compute group deletions when enforce-group-delete is enabled
|
|
258
|
+
let groupDeletions: string[] = [];
|
|
259
|
+
if (config.enforceGroupDelete) {
|
|
260
|
+
logger.syncLog('info', 'Computing group deletions (enforce-group-delete enabled)...', 'preview');
|
|
261
|
+
const sourceGroups = await sourceProvider.getGroups();
|
|
262
|
+
const expectedTargetGroups = new Set<string>();
|
|
263
|
+
for (const sg of sourceGroups) {
|
|
264
|
+
const targetPath = this.computeTargetFullPath(
|
|
265
|
+
sg.fullPath, sourceConn.groupFilter, config.targetGroupOffset,
|
|
266
|
+
);
|
|
267
|
+
expectedTargetGroups.add(targetPath.toLowerCase());
|
|
268
|
+
}
|
|
269
|
+
// Also include the offset itself and the obsolete group
|
|
270
|
+
if (config.targetGroupOffset) {
|
|
271
|
+
expectedTargetGroups.add(config.targetGroupOffset.toLowerCase());
|
|
272
|
+
expectedTargetGroups.add(`${config.targetGroupOffset}/obsolete`.toLowerCase());
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const targetGroups = await targetProvider.getGroups();
|
|
276
|
+
const scopePrefix = config.targetGroupOffset;
|
|
277
|
+
|
|
278
|
+
for (const tg of targetGroups) {
|
|
279
|
+
if (this.isObsoletePath(tg.fullPath)) continue;
|
|
280
|
+
if (scopePrefix && !tg.fullPath.toLowerCase().startsWith(scopePrefix.toLowerCase() + '/')) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (!expectedTargetGroups.has(tg.fullPath.toLowerCase())) {
|
|
284
|
+
groupDeletions.push(tg.fullPath);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
logger.syncLog('success', `Preview complete: ${mappings.length} mappings, ${deletions.length} deletions, ${groupDeletions.length} group deletions`, 'preview');
|
|
290
|
+
return { mappings, deletions, groupDeletions };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ============================================================================
|
|
294
|
+
// Sync Engine
|
|
295
|
+
// ============================================================================
|
|
296
|
+
|
|
297
|
+
async executeSync(configId: string, force = false): Promise<void> {
|
|
298
|
+
if (this.stopping) {
|
|
299
|
+
logger.warn(`SyncManager is stopping, skipping sync ${configId}`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (this.runningSync.has(configId)) {
|
|
304
|
+
logger.warn(`Sync ${configId} already running, skipping`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const config = this.configs.find((c) => c.id === configId);
|
|
309
|
+
if (!config) return;
|
|
310
|
+
if (config.status === 'paused' && !force) return;
|
|
311
|
+
|
|
312
|
+
this.runningSync.add(configId);
|
|
313
|
+
this.syncedGroupMeta.clear();
|
|
314
|
+
this.currentSyncConfig = config;
|
|
315
|
+
const startTime = Date.now();
|
|
316
|
+
logger.syncLog('info', `Sync started for "${config.name}"`, 'sync');
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
const sourceConn = this.connectionManager.getConnection(config.sourceConnectionId);
|
|
320
|
+
const targetConn = this.connectionManager.getConnection(config.targetConnectionId);
|
|
321
|
+
if (!sourceConn) throw new Error(`Source connection not found: ${config.sourceConnectionId}`);
|
|
322
|
+
if (!targetConn) throw new Error(`Target connection not found: ${config.targetConnectionId}`);
|
|
323
|
+
|
|
324
|
+
this.validateSyncConfig(config);
|
|
325
|
+
|
|
326
|
+
// Get all projects from source
|
|
327
|
+
const sourceProvider = this.connectionManager.getProvider(config.sourceConnectionId);
|
|
328
|
+
logger.syncLog('info', `Fetching source projects from "${sourceConn.name}"...`, 'api');
|
|
329
|
+
const allProjects = await sourceProvider.getProjects();
|
|
330
|
+
const projects = allProjects.filter(p => !this.isObsoletePath(p.fullPath));
|
|
331
|
+
logger.syncLog('info', `Found ${projects.length} source projects (${allProjects.length - projects.length} obsolete excluded)`, 'api');
|
|
332
|
+
|
|
333
|
+
let synced = 0;
|
|
334
|
+
const CONCURRENCY = 10;
|
|
335
|
+
for (let i = 0; i < projects.length; i += CONCURRENCY) {
|
|
336
|
+
const batch = projects.slice(i, i + CONCURRENCY);
|
|
337
|
+
await Promise.all(batch.map(async (project) => {
|
|
338
|
+
try {
|
|
339
|
+
logger.syncLog('info', `Syncing ${project.fullPath}...`, 'sync');
|
|
340
|
+
await this.syncRepo(config, project, sourceConn, targetConn);
|
|
341
|
+
synced++;
|
|
342
|
+
await this.updateRepoStatus(config.id, project.fullPath, {
|
|
343
|
+
status: 'synced',
|
|
344
|
+
lastSyncAt: Date.now(),
|
|
345
|
+
lastSyncError: undefined,
|
|
346
|
+
});
|
|
347
|
+
logger.syncLog('success', `Synced ${project.fullPath}`, 'sync');
|
|
348
|
+
} catch (err) {
|
|
349
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
350
|
+
await this.updateRepoStatus(config.id, project.fullPath, {
|
|
351
|
+
status: 'error',
|
|
352
|
+
lastSyncError: errMsg,
|
|
353
|
+
lastSyncAt: Date.now(),
|
|
354
|
+
});
|
|
355
|
+
logger.syncLog('error', `Sync failed for ${project.fullPath}: ${errMsg}`, 'sync');
|
|
356
|
+
}
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Enforce deletion: move stale target repos to obsolete
|
|
361
|
+
if (config.enforceDelete) {
|
|
362
|
+
logger.syncLog('info', 'Checking for stale target repos...', 'sync');
|
|
363
|
+
await this.enforceDeleteStaleRepos(config, projects, sourceConn, targetConn);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Enforce group deletion: move stale target groups to obsolete
|
|
367
|
+
if (config.enforceGroupDelete) {
|
|
368
|
+
logger.syncLog('info', 'Checking for stale target groups...', 'sync');
|
|
369
|
+
await this.enforceDeleteStaleGroups(config, sourceConn, targetConn);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
config.lastSyncAt = Date.now();
|
|
373
|
+
config.reposSynced = synced;
|
|
374
|
+
config.lastSyncDurationMs = Date.now() - startTime;
|
|
375
|
+
config.lastSyncError = undefined;
|
|
376
|
+
if (config.status === 'error') config.status = 'active';
|
|
377
|
+
await this.persistConfig(config);
|
|
378
|
+
|
|
379
|
+
logger.syncLog('success', `Sync complete for "${config.name}": ${synced}/${projects.length} repos in ${config.lastSyncDurationMs}ms`, 'sync');
|
|
380
|
+
} catch (err) {
|
|
381
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
382
|
+
config.lastSyncError = errMsg;
|
|
383
|
+
config.lastSyncAt = Date.now();
|
|
384
|
+
config.lastSyncDurationMs = Date.now() - startTime;
|
|
385
|
+
config.status = 'error';
|
|
386
|
+
await this.persistConfig(config);
|
|
387
|
+
logger.syncLog('error', `Sync config "${config.name}" failed: ${errMsg}`, 'sync');
|
|
388
|
+
} finally {
|
|
389
|
+
this.runningSync.delete(configId);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ============================================================================
|
|
394
|
+
// Single Repo Sync
|
|
395
|
+
// ============================================================================
|
|
396
|
+
|
|
397
|
+
private async syncRepo(
|
|
398
|
+
config: interfaces.data.ISyncConfig,
|
|
399
|
+
project: interfaces.data.IProject,
|
|
400
|
+
sourceConn: interfaces.data.IProviderConnection,
|
|
401
|
+
targetConn: interfaces.data.IProviderConnection,
|
|
402
|
+
): Promise<void> {
|
|
403
|
+
const targetFullPath = this.computeTargetFullPath(
|
|
404
|
+
project.fullPath, sourceConn.groupFilter, config.targetGroupOffset,
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
// Build authenticated git URLs
|
|
408
|
+
const sourceUrl = this.buildAuthUrl(sourceConn, project.fullPath);
|
|
409
|
+
const targetUrl = this.buildAuthUrl(targetConn, targetFullPath);
|
|
410
|
+
|
|
411
|
+
// Mirror directory for this repo
|
|
412
|
+
const mirrorDir = plugins.path.join(
|
|
413
|
+
this.mirrorsPath,
|
|
414
|
+
config.id,
|
|
415
|
+
this.sanitizePath(project.fullPath),
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
// Ensure target group/project hierarchy exists
|
|
419
|
+
await this.ensureTargetExists(targetConn, targetFullPath, project, sourceConn, sourceConn.groupFilter, config.targetGroupOffset);
|
|
420
|
+
|
|
421
|
+
// API-based ref comparison (fast path — avoids git clone when refs already match)
|
|
422
|
+
const sourceProvider = this.connectionManager.getProvider(sourceConn.id);
|
|
423
|
+
const targetProvider = this.connectionManager.getProvider(targetConn.id);
|
|
424
|
+
const apiRefsMatch = await this.refsMatchViaApi(
|
|
425
|
+
sourceProvider, targetProvider, project.fullPath, targetFullPath,
|
|
426
|
+
);
|
|
427
|
+
if (apiRefsMatch === true) {
|
|
428
|
+
logger.syncLog('info', `Refs match via API for ${project.fullPath}, skipping git`, 'api');
|
|
429
|
+
await this.syncProjectMetadata(config, sourceConn, targetConn, project.fullPath, targetFullPath);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Clone or fetch from source
|
|
434
|
+
try {
|
|
435
|
+
const exists = await this.dirExists(mirrorDir);
|
|
436
|
+
if (!exists) {
|
|
437
|
+
await plugins.fs.mkdir(mirrorDir, { recursive: true });
|
|
438
|
+
await this.runGit(['clone', '--bare', sourceUrl, '.'], mirrorDir);
|
|
439
|
+
}
|
|
440
|
+
// Ensure fetch refspec is configured (bare clones don't set one by default,
|
|
441
|
+
// which prevents tracking branch renames like master -> main)
|
|
442
|
+
await this.runGit(
|
|
443
|
+
['config', 'remote.origin.fetch', '+refs/heads/*:refs/heads/*'], mirrorDir,
|
|
444
|
+
);
|
|
445
|
+
// Update source remote URL in case connection changed
|
|
446
|
+
try {
|
|
447
|
+
await this.runGit(['remote', 'set-url', 'origin', sourceUrl], mirrorDir);
|
|
448
|
+
} catch {
|
|
449
|
+
// Ignore errors
|
|
450
|
+
}
|
|
451
|
+
// Fetch latest refs from source (--prune removes branches deleted on remote)
|
|
452
|
+
await this.runGit(['fetch', '--prune', 'origin'], mirrorDir);
|
|
453
|
+
} catch (err: any) {
|
|
454
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
455
|
+
if (msg.includes("couldn't find remote ref HEAD")) {
|
|
456
|
+
logger.syncLog('warn', `Skipping empty repo ${project.fullPath} (no HEAD ref)`, 'git');
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
throw err;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Set up target remote and push
|
|
463
|
+
const remotes = await this.runGit(['remote'], mirrorDir);
|
|
464
|
+
if (!remotes.includes('target')) {
|
|
465
|
+
await this.runGit(['remote', 'add', 'target', targetUrl], mirrorDir);
|
|
466
|
+
} else {
|
|
467
|
+
await this.runGit(['remote', 'set-url', 'target', targetUrl], mirrorDir);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Check for unrelated history before mirror-pushing
|
|
471
|
+
const isUnrelated = await this.checkUnrelatedHistory(mirrorDir);
|
|
472
|
+
if (isUnrelated) {
|
|
473
|
+
logger.syncLog('warn', `Target "${targetFullPath}" has unrelated history — moving to obsolete`, 'git');
|
|
474
|
+
await this.moveToObsolete(targetConn, targetFullPath, config.targetGroupOffset);
|
|
475
|
+
// Re-create fresh target
|
|
476
|
+
await this.ensureTargetExists(targetConn, targetFullPath, project, sourceConn, sourceConn.groupFilter, config.targetGroupOffset);
|
|
477
|
+
this.actionLog.append({
|
|
478
|
+
actionType: 'obsolete',
|
|
479
|
+
entityType: 'sync',
|
|
480
|
+
entityId: config.id,
|
|
481
|
+
entityName: config.name,
|
|
482
|
+
details: `Moved unrelated repo "${targetFullPath}" to obsolete`,
|
|
483
|
+
username: 'system',
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Compare refs to determine if push is needed
|
|
488
|
+
const refsAlreadyMatch = !isUnrelated && await this.refsMatch(mirrorDir);
|
|
489
|
+
|
|
490
|
+
if (refsAlreadyMatch) {
|
|
491
|
+
logger.syncLog('info', `Refs already match for ${project.fullPath}, skipping push`, 'api');
|
|
492
|
+
} else {
|
|
493
|
+
// Phase 1: push all refs without pruning (ensures target has all source branches)
|
|
494
|
+
await this.runGit([
|
|
495
|
+
'push', 'target',
|
|
496
|
+
'+refs/heads/*:refs/heads/*',
|
|
497
|
+
'+refs/tags/*:refs/tags/*',
|
|
498
|
+
], mirrorDir);
|
|
499
|
+
|
|
500
|
+
// Phase 2: sync default_branch now that all branches exist on target
|
|
501
|
+
await this.syncDefaultBranchBeforePush(sourceConn, targetConn, project.fullPath, targetFullPath);
|
|
502
|
+
|
|
503
|
+
// Phase 2b: unprotect stale branches on target so --prune can delete them
|
|
504
|
+
await this.unprotectStaleBranches(targetConn, targetFullPath, mirrorDir);
|
|
505
|
+
|
|
506
|
+
// Phase 3: push with --prune to remove stale branches (safe now that default_branch is correct)
|
|
507
|
+
await this.runGit([
|
|
508
|
+
'push', 'target',
|
|
509
|
+
'+refs/heads/*:refs/heads/*',
|
|
510
|
+
'+refs/tags/*:refs/tags/*',
|
|
511
|
+
'--prune',
|
|
512
|
+
], mirrorDir);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Sync project metadata (description, visibility, topics, default_branch, avatar)
|
|
516
|
+
await this.syncProjectMetadata(config, sourceConn, targetConn, project.fullPath, targetFullPath);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ============================================================================
|
|
520
|
+
// Target Hierarchy Creation
|
|
521
|
+
// ============================================================================
|
|
522
|
+
|
|
523
|
+
private async ensureTargetExists(
|
|
524
|
+
targetConn: interfaces.data.IProviderConnection,
|
|
525
|
+
targetFullPath: string,
|
|
526
|
+
sourceProject: interfaces.data.IProject,
|
|
527
|
+
sourceConn?: interfaces.data.IProviderConnection,
|
|
528
|
+
sourceGroupFilter?: string,
|
|
529
|
+
targetGroupOffset?: string,
|
|
530
|
+
): Promise<void> {
|
|
531
|
+
const segments = targetFullPath.split('/');
|
|
532
|
+
const projectName = segments.pop()!;
|
|
533
|
+
const groupSegments = segments;
|
|
534
|
+
|
|
535
|
+
if (targetConn.providerType === 'gitlab') {
|
|
536
|
+
await this.ensureGitLabTarget(targetConn, groupSegments, projectName, sourceProject, sourceConn, sourceGroupFilter, targetGroupOffset);
|
|
537
|
+
} else {
|
|
538
|
+
await this.ensureGiteaTarget(targetConn, groupSegments, projectName, sourceProject, sourceConn, sourceGroupFilter, targetGroupOffset);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private async ensureGitLabTarget(
|
|
543
|
+
conn: interfaces.data.IProviderConnection,
|
|
544
|
+
groupSegments: string[],
|
|
545
|
+
projectName: string,
|
|
546
|
+
sourceProject: interfaces.data.IProject,
|
|
547
|
+
sourceConn?: interfaces.data.IProviderConnection,
|
|
548
|
+
sourceGroupFilter?: string,
|
|
549
|
+
targetGroupOffset?: string,
|
|
550
|
+
): Promise<void> {
|
|
551
|
+
const client = new plugins.gitlabClient.GitLabClient(conn.baseUrl, conn.token);
|
|
552
|
+
|
|
553
|
+
// Walk group hierarchy top-down, creating each if needed
|
|
554
|
+
let parentId: number | undefined = undefined;
|
|
555
|
+
let currentPath = '';
|
|
556
|
+
|
|
557
|
+
for (const segment of groupSegments) {
|
|
558
|
+
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
|
559
|
+
try {
|
|
560
|
+
const group = await client.getGroup(currentPath);
|
|
561
|
+
parentId = group.id;
|
|
562
|
+
} catch {
|
|
563
|
+
// Group doesn't exist — create it
|
|
564
|
+
try {
|
|
565
|
+
const newGroup = await client.createGroup(segment, segment, parentId);
|
|
566
|
+
parentId = newGroup.id;
|
|
567
|
+
logger.info(`Created GitLab group: ${currentPath}`);
|
|
568
|
+
} catch (createErr: any) {
|
|
569
|
+
// 409 = already exists (race condition), try fetching again
|
|
570
|
+
if (String(createErr).includes('409') || String(createErr).includes('already')) {
|
|
571
|
+
const group = await client.getGroup(currentPath);
|
|
572
|
+
parentId = group.id;
|
|
573
|
+
} else {
|
|
574
|
+
throw createErr;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Sync group metadata from source (once per group per sync cycle)
|
|
580
|
+
if (sourceConn && !this.syncedGroupMeta.has(currentPath)) {
|
|
581
|
+
const sourceGroupPath = this.reverseTargetGroupPath(currentPath, sourceGroupFilter, targetGroupOffset);
|
|
582
|
+
if (sourceGroupPath) {
|
|
583
|
+
this.syncedGroupMeta.add(currentPath);
|
|
584
|
+
await this.syncGroupMetadata(sourceConn, conn, sourceGroupPath, currentPath);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Create the project if it doesn't exist
|
|
590
|
+
const projectPath = groupSegments.length > 0
|
|
591
|
+
? `${groupSegments.join('/')}/${projectName}`
|
|
592
|
+
: projectName;
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
// Check if project exists by path
|
|
596
|
+
await client.getGroup(projectPath);
|
|
597
|
+
// If this succeeds, it's actually a group, not a project... unlikely but handle
|
|
598
|
+
} catch {
|
|
599
|
+
// Project doesn't exist as a group path; try creating it
|
|
600
|
+
try {
|
|
601
|
+
await client.createProject(projectName, {
|
|
602
|
+
path: projectName,
|
|
603
|
+
namespaceId: parentId,
|
|
604
|
+
description: sourceProject.description,
|
|
605
|
+
visibility: sourceProject.visibility || 'private',
|
|
606
|
+
});
|
|
607
|
+
logger.info(`Created GitLab project: ${projectPath}`);
|
|
608
|
+
} catch (createErr: any) {
|
|
609
|
+
// Already exists is fine
|
|
610
|
+
if (!String(createErr).includes('409') && !String(createErr).includes('already been taken')) {
|
|
611
|
+
throw createErr;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private async ensureGiteaTarget(
|
|
618
|
+
conn: interfaces.data.IProviderConnection,
|
|
619
|
+
groupSegments: string[],
|
|
620
|
+
projectName: string,
|
|
621
|
+
sourceProject: interfaces.data.IProject,
|
|
622
|
+
sourceConn?: interfaces.data.IProviderConnection,
|
|
623
|
+
sourceGroupFilter?: string,
|
|
624
|
+
targetGroupOffset?: string,
|
|
625
|
+
): Promise<void> {
|
|
626
|
+
const client = new plugins.giteaClient.GiteaClient(conn.baseUrl, conn.token);
|
|
627
|
+
|
|
628
|
+
// Gitea has flat orgs (no nesting). Use the first segment as org name.
|
|
629
|
+
// If there are nested segments, join them into the repo name.
|
|
630
|
+
const orgName = groupSegments[0] || conn.groupFilter || 'default';
|
|
631
|
+
const repoName = groupSegments.length > 1
|
|
632
|
+
? [...groupSegments.slice(1), projectName].join('-')
|
|
633
|
+
: projectName;
|
|
634
|
+
|
|
635
|
+
// Ensure org exists
|
|
636
|
+
try {
|
|
637
|
+
await client.getOrg(orgName);
|
|
638
|
+
} catch {
|
|
639
|
+
try {
|
|
640
|
+
await client.createOrg(orgName, { visibility: 'public' });
|
|
641
|
+
logger.info(`Created Gitea org: ${orgName}`);
|
|
642
|
+
} catch (createErr: any) {
|
|
643
|
+
if (!String(createErr).includes('409') && !String(createErr).includes('already')) {
|
|
644
|
+
throw createErr;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Sync org metadata from source if we have the source connection
|
|
650
|
+
if (sourceConn && groupSegments[0] && !this.syncedGroupMeta.has(groupSegments[0])) {
|
|
651
|
+
const sourceGroupPath = this.reverseTargetGroupPath(groupSegments[0], sourceGroupFilter, targetGroupOffset);
|
|
652
|
+
if (sourceGroupPath) {
|
|
653
|
+
this.syncedGroupMeta.add(groupSegments[0]);
|
|
654
|
+
await this.syncGroupMetadata(sourceConn, conn, sourceGroupPath, groupSegments[0]);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Ensure repo exists in org
|
|
659
|
+
try {
|
|
660
|
+
await client.createOrgRepo(orgName, repoName, {
|
|
661
|
+
description: sourceProject.description,
|
|
662
|
+
private: sourceProject.visibility !== 'public',
|
|
663
|
+
});
|
|
664
|
+
logger.info(`Created Gitea repo: ${orgName}/${repoName}`);
|
|
665
|
+
} catch (createErr: any) {
|
|
666
|
+
// Already exists is fine
|
|
667
|
+
if (!String(createErr).includes('409') && !String(createErr).includes('already')) {
|
|
668
|
+
throw createErr;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ============================================================================
|
|
674
|
+
// Enforce Delete — remove stale target repos
|
|
675
|
+
// ============================================================================
|
|
676
|
+
|
|
677
|
+
private async enforceDeleteStaleRepos(
|
|
678
|
+
config: interfaces.data.ISyncConfig,
|
|
679
|
+
sourceProjects: interfaces.data.IProject[],
|
|
680
|
+
sourceConn: interfaces.data.IProviderConnection,
|
|
681
|
+
targetConn: interfaces.data.IProviderConnection,
|
|
682
|
+
): Promise<void> {
|
|
683
|
+
// Build set of expected target fullPaths from source
|
|
684
|
+
const expectedTargetPaths = new Set<string>();
|
|
685
|
+
for (const project of sourceProjects) {
|
|
686
|
+
const targetFullPath = this.computeTargetFullPath(
|
|
687
|
+
project.fullPath, sourceConn.groupFilter, config.targetGroupOffset,
|
|
688
|
+
);
|
|
689
|
+
expectedTargetPaths.add(targetFullPath.toLowerCase());
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Scope prefix — only delete repos under this prefix
|
|
693
|
+
const scopePrefix = config.targetGroupOffset;
|
|
694
|
+
|
|
695
|
+
// List all target projects (filtered by connection's groupFilter)
|
|
696
|
+
const targetProvider = this.connectionManager.getProvider(config.targetConnectionId);
|
|
697
|
+
const targetProjects = await targetProvider.getProjects();
|
|
698
|
+
|
|
699
|
+
// Delete target projects not in the expected set, scoped to the prefix
|
|
700
|
+
for (const targetProject of targetProjects) {
|
|
701
|
+
if (this.isObsoletePath(targetProject.fullPath)) continue;
|
|
702
|
+
// Skip repos outside our managed prefix
|
|
703
|
+
if (scopePrefix && !targetProject.fullPath.toLowerCase().startsWith(scopePrefix.toLowerCase() + '/')) {
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
if (!expectedTargetPaths.has(targetProject.fullPath.toLowerCase())) {
|
|
707
|
+
try {
|
|
708
|
+
await this.moveToObsolete(targetConn, targetProject.fullPath, config.targetGroupOffset);
|
|
709
|
+
logger.syncLog('warn', `Moved stale target repo "${targetProject.fullPath}" to obsolete`, 'sync');
|
|
710
|
+
this.actionLog.append({
|
|
711
|
+
actionType: 'obsolete',
|
|
712
|
+
entityType: 'sync',
|
|
713
|
+
entityId: config.id,
|
|
714
|
+
entityName: config.name,
|
|
715
|
+
details: `Moved stale target repo "${targetProject.fullPath}" to obsolete`,
|
|
716
|
+
username: 'system',
|
|
717
|
+
});
|
|
718
|
+
} catch (err) {
|
|
719
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
720
|
+
logger.syncLog('error', `Enforce-delete failed for "${targetProject.fullPath}": ${errMsg}`, 'sync');
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// ============================================================================
|
|
727
|
+
// Enforce Delete — remove stale target groups/orgs
|
|
728
|
+
// ============================================================================
|
|
729
|
+
|
|
730
|
+
private async enforceDeleteStaleGroups(
|
|
731
|
+
config: interfaces.data.ISyncConfig,
|
|
732
|
+
sourceConn: interfaces.data.IProviderConnection,
|
|
733
|
+
targetConn: interfaces.data.IProviderConnection,
|
|
734
|
+
): Promise<void> {
|
|
735
|
+
const sourceProvider = this.connectionManager.getProvider(config.sourceConnectionId);
|
|
736
|
+
const targetProvider = this.connectionManager.getProvider(config.targetConnectionId);
|
|
737
|
+
|
|
738
|
+
// Build expected target group paths from source groups
|
|
739
|
+
const sourceGroups = await sourceProvider.getGroups();
|
|
740
|
+
const expectedTargetGroups = new Set<string>();
|
|
741
|
+
for (const sg of sourceGroups) {
|
|
742
|
+
const targetPath = this.computeTargetFullPath(
|
|
743
|
+
sg.fullPath, sourceConn.groupFilter, config.targetGroupOffset,
|
|
744
|
+
);
|
|
745
|
+
expectedTargetGroups.add(targetPath.toLowerCase());
|
|
746
|
+
}
|
|
747
|
+
// Always keep the offset itself and the obsolete group
|
|
748
|
+
if (config.targetGroupOffset) {
|
|
749
|
+
expectedTargetGroups.add(config.targetGroupOffset.toLowerCase());
|
|
750
|
+
expectedTargetGroups.add(`${config.targetGroupOffset}/obsolete`.toLowerCase());
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Find stale groups
|
|
754
|
+
const targetGroups = await targetProvider.getGroups();
|
|
755
|
+
const scopePrefix = config.targetGroupOffset;
|
|
756
|
+
const staleGroups: interfaces.data.IGroup[] = [];
|
|
757
|
+
|
|
758
|
+
for (const tg of targetGroups) {
|
|
759
|
+
if (this.isObsoletePath(tg.fullPath)) continue;
|
|
760
|
+
if (scopePrefix && !tg.fullPath.toLowerCase().startsWith(scopePrefix.toLowerCase() + '/')) {
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (!expectedTargetGroups.has(tg.fullPath.toLowerCase())) {
|
|
764
|
+
staleGroups.push(tg);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (staleGroups.length === 0) return;
|
|
769
|
+
|
|
770
|
+
// Sort by path depth (shallowest first) to move top-level groups first
|
|
771
|
+
staleGroups.sort((a, b) => {
|
|
772
|
+
const depthA = a.fullPath.split('/').length;
|
|
773
|
+
const depthB = b.fullPath.split('/').length;
|
|
774
|
+
return depthA - depthB;
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
// Track moved prefixes to skip children already moved with their parent
|
|
778
|
+
const movedPrefixes: string[] = [];
|
|
779
|
+
|
|
780
|
+
for (const group of staleGroups) {
|
|
781
|
+
// Skip if a parent was already moved (GitLab moves children along)
|
|
782
|
+
const isChildOfMoved = movedPrefixes.some(
|
|
783
|
+
(prefix) => group.fullPath.toLowerCase().startsWith(prefix.toLowerCase() + '/'),
|
|
784
|
+
);
|
|
785
|
+
if (isChildOfMoved && targetConn.providerType === 'gitlab') continue;
|
|
786
|
+
|
|
787
|
+
try {
|
|
788
|
+
if (targetConn.providerType === 'gitlab') {
|
|
789
|
+
await this.moveGroupToObsolete(targetConn, group.fullPath, config.targetGroupOffset);
|
|
790
|
+
} else {
|
|
791
|
+
await this.moveGiteaOrgToObsolete(targetConn, group.fullPath, config.targetGroupOffset);
|
|
792
|
+
}
|
|
793
|
+
movedPrefixes.push(group.fullPath);
|
|
794
|
+
logger.syncLog('warn', `Moved stale group "${group.fullPath}" to obsolete`, 'sync');
|
|
795
|
+
this.actionLog.append({
|
|
796
|
+
actionType: 'obsolete',
|
|
797
|
+
entityType: 'sync',
|
|
798
|
+
entityId: config.id,
|
|
799
|
+
entityName: config.name,
|
|
800
|
+
details: `Moved stale target group "${group.fullPath}" to obsolete`,
|
|
801
|
+
username: 'system',
|
|
802
|
+
});
|
|
803
|
+
} catch (err) {
|
|
804
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
805
|
+
logger.syncLog('error', `Enforce-group-delete failed for "${group.fullPath}": ${errMsg}`, 'sync');
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Move a GitLab group to the obsolete group with a unique suffix.
|
|
812
|
+
* Transfers the entire group (including subgroups and projects).
|
|
813
|
+
*/
|
|
814
|
+
private async moveGroupToObsolete(
|
|
815
|
+
targetConn: interfaces.data.IProviderConnection,
|
|
816
|
+
groupFullPath: string,
|
|
817
|
+
basePath?: string,
|
|
818
|
+
): Promise<void> {
|
|
819
|
+
const suffix = this.generateSuffix();
|
|
820
|
+
const obsoleteTarget = await this.ensureObsoleteGroup(targetConn, basePath);
|
|
821
|
+
if (obsoleteTarget.type !== 'gitlab') return;
|
|
822
|
+
|
|
823
|
+
// Get group by path
|
|
824
|
+
const group = await this.rawApiCall(
|
|
825
|
+
targetConn, 'GET',
|
|
826
|
+
`/api/v4/groups/${encodeURIComponent(groupFullPath)}`,
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
// Transfer group to be a child of the obsolete group
|
|
830
|
+
await this.rawApiCall(
|
|
831
|
+
targetConn, 'POST',
|
|
832
|
+
`/api/v4/groups/${group.id}/transfer`,
|
|
833
|
+
{ group_id: obsoleteTarget.groupId },
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
// Rename with suffix + set private
|
|
837
|
+
const originalPath = groupFullPath.split('/').pop()!;
|
|
838
|
+
await this.rawApiCall(
|
|
839
|
+
targetConn, 'PUT',
|
|
840
|
+
`/api/v4/groups/${group.id}`,
|
|
841
|
+
{ name: `${originalPath}-${suffix}`, path: `${originalPath}-${suffix}`, visibility: 'private' },
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
logger.info(`Moved GitLab group "${groupFullPath}" to obsolete as "${originalPath}-${suffix}"`);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Move all repos in a Gitea org to obsolete, then delete the empty org.
|
|
849
|
+
* Gitea orgs can't be transferred, so we move repos individually.
|
|
850
|
+
*/
|
|
851
|
+
private async moveGiteaOrgToObsolete(
|
|
852
|
+
targetConn: interfaces.data.IProviderConnection,
|
|
853
|
+
orgName: string,
|
|
854
|
+
basePath?: string,
|
|
855
|
+
): Promise<void> {
|
|
856
|
+
// List all repos in the stale org (auto-paginate)
|
|
857
|
+
const allRepos: any[] = [];
|
|
858
|
+
let page = 1;
|
|
859
|
+
const perPage = 50;
|
|
860
|
+
while (true) {
|
|
861
|
+
const repos = await this.rawApiCall(
|
|
862
|
+
targetConn, 'GET',
|
|
863
|
+
`/api/v1/orgs/${encodeURIComponent(orgName)}/repos?page=${page}&limit=${perPage}`,
|
|
864
|
+
);
|
|
865
|
+
const repoList = Array.isArray(repos) ? repos : [];
|
|
866
|
+
allRepos.push(...repoList);
|
|
867
|
+
if (repoList.length < perPage) break;
|
|
868
|
+
page++;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Move each repo to obsolete
|
|
872
|
+
for (const repo of allRepos) {
|
|
873
|
+
try {
|
|
874
|
+
await this.moveToObsolete(targetConn, `${orgName}/${repo.name}`, basePath);
|
|
875
|
+
} catch (err) {
|
|
876
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
877
|
+
logger.error(`Failed to move repo "${orgName}/${repo.name}" to obsolete: ${errMsg}`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Delete the now-empty org
|
|
882
|
+
try {
|
|
883
|
+
await this.rawApiCall(
|
|
884
|
+
targetConn, 'DELETE',
|
|
885
|
+
`/api/v1/orgs/${encodeURIComponent(orgName)}`,
|
|
886
|
+
);
|
|
887
|
+
logger.info(`Deleted empty Gitea org: ${orgName}`);
|
|
888
|
+
} catch (err) {
|
|
889
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
890
|
+
logger.error(`Failed to delete empty Gitea org "${orgName}": ${errMsg}`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// ============================================================================
|
|
895
|
+
// Helpers
|
|
896
|
+
// ============================================================================
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Returns true if the given full path belongs to an obsolete namespace.
|
|
900
|
+
* Matches path segments named "obsolete" or ending with "-obsolete".
|
|
901
|
+
*/
|
|
902
|
+
private isObsoletePath(fullPath: string): boolean {
|
|
903
|
+
const segments = fullPath.toLowerCase().split('/');
|
|
904
|
+
return segments.some(s => s === 'obsolete' || s.endsWith('-obsolete'));
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
private buildAuthUrl(conn: interfaces.data.IProviderConnection, repoPath: string): string {
|
|
908
|
+
const url = new URL(conn.baseUrl);
|
|
909
|
+
if (conn.providerType === 'gitlab') {
|
|
910
|
+
return `${url.protocol}//oauth2:${conn.token}@${url.host}/${repoPath}.git`;
|
|
911
|
+
} else {
|
|
912
|
+
return `${url.protocol}//gitea-token:${conn.token}@${url.host}/${repoPath}.git`;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
private computeRelativePath(fullPath: string, groupFilter?: string): string {
|
|
917
|
+
if (!groupFilter) return fullPath;
|
|
918
|
+
if (fullPath.startsWith(groupFilter + '/')) {
|
|
919
|
+
return fullPath.substring(groupFilter.length + 1);
|
|
920
|
+
}
|
|
921
|
+
return fullPath;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Reverse-map a target group path to the corresponding source group path.
|
|
926
|
+
* Returns null if the target path is part of the offset itself (not a source-derived group).
|
|
927
|
+
*/
|
|
928
|
+
private reverseTargetGroupPath(
|
|
929
|
+
targetGroupPath: string,
|
|
930
|
+
sourceGroupFilter?: string,
|
|
931
|
+
targetGroupOffset?: string,
|
|
932
|
+
): string | null {
|
|
933
|
+
let relativePath = targetGroupPath;
|
|
934
|
+
|
|
935
|
+
// Strip the target offset prefix
|
|
936
|
+
if (targetGroupOffset) {
|
|
937
|
+
if (targetGroupPath === targetGroupOffset) {
|
|
938
|
+
// This IS the offset group itself, not a source-derived group
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
if (targetGroupPath.startsWith(targetGroupOffset + '/')) {
|
|
942
|
+
relativePath = targetGroupPath.substring(targetGroupOffset.length + 1);
|
|
943
|
+
} else {
|
|
944
|
+
// Target path is not under the offset — can't reverse-map
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Re-add the source group filter prefix
|
|
950
|
+
if (sourceGroupFilter) {
|
|
951
|
+
return `${sourceGroupFilter}/${relativePath}`;
|
|
952
|
+
}
|
|
953
|
+
return relativePath;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
private computeTargetFullPath(
|
|
957
|
+
sourceFullPath: string,
|
|
958
|
+
sourceGroupFilter?: string,
|
|
959
|
+
targetGroupOffset?: string,
|
|
960
|
+
): string {
|
|
961
|
+
const relativePath = this.computeRelativePath(sourceFullPath, sourceGroupFilter);
|
|
962
|
+
return targetGroupOffset ? `${targetGroupOffset}/${relativePath}` : relativePath;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Validates that the sync config's targetGroupOffset is reachable
|
|
967
|
+
* by the target connection's groupFilter. Throws when enforceDelete
|
|
968
|
+
* is on and the offset is outside the filter scope.
|
|
969
|
+
*/
|
|
970
|
+
private validateSyncConfig(config: {
|
|
971
|
+
targetConnectionId: string;
|
|
972
|
+
targetGroupOffset?: string;
|
|
973
|
+
intervalMinutes?: number;
|
|
974
|
+
enforceDelete: boolean;
|
|
975
|
+
}): void {
|
|
976
|
+
validateIntervalMinutes(config.intervalMinutes, 'sync intervalMinutes');
|
|
977
|
+
if (!config.targetGroupOffset) return;
|
|
978
|
+
const targetConn = this.connectionManager.getConnection(config.targetConnectionId);
|
|
979
|
+
if (!targetConn?.groupFilter) return;
|
|
980
|
+
|
|
981
|
+
const offset = config.targetGroupOffset.toLowerCase();
|
|
982
|
+
const filter = targetConn.groupFilter.toLowerCase();
|
|
983
|
+
const inScope = offset === filter || offset.startsWith(filter + '/');
|
|
984
|
+
|
|
985
|
+
if (!inScope && config.enforceDelete) {
|
|
986
|
+
throw new Error(
|
|
987
|
+
`Target group offset "${config.targetGroupOffset}" is outside target connection's ` +
|
|
988
|
+
`group filter "${targetConn.groupFilter}". With enforce-delete enabled, the sync ` +
|
|
989
|
+
`engine cannot list repos under the offset. Either change the offset to be within ` +
|
|
990
|
+
`"${targetConn.groupFilter}/...", remove the target's group filter, or disable enforce-delete.`
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (!inScope) {
|
|
995
|
+
logger.warn(
|
|
996
|
+
`Target group offset "${config.targetGroupOffset}" is outside target connection's ` +
|
|
997
|
+
`group filter "${targetConn.groupFilter}". Git pushes will work but repos won't ` +
|
|
998
|
+
`appear in the target connection's listings.`
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// ============================================================================
|
|
1004
|
+
// Metadata Sync
|
|
1005
|
+
// ============================================================================
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Download binary data (e.g. avatar image) with provider auth headers.
|
|
1009
|
+
* Returns null on 404 or error.
|
|
1010
|
+
*/
|
|
1011
|
+
private async rawBinaryFetch(
|
|
1012
|
+
conn: interfaces.data.IProviderConnection,
|
|
1013
|
+
url: string,
|
|
1014
|
+
): Promise<Uint8Array | null> {
|
|
1015
|
+
try {
|
|
1016
|
+
const headers: Record<string, string> = {};
|
|
1017
|
+
if (conn.providerType === 'gitlab') {
|
|
1018
|
+
headers['PRIVATE-TOKEN'] = conn.token;
|
|
1019
|
+
} else {
|
|
1020
|
+
headers['Authorization'] = `token ${conn.token}`;
|
|
1021
|
+
}
|
|
1022
|
+
const resp = await fetch(url, { headers });
|
|
1023
|
+
if (!resp.ok) {
|
|
1024
|
+
await resp.body?.cancel();
|
|
1025
|
+
return null;
|
|
1026
|
+
}
|
|
1027
|
+
return new Uint8Array(await resp.arrayBuffer());
|
|
1028
|
+
} catch {
|
|
1029
|
+
return null;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Raw multipart call for avatar uploads (GitLab requires multipart FormData).
|
|
1035
|
+
*/
|
|
1036
|
+
private async rawMultipartCall(
|
|
1037
|
+
conn: interfaces.data.IProviderConnection,
|
|
1038
|
+
method: string,
|
|
1039
|
+
apiPath: string,
|
|
1040
|
+
formData: FormData,
|
|
1041
|
+
): Promise<any> {
|
|
1042
|
+
const baseUrl = conn.baseUrl.replace(/\/+$/, '');
|
|
1043
|
+
const url = `${baseUrl}${apiPath}`;
|
|
1044
|
+
const headers: Record<string, string> = {};
|
|
1045
|
+
if (conn.providerType === 'gitlab') {
|
|
1046
|
+
headers['PRIVATE-TOKEN'] = conn.token;
|
|
1047
|
+
} else {
|
|
1048
|
+
headers['Authorization'] = `token ${conn.token}`;
|
|
1049
|
+
}
|
|
1050
|
+
// Do NOT set Content-Type — let fetch set the multipart boundary
|
|
1051
|
+
const resp = await fetch(url, { method, headers, body: formData });
|
|
1052
|
+
if (!resp.ok) {
|
|
1053
|
+
const text = await resp.text();
|
|
1054
|
+
throw new Error(`${method} ${apiPath}: ${resp.status} - ${text}`);
|
|
1055
|
+
}
|
|
1056
|
+
try {
|
|
1057
|
+
return await resp.json();
|
|
1058
|
+
} catch {
|
|
1059
|
+
return undefined;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Normalize visibility values between GitLab and Gitea.
|
|
1065
|
+
* GitLab: "public" | "internal" | "private"
|
|
1066
|
+
* Gitea: "public" | "limited" | "private"
|
|
1067
|
+
*/
|
|
1068
|
+
private normalizeVisibility(visibility: string): string {
|
|
1069
|
+
const v = visibility?.toLowerCase() || 'private';
|
|
1070
|
+
if (v === 'limited') return 'internal';
|
|
1071
|
+
return v;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Guess MIME type from binary content or URL for avatar uploads.
|
|
1076
|
+
*/
|
|
1077
|
+
private guessAvatarMimeType(data: Uint8Array, url: string): string {
|
|
1078
|
+
// Check magic bytes
|
|
1079
|
+
if (data[0] === 0x89 && data[1] === 0x50) return 'image/png';
|
|
1080
|
+
if (data[0] === 0xFF && data[1] === 0xD8) return 'image/jpeg';
|
|
1081
|
+
if (data[0] === 0x47 && data[1] === 0x49) return 'image/gif';
|
|
1082
|
+
// SVG: text-based XML, no magic bytes — check content
|
|
1083
|
+
const textStart = new TextDecoder().decode(data.slice(0, 200));
|
|
1084
|
+
if (textStart.includes('<svg') || textStart.includes('<?xml')) return 'image/svg+xml';
|
|
1085
|
+
// Fallback: check URL extension
|
|
1086
|
+
if (url.includes('.png')) return 'image/png';
|
|
1087
|
+
if (url.includes('.jpg') || url.includes('.jpeg')) return 'image/jpeg';
|
|
1088
|
+
if (url.includes('.gif')) return 'image/gif';
|
|
1089
|
+
if (url.includes('.svg')) return 'image/svg+xml';
|
|
1090
|
+
return 'image/png'; // default
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Pre-push: ensure target's default_branch matches source so --prune won't delete it.
|
|
1095
|
+
*/
|
|
1096
|
+
private async syncDefaultBranchBeforePush(
|
|
1097
|
+
sourceConn: interfaces.data.IProviderConnection,
|
|
1098
|
+
targetConn: interfaces.data.IProviderConnection,
|
|
1099
|
+
sourceFullPath: string,
|
|
1100
|
+
targetFullPath: string,
|
|
1101
|
+
): Promise<void> {
|
|
1102
|
+
try {
|
|
1103
|
+
const sourceProject = await this.fetchProjectRaw(sourceConn, sourceFullPath);
|
|
1104
|
+
if (!sourceProject) return;
|
|
1105
|
+
const targetProject = await this.fetchProjectRaw(targetConn, targetFullPath);
|
|
1106
|
+
if (!targetProject) return;
|
|
1107
|
+
|
|
1108
|
+
const sourceBranch = sourceProject.default_branch || 'main';
|
|
1109
|
+
const targetBranch = targetProject.default_branch || 'main';
|
|
1110
|
+
|
|
1111
|
+
if (sourceBranch !== targetBranch) {
|
|
1112
|
+
logger.syncLog('info', `Updating default branch for ${targetFullPath}: ${targetBranch} -> ${sourceBranch}`, 'api');
|
|
1113
|
+
if (targetConn.providerType === 'gitlab') {
|
|
1114
|
+
await this.rawApiCall(targetConn, 'PUT', `/api/v4/projects/${targetProject.id}`, {
|
|
1115
|
+
default_branch: sourceBranch,
|
|
1116
|
+
});
|
|
1117
|
+
} else {
|
|
1118
|
+
const segments = targetFullPath.split('/');
|
|
1119
|
+
const repo = segments.pop()!;
|
|
1120
|
+
const owner = segments[0] || '';
|
|
1121
|
+
await this.rawApiCall(targetConn, 'PATCH', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, {
|
|
1122
|
+
default_branch: sourceBranch,
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
} catch (err) {
|
|
1127
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1128
|
+
logger.syncLog('warn', `Pre-push default_branch sync failed for ${targetFullPath}: ${errMsg}`, 'api');
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Unprotect branches on the target that no longer exist in the source,
|
|
1134
|
+
* so that git push --prune can delete them.
|
|
1135
|
+
*/
|
|
1136
|
+
private async unprotectStaleBranches(
|
|
1137
|
+
targetConn: interfaces.data.IProviderConnection,
|
|
1138
|
+
targetFullPath: string,
|
|
1139
|
+
mirrorDir: string,
|
|
1140
|
+
): Promise<void> {
|
|
1141
|
+
if (targetConn.providerType !== 'gitlab') return;
|
|
1142
|
+
try {
|
|
1143
|
+
const targetProject = await this.fetchProjectRaw(targetConn, targetFullPath);
|
|
1144
|
+
if (!targetProject) return;
|
|
1145
|
+
|
|
1146
|
+
const client = new plugins.gitlabClient.GitLabClient(targetConn.baseUrl, targetConn.token);
|
|
1147
|
+
const protectedBranches = await client.requestGetProtectedBranches(targetProject.id);
|
|
1148
|
+
if (protectedBranches.length === 0) return;
|
|
1149
|
+
|
|
1150
|
+
// Get list of branches in the local mirror (= source branches)
|
|
1151
|
+
const localBranchOutput = await this.runGit(['branch', '--list'], mirrorDir);
|
|
1152
|
+
const localBranches = new Set(
|
|
1153
|
+
localBranchOutput.split('\n').map(b => b.trim().replace(/^\* /, '')).filter(Boolean),
|
|
1154
|
+
);
|
|
1155
|
+
|
|
1156
|
+
for (const pb of protectedBranches) {
|
|
1157
|
+
if (!localBranches.has(pb.name)) {
|
|
1158
|
+
logger.syncLog('info', `Unprotecting stale branch "${pb.name}" on ${targetFullPath}`, 'api');
|
|
1159
|
+
await client.requestUnprotectBranch(targetProject.id, pb.name);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
} catch (err) {
|
|
1163
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1164
|
+
logger.syncLog('warn', `Failed to unprotect stale branches for ${targetFullPath}: ${errMsg}`, 'api');
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Sync project metadata (description, visibility, topics, default_branch, avatar)
|
|
1170
|
+
* from source to target after the git push.
|
|
1171
|
+
*/
|
|
1172
|
+
private async syncProjectMetadata(
|
|
1173
|
+
config: interfaces.data.ISyncConfig,
|
|
1174
|
+
sourceConn: interfaces.data.IProviderConnection,
|
|
1175
|
+
targetConn: interfaces.data.IProviderConnection,
|
|
1176
|
+
sourceFullPath: string,
|
|
1177
|
+
targetFullPath: string,
|
|
1178
|
+
): Promise<void> {
|
|
1179
|
+
try {
|
|
1180
|
+
// Fetch source project raw JSON
|
|
1181
|
+
const sourceProject = await this.fetchProjectRaw(sourceConn, sourceFullPath);
|
|
1182
|
+
if (!sourceProject) return;
|
|
1183
|
+
|
|
1184
|
+
// Fetch target project raw JSON
|
|
1185
|
+
const targetProject = await this.fetchProjectRaw(targetConn, targetFullPath);
|
|
1186
|
+
if (!targetProject) return;
|
|
1187
|
+
|
|
1188
|
+
// Extract normalized metadata from both
|
|
1189
|
+
const sourceMeta = this.extractProjectMeta(sourceConn, sourceProject);
|
|
1190
|
+
const targetMeta = this.extractProjectMeta(targetConn, targetProject);
|
|
1191
|
+
|
|
1192
|
+
// Append mirror hint to description if enabled
|
|
1193
|
+
if (config.addMirrorHint) {
|
|
1194
|
+
const mirrorUrl = `${sourceConn.baseUrl.replace(/\/+$/, '')}/${sourceFullPath}`;
|
|
1195
|
+
sourceMeta.description = `${sourceMeta.description}\n\n(This is a mirror of ${mirrorUrl})`.trim();
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Diff and update text metadata
|
|
1199
|
+
const changes: string[] = [];
|
|
1200
|
+
|
|
1201
|
+
if (sourceMeta.description !== targetMeta.description) changes.push('description');
|
|
1202
|
+
if (this.normalizeVisibility(sourceMeta.visibility) !== this.normalizeVisibility(targetMeta.visibility)) changes.push('visibility');
|
|
1203
|
+
if (JSON.stringify([...sourceMeta.topics].sort()) !== JSON.stringify([...targetMeta.topics].sort())) changes.push('topics');
|
|
1204
|
+
if (sourceMeta.defaultBranch !== targetMeta.defaultBranch) changes.push('default_branch');
|
|
1205
|
+
|
|
1206
|
+
if (changes.length > 0) {
|
|
1207
|
+
logger.syncLog('info', `Syncing metadata for ${targetFullPath}: ${changes.join(', ')}`, 'api');
|
|
1208
|
+
await this.updateProjectMeta(targetConn, targetFullPath, targetProject, sourceMeta);
|
|
1209
|
+
logger.syncLog('success', `Updated metadata for ${targetFullPath}: ${changes.join(', ')}`, 'api');
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// Sync avatar
|
|
1213
|
+
if (sourceMeta.avatarUrl) {
|
|
1214
|
+
await this.syncProjectAvatar(sourceConn, targetConn, sourceFullPath, targetFullPath, sourceMeta.avatarUrl, targetProject);
|
|
1215
|
+
} else if (config.useGroupAvatarsForProjects) {
|
|
1216
|
+
// Project has no avatar — inherit from parent group
|
|
1217
|
+
const groupPath = sourceFullPath.substring(0, sourceFullPath.lastIndexOf('/'));
|
|
1218
|
+
let groupAvatarApplied = false;
|
|
1219
|
+
if (groupPath) {
|
|
1220
|
+
try {
|
|
1221
|
+
const sourceGroup = await this.fetchGroupRaw(sourceConn, groupPath);
|
|
1222
|
+
if (sourceGroup) {
|
|
1223
|
+
const groupMeta = this.extractGroupMeta(sourceConn, sourceGroup);
|
|
1224
|
+
if (groupMeta.avatarUrl) {
|
|
1225
|
+
await this.syncProjectAvatar(sourceConn, targetConn, sourceFullPath, targetFullPath, groupMeta.avatarUrl, targetProject);
|
|
1226
|
+
groupAvatarApplied = true;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
} catch (err) {
|
|
1230
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1231
|
+
logger.syncLog('warn', `Group avatar sync failed for ${targetFullPath}: ${errMsg}`, 'api');
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
// If group also has no avatar, remove target avatar
|
|
1235
|
+
if (!groupAvatarApplied && targetMeta.avatarUrl) {
|
|
1236
|
+
await this.removeProjectAvatar(targetConn, targetFullPath, targetProject);
|
|
1237
|
+
}
|
|
1238
|
+
} else {
|
|
1239
|
+
// No source avatar, no group fallback — remove target avatar if present
|
|
1240
|
+
if (targetMeta.avatarUrl) {
|
|
1241
|
+
await this.removeProjectAvatar(targetConn, targetFullPath, targetProject);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1246
|
+
logger.syncLog('error', `Metadata sync failed for ${targetFullPath}: ${errMsg}`, 'api');
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Sync group/org metadata (description, visibility, avatar) from source to target.
|
|
1252
|
+
*/
|
|
1253
|
+
private async syncGroupMetadata(
|
|
1254
|
+
sourceConn: interfaces.data.IProviderConnection,
|
|
1255
|
+
targetConn: interfaces.data.IProviderConnection,
|
|
1256
|
+
sourceGroupPath: string,
|
|
1257
|
+
targetGroupPath: string,
|
|
1258
|
+
): Promise<void> {
|
|
1259
|
+
try {
|
|
1260
|
+
const sourceGroup = await this.fetchGroupRaw(sourceConn, sourceGroupPath);
|
|
1261
|
+
if (!sourceGroup) return;
|
|
1262
|
+
|
|
1263
|
+
const targetGroup = await this.fetchGroupRaw(targetConn, targetGroupPath);
|
|
1264
|
+
if (!targetGroup) return;
|
|
1265
|
+
|
|
1266
|
+
const sourceMeta = this.extractGroupMeta(sourceConn, sourceGroup);
|
|
1267
|
+
const targetMeta = this.extractGroupMeta(targetConn, targetGroup);
|
|
1268
|
+
|
|
1269
|
+
// Append mirror hint to description if enabled
|
|
1270
|
+
if (this.currentSyncConfig?.addMirrorHint) {
|
|
1271
|
+
const mirrorUrl = `${sourceConn.baseUrl.replace(/\/+$/, '')}/${sourceGroupPath}`;
|
|
1272
|
+
sourceMeta.description = `${sourceMeta.description}\n\n(This is a mirror of ${mirrorUrl})`.trim();
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
const changes: string[] = [];
|
|
1276
|
+
if (sourceMeta.description !== targetMeta.description) changes.push('description');
|
|
1277
|
+
if (this.normalizeVisibility(sourceMeta.visibility) !== this.normalizeVisibility(targetMeta.visibility)) changes.push('visibility');
|
|
1278
|
+
|
|
1279
|
+
if (changes.length > 0) {
|
|
1280
|
+
logger.syncLog('info', `Syncing group metadata for ${targetGroupPath}: ${changes.join(', ')}`, 'api');
|
|
1281
|
+
await this.updateGroupMeta(targetConn, targetGroupPath, targetGroup, sourceMeta);
|
|
1282
|
+
logger.syncLog('success', `Updated group metadata for ${targetGroupPath}: ${changes.join(', ')}`, 'api');
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// Sync avatar
|
|
1286
|
+
if (sourceMeta.avatarUrl) {
|
|
1287
|
+
await this.syncGroupAvatar(sourceConn, targetConn, sourceGroupPath, targetGroupPath, sourceMeta.avatarUrl, targetGroup);
|
|
1288
|
+
} else if (targetMeta.avatarUrl) {
|
|
1289
|
+
await this.removeGroupAvatar(targetConn, targetGroupPath, targetGroup);
|
|
1290
|
+
}
|
|
1291
|
+
} catch (err) {
|
|
1292
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1293
|
+
logger.syncLog('error', `Group metadata sync failed for ${targetGroupPath}: ${errMsg}`, 'api');
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// ---- Raw metadata fetchers ----
|
|
1298
|
+
|
|
1299
|
+
private async fetchProjectRaw(conn: interfaces.data.IProviderConnection, fullPath: string): Promise<any> {
|
|
1300
|
+
if (conn.providerType === 'gitlab') {
|
|
1301
|
+
return await this.rawApiCall(conn, 'GET', `/api/v4/projects/${encodeURIComponent(fullPath)}`);
|
|
1302
|
+
} else {
|
|
1303
|
+
const segments = fullPath.split('/');
|
|
1304
|
+
const repo = segments.pop()!;
|
|
1305
|
+
const owner = segments[0] || '';
|
|
1306
|
+
return await this.rawApiCall(conn, 'GET', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
private async fetchGroupRaw(conn: interfaces.data.IProviderConnection, groupPath: string): Promise<any> {
|
|
1311
|
+
if (conn.providerType === 'gitlab') {
|
|
1312
|
+
return await this.rawApiCall(conn, 'GET', `/api/v4/groups/${encodeURIComponent(groupPath)}`);
|
|
1313
|
+
} else {
|
|
1314
|
+
// Gitea orgs are flat — the group path IS the org name
|
|
1315
|
+
const orgName = groupPath.split('/')[0] || groupPath;
|
|
1316
|
+
return await this.rawApiCall(conn, 'GET', `/api/v1/orgs/${encodeURIComponent(orgName)}`);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// ---- Metadata extractors ----
|
|
1321
|
+
|
|
1322
|
+
private extractProjectMeta(conn: interfaces.data.IProviderConnection, raw: any): {
|
|
1323
|
+
description: string; visibility: string; topics: string[]; defaultBranch: string; avatarUrl: string;
|
|
1324
|
+
} {
|
|
1325
|
+
if (conn.providerType === 'gitlab') {
|
|
1326
|
+
return {
|
|
1327
|
+
description: raw.description || '',
|
|
1328
|
+
visibility: raw.visibility || 'private',
|
|
1329
|
+
topics: raw.topics || [],
|
|
1330
|
+
defaultBranch: raw.default_branch || 'main',
|
|
1331
|
+
avatarUrl: raw.avatar_url || '',
|
|
1332
|
+
};
|
|
1333
|
+
} else {
|
|
1334
|
+
return {
|
|
1335
|
+
description: raw.description || '',
|
|
1336
|
+
visibility: raw.private ? 'private' : 'public',
|
|
1337
|
+
topics: raw.topics || [],
|
|
1338
|
+
defaultBranch: raw.default_branch || 'main',
|
|
1339
|
+
avatarUrl: raw.avatar_url || '',
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
private extractGroupMeta(conn: interfaces.data.IProviderConnection, raw: any): {
|
|
1345
|
+
description: string; visibility: string; avatarUrl: string;
|
|
1346
|
+
} {
|
|
1347
|
+
if (conn.providerType === 'gitlab') {
|
|
1348
|
+
return {
|
|
1349
|
+
description: raw.description || '',
|
|
1350
|
+
visibility: raw.visibility || 'private',
|
|
1351
|
+
avatarUrl: raw.avatar_url || '',
|
|
1352
|
+
};
|
|
1353
|
+
} else {
|
|
1354
|
+
return {
|
|
1355
|
+
description: raw.description || '',
|
|
1356
|
+
visibility: raw.visibility || 'public',
|
|
1357
|
+
avatarUrl: raw.avatar_url || '',
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// ---- Metadata updaters ----
|
|
1363
|
+
|
|
1364
|
+
private async updateProjectMeta(
|
|
1365
|
+
conn: interfaces.data.IProviderConnection,
|
|
1366
|
+
fullPath: string,
|
|
1367
|
+
rawProject: any,
|
|
1368
|
+
meta: { description: string; visibility: string; topics: string[]; defaultBranch: string },
|
|
1369
|
+
): Promise<void> {
|
|
1370
|
+
if (conn.providerType === 'gitlab') {
|
|
1371
|
+
// Update description, visibility, topics (always safe)
|
|
1372
|
+
await this.rawApiCall(conn, 'PUT', `/api/v4/projects/${rawProject.id}`, {
|
|
1373
|
+
description: meta.description,
|
|
1374
|
+
visibility: this.normalizeVisibility(meta.visibility),
|
|
1375
|
+
topics: meta.topics,
|
|
1376
|
+
});
|
|
1377
|
+
// Update default_branch separately — may fail if the branch doesn't exist in git
|
|
1378
|
+
try {
|
|
1379
|
+
await this.rawApiCall(conn, 'PUT', `/api/v4/projects/${rawProject.id}`, {
|
|
1380
|
+
default_branch: meta.defaultBranch,
|
|
1381
|
+
});
|
|
1382
|
+
} catch (err) {
|
|
1383
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1384
|
+
logger.syncLog('warn', `Could not set default_branch to "${meta.defaultBranch}" for ${fullPath}: ${errMsg}`, 'api');
|
|
1385
|
+
}
|
|
1386
|
+
} else {
|
|
1387
|
+
const segments = fullPath.split('/');
|
|
1388
|
+
const repo = segments.pop()!;
|
|
1389
|
+
const owner = segments[0] || '';
|
|
1390
|
+
const encodedOwner = encodeURIComponent(owner);
|
|
1391
|
+
const encodedRepo = encodeURIComponent(repo);
|
|
1392
|
+
// Update description, visibility
|
|
1393
|
+
await this.rawApiCall(conn, 'PATCH', `/api/v1/repos/${encodedOwner}/${encodedRepo}`, {
|
|
1394
|
+
description: meta.description,
|
|
1395
|
+
private: this.normalizeVisibility(meta.visibility) === 'private',
|
|
1396
|
+
});
|
|
1397
|
+
// Update default_branch separately — may fail if the branch doesn't exist in git
|
|
1398
|
+
try {
|
|
1399
|
+
await this.rawApiCall(conn, 'PATCH', `/api/v1/repos/${encodedOwner}/${encodedRepo}`, {
|
|
1400
|
+
default_branch: meta.defaultBranch,
|
|
1401
|
+
});
|
|
1402
|
+
} catch (err) {
|
|
1403
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1404
|
+
logger.syncLog('warn', `Could not set default_branch to "${meta.defaultBranch}" for ${fullPath}: ${errMsg}`, 'api');
|
|
1405
|
+
}
|
|
1406
|
+
// Topics are a separate endpoint in Gitea
|
|
1407
|
+
await this.rawApiCall(conn, 'PUT', `/api/v1/repos/${encodedOwner}/${encodedRepo}/topics`, {
|
|
1408
|
+
topics: meta.topics,
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
private async updateGroupMeta(
|
|
1414
|
+
conn: interfaces.data.IProviderConnection,
|
|
1415
|
+
groupPath: string,
|
|
1416
|
+
rawGroup: any,
|
|
1417
|
+
meta: { description: string; visibility: string },
|
|
1418
|
+
): Promise<void> {
|
|
1419
|
+
if (conn.providerType === 'gitlab') {
|
|
1420
|
+
try {
|
|
1421
|
+
await this.rawApiCall(conn, 'PUT', `/api/v4/groups/${rawGroup.id}`, {
|
|
1422
|
+
description: meta.description,
|
|
1423
|
+
visibility: this.normalizeVisibility(meta.visibility),
|
|
1424
|
+
});
|
|
1425
|
+
} catch (err) {
|
|
1426
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1427
|
+
if (errMsg.includes('visibility_level') || errMsg.includes('visibility')) {
|
|
1428
|
+
logger.syncLog('warn', `Cannot sync visibility for group ${groupPath} (contains projects with higher visibility), syncing description only`, 'api');
|
|
1429
|
+
await this.rawApiCall(conn, 'PUT', `/api/v4/groups/${rawGroup.id}`, {
|
|
1430
|
+
description: meta.description,
|
|
1431
|
+
});
|
|
1432
|
+
} else {
|
|
1433
|
+
throw err;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
} else {
|
|
1437
|
+
const orgName = groupPath.split('/')[0] || groupPath;
|
|
1438
|
+
await this.rawApiCall(conn, 'PATCH', `/api/v1/orgs/${encodeURIComponent(orgName)}`, {
|
|
1439
|
+
description: meta.description,
|
|
1440
|
+
visibility: this.normalizeVisibility(meta.visibility) === 'private' ? 'private' : 'public',
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// ---- Avatar sync ----
|
|
1446
|
+
|
|
1447
|
+
private async syncProjectAvatar(
|
|
1448
|
+
sourceConn: interfaces.data.IProviderConnection,
|
|
1449
|
+
targetConn: interfaces.data.IProviderConnection,
|
|
1450
|
+
_sourceFullPath: string,
|
|
1451
|
+
targetFullPath: string,
|
|
1452
|
+
sourceAvatarUrl: string,
|
|
1453
|
+
targetRawProject: any,
|
|
1454
|
+
): Promise<void> {
|
|
1455
|
+
// Resolve relative avatar URLs
|
|
1456
|
+
const resolvedSourceUrl = sourceAvatarUrl.startsWith('http')
|
|
1457
|
+
? sourceAvatarUrl
|
|
1458
|
+
: `${sourceConn.baseUrl.replace(/\/+$/, '')}${sourceAvatarUrl}`;
|
|
1459
|
+
|
|
1460
|
+
const sourceAvatarData = await this.rawBinaryFetch(sourceConn, resolvedSourceUrl);
|
|
1461
|
+
if (!sourceAvatarData || sourceAvatarData.length === 0) return;
|
|
1462
|
+
|
|
1463
|
+
// Skip SVG avatars — not supported by GitLab project endpoints
|
|
1464
|
+
const mimeType = this.guessAvatarMimeType(sourceAvatarData, resolvedSourceUrl);
|
|
1465
|
+
if (mimeType === 'image/svg+xml') {
|
|
1466
|
+
logger.syncLog('warn', `Skipping SVG avatar for ${targetFullPath} (not supported by target)`, 'api');
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// Check in-memory cache: skip if source hasn't changed since last upload
|
|
1471
|
+
const sourceHash = await this.hashBytes(sourceAvatarData);
|
|
1472
|
+
const cacheKey = `project:${targetFullPath}`;
|
|
1473
|
+
if (this.avatarUploadCache.get(cacheKey) === sourceHash) {
|
|
1474
|
+
return; // Source avatar unchanged since last upload
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// Compare with target's current avatar to avoid unnecessary uploads
|
|
1478
|
+
const targetMeta = this.extractProjectMeta(targetConn, targetRawProject);
|
|
1479
|
+
if (targetMeta.avatarUrl) {
|
|
1480
|
+
try {
|
|
1481
|
+
const resolvedTargetUrl = targetMeta.avatarUrl.startsWith('http')
|
|
1482
|
+
? targetMeta.avatarUrl
|
|
1483
|
+
: `${targetConn.baseUrl.replace(/\/+$/, '')}${targetMeta.avatarUrl}`;
|
|
1484
|
+
const targetAvatarData = await this.rawBinaryFetch(targetConn, resolvedTargetUrl);
|
|
1485
|
+
if (targetAvatarData && this.binaryEqual(sourceAvatarData, targetAvatarData)) {
|
|
1486
|
+
this.avatarUploadCache.set(cacheKey, sourceHash);
|
|
1487
|
+
return; // Avatars are identical — skip upload
|
|
1488
|
+
}
|
|
1489
|
+
} catch {
|
|
1490
|
+
// Failed to fetch target avatar — proceed with upload as safe fallback
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
logger.syncLog('info', `Syncing avatar for ${targetFullPath}...`, 'api');
|
|
1495
|
+
|
|
1496
|
+
if (targetConn.providerType === 'gitlab') {
|
|
1497
|
+
// GitLab: multipart upload
|
|
1498
|
+
const blob = new Blob([sourceAvatarData.buffer as ArrayBuffer], { type: mimeType });
|
|
1499
|
+
const ext = mimeType.split('/')[1] || 'png';
|
|
1500
|
+
const formData = new FormData();
|
|
1501
|
+
formData.append('avatar', blob, `avatar.${ext}`);
|
|
1502
|
+
await this.rawMultipartCall(
|
|
1503
|
+
targetConn, 'PUT',
|
|
1504
|
+
`/api/v4/projects/${targetRawProject.id}`,
|
|
1505
|
+
formData,
|
|
1506
|
+
);
|
|
1507
|
+
} else {
|
|
1508
|
+
// Gitea: base64 JSON upload
|
|
1509
|
+
const segments = targetFullPath.split('/');
|
|
1510
|
+
const repo = segments.pop()!;
|
|
1511
|
+
const owner = segments[0] || '';
|
|
1512
|
+
const base64Image = this.uint8ArrayToBase64(sourceAvatarData);
|
|
1513
|
+
await this.rawApiCall(
|
|
1514
|
+
targetConn, 'POST',
|
|
1515
|
+
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/avatar`,
|
|
1516
|
+
{ image: base64Image },
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
this.avatarUploadCache.set(cacheKey, sourceHash);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
private async removeProjectAvatar(
|
|
1524
|
+
targetConn: interfaces.data.IProviderConnection,
|
|
1525
|
+
targetFullPath: string,
|
|
1526
|
+
targetRawProject: any,
|
|
1527
|
+
): Promise<void> {
|
|
1528
|
+
logger.syncLog('info', `Removing avatar from ${targetFullPath}`, 'api');
|
|
1529
|
+
if (targetConn.providerType === 'gitlab') {
|
|
1530
|
+
await this.rawApiCall(targetConn, 'PUT', `/api/v4/projects/${targetRawProject.id}`, {
|
|
1531
|
+
avatar: '',
|
|
1532
|
+
});
|
|
1533
|
+
} else {
|
|
1534
|
+
const segments = targetFullPath.split('/');
|
|
1535
|
+
const repo = segments.pop()!;
|
|
1536
|
+
const owner = segments[0] || '';
|
|
1537
|
+
await this.rawApiCall(targetConn, 'DELETE',
|
|
1538
|
+
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/avatar`);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
private async syncGroupAvatar(
|
|
1543
|
+
sourceConn: interfaces.data.IProviderConnection,
|
|
1544
|
+
targetConn: interfaces.data.IProviderConnection,
|
|
1545
|
+
_sourceGroupPath: string,
|
|
1546
|
+
targetGroupPath: string,
|
|
1547
|
+
sourceAvatarUrl: string,
|
|
1548
|
+
targetRawGroup: any,
|
|
1549
|
+
): Promise<void> {
|
|
1550
|
+
const resolvedSourceUrl = sourceAvatarUrl.startsWith('http')
|
|
1551
|
+
? sourceAvatarUrl
|
|
1552
|
+
: `${sourceConn.baseUrl.replace(/\/+$/, '')}${sourceAvatarUrl}`;
|
|
1553
|
+
|
|
1554
|
+
const sourceAvatarData = await this.rawBinaryFetch(sourceConn, resolvedSourceUrl);
|
|
1555
|
+
if (!sourceAvatarData || sourceAvatarData.length === 0) return;
|
|
1556
|
+
|
|
1557
|
+
// Skip SVG avatars — not supported by GitLab project endpoints
|
|
1558
|
+
const mimeType = this.guessAvatarMimeType(sourceAvatarData, resolvedSourceUrl);
|
|
1559
|
+
if (mimeType === 'image/svg+xml') {
|
|
1560
|
+
logger.syncLog('warn', `Skipping SVG avatar for group ${targetGroupPath} (not supported by target)`, 'api');
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// Check in-memory cache: skip if source hasn't changed since last upload
|
|
1565
|
+
const sourceHash = await this.hashBytes(sourceAvatarData);
|
|
1566
|
+
const cacheKey = `group:${targetGroupPath}`;
|
|
1567
|
+
if (this.avatarUploadCache.get(cacheKey) === sourceHash) {
|
|
1568
|
+
return; // Source avatar unchanged since last upload
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// Compare with target's current avatar to avoid unnecessary uploads
|
|
1572
|
+
const targetMeta = this.extractGroupMeta(targetConn, targetRawGroup);
|
|
1573
|
+
if (targetMeta.avatarUrl) {
|
|
1574
|
+
try {
|
|
1575
|
+
const resolvedTargetUrl = targetMeta.avatarUrl.startsWith('http')
|
|
1576
|
+
? targetMeta.avatarUrl
|
|
1577
|
+
: `${targetConn.baseUrl.replace(/\/+$/, '')}${targetMeta.avatarUrl}`;
|
|
1578
|
+
const targetAvatarData = await this.rawBinaryFetch(targetConn, resolvedTargetUrl);
|
|
1579
|
+
if (targetAvatarData && this.binaryEqual(sourceAvatarData, targetAvatarData)) {
|
|
1580
|
+
this.avatarUploadCache.set(cacheKey, sourceHash);
|
|
1581
|
+
return; // Avatars are identical — skip upload
|
|
1582
|
+
}
|
|
1583
|
+
} catch {
|
|
1584
|
+
// Failed to fetch target avatar — proceed with upload as safe fallback
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
logger.syncLog('info', `Syncing avatar for group ${targetGroupPath}...`, 'api');
|
|
1589
|
+
|
|
1590
|
+
if (targetConn.providerType === 'gitlab') {
|
|
1591
|
+
const blob = new Blob([sourceAvatarData.buffer as ArrayBuffer], { type: mimeType });
|
|
1592
|
+
const ext = mimeType.split('/')[1] || 'png';
|
|
1593
|
+
const formData = new FormData();
|
|
1594
|
+
formData.append('avatar', blob, `avatar.${ext}`);
|
|
1595
|
+
await this.rawMultipartCall(
|
|
1596
|
+
targetConn, 'PUT',
|
|
1597
|
+
`/api/v4/groups/${targetRawGroup.id}`,
|
|
1598
|
+
formData,
|
|
1599
|
+
);
|
|
1600
|
+
} else {
|
|
1601
|
+
const orgName = targetGroupPath.split('/')[0] || targetGroupPath;
|
|
1602
|
+
const base64Image = this.uint8ArrayToBase64(sourceAvatarData);
|
|
1603
|
+
await this.rawApiCall(
|
|
1604
|
+
targetConn, 'POST',
|
|
1605
|
+
`/api/v1/orgs/${encodeURIComponent(orgName)}/avatar`,
|
|
1606
|
+
{ image: base64Image },
|
|
1607
|
+
);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
this.avatarUploadCache.set(cacheKey, sourceHash);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
private async removeGroupAvatar(
|
|
1614
|
+
targetConn: interfaces.data.IProviderConnection,
|
|
1615
|
+
targetGroupPath: string,
|
|
1616
|
+
targetRawGroup: any,
|
|
1617
|
+
): Promise<void> {
|
|
1618
|
+
logger.syncLog('info', `Removing avatar from group ${targetGroupPath}`, 'api');
|
|
1619
|
+
if (targetConn.providerType === 'gitlab') {
|
|
1620
|
+
await this.rawApiCall(targetConn, 'PUT', `/api/v4/groups/${targetRawGroup.id}`, {
|
|
1621
|
+
avatar: '',
|
|
1622
|
+
});
|
|
1623
|
+
} else {
|
|
1624
|
+
const orgName = targetGroupPath.split('/')[0] || targetGroupPath;
|
|
1625
|
+
await this.rawApiCall(targetConn, 'DELETE',
|
|
1626
|
+
`/api/v1/orgs/${encodeURIComponent(orgName)}/avatar`);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
private binaryEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
1631
|
+
if (a.length !== b.length) return false;
|
|
1632
|
+
for (let i = 0; i < a.length; i++) {
|
|
1633
|
+
if (a[i] !== b[i]) return false;
|
|
1634
|
+
}
|
|
1635
|
+
return true;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
private async hashBytes(data: Uint8Array): Promise<string> {
|
|
1639
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data.buffer as ArrayBuffer);
|
|
1640
|
+
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
private uint8ArrayToBase64(bytes: Uint8Array): string {
|
|
1644
|
+
let binary = '';
|
|
1645
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1646
|
+
binary += String.fromCharCode(bytes[i]);
|
|
1647
|
+
}
|
|
1648
|
+
return btoa(binary);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// ============================================================================
|
|
1652
|
+
// Obsolete — move repos instead of deleting/overwriting
|
|
1653
|
+
// ============================================================================
|
|
1654
|
+
|
|
1655
|
+
/**
|
|
1656
|
+
* Raw HTTP call for API endpoints not supported by the client libraries.
|
|
1657
|
+
*/
|
|
1658
|
+
private async rawApiCall(
|
|
1659
|
+
conn: interfaces.data.IProviderConnection,
|
|
1660
|
+
method: string,
|
|
1661
|
+
apiPath: string,
|
|
1662
|
+
body?: any,
|
|
1663
|
+
): Promise<any> {
|
|
1664
|
+
const baseUrl = conn.baseUrl.replace(/\/+$/, '');
|
|
1665
|
+
const url = `${baseUrl}${apiPath}`;
|
|
1666
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
1667
|
+
if (conn.providerType === 'gitlab') {
|
|
1668
|
+
headers['PRIVATE-TOKEN'] = conn.token;
|
|
1669
|
+
} else {
|
|
1670
|
+
headers['Authorization'] = `token ${conn.token}`;
|
|
1671
|
+
}
|
|
1672
|
+
const resp = await fetch(url, {
|
|
1673
|
+
method,
|
|
1674
|
+
headers,
|
|
1675
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
1676
|
+
});
|
|
1677
|
+
if (!resp.ok) {
|
|
1678
|
+
const text = await resp.text();
|
|
1679
|
+
throw new Error(`${method} ${apiPath}: ${resp.status} - ${text}`);
|
|
1680
|
+
}
|
|
1681
|
+
try {
|
|
1682
|
+
return await resp.json();
|
|
1683
|
+
} catch {
|
|
1684
|
+
return undefined;
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
private generateSuffix(): string {
|
|
1689
|
+
return crypto.randomUUID().substring(0, 6);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* Ensure an "obsolete" group/org exists under the target base path.
|
|
1694
|
+
* Returns the obsolete group/org identifier needed for transfers.
|
|
1695
|
+
*/
|
|
1696
|
+
private async ensureObsoleteGroup(
|
|
1697
|
+
targetConn: interfaces.data.IProviderConnection,
|
|
1698
|
+
basePath?: string,
|
|
1699
|
+
): Promise<{ type: 'gitlab'; groupId: number } | { type: 'gitea'; orgName: string }> {
|
|
1700
|
+
if (targetConn.providerType === 'gitlab') {
|
|
1701
|
+
const client = new plugins.gitlabClient.GitLabClient(targetConn.baseUrl, targetConn.token);
|
|
1702
|
+
|
|
1703
|
+
// Walk the basePath to find the parent group, then create "obsolete" subgroup
|
|
1704
|
+
let parentId: number | undefined;
|
|
1705
|
+
if (basePath) {
|
|
1706
|
+
const parentGroup = await client.getGroup(basePath);
|
|
1707
|
+
parentId = parentGroup.id;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
// Try to get existing obsolete group
|
|
1711
|
+
const obsoletePath = basePath ? `${basePath}/obsolete` : 'obsolete';
|
|
1712
|
+
try {
|
|
1713
|
+
const group = await client.getGroup(obsoletePath);
|
|
1714
|
+
return { type: 'gitlab', groupId: group.id };
|
|
1715
|
+
} catch {
|
|
1716
|
+
// Doesn't exist — create it
|
|
1717
|
+
try {
|
|
1718
|
+
const newGroup = await client.createGroup('obsolete', 'obsolete', parentId);
|
|
1719
|
+
// Set to private via raw API (createGroup defaults to private already)
|
|
1720
|
+
logger.info(`Created GitLab obsolete group: ${obsoletePath}`);
|
|
1721
|
+
return { type: 'gitlab', groupId: newGroup.id };
|
|
1722
|
+
} catch (createErr: any) {
|
|
1723
|
+
if (String(createErr).includes('409') || String(createErr).includes('already')) {
|
|
1724
|
+
const group = await client.getGroup(obsoletePath);
|
|
1725
|
+
return { type: 'gitlab', groupId: group.id };
|
|
1726
|
+
}
|
|
1727
|
+
throw createErr;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
} else {
|
|
1731
|
+
// Gitea: flat orgs — create "{org}-obsolete"
|
|
1732
|
+
const client = new plugins.giteaClient.GiteaClient(targetConn.baseUrl, targetConn.token);
|
|
1733
|
+
const segments = basePath ? basePath.split('/') : [];
|
|
1734
|
+
const orgName = segments[0] || targetConn.groupFilter || 'default';
|
|
1735
|
+
const obsoleteOrg = `${orgName}-obsolete`;
|
|
1736
|
+
|
|
1737
|
+
try {
|
|
1738
|
+
await client.getOrg(obsoleteOrg);
|
|
1739
|
+
} catch {
|
|
1740
|
+
try {
|
|
1741
|
+
await client.createOrg(obsoleteOrg, { visibility: 'private' });
|
|
1742
|
+
logger.info(`Created Gitea obsolete org: ${obsoleteOrg}`);
|
|
1743
|
+
} catch (createErr: any) {
|
|
1744
|
+
if (!String(createErr).includes('409') && !String(createErr).includes('already')) {
|
|
1745
|
+
throw createErr;
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
return { type: 'gitea', orgName: obsoleteOrg };
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
/**
|
|
1754
|
+
* Move a target repo to the "obsolete" group with a unique suffix.
|
|
1755
|
+
* The project is also set to private.
|
|
1756
|
+
*/
|
|
1757
|
+
private async moveToObsolete(
|
|
1758
|
+
targetConn: interfaces.data.IProviderConnection,
|
|
1759
|
+
targetFullPath: string,
|
|
1760
|
+
basePath?: string,
|
|
1761
|
+
): Promise<void> {
|
|
1762
|
+
const suffix = this.generateSuffix();
|
|
1763
|
+
const obsoleteTarget = await this.ensureObsoleteGroup(targetConn, basePath);
|
|
1764
|
+
|
|
1765
|
+
if (obsoleteTarget.type === 'gitlab') {
|
|
1766
|
+
// 1. Get project by path
|
|
1767
|
+
const project = await this.rawApiCall(
|
|
1768
|
+
targetConn, 'GET',
|
|
1769
|
+
`/api/v4/projects/${encodeURIComponent(targetFullPath)}`,
|
|
1770
|
+
);
|
|
1771
|
+
const projectId = project.id;
|
|
1772
|
+
|
|
1773
|
+
// 2. Transfer to obsolete group
|
|
1774
|
+
await this.rawApiCall(
|
|
1775
|
+
targetConn, 'PUT',
|
|
1776
|
+
`/api/v4/projects/${projectId}/transfer`,
|
|
1777
|
+
{ namespace: obsoleteTarget.groupId },
|
|
1778
|
+
);
|
|
1779
|
+
|
|
1780
|
+
// 3. Rename with suffix + set private
|
|
1781
|
+
const originalPath = targetFullPath.split('/').pop()!;
|
|
1782
|
+
await this.rawApiCall(
|
|
1783
|
+
targetConn, 'PUT',
|
|
1784
|
+
`/api/v4/projects/${projectId}`,
|
|
1785
|
+
{ name: `${originalPath}-${suffix}`, path: `${originalPath}-${suffix}`, visibility: 'private' },
|
|
1786
|
+
);
|
|
1787
|
+
|
|
1788
|
+
logger.info(`Moved GitLab project "${targetFullPath}" to obsolete as "${originalPath}-${suffix}"`);
|
|
1789
|
+
} else {
|
|
1790
|
+
// Gitea: parse owner/repo
|
|
1791
|
+
const segments = targetFullPath.split('/');
|
|
1792
|
+
const repo = segments.pop()!;
|
|
1793
|
+
const owner = segments[0] || '';
|
|
1794
|
+
|
|
1795
|
+
// 1. Transfer to obsolete org
|
|
1796
|
+
await this.rawApiCall(
|
|
1797
|
+
targetConn, 'POST',
|
|
1798
|
+
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/transfer`,
|
|
1799
|
+
{ new_owner: obsoleteTarget.orgName },
|
|
1800
|
+
);
|
|
1801
|
+
|
|
1802
|
+
// 2. Rename with suffix + set private
|
|
1803
|
+
await this.rawApiCall(
|
|
1804
|
+
targetConn, 'PATCH',
|
|
1805
|
+
`/api/v1/repos/${encodeURIComponent(obsoleteTarget.orgName)}/${encodeURIComponent(repo)}`,
|
|
1806
|
+
{ name: `${repo}-${suffix}`, private: true },
|
|
1807
|
+
);
|
|
1808
|
+
|
|
1809
|
+
logger.info(`Moved Gitea repo "${targetFullPath}" to obsolete as "${repo}-${suffix}"`);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* Check whether the target remote in a bare mirror dir has unrelated history
|
|
1815
|
+
* compared to the source (origin). Returns true if histories are completely disjoint.
|
|
1816
|
+
*/
|
|
1817
|
+
private async checkUnrelatedHistory(mirrorDir: string): Promise<boolean> {
|
|
1818
|
+
// Fetch target refs into the bare mirror
|
|
1819
|
+
try {
|
|
1820
|
+
await this.runGit(['fetch', 'target'], mirrorDir);
|
|
1821
|
+
} catch {
|
|
1822
|
+
// Target is empty or unreachable — not unrelated
|
|
1823
|
+
return false;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// Get any source ref
|
|
1827
|
+
const sourceRefs = await this.runGit(
|
|
1828
|
+
['for-each-ref', '--format=%(objectname)', 'refs/heads/'], mirrorDir,
|
|
1829
|
+
);
|
|
1830
|
+
const sourceRef = sourceRefs.trim().split('\n')[0];
|
|
1831
|
+
if (!sourceRef) return false; // Source is empty
|
|
1832
|
+
|
|
1833
|
+
// Get any target ref
|
|
1834
|
+
const targetRefs = await this.runGit(
|
|
1835
|
+
['for-each-ref', '--format=%(objectname)', 'refs/remotes/target/'], mirrorDir,
|
|
1836
|
+
);
|
|
1837
|
+
const targetRef = targetRefs.trim().split('\n')[0];
|
|
1838
|
+
if (!targetRef) return false; // Target is empty
|
|
1839
|
+
|
|
1840
|
+
// Check for common ancestor
|
|
1841
|
+
try {
|
|
1842
|
+
await this.runGit(['merge-base', sourceRef, targetRef], mirrorDir);
|
|
1843
|
+
return false; // Common ancestor found — related
|
|
1844
|
+
} catch {
|
|
1845
|
+
return true; // No common ancestor — unrelated
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
private sanitizePath(fullPath: string): string {
|
|
1850
|
+
return fullPath.replace(/[^a-zA-Z0-9._/-]/g, '_');
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
private async dirExists(dirPath: string): Promise<boolean> {
|
|
1854
|
+
try {
|
|
1855
|
+
const stat = await plugins.fs.stat(dirPath);
|
|
1856
|
+
return stat.isDirectory();
|
|
1857
|
+
} catch {
|
|
1858
|
+
return false;
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/**
|
|
1863
|
+
* Fetch all branch and tag SHAs from a repo via provider API.
|
|
1864
|
+
* Returns null on any error (safe fallback to git-based comparison).
|
|
1865
|
+
*/
|
|
1866
|
+
private async listRefsViaProvider(
|
|
1867
|
+
provider: BaseProvider,
|
|
1868
|
+
fullPath: string,
|
|
1869
|
+
): Promise<{ branches: Map<string, string>; tags: Map<string, string> } | null> {
|
|
1870
|
+
try {
|
|
1871
|
+
const [branches, tags] = await Promise.all([
|
|
1872
|
+
provider.getBranches(fullPath),
|
|
1873
|
+
provider.getTags(fullPath),
|
|
1874
|
+
]);
|
|
1875
|
+
return {
|
|
1876
|
+
branches: new Map(branches.map((b) => [b.name, b.commitSha])),
|
|
1877
|
+
tags: new Map(tags.map((t) => [t.name, t.commitSha])),
|
|
1878
|
+
};
|
|
1879
|
+
} catch {
|
|
1880
|
+
return null;
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
/**
|
|
1885
|
+
* Compare refs between source and target via provider API (no git clone needed).
|
|
1886
|
+
* Returns true (match), false (differ), or null (can't determine — fall through to git).
|
|
1887
|
+
*/
|
|
1888
|
+
private async refsMatchViaApi(
|
|
1889
|
+
sourceProvider: BaseProvider,
|
|
1890
|
+
targetProvider: BaseProvider,
|
|
1891
|
+
sourceFullPath: string,
|
|
1892
|
+
targetFullPath: string,
|
|
1893
|
+
): Promise<boolean | null> {
|
|
1894
|
+
const [sourceRefs, targetRefs] = await Promise.all([
|
|
1895
|
+
this.listRefsViaProvider(sourceProvider, sourceFullPath),
|
|
1896
|
+
this.listRefsViaProvider(targetProvider, targetFullPath),
|
|
1897
|
+
]);
|
|
1898
|
+
if (!sourceRefs || !targetRefs) return null;
|
|
1899
|
+
|
|
1900
|
+
// Compare branches
|
|
1901
|
+
if (sourceRefs.branches.size !== targetRefs.branches.size) return false;
|
|
1902
|
+
for (const [name, sha] of sourceRefs.branches) {
|
|
1903
|
+
if (targetRefs.branches.get(name) !== sha) return false;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
// Compare tags
|
|
1907
|
+
if (sourceRefs.tags.size !== targetRefs.tags.size) return false;
|
|
1908
|
+
for (const [name, sha] of sourceRefs.tags) {
|
|
1909
|
+
if (targetRefs.tags.get(name) !== sha) return false;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
return true;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
/**
|
|
1916
|
+
* Compare local refs (source) with target remote refs.
|
|
1917
|
+
* Returns true when all branches and tags are identical — no push needed.
|
|
1918
|
+
*/
|
|
1919
|
+
private async refsMatch(mirrorDir: string): Promise<boolean> {
|
|
1920
|
+
try {
|
|
1921
|
+
// Local branches (source)
|
|
1922
|
+
const localHeadsRaw = await this.runGit(
|
|
1923
|
+
['for-each-ref', '--format=%(refname:strip=2) %(objectname)', 'refs/heads/'], mirrorDir,
|
|
1924
|
+
);
|
|
1925
|
+
// Target branches (fetched by checkUnrelatedHistory)
|
|
1926
|
+
const targetHeadsRaw = await this.runGit(
|
|
1927
|
+
['for-each-ref', '--format=%(refname:strip=3) %(objectname)', 'refs/remotes/target/'], mirrorDir,
|
|
1928
|
+
);
|
|
1929
|
+
|
|
1930
|
+
// Local tags
|
|
1931
|
+
const localTagsRaw = await this.runGit(
|
|
1932
|
+
['for-each-ref', '--format=%(refname:strip=2) %(objectname)', 'refs/tags/'], mirrorDir,
|
|
1933
|
+
);
|
|
1934
|
+
// Target tags via ls-remote (avoids shared refs/tags/ namespace ambiguity in bare repos)
|
|
1935
|
+
const targetTagsRaw = await this.runGit(['ls-remote', '--tags', 'target'], mirrorDir);
|
|
1936
|
+
|
|
1937
|
+
const parseRefLines = (raw: string): Map<string, string> => {
|
|
1938
|
+
const map = new Map<string, string>();
|
|
1939
|
+
for (const line of raw.trim().split('\n')) {
|
|
1940
|
+
if (!line.trim()) continue;
|
|
1941
|
+
const parts = line.trim().split(/\s+/);
|
|
1942
|
+
if (parts.length >= 2) {
|
|
1943
|
+
map.set(parts[0], parts[1]);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
return map;
|
|
1947
|
+
};
|
|
1948
|
+
|
|
1949
|
+
const parseLsRemoteTags = (raw: string): Map<string, string> => {
|
|
1950
|
+
const map = new Map<string, string>();
|
|
1951
|
+
for (const line of raw.trim().split('\n')) {
|
|
1952
|
+
if (!line.trim()) continue;
|
|
1953
|
+
// Skip ^{} dereference lines
|
|
1954
|
+
if (line.includes('^{}')) continue;
|
|
1955
|
+
const parts = line.trim().split(/\s+/);
|
|
1956
|
+
if (parts.length >= 2) {
|
|
1957
|
+
// parts[0] = sha, parts[1] = refs/tags/name
|
|
1958
|
+
const tagName = parts[1].replace('refs/tags/', '');
|
|
1959
|
+
map.set(tagName, parts[0]);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
return map;
|
|
1963
|
+
};
|
|
1964
|
+
|
|
1965
|
+
const localHeads = parseRefLines(localHeadsRaw);
|
|
1966
|
+
const targetHeads = parseRefLines(targetHeadsRaw);
|
|
1967
|
+
const localTags = parseRefLines(localTagsRaw);
|
|
1968
|
+
const targetTags = parseLsRemoteTags(targetTagsRaw);
|
|
1969
|
+
|
|
1970
|
+
// Compare branches
|
|
1971
|
+
if (localHeads.size !== targetHeads.size) return false;
|
|
1972
|
+
for (const [name, sha] of localHeads) {
|
|
1973
|
+
if (targetHeads.get(name) !== sha) return false;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// Compare tags
|
|
1977
|
+
if (localTags.size !== targetTags.size) return false;
|
|
1978
|
+
for (const [name, sha] of localTags) {
|
|
1979
|
+
if (targetTags.get(name) !== sha) return false;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
return true;
|
|
1983
|
+
} catch {
|
|
1984
|
+
// On any error, fall back to pushing (safe default)
|
|
1985
|
+
return false;
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
private async runGit(args: string[], cwd?: string): Promise<string> {
|
|
1990
|
+
if (this.stopping) {
|
|
1991
|
+
throw new Error('SyncManager is stopping');
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
return await new Promise<string>((resolve, reject) => {
|
|
1995
|
+
const child = plugins.childProcess.spawn('git', args, {
|
|
1996
|
+
cwd,
|
|
1997
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
1998
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1999
|
+
});
|
|
2000
|
+
this.activeGitChildren.add(child);
|
|
2001
|
+
const stdoutChunks: Uint8Array[] = [];
|
|
2002
|
+
const stderrChunks: Uint8Array[] = [];
|
|
2003
|
+
let settled = false;
|
|
2004
|
+
|
|
2005
|
+
const finish = (callback: () => void): void => {
|
|
2006
|
+
if (settled) return;
|
|
2007
|
+
settled = true;
|
|
2008
|
+
this.activeGitChildren.delete(child);
|
|
2009
|
+
callback();
|
|
2010
|
+
};
|
|
2011
|
+
|
|
2012
|
+
child.stdout.on('data', (chunk: Uint8Array) => stdoutChunks.push(chunk));
|
|
2013
|
+
child.stderr.on('data', (chunk: Uint8Array) => stderrChunks.push(chunk));
|
|
2014
|
+
child.on('error', (err) => finish(() => reject(err)));
|
|
2015
|
+
child.on('close', (code: number | null, signal: NodeJS.Signals | null) => finish(() => {
|
|
2016
|
+
const stderr = plugins.Buffer.concat(stderrChunks).toString('utf8');
|
|
2017
|
+
if (code !== 0) {
|
|
2018
|
+
const exitInfo = signal ? `signal ${signal}` : `code ${code}`;
|
|
2019
|
+
reject(new Error(`git ${args[0]} failed with ${exitInfo}: ${stderr.trim()}`));
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
resolve(plugins.Buffer.concat(stdoutChunks).toString('utf8'));
|
|
2023
|
+
}));
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// ============================================================================
|
|
2028
|
+
// Persistence
|
|
2029
|
+
// ============================================================================
|
|
2030
|
+
|
|
2031
|
+
private async loadConfigs(): Promise<void> {
|
|
2032
|
+
const keys = await this.storageManager.list(SYNC_PREFIX);
|
|
2033
|
+
this.configs = [];
|
|
2034
|
+
for (const key of keys) {
|
|
2035
|
+
const config = await this.storageManager.getJSON<interfaces.data.ISyncConfig>(key);
|
|
2036
|
+
if (config) {
|
|
2037
|
+
try {
|
|
2038
|
+
config.intervalMinutes = validateIntervalMinutes(
|
|
2039
|
+
config.intervalMinutes,
|
|
2040
|
+
`sync "${config.name}" intervalMinutes`,
|
|
2041
|
+
5,
|
|
2042
|
+
);
|
|
2043
|
+
} catch (err) {
|
|
2044
|
+
config.status = 'error';
|
|
2045
|
+
config.lastSyncError = err instanceof Error ? err.message : String(err);
|
|
2046
|
+
await this.persistConfig(config);
|
|
2047
|
+
}
|
|
2048
|
+
this.configs.push(config);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
private async persistConfig(config: interfaces.data.ISyncConfig): Promise<void> {
|
|
2054
|
+
await this.storageManager.setJSON(`${SYNC_PREFIX}${config.id}.json`, config);
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
private async updateRepoStatus(
|
|
2058
|
+
syncConfigId: string,
|
|
2059
|
+
sourceFullPath: string,
|
|
2060
|
+
updates: Partial<interfaces.data.ISyncRepoStatus>,
|
|
2061
|
+
): Promise<void> {
|
|
2062
|
+
const hash = this.sanitizePath(sourceFullPath).replace(/\//g, '__');
|
|
2063
|
+
const key = `${SYNC_STATUS_PREFIX}${syncConfigId}/${hash}.json`;
|
|
2064
|
+
let status = await this.storageManager.getJSON<interfaces.data.ISyncRepoStatus>(key);
|
|
2065
|
+
if (!status) {
|
|
2066
|
+
status = {
|
|
2067
|
+
id: hash,
|
|
2068
|
+
syncConfigId,
|
|
2069
|
+
sourceFullPath,
|
|
2070
|
+
targetFullPath: '',
|
|
2071
|
+
lastSyncAt: 0,
|
|
2072
|
+
status: 'pending',
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2075
|
+
Object.assign(status, updates);
|
|
2076
|
+
await this.storageManager.setJSON(key, status);
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
// ============================================================================
|
|
2080
|
+
// Timer Management
|
|
2081
|
+
// ============================================================================
|
|
2082
|
+
|
|
2083
|
+
private startTimer(config: interfaces.data.ISyncConfig): void {
|
|
2084
|
+
this.stopTimer(config.id);
|
|
2085
|
+
let intervalMs: number;
|
|
2086
|
+
try {
|
|
2087
|
+
intervalMs = intervalMinutesToMs(config.intervalMinutes, `sync "${config.name}" intervalMinutes`);
|
|
2088
|
+
} catch (err) {
|
|
2089
|
+
logger.error(`Timer not started for sync "${config.name}": ${err}`);
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
const timerId = setInterval(() => {
|
|
2093
|
+
this.executeSync(config.id).catch((err) =>
|
|
2094
|
+
logger.error(`Scheduled sync for ${config.name} failed: ${err}`)
|
|
2095
|
+
);
|
|
2096
|
+
}, intervalMs);
|
|
2097
|
+
unrefTimer(timerId);
|
|
2098
|
+
this.timers.set(config.id, timerId);
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
private stopTimer(configId: string): void {
|
|
2102
|
+
const timerId = this.timers.get(configId);
|
|
2103
|
+
if (timerId !== undefined) {
|
|
2104
|
+
clearInterval(timerId);
|
|
2105
|
+
this.timers.delete(configId);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
private async waitForRunningSyncs(): Promise<void> {
|
|
2110
|
+
while (this.runningSync.size > 0 || this.activeGitChildren.size > 0) {
|
|
2111
|
+
await new Promise<void>((resolve) => {
|
|
2112
|
+
const timer = setTimeout(resolve, 100);
|
|
2113
|
+
unrefTimer(timer);
|
|
2114
|
+
});
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
}
|