@foss.global/codefeed 3.1.0 → 4.0.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 +26 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.giteasourceinventory.d.ts +2 -1
- package/dist_ts/classes.giteasourceinventory.js +18 -7
- package/dist_ts/classes.giteasourcetransfer.d.ts +26 -10
- package/dist_ts/classes.giteasourcetransfer.js +108 -46
- package/dist_ts/giteasourceinventory.git.d.ts +9 -5
- package/dist_ts/giteasourceinventory.git.js +17 -10
- package/package.json +4 -4
- package/readme.md +8 -6
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.giteasourceinventory.ts +16 -7
- package/ts/classes.giteasourcetransfer.ts +107 -44
- package/ts/giteasourceinventory.git.ts +27 -12
|
@@ -29,10 +29,15 @@ export interface IGiteaSourceTransferOptions {
|
|
|
29
29
|
lfsTransferTimeoutMs?: number;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* A source repository: its owner's and its own name, which Git and LFS reach
|
|
34
|
+
* it by, and the id Gitea gave it, which every read checks those names against.
|
|
35
|
+
*/
|
|
33
36
|
export interface IGiteaSourceRepository {
|
|
34
37
|
owner: string;
|
|
35
38
|
name: string;
|
|
39
|
+
/** Gitea's repository id, a decimal string. */
|
|
40
|
+
id: string;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
export interface IGiteaMirrorImportOptions {
|
|
@@ -106,35 +111,34 @@ export class GiteaSourceTransfer {
|
|
|
106
111
|
* the GitManager's data directory, in the process-wide `GitScratchBudget`,
|
|
107
112
|
* and the reservation is released once the clone has ended. While it runs
|
|
108
113
|
* the import holds `target`: writes to it through the GitManager fail there
|
|
109
|
-
* with `repositoryBusy`.
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* `
|
|
114
|
-
*
|
|
115
|
-
*
|
|
114
|
+
* with `repositoryBusy`. Git clones by name, so the repository `id` is
|
|
115
|
+
* read by id both before the clone and after it, and must still be named
|
|
116
|
+
* `owner/name` (compared without case, as Gitea looks names up): a rename,
|
|
117
|
+
* a transfer or a rename and re-create rejects with
|
|
118
|
+
* `repository-identity-changed`, a deleted repository with `http-404`, also
|
|
119
|
+
* when the clone itself failed. A rejection from the read after the clone
|
|
120
|
+
* leaves the clone at `target` for the caller to discard. Rejects with a
|
|
121
|
+
* fixed `ForgeInventoryError` code: `invalid-repository`, `invalid-target`,
|
|
122
|
+
* `gitmanager-not-running`, a REST code such as `http-404`,
|
|
123
|
+
* `repository-identity-changed`, `git-workdir-failed`,
|
|
124
|
+
* `git-workdir-too-small`, `git-mirror-failed` (also when GitManager refuses
|
|
125
|
+
* `target`, such as with `repositoryBusy` while another transfer holds it)
|
|
126
|
+
* or `request-aborted` (an abort, or a GitManager stop that cancelled the
|
|
127
|
+
* clone). GitManager error text is never passed on.
|
|
116
128
|
*/
|
|
117
129
|
public async importMirror(
|
|
118
130
|
repositoryArg: IGiteaSourceRepository,
|
|
119
131
|
gitManagerArg: plugins.TGitManager,
|
|
120
132
|
optionsArg: IGiteaMirrorImportOptions,
|
|
121
133
|
): Promise<IGiteaMirrorImport> {
|
|
122
|
-
const
|
|
134
|
+
const repository = this.#repository(repositoryArg);
|
|
135
|
+
const { owner, name } = repository;
|
|
123
136
|
const target = optionsArg?.target;
|
|
124
137
|
if (typeof target?.org !== 'string' || typeof target?.repo !== 'string') throw new validation.ForgeInventoryError('invalid-target');
|
|
125
138
|
const { signal } = optionsArg;
|
|
126
139
|
if (signal?.aborted) throw new validation.ForgeInventoryError('request-aborted');
|
|
127
140
|
if (!gitManagerArg.running) throw new validation.ForgeInventoryError('gitmanager-not-running');
|
|
128
|
-
|
|
129
|
-
try {
|
|
130
|
-
const repository = validation.record((await this.#client.request(
|
|
131
|
-
new URL(`${this.#options.baseUrl}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`), { signal },
|
|
132
|
-
)).body);
|
|
133
|
-
reportedSizeKiB = BigInt(validation.decimal(repository.size));
|
|
134
|
-
} catch (error) {
|
|
135
|
-
if (signal?.aborted) throw new validation.ForgeInventoryError('request-aborted');
|
|
136
|
-
throw error;
|
|
137
|
-
}
|
|
141
|
+
const reportedSizeKiB = BigInt(validation.decimal((await this.#identified(repository, signal)).size));
|
|
138
142
|
let dataDir: string;
|
|
139
143
|
try {
|
|
140
144
|
dataDir = (await gitManagerArg.getStatus()).dataDir;
|
|
@@ -142,25 +146,31 @@ export class GiteaSourceTransfer {
|
|
|
142
146
|
throw new validation.ForgeInventoryError('git-workdir-failed');
|
|
143
147
|
}
|
|
144
148
|
const release = await reserveMirrorScratch(dataDir, reportedSizeKiB, signal);
|
|
149
|
+
let imported: plugins.TGitManagerRepositoryInfo;
|
|
145
150
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
151
|
+
try {
|
|
152
|
+
imported = await gitManagerArg.importMirror({
|
|
153
|
+
org: target.org,
|
|
154
|
+
repo: target.repo,
|
|
155
|
+
remoteUrl: giteaCloneUrl(this.#options.baseUrl, owner, name),
|
|
156
|
+
credential: { kind: 'basic', username: this.#options.login, password: this.#options.token },
|
|
157
|
+
caCertificates: this.#options.caCertificates,
|
|
158
|
+
timeoutMs: this.#options.gitTimeoutMs,
|
|
159
|
+
}, { signal });
|
|
160
|
+
} finally {
|
|
161
|
+
// The import settles only after Git has exited, so the scratch is free again.
|
|
162
|
+
release();
|
|
163
|
+
}
|
|
155
164
|
} catch (error) {
|
|
156
165
|
const { GitManagerError } = await plugins.getGitManager();
|
|
157
|
-
|
|
166
|
+
if (signal?.aborted || (error instanceof GitManagerError && error.code === 'transferAborted')) {
|
|
167
|
+
throw new validation.ForgeInventoryError('request-aborted');
|
|
168
|
+
}
|
|
158
169
|
// GitManager errors are redacted, but still remote text.
|
|
159
|
-
|
|
160
|
-
} finally {
|
|
161
|
-
// The import settles only after Git has exited, so the scratch is free again.
|
|
162
|
-
release();
|
|
170
|
+
return this.#explained(repository, new validation.ForgeInventoryError('git-mirror-failed'), signal);
|
|
163
171
|
}
|
|
172
|
+
await this.#identified(repository, signal);
|
|
173
|
+
return { repository: imported, reportedSizeKiB: reportedSizeKiB.toString() };
|
|
164
174
|
}
|
|
165
175
|
|
|
166
176
|
/**
|
|
@@ -169,11 +179,16 @@ export class GiteaSourceTransfer {
|
|
|
169
179
|
* the action's headers only. Resolves with `{ status: 'missing' }` when
|
|
170
180
|
* Gitea answers error 404, otherwise with a stream of the object verified
|
|
171
181
|
* against `oid` and `size` as described for `LfsBatchClient.download`.
|
|
172
|
-
*
|
|
173
|
-
*
|
|
182
|
+
* LFS reaches the repository by name, so once Gitea has answered, the
|
|
183
|
+
* repository `id` is read by id as for `importMirror`; when it is no longer
|
|
184
|
+
* named `owner/name` the download is discarded with
|
|
185
|
+
* `repository-identity-changed`, when it is deleted with `http-404`, also
|
|
186
|
+
* when the download itself failed. Rejects with a fixed code, such as
|
|
187
|
+
* `lfs-object-error-422` for a size Gitea disagrees with.
|
|
174
188
|
*/
|
|
175
189
|
public async downloadLfsObject(repositoryArg: IGiteaSourceRepository, oidArg: string, sizeArg: number, optionsArg: ILfsRequestOptions = {}): Promise<TLfsDownload> {
|
|
176
|
-
const
|
|
190
|
+
const repository = this.#repository(repositoryArg);
|
|
191
|
+
const { owner, name } = repository;
|
|
177
192
|
const client = new LfsBatchClient({
|
|
178
193
|
endpoint: `${giteaCloneUrl(this.#options.baseUrl, owner, name)}/info/lfs`,
|
|
179
194
|
// Gitea's LFS routes take the token as a Basic password, not as a `token` authorization.
|
|
@@ -184,18 +199,64 @@ export class GiteaSourceTransfer {
|
|
|
184
199
|
maxResponseBytes: this.#options.maxResponseBytes,
|
|
185
200
|
batchSize: 1,
|
|
186
201
|
});
|
|
202
|
+
let download: TLfsDownload;
|
|
187
203
|
try {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
204
|
+
download = await client.download({ oid: oidArg, size: sizeArg }, optionsArg);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
client.close();
|
|
207
|
+
if (optionsArg.signal?.aborted) throw error;
|
|
208
|
+
return this.#explained(repository, error, optionsArg.signal);
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
// The batch answer and the object request have both reached Gitea by now; neither can still go to another repository.
|
|
212
|
+
await this.#identified(repository, optionsArg.signal);
|
|
195
213
|
} catch (error) {
|
|
214
|
+
if (download.status === 'available') download.stream.destroy();
|
|
196
215
|
client.close();
|
|
197
216
|
throw error;
|
|
198
217
|
}
|
|
218
|
+
if (download.status === 'available') {
|
|
219
|
+
download.stream.once('close', () => client.close());
|
|
220
|
+
} else {
|
|
221
|
+
client.close();
|
|
222
|
+
}
|
|
223
|
+
return download;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Reads the repository by its id and checks that Gitea still names it
|
|
228
|
+
* `owner/name`, without case as Gitea looks names up: other names reject
|
|
229
|
+
* with `repository-identity-changed`, a deleted repository with `http-404`.
|
|
230
|
+
*/
|
|
231
|
+
async #identified(repositoryArg: IGiteaSourceRepository, signalArg: AbortSignal | undefined): Promise<Record<string, unknown>> {
|
|
232
|
+
let record: Record<string, unknown>;
|
|
233
|
+
try {
|
|
234
|
+
record = validation.record((await this.#client.request(
|
|
235
|
+
new URL(`${this.#options.baseUrl}/api/v1/repositories/${repositoryArg.id}`), { signal: signalArg },
|
|
236
|
+
)).body);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (signalArg?.aborted) throw new validation.ForgeInventoryError('request-aborted');
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
const owner = validation.record(record.owner).login;
|
|
242
|
+
if (validation.decimal(record.id) !== repositoryArg.id || typeof owner !== 'string' || typeof record.name !== 'string'
|
|
243
|
+
|| owner.toLowerCase() !== repositoryArg.owner.toLowerCase() || record.name.toLowerCase() !== repositoryArg.name.toLowerCase()) {
|
|
244
|
+
throw new validation.ForgeInventoryError('repository-identity-changed');
|
|
245
|
+
}
|
|
246
|
+
return record;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Rejects with `errorArg`, a failed read by name, unless the identity read
|
|
251
|
+
* explains it: then with `repository-identity-changed` or `http-404`.
|
|
252
|
+
*/
|
|
253
|
+
async #explained(repositoryArg: IGiteaSourceRepository, errorArg: unknown, signalArg: AbortSignal | undefined): Promise<never> {
|
|
254
|
+
try {
|
|
255
|
+
await this.#identified(repositoryArg, signalArg);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (error instanceof validation.ForgeInventoryError && (error.code === 'repository-identity-changed' || error.code === 'http-404')) throw error;
|
|
258
|
+
}
|
|
259
|
+
throw errorArg;
|
|
199
260
|
}
|
|
200
261
|
|
|
201
262
|
#repository(repositoryArg: IGiteaSourceRepository): IGiteaSourceRepository {
|
|
@@ -204,8 +265,10 @@ export class GiteaSourceTransfer {
|
|
|
204
265
|
if (segments.length !== 1) throw new validation.ForgeInventoryError('invalid-repository');
|
|
205
266
|
return segments[0];
|
|
206
267
|
};
|
|
268
|
+
const id = repositoryArg?.id;
|
|
269
|
+
if (typeof id !== 'string') throw new validation.ForgeInventoryError('invalid-repository');
|
|
207
270
|
try {
|
|
208
|
-
return { owner: single(repositoryArg?.owner), name: single(repositoryArg?.name) };
|
|
271
|
+
return { owner: single(repositoryArg?.owner), name: single(repositoryArg?.name), id: validation.decimal(id) };
|
|
209
272
|
} catch {
|
|
210
273
|
throw new validation.ForgeInventoryError('invalid-repository');
|
|
211
274
|
}
|
|
@@ -5,6 +5,14 @@ import type { ForgeRequestClient } from './forgeinventory.client.js';
|
|
|
5
5
|
type TMeasure = plugins.fossInterfaces.data.TForgeSourceMeasure;
|
|
6
6
|
type IGitCounts = Omit<plugins.fossInterfaces.data.IForgeSourceGitCounts, 'reportedSizeKiB'>;
|
|
7
7
|
type ILfsCounts = plugins.fossInterfaces.data.IForgeSourceLfsCounts;
|
|
8
|
+
type IHead = plugins.fossInterfaces.data.IForgeSourceRepositoryHead;
|
|
9
|
+
|
|
10
|
+
/** The Git level measure of one repository; `head` is null when the mirror's refs could not be read. */
|
|
11
|
+
export interface IGiteaMirrorMeasure {
|
|
12
|
+
git: IGitCounts;
|
|
13
|
+
lfs: ILfsCounts;
|
|
14
|
+
head: IHead | null;
|
|
15
|
+
}
|
|
8
16
|
|
|
9
17
|
export interface IGiteaMirrorSource {
|
|
10
18
|
/** Instance root without a trailing slash. */
|
|
@@ -185,13 +193,13 @@ const stepFailure = (errorArg: unknown, stepArg: string, signalArg?: AbortSignal
|
|
|
185
193
|
|
|
186
194
|
/**
|
|
187
195
|
* Measures one repository at the Git level: clones every ref Gitea advertises
|
|
188
|
-
* into a temporary mirror, counts its refs and objects, finds the LFS pointers
|
|
196
|
+
* into a temporary mirror, records its HEAD, counts its refs and objects, finds the LFS pointers
|
|
189
197
|
* of every reachable revision and checks through the LFS batch API which of
|
|
190
198
|
* their objects Gitea holds. The mirror is removed afterwards. The clone starts
|
|
191
199
|
* only once its scratch reservation fits the work directory's free space; a
|
|
192
200
|
* repository too large for it fails with `git-workdir-too-small`.
|
|
193
201
|
*/
|
|
194
|
-
export const measureGiteaMirror = async (sourceArg: IGiteaMirrorSource): Promise<
|
|
202
|
+
export const measureGiteaMirror = async (sourceArg: IGiteaMirrorSource): Promise<IGiteaMirrorMeasure> => {
|
|
195
203
|
const { signal } = sourceArg;
|
|
196
204
|
if (signal?.aborted) throw new validation.ForgeInventoryError('request-aborted');
|
|
197
205
|
let release: () => void;
|
|
@@ -199,7 +207,7 @@ export const measureGiteaMirror = async (sourceArg: IGiteaMirrorSource): Promise
|
|
|
199
207
|
release = await reserveMirrorScratch(sourceArg.workDir, sourceArg.reportedSizeKiB, signal);
|
|
200
208
|
} catch (error) {
|
|
201
209
|
const failure = stepFailure(error, 'git-workdir-failed', signal);
|
|
202
|
-
return { git: failedGit(failure), lfs: failedLfs(failure) };
|
|
210
|
+
return { git: failedGit(failure), lfs: failedLfs(failure), head: null };
|
|
203
211
|
}
|
|
204
212
|
try {
|
|
205
213
|
return await measureReservedMirror(sourceArg);
|
|
@@ -208,14 +216,14 @@ export const measureGiteaMirror = async (sourceArg: IGiteaMirrorSource): Promise
|
|
|
208
216
|
}
|
|
209
217
|
};
|
|
210
218
|
|
|
211
|
-
const measureReservedMirror = async (sourceArg: IGiteaMirrorSource): Promise<
|
|
219
|
+
const measureReservedMirror = async (sourceArg: IGiteaMirrorSource): Promise<IGiteaMirrorMeasure> => {
|
|
212
220
|
const { signal } = sourceArg;
|
|
213
221
|
const { GitManager } = await plugins.getGitManager();
|
|
214
222
|
let directory: string;
|
|
215
223
|
try {
|
|
216
224
|
directory = await plugins.fs.mkdtemp(plugins.path.join(sourceArg.workDir, 'mirror-'));
|
|
217
225
|
} catch {
|
|
218
|
-
return { git: failedGit('git-workdir-failed'), lfs: failedLfs('git-workdir-failed') };
|
|
226
|
+
return { git: failedGit('git-workdir-failed'), lfs: failedLfs('git-workdir-failed'), head: null };
|
|
219
227
|
}
|
|
220
228
|
const manager = new GitManager({ dataDir: directory });
|
|
221
229
|
// The engine serves this measurement alone; stopping it ends the ref, object
|
|
@@ -237,16 +245,16 @@ const measureReservedMirror = async (sourceArg: IGiteaMirrorSource): Promise<{ g
|
|
|
237
245
|
}, { signal });
|
|
238
246
|
} catch (error) {
|
|
239
247
|
const failure = stepFailure(error, 'git-mirror-failed', signal);
|
|
240
|
-
return { git: failedGit(failure), lfs: failedLfs(failure) };
|
|
248
|
+
return { git: failedGit(failure), lfs: failedLfs(failure), head: null };
|
|
241
249
|
}
|
|
242
|
-
const git = await measureRefsAndObjects(manager, selector, signal);
|
|
250
|
+
const { git, head } = await measureRefsAndObjects(manager, selector, signal);
|
|
243
251
|
let pointers: Array<{ oid: string; sizeBytes: number }>;
|
|
244
252
|
try {
|
|
245
253
|
pointers = await manager.listRepositoryLfsPointers(selector);
|
|
246
254
|
} catch (error) {
|
|
247
|
-
return { git, lfs: failedLfs(stepFailure(error, 'lfs-pointer-scan-failed', signal)) };
|
|
255
|
+
return { git, lfs: failedLfs(stepFailure(error, 'lfs-pointer-scan-failed', signal)), head };
|
|
248
256
|
}
|
|
249
|
-
return { git, lfs: await giteaLfsPresence(sourceArg, pointers) };
|
|
257
|
+
return { git, lfs: await giteaLfsPresence(sourceArg, pointers), head };
|
|
250
258
|
} finally {
|
|
251
259
|
signal?.removeEventListener('abort', abort);
|
|
252
260
|
await manager.stop().catch(() => undefined);
|
|
@@ -258,8 +266,9 @@ const measureRefsAndObjects = async (
|
|
|
258
266
|
managerArg: plugins.TGitManager,
|
|
259
267
|
selectorArg: { org: string; repo: string },
|
|
260
268
|
signalArg?: AbortSignal,
|
|
261
|
-
): Promise<
|
|
269
|
+
): Promise<Pick<IGiteaMirrorMeasure, 'git' | 'head'>> => {
|
|
262
270
|
const counts = { branches: 0, tags: 0, pullRequestRefs: 0, otherRefs: 0 };
|
|
271
|
+
let head: IHead | null = null;
|
|
263
272
|
let refsFailure: string | null = null;
|
|
264
273
|
try {
|
|
265
274
|
let cursor: string | undefined;
|
|
@@ -267,6 +276,8 @@ const measureRefsAndObjects = async (
|
|
|
267
276
|
const page = await managerArg.listRefs({ ...selectorArg, cursor });
|
|
268
277
|
// Nothing writes to the private mirror, so a changed inventory is a failure.
|
|
269
278
|
if (page.status === 'stale') throw new validation.ForgeInventoryError('git-refs-changed');
|
|
279
|
+
// Every page carries HEAD; its digest binds it, so all pages agree. An unborn HEAD has no object.
|
|
280
|
+
head = { target: page.head.symbolicTarget, resolves: page.head.objectId !== null };
|
|
270
281
|
for (const ref of page.refs) {
|
|
271
282
|
if (ref.name.startsWith('refs/heads/')) counts.branches++;
|
|
272
283
|
else if (ref.name.startsWith('refs/tags/')) counts.tags++;
|
|
@@ -276,6 +287,7 @@ const measureRefsAndObjects = async (
|
|
|
276
287
|
cursor = page.pagination.status === 'more' ? page.pagination.cursor : undefined;
|
|
277
288
|
} while (cursor !== undefined);
|
|
278
289
|
} catch (error) {
|
|
290
|
+
head = null;
|
|
279
291
|
refsFailure = stepFailure(error, 'git-refs-failed', signalArg);
|
|
280
292
|
}
|
|
281
293
|
const refs = (valueArg: number) => refsFailure === null ? complete(valueArg) : partial(0, refsFailure);
|
|
@@ -289,8 +301,11 @@ const measureRefsAndObjects = async (
|
|
|
289
301
|
objects = objectBytes = partial(0, stepFailure(error, 'git-size-failed', signalArg));
|
|
290
302
|
}
|
|
291
303
|
return {
|
|
292
|
-
|
|
293
|
-
|
|
304
|
+
git: {
|
|
305
|
+
branches: refs(counts.branches), tags: refs(counts.tags), pullRequestRefs: refs(counts.pullRequestRefs), otherRefs: refs(counts.otherRefs),
|
|
306
|
+
objects, objectBytes,
|
|
307
|
+
},
|
|
308
|
+
head,
|
|
294
309
|
};
|
|
295
310
|
};
|
|
296
311
|
|