@serve.zone/gitops 3.0.0 → 3.1.0
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/changelog.md +17 -0
- package/deno.json +1 -1
- package/dist_serve/bundle.js +726 -694
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes/connectionmanager.d.ts +5 -1
- package/dist_ts/classes/connectionmanager.js +62 -7
- package/dist_ts/classes/syncmanager.d.ts +40 -0
- package/dist_ts/classes/syncmanager.js +804 -111
- package/dist_ts/classes/syncpath.d.ts +13 -0
- package/dist_ts/classes/syncpath.js +53 -0
- package/dist_ts/opsserver/handlers/connections.handler.js +8 -2
- package/dist_ts/opsserver/handlers/sync.handler.js +5 -1
- package/dist_ts/providers/classes.baseprovider.d.ts +22 -0
- package/dist_ts/providers/classes.baseprovider.js +93 -1
- package/dist_ts/providers/classes.giteaprovider.d.ts +18 -1
- package/dist_ts/providers/classes.giteaprovider.js +236 -1
- package/dist_ts/providers/classes.gitlabprovider.d.ts +16 -1
- package/dist_ts/providers/classes.gitlabprovider.js +235 -1
- package/dist_ts_interfaces/data/artifact.d.ts +39 -0
- package/dist_ts_interfaces/data/artifact.js +2 -0
- package/dist_ts_interfaces/data/connection.d.ts +3 -0
- package/dist_ts_interfaces/data/index.d.ts +1 -0
- package/dist_ts_interfaces/data/index.js +2 -1
- package/dist_ts_interfaces/data/sync.d.ts +21 -0
- package/dist_ts_interfaces/requests/connections.d.ts +6 -0
- package/dist_ts_interfaces/requests/sync.d.ts +4 -0
- package/package.json +3 -3
- package/readme.md +8 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes/connectionmanager.ts +61 -6
- package/ts/classes/syncmanager.ts +1095 -119
- package/ts/classes/syncpath.ts +71 -0
- package/ts/opsserver/handlers/connections.handler.ts +9 -0
- package/ts/opsserver/handlers/sync.handler.ts +4 -0
- package/ts/providers/classes.baseprovider.ts +137 -0
- package/ts/providers/classes.giteaprovider.ts +293 -1
- package/ts/providers/classes.gitlabprovider.ts +292 -1
- package/ts_interfaces/data/artifact.ts +43 -0
- package/ts_interfaces/data/connection.ts +3 -0
- package/ts_interfaces/data/index.ts +1 -0
- package/ts_interfaces/data/sync.ts +22 -0
- package/ts_interfaces/requests/connections.ts +6 -0
- package/ts_interfaces/requests/sync.ts +4 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate.ts +10 -0
- package/ts_web/elements/views/connections/index.ts +26 -0
- package/ts_web/elements/views/sync/index.ts +26 -4
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export interface ISyncPathMappingOptions {
|
|
2
|
+
sourceFullPath: string;
|
|
3
|
+
sourceGroupFilter?: string;
|
|
4
|
+
targetGroupOffset?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface IContainerImagePathOptions extends ISyncPathMappingOptions {
|
|
8
|
+
sourceImagePath: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function normalizeSyncPath(pathArg: string): string {
|
|
12
|
+
return pathArg
|
|
13
|
+
.split('/')
|
|
14
|
+
.map((segment) => segment.trim())
|
|
15
|
+
.filter(Boolean)
|
|
16
|
+
.join('/');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function computeRelativePath(sourceFullPath: string, sourceGroupFilter?: string): string {
|
|
20
|
+
const sourcePath = normalizeSyncPath(sourceFullPath);
|
|
21
|
+
const filterPath = sourceGroupFilter ? normalizeSyncPath(sourceGroupFilter) : '';
|
|
22
|
+
if (!filterPath) return sourcePath;
|
|
23
|
+
if (sourcePath === filterPath) return '';
|
|
24
|
+
if (sourcePath.startsWith(`${filterPath}/`)) {
|
|
25
|
+
return sourcePath.substring(filterPath.length + 1);
|
|
26
|
+
}
|
|
27
|
+
return sourcePath;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function computeTargetFullPath(options: ISyncPathMappingOptions): string {
|
|
31
|
+
const relativePath = computeRelativePath(options.sourceFullPath, options.sourceGroupFilter);
|
|
32
|
+
const targetOffset = options.targetGroupOffset ? normalizeSyncPath(options.targetGroupOffset) : '';
|
|
33
|
+
return targetOffset ? `${targetOffset}/${relativePath}` : relativePath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function computeTargetContainerImagePath(options: IContainerImagePathOptions): string {
|
|
37
|
+
const sourceProjectPath = normalizeSyncPath(options.sourceFullPath);
|
|
38
|
+
const sourceImagePath = normalizeSyncPath(options.sourceImagePath);
|
|
39
|
+
const targetProjectPath = computeTargetFullPath(options);
|
|
40
|
+
|
|
41
|
+
if (sourceImagePath === sourceProjectPath) {
|
|
42
|
+
return targetProjectPath;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (sourceImagePath.startsWith(`${sourceProjectPath}/`)) {
|
|
46
|
+
return `${targetProjectPath}/${sourceImagePath.substring(sourceProjectPath.length + 1)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const relativeImagePath = computeRelativePath(sourceImagePath, options.sourceGroupFilter);
|
|
50
|
+
const targetOffset = options.targetGroupOffset ? normalizeSyncPath(options.targetGroupOffset) : '';
|
|
51
|
+
return targetOffset ? `${targetOffset}/${relativeImagePath}` : relativeImagePath;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function reverseTargetGroupPath(
|
|
55
|
+
targetGroupPath: string,
|
|
56
|
+
sourceGroupFilter?: string,
|
|
57
|
+
targetGroupOffset?: string,
|
|
58
|
+
): string | null {
|
|
59
|
+
const targetPath = normalizeSyncPath(targetGroupPath);
|
|
60
|
+
const offsetPath = targetGroupOffset ? normalizeSyncPath(targetGroupOffset) : '';
|
|
61
|
+
let relativePath = targetPath;
|
|
62
|
+
|
|
63
|
+
if (offsetPath) {
|
|
64
|
+
if (targetPath === offsetPath) return null;
|
|
65
|
+
if (!targetPath.startsWith(`${offsetPath}/`)) return null;
|
|
66
|
+
relativePath = targetPath.substring(offsetPath.length + 1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const sourceFilter = sourceGroupFilter ? normalizeSyncPath(sourceGroupFilter) : '';
|
|
70
|
+
return sourceFilter ? `${sourceFilter}/${relativePath}` : relativePath;
|
|
71
|
+
}
|
|
@@ -40,6 +40,9 @@ export class ConnectionsHandler {
|
|
|
40
40
|
dataArg.baseUrl,
|
|
41
41
|
dataArg.token,
|
|
42
42
|
dataArg.groupFilter,
|
|
43
|
+
dataArg.registryUrl,
|
|
44
|
+
dataArg.registryUsername,
|
|
45
|
+
dataArg.registryToken,
|
|
43
46
|
);
|
|
44
47
|
this.actionLog.append({
|
|
45
48
|
actionType: 'create',
|
|
@@ -67,6 +70,9 @@ export class ConnectionsHandler {
|
|
|
67
70
|
baseUrl: dataArg.baseUrl,
|
|
68
71
|
token: dataArg.token,
|
|
69
72
|
groupFilter: dataArg.groupFilter,
|
|
73
|
+
registryUrl: dataArg.registryUrl,
|
|
74
|
+
registryUsername: dataArg.registryUsername,
|
|
75
|
+
registryToken: dataArg.registryToken,
|
|
70
76
|
},
|
|
71
77
|
);
|
|
72
78
|
const fields = [
|
|
@@ -74,6 +80,9 @@ export class ConnectionsHandler {
|
|
|
74
80
|
dataArg.baseUrl && 'baseUrl',
|
|
75
81
|
dataArg.token && 'token',
|
|
76
82
|
dataArg.groupFilter !== undefined && 'groupFilter',
|
|
83
|
+
dataArg.registryUrl !== undefined && 'registryUrl',
|
|
84
|
+
dataArg.registryUsername !== undefined && 'registryUsername',
|
|
85
|
+
dataArg.registryToken && 'registryToken',
|
|
77
86
|
].filter(Boolean).join(', ');
|
|
78
87
|
this.actionLog.append({
|
|
79
88
|
actionType: 'update',
|
|
@@ -70,6 +70,8 @@ export class SyncHandler {
|
|
|
70
70
|
intervalMinutes: dataArg.intervalMinutes,
|
|
71
71
|
enforceDelete: dataArg.enforceDelete,
|
|
72
72
|
enforceGroupDelete: dataArg.enforceGroupDelete,
|
|
73
|
+
syncReleases: dataArg.syncReleases,
|
|
74
|
+
syncContainerImages: dataArg.syncContainerImages,
|
|
73
75
|
addMirrorHint: dataArg.addMirrorHint,
|
|
74
76
|
useGroupAvatarsForProjects: dataArg.useGroupAvatarsForProjects,
|
|
75
77
|
});
|
|
@@ -98,6 +100,8 @@ export class SyncHandler {
|
|
|
98
100
|
intervalMinutes: dataArg.intervalMinutes,
|
|
99
101
|
enforceDelete: dataArg.enforceDelete,
|
|
100
102
|
enforceGroupDelete: dataArg.enforceGroupDelete,
|
|
103
|
+
syncReleases: dataArg.syncReleases,
|
|
104
|
+
syncContainerImages: dataArg.syncContainerImages,
|
|
101
105
|
addMirrorHint: dataArg.addMirrorHint,
|
|
102
106
|
useGroupAvatarsForProjects: dataArg.useGroupAvatarsForProjects,
|
|
103
107
|
});
|
|
@@ -17,6 +17,18 @@ export interface IPipelineListOptions extends IListOptions {
|
|
|
17
17
|
source?: string;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export interface IReleaseAssetPayload {
|
|
21
|
+
data: Uint8Array;
|
|
22
|
+
contentType?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface IReleaseAssetTransferOptions {
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
maxBytes?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const PROVIDER_REQUEST_TIMEOUT_MS = 2 * 60 * 1000;
|
|
31
|
+
|
|
20
32
|
/**
|
|
21
33
|
* Abstract base class for Git provider implementations.
|
|
22
34
|
* Subclasses implement Gitea API v1 or GitLab API v4.
|
|
@@ -96,4 +108,129 @@ export abstract class BaseProvider {
|
|
|
96
108
|
// Directory listing
|
|
97
109
|
abstract getDirectoryContents(projectFullPath: string, dirPath: string, ref?: string): Promise<{ name: string; path: string; type: string }[] | null>;
|
|
98
110
|
|
|
111
|
+
// Releases / release assets
|
|
112
|
+
abstract getReleases(projectFullPath: string): Promise<interfaces.data.IRelease[]>;
|
|
113
|
+
abstract upsertRelease(projectFullPath: string, release: interfaces.data.IRelease): Promise<interfaces.data.IRelease>;
|
|
114
|
+
abstract deleteRelease(projectFullPath: string, tagName: string): Promise<void>;
|
|
115
|
+
abstract deleteReleaseAsset(projectFullPath: string, release: interfaces.data.IRelease, asset: interfaces.data.IReleaseAsset): Promise<void>;
|
|
116
|
+
abstract downloadReleaseAsset(projectFullPath: string, release: interfaces.data.IRelease, asset: interfaces.data.IReleaseAsset, options?: IReleaseAssetTransferOptions): Promise<IReleaseAssetPayload | null>;
|
|
117
|
+
abstract uploadReleaseAsset(projectFullPath: string, release: interfaces.data.IRelease, asset: interfaces.data.IReleaseAsset, payload: IReleaseAssetPayload, options?: IReleaseAssetTransferOptions): Promise<interfaces.data.IReleaseAsset>;
|
|
118
|
+
abstract createReleaseAssetLink(projectFullPath: string, release: interfaces.data.IRelease, asset: interfaces.data.IReleaseAsset): Promise<interfaces.data.IReleaseAsset>;
|
|
119
|
+
|
|
120
|
+
// Container images
|
|
121
|
+
abstract getContainerImageRepositories(projectFullPath: string): Promise<interfaces.data.IContainerImageRepository[]>;
|
|
122
|
+
abstract deleteContainerImageTag(projectFullPath: string, repositoryId: string, tagName: string): Promise<void>;
|
|
123
|
+
|
|
124
|
+
protected async readReleaseAssetPayload(
|
|
125
|
+
response: Response,
|
|
126
|
+
assetName: string,
|
|
127
|
+
fallbackContentType: string | undefined,
|
|
128
|
+
options: IReleaseAssetTransferOptions | undefined,
|
|
129
|
+
): Promise<IReleaseAssetPayload> {
|
|
130
|
+
const maxBytes = options?.maxBytes;
|
|
131
|
+
const contentLengthHeader = response.headers.get('content-length');
|
|
132
|
+
const contentLength = contentLengthHeader ? Number(contentLengthHeader) : undefined;
|
|
133
|
+
if (maxBytes && contentLength && contentLength > maxBytes) {
|
|
134
|
+
if (response.body) {
|
|
135
|
+
try {
|
|
136
|
+
await response.body.cancel();
|
|
137
|
+
} catch {
|
|
138
|
+
// Best-effort cleanup before rejecting the oversized transfer.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
throw new Error(`Release asset ${assetName} is ${contentLength} bytes, exceeding the ${maxBytes} byte limit`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!response.body) {
|
|
145
|
+
const buffer = await response.arrayBuffer();
|
|
146
|
+
if (maxBytes && buffer.byteLength > maxBytes) {
|
|
147
|
+
throw new Error(`Release asset ${assetName} is ${buffer.byteLength} bytes, exceeding the ${maxBytes} byte limit`);
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
data: new Uint8Array(buffer),
|
|
151
|
+
contentType: response.headers.get('content-type') || fallbackContentType,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const reader = response.body.getReader();
|
|
156
|
+
const chunks: Uint8Array[] = [];
|
|
157
|
+
let totalBytes = 0;
|
|
158
|
+
try {
|
|
159
|
+
while (true) {
|
|
160
|
+
const { done, value } = await reader.read();
|
|
161
|
+
if (done) break;
|
|
162
|
+
totalBytes += value.byteLength;
|
|
163
|
+
if (maxBytes && totalBytes > maxBytes) {
|
|
164
|
+
await reader.cancel();
|
|
165
|
+
throw new Error(`Release asset ${assetName} exceeds the ${maxBytes} byte limit`);
|
|
166
|
+
}
|
|
167
|
+
chunks.push(value);
|
|
168
|
+
}
|
|
169
|
+
} finally {
|
|
170
|
+
reader.releaseLock();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const data = new Uint8Array(totalBytes);
|
|
174
|
+
let offset = 0;
|
|
175
|
+
for (const chunk of chunks) {
|
|
176
|
+
data.set(chunk, offset);
|
|
177
|
+
offset += chunk.byteLength;
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
data,
|
|
181
|
+
contentType: response.headers.get('content-type') || fallbackContentType,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
protected getPayloadArrayBuffer(payload: IReleaseAssetPayload): ArrayBuffer {
|
|
186
|
+
const copy = new Uint8Array(payload.data.byteLength);
|
|
187
|
+
copy.set(payload.data);
|
|
188
|
+
return copy.buffer;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
protected async fetchJsonWithTimeout<T>(
|
|
192
|
+
url: string,
|
|
193
|
+
init: RequestInit,
|
|
194
|
+
label: string,
|
|
195
|
+
): Promise<T> {
|
|
196
|
+
return await this.fetchWithTimeout(url, init, label, async (response) => await response.json() as T);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
protected async fetchVoidWithTimeout(
|
|
200
|
+
url: string,
|
|
201
|
+
init: RequestInit,
|
|
202
|
+
label: string,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
await this.fetchWithTimeout(url, init, label, async (response) => {
|
|
205
|
+
await response.text().catch(() => undefined);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async fetchWithTimeout<T>(
|
|
210
|
+
url: string,
|
|
211
|
+
init: RequestInit,
|
|
212
|
+
label: string,
|
|
213
|
+
parser: (response: Response) => Promise<T>,
|
|
214
|
+
): Promise<T> {
|
|
215
|
+
const controller = new AbortController();
|
|
216
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
217
|
+
try {
|
|
218
|
+
timeout = setTimeout(() => {
|
|
219
|
+
controller.abort(new Error(`${label} timed out after ${PROVIDER_REQUEST_TIMEOUT_MS}ms`));
|
|
220
|
+
}, PROVIDER_REQUEST_TIMEOUT_MS);
|
|
221
|
+
(timeout as any).unref?.();
|
|
222
|
+
const response = await fetch(url, {
|
|
223
|
+
...init,
|
|
224
|
+
signal: controller.signal,
|
|
225
|
+
});
|
|
226
|
+
if (!response.ok) {
|
|
227
|
+
const text = await response.text();
|
|
228
|
+
throw new Error(`${label}: ${response.status} - ${text}`);
|
|
229
|
+
}
|
|
230
|
+
return await parser(response);
|
|
231
|
+
} finally {
|
|
232
|
+
if (timeout) clearTimeout(timeout);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
99
236
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as plugins from '../plugins.js';
|
|
2
2
|
import type * as interfaces from '../../ts_interfaces/index.js';
|
|
3
|
-
import { BaseProvider, type ITestConnectionResult, type IListOptions, type IPipelineListOptions } from './classes.baseprovider.js';
|
|
3
|
+
import { BaseProvider, type IReleaseAssetPayload, type IReleaseAssetTransferOptions, type ITestConnectionResult, type IListOptions, type IPipelineListOptions } from './classes.baseprovider.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Gitea API v1 provider implementation
|
|
@@ -191,8 +191,300 @@ export class GiteaProvider extends BaseProvider {
|
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
// --- Releases / Release Assets ---
|
|
195
|
+
|
|
196
|
+
async getReleases(projectFullPath: string): Promise<interfaces.data.IRelease[]> {
|
|
197
|
+
const { owner, repo } = this.splitOwnerRepo(projectFullPath);
|
|
198
|
+
try {
|
|
199
|
+
const releases = await this.requestPaginated<any>(
|
|
200
|
+
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`,
|
|
201
|
+
);
|
|
202
|
+
return releases.map((release) => this.mapRelease(release));
|
|
203
|
+
} catch (err) {
|
|
204
|
+
if (this.isNotFoundError(err)) return [];
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async upsertRelease(projectFullPath: string, release: interfaces.data.IRelease): Promise<interfaces.data.IRelease> {
|
|
210
|
+
const { owner, repo } = this.splitOwnerRepo(projectFullPath);
|
|
211
|
+
const basePath = `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
|
212
|
+
const body: Record<string, unknown> = {
|
|
213
|
+
tag_name: release.tagName,
|
|
214
|
+
name: release.name || release.tagName,
|
|
215
|
+
body: release.description || '',
|
|
216
|
+
draft: !!release.draft,
|
|
217
|
+
prerelease: !!release.preRelease,
|
|
218
|
+
};
|
|
219
|
+
if (release.targetCommitish) body.target_commitish = release.targetCommitish;
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const existing = await this.requestJson<any>(
|
|
223
|
+
'GET',
|
|
224
|
+
`${basePath}/releases/tags/${encodeURIComponent(release.tagName)}`,
|
|
225
|
+
);
|
|
226
|
+
const updated = await this.requestJson<any>(
|
|
227
|
+
'PATCH',
|
|
228
|
+
`${basePath}/releases/${existing.id}`,
|
|
229
|
+
body,
|
|
230
|
+
);
|
|
231
|
+
return this.mapRelease(updated);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
if (!this.isNotFoundError(err)) throw err;
|
|
234
|
+
const created = await this.requestJson<any>('POST', `${basePath}/releases`, body);
|
|
235
|
+
return this.mapRelease(created);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async deleteRelease(projectFullPath: string, tagName: string): Promise<void> {
|
|
240
|
+
const { owner, repo } = this.splitOwnerRepo(projectFullPath);
|
|
241
|
+
try {
|
|
242
|
+
const release = await this.getReleaseByTag(projectFullPath, tagName);
|
|
243
|
+
if (!release?.id) return;
|
|
244
|
+
await this.requestVoid(
|
|
245
|
+
'DELETE',
|
|
246
|
+
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${encodeURIComponent(release.id)}`,
|
|
247
|
+
);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if (!this.isNotFoundError(err)) throw err;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async deleteReleaseAsset(
|
|
254
|
+
projectFullPath: string,
|
|
255
|
+
release: interfaces.data.IRelease,
|
|
256
|
+
asset: interfaces.data.IReleaseAsset,
|
|
257
|
+
): Promise<void> {
|
|
258
|
+
if (asset.kind !== 'attachment' || !asset.id || !release.id) return;
|
|
259
|
+
const { owner, repo } = this.splitOwnerRepo(projectFullPath);
|
|
260
|
+
await this.requestVoid(
|
|
261
|
+
'DELETE',
|
|
262
|
+
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${encodeURIComponent(release.id)}/assets/${encodeURIComponent(asset.id)}`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async downloadReleaseAsset(
|
|
267
|
+
_projectFullPath: string,
|
|
268
|
+
_release: interfaces.data.IRelease,
|
|
269
|
+
asset: interfaces.data.IReleaseAsset,
|
|
270
|
+
options?: IReleaseAssetTransferOptions,
|
|
271
|
+
): Promise<IReleaseAssetPayload | null> {
|
|
272
|
+
const url = asset.downloadUrl || asset.url;
|
|
273
|
+
if (!url) return null;
|
|
274
|
+
const resolvedUrl = url.startsWith('http') ? url : `${this.baseUrl}${url}`;
|
|
275
|
+
const response = await fetch(resolvedUrl, {
|
|
276
|
+
headers: { Authorization: `token ${this.token}` },
|
|
277
|
+
signal: options?.signal,
|
|
278
|
+
});
|
|
279
|
+
if (!response.ok) {
|
|
280
|
+
const text = await response.text();
|
|
281
|
+
throw new Error(`GET release asset ${asset.name}: ${response.status} - ${text}`);
|
|
282
|
+
}
|
|
283
|
+
return await this.readReleaseAssetPayload(response, asset.name, asset.contentType, options);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async uploadReleaseAsset(
|
|
287
|
+
projectFullPath: string,
|
|
288
|
+
release: interfaces.data.IRelease,
|
|
289
|
+
asset: interfaces.data.IReleaseAsset,
|
|
290
|
+
payload: IReleaseAssetPayload,
|
|
291
|
+
options?: IReleaseAssetTransferOptions,
|
|
292
|
+
): Promise<interfaces.data.IReleaseAsset> {
|
|
293
|
+
const releaseId = release.id || (await this.getReleaseByTag(projectFullPath, release.tagName))?.id;
|
|
294
|
+
if (!releaseId) {
|
|
295
|
+
throw new Error(`Cannot upload release asset ${asset.name}: target release has no ID`);
|
|
296
|
+
}
|
|
297
|
+
const { owner, repo } = this.splitOwnerRepo(projectFullPath);
|
|
298
|
+
const formData = new FormData();
|
|
299
|
+
const blob = new Blob([this.getPayloadArrayBuffer(payload)], {
|
|
300
|
+
type: payload.contentType || asset.contentType || 'application/octet-stream',
|
|
301
|
+
});
|
|
302
|
+
formData.append('attachment', blob, asset.name);
|
|
303
|
+
const response = await fetch(
|
|
304
|
+
`${this.baseUrl}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${encodeURIComponent(releaseId)}/assets?name=${encodeURIComponent(asset.name)}`,
|
|
305
|
+
{
|
|
306
|
+
method: 'POST',
|
|
307
|
+
headers: { Authorization: `token ${this.token}` },
|
|
308
|
+
body: formData,
|
|
309
|
+
signal: options?.signal,
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
if (!response.ok) {
|
|
313
|
+
const text = await response.text();
|
|
314
|
+
throw new Error(`POST release asset ${asset.name}: ${response.status} - ${text}`);
|
|
315
|
+
}
|
|
316
|
+
return this.mapReleaseAsset(await response.json());
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async createReleaseAssetLink(
|
|
320
|
+
_projectFullPath: string,
|
|
321
|
+
_release: interfaces.data.IRelease,
|
|
322
|
+
asset: interfaces.data.IReleaseAsset,
|
|
323
|
+
): Promise<interfaces.data.IReleaseAsset> {
|
|
324
|
+
throw new Error(`Gitea releases do not support link-only asset "${asset.name}"`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// --- Container Images ---
|
|
328
|
+
|
|
329
|
+
async getContainerImageRepositories(projectFullPath: string): Promise<interfaces.data.IContainerImageRepository[]> {
|
|
330
|
+
const { owner, repo } = this.splitOwnerRepo(projectFullPath);
|
|
331
|
+
try {
|
|
332
|
+
const packages = await this.requestPaginated<any>(
|
|
333
|
+
`/api/v1/packages/${encodeURIComponent(owner)}?type=container&q=${encodeURIComponent(repo)}`,
|
|
334
|
+
);
|
|
335
|
+
const byName = new Map<string, any[]>();
|
|
336
|
+
for (const packageItem of packages) {
|
|
337
|
+
if (packageItem.type !== 'container') continue;
|
|
338
|
+
const repositoryFullName = packageItem.repository?.full_name;
|
|
339
|
+
if (!repositoryFullName || repositoryFullName !== projectFullPath) continue;
|
|
340
|
+
const name = packageItem.name || repo;
|
|
341
|
+
const list = byName.get(name) || [];
|
|
342
|
+
list.push(packageItem);
|
|
343
|
+
byName.set(name, list);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const registryHost = new URL(this.baseUrl).host;
|
|
347
|
+
return [...byName.entries()].map(([packageName, versions]) => {
|
|
348
|
+
const imagePath = packageName.startsWith(`${owner}/`) ? packageName : `${owner}/${packageName}`;
|
|
349
|
+
return {
|
|
350
|
+
id: imagePath,
|
|
351
|
+
name: packageName,
|
|
352
|
+
path: imagePath,
|
|
353
|
+
location: `${registryHost}/${imagePath}`,
|
|
354
|
+
projectFullPath,
|
|
355
|
+
tags: versions.map((version) => ({
|
|
356
|
+
name: version.version,
|
|
357
|
+
location: `${registryHost}/${imagePath}:${version.version}`,
|
|
358
|
+
createdAt: version.created_at,
|
|
359
|
+
})),
|
|
360
|
+
};
|
|
361
|
+
});
|
|
362
|
+
} catch (err) {
|
|
363
|
+
if (this.isNotFoundError(err)) return [];
|
|
364
|
+
throw err;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async deleteContainerImageTag(
|
|
369
|
+
_projectFullPath: string,
|
|
370
|
+
repositoryId: string,
|
|
371
|
+
tagName: string,
|
|
372
|
+
): Promise<void> {
|
|
373
|
+
const [owner, ...packageNameParts] = repositoryId.split('/');
|
|
374
|
+
const packageName = packageNameParts.join('/');
|
|
375
|
+
if (!owner || !packageName) {
|
|
376
|
+
throw new Error(`Invalid Gitea container repository ID: ${repositoryId}`);
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
await this.requestVoid(
|
|
380
|
+
'DELETE',
|
|
381
|
+
`/api/v1/packages/${encodeURIComponent(owner)}/container/${encodeURIComponent(packageName)}/${encodeURIComponent(tagName)}`,
|
|
382
|
+
);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
if (!this.isNotFoundError(err)) throw err;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
194
388
|
// --- Mappers ---
|
|
195
389
|
|
|
390
|
+
private splitOwnerRepo(projectFullPath: string): { owner: string; repo: string } {
|
|
391
|
+
const [owner, ...repoParts] = projectFullPath.split('/');
|
|
392
|
+
const repo = repoParts.join('/');
|
|
393
|
+
if (!owner || !repo) {
|
|
394
|
+
throw new Error(`Invalid Gitea repository path: ${projectFullPath}`);
|
|
395
|
+
}
|
|
396
|
+
return { owner, repo };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private async requestPaginated<T>(path: string): Promise<T[]> {
|
|
400
|
+
const items: T[] = [];
|
|
401
|
+
const limit = 100;
|
|
402
|
+
const maxPages = 1000;
|
|
403
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
404
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
405
|
+
const requestPath = `${path}${separator}page=${page}&limit=${limit}`;
|
|
406
|
+
const pageItems = await this.requestJson<T[]>('GET', requestPath);
|
|
407
|
+
if (!Array.isArray(pageItems) || pageItems.length === 0) break;
|
|
408
|
+
items.push(...pageItems);
|
|
409
|
+
if (pageItems.length < limit) break;
|
|
410
|
+
}
|
|
411
|
+
if (items.length >= limit * maxPages) {
|
|
412
|
+
throw new Error(`Pagination exceeded ${maxPages} pages for ${path}`);
|
|
413
|
+
}
|
|
414
|
+
return items;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private async requestJson<T>(method: string, path: string, body?: Record<string, unknown>): Promise<T> {
|
|
418
|
+
const headers: Record<string, string> = { Authorization: `token ${this.token}` };
|
|
419
|
+
if (body) headers['Content-Type'] = 'application/json';
|
|
420
|
+
return await this.fetchJsonWithTimeout<T>(
|
|
421
|
+
`${this.baseUrl.replace(/\/+$/, '')}${path}`,
|
|
422
|
+
{
|
|
423
|
+
method,
|
|
424
|
+
headers,
|
|
425
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
426
|
+
},
|
|
427
|
+
`Gitea ${method} ${path}`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private async requestVoid(method: string, path: string): Promise<void> {
|
|
432
|
+
await this.fetchVoidWithTimeout(
|
|
433
|
+
`${this.baseUrl.replace(/\/+$/, '')}${path}`,
|
|
434
|
+
{
|
|
435
|
+
method,
|
|
436
|
+
headers: { Authorization: `token ${this.token}` },
|
|
437
|
+
},
|
|
438
|
+
`Gitea ${method} ${path}`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private isNotFoundError(err: unknown): boolean {
|
|
443
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
444
|
+
return errMsg.includes(': 404 ') || errMsg.includes(': 404 -') || errMsg.includes(' 404 Not Found');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private async getReleaseByTag(projectFullPath: string, tagName: string): Promise<interfaces.data.IRelease | null> {
|
|
448
|
+
const { owner, repo } = this.splitOwnerRepo(projectFullPath);
|
|
449
|
+
try {
|
|
450
|
+
const release = await this.requestJson<any>(
|
|
451
|
+
'GET',
|
|
452
|
+
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tagName)}`,
|
|
453
|
+
);
|
|
454
|
+
return this.mapRelease(release);
|
|
455
|
+
} catch (err) {
|
|
456
|
+
if (this.isNotFoundError(err)) return null;
|
|
457
|
+
throw err;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private mapRelease(raw: any): interfaces.data.IRelease {
|
|
462
|
+
const assets = Array.isArray(raw.assets) ? raw.assets : [];
|
|
463
|
+
return {
|
|
464
|
+
id: raw.id ? String(raw.id) : undefined,
|
|
465
|
+
tagName: raw.tag_name,
|
|
466
|
+
name: raw.name || raw.tag_name,
|
|
467
|
+
description: raw.body || '',
|
|
468
|
+
releasedAt: raw.published_at,
|
|
469
|
+
createdAt: raw.created_at,
|
|
470
|
+
draft: !!raw.draft,
|
|
471
|
+
preRelease: !!raw.prerelease,
|
|
472
|
+
targetCommitish: raw.target_commitish,
|
|
473
|
+
assets: assets.map((asset: any) => this.mapReleaseAsset(asset)),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private mapReleaseAsset(raw: any): interfaces.data.IReleaseAsset {
|
|
478
|
+
return {
|
|
479
|
+
id: raw.id ? String(raw.id) : undefined,
|
|
480
|
+
name: raw.name || '',
|
|
481
|
+
kind: 'attachment',
|
|
482
|
+
url: raw.browser_download_url,
|
|
483
|
+
downloadUrl: raw.browser_download_url,
|
|
484
|
+
size: raw.size,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
196
488
|
private mapProject(r: plugins.giteaClient.GiteaRepository): interfaces.data.IProject {
|
|
197
489
|
return {
|
|
198
490
|
id: r.fullName || String(r.id),
|