@foss.global/codefeed 2.1.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.
@@ -9,11 +9,12 @@ export interface IForgeTransportResponse {
9
9
  }
10
10
 
11
11
  /**
12
- * Sends one credential-scoped GET and resolves once response headers arrive.
13
- * A redirect status rejects and is never followed; aborting `signal` discards
14
- * the connection, also while the body is still being read.
12
+ * Sends one credential-scoped GET, or a POST of `body` when one is given, and
13
+ * resolves once response headers arrive. A redirect status rejects and is
14
+ * never followed; aborting `signal` discards the connection, also while the
15
+ * body is still being read.
15
16
  */
16
- export type TForgeTransport = (url: URL, headers: Record<string, string>, signal: AbortSignal) => Promise<IForgeTransportResponse>;
17
+ export type TForgeTransport = (url: URL, headers: Record<string, string>, signal: AbortSignal, body?: Uint8Array<ArrayBuffer>) => Promise<IForgeTransportResponse>;
17
18
 
18
19
  /** The fetch statuses whose redirect mode `error` turns them into a network error. */
19
20
  const redirectStatuses: ReadonlySet<number> = new Set([301, 302, 303, 307, 308]);
@@ -26,48 +27,130 @@ const redirectStatuses: ReadonlySet<number> = new Set([301, 302, 303, 307, 308])
26
27
  const idleSocketTimeoutMs = 4_000;
27
28
 
28
29
  /** Node's global `fetch` with the process-wide default trust store. */
29
- export const globalFetchTransport: TForgeTransport = async (url, headers, signal) => {
30
- const response = await fetch(url, { method: 'GET', headers, redirect: 'error', credentials: 'omit', signal });
30
+ export const globalFetchTransport: TForgeTransport = async (url, headers, signal, body) => {
31
+ const response = await fetch(url, { method: body ? 'POST' : 'GET', headers, body, redirect: 'error', credentials: 'omit', signal });
31
32
  return { status: response.status, headers: response.headers, body: response.body };
32
33
  };
33
34
 
35
+ /** The agents a node request may use; a URL whose protocol has no agent is refused. */
36
+ export interface INodeRequestAgents {
37
+ http?: InstanceType<typeof plugins.http.Agent>;
38
+ https?: InstanceType<typeof plugins.https.Agent>;
39
+ }
40
+
41
+ export interface INodeRequest {
42
+ method: string;
43
+ headers: Record<string, string>;
44
+ /** Sent in full, or streamed with backpressure; a streamed body needs its own `content-length` header. */
45
+ body?: Uint8Array | AsyncIterable<Uint8Array>;
46
+ signal: AbortSignal;
47
+ agents: INodeRequestAgents;
48
+ }
49
+
50
+ export interface INodeResponse extends IForgeTransportResponse {
51
+ /**
52
+ * Settles once the request body has been sent, or rejects with the error
53
+ * that ended it, such as the body source's own error. Always handled.
54
+ */
55
+ sent: Promise<void>;
56
+ }
57
+
58
+ /**
59
+ * Idle keep-alive agents that unreference their pooled sockets, so pooled
60
+ * connections never keep the process alive.
61
+ */
62
+ export const createNodeAgents = (caCertificatesArg?: string[], allowHttpArg = false): INodeRequestAgents => ({
63
+ https: new plugins.https.Agent({ ...(caCertificatesArg ? { ca: caCertificatesArg } : {}), keepAlive: true, timeout: idleSocketTimeoutMs }),
64
+ ...(allowHttpArg ? { http: new plugins.http.Agent({ keepAlive: true, timeout: idleSocketTimeoutMs }) } : {}),
65
+ });
66
+
67
+ /** Waits until the request can take more body bytes; rejects once it has closed. */
68
+ const drained = (requestArg: InstanceType<typeof plugins.http.ClientRequest>): Promise<void> => new Promise((resolve, reject) => {
69
+ const done = () => { requestArg.off('drain', done); requestArg.off('close', closed); resolve(); };
70
+ const closed = () => { requestArg.off('drain', done); requestArg.off('close', closed); reject(new Error('Request closed.')); };
71
+ requestArg.on('drain', done);
72
+ requestArg.on('close', closed);
73
+ });
74
+
75
+ /**
76
+ * Sends one request through `node:http` or `node:https` and resolves once
77
+ * response headers arrive. A redirect status rejects and is never followed;
78
+ * aborting `signal` discards the connection, also while a body is still being
79
+ * sent or read. A streamed body that fails destroys the request and rejects.
80
+ */
81
+ export const sendNodeRequest = (url: URL, requestArg: INodeRequest): Promise<INodeResponse> => new Promise<INodeResponse>((resolve, reject) => {
82
+ const { signal } = requestArg;
83
+ if (signal.aborted) return reject(signal.reason);
84
+ const agent = url.protocol === 'https:' ? requestArg.agents.https : url.protocol === 'http:' ? requestArg.agents.http : undefined;
85
+ if (!agent) return reject(new Error('Refused protocol.'));
86
+ const module = url.protocol === 'https:' ? plugins.https : plugins.http;
87
+ // Bodies are read as sent, so ask for them without a content coding.
88
+ const request = module.request(url, { method: requestArg.method, headers: { ...requestArg.headers, 'accept-encoding': 'identity' }, agent });
89
+ let settled = false;
90
+ const fail = (errorArg: unknown) => {
91
+ if (!settled) {
92
+ settled = true;
93
+ reject(errorArg);
94
+ }
95
+ };
96
+ // Stays attached for the request's lifetime, so a connection failure while
97
+ // the body is read never becomes an unhandled 'error' event.
98
+ request.on('error', fail);
99
+ // Destroyed without an error on purpose: an error would be re-emitted on the
100
+ // socket, which no longer has a listener once a keep-alive response completed.
101
+ const abort = () => {
102
+ fail(signal.reason);
103
+ request.destroy();
104
+ };
105
+ signal.addEventListener('abort', abort, { once: true });
106
+ request.once('close', () => signal.removeEventListener('abort', abort));
107
+ const sent = (async () => {
108
+ const { body } = requestArg;
109
+ if (body === undefined || body instanceof Uint8Array) {
110
+ request.end(body);
111
+ return;
112
+ }
113
+ try {
114
+ for await (const chunk of body) {
115
+ if (request.destroyed) throw new Error('Request closed.');
116
+ if (!request.write(chunk)) await drained(request);
117
+ }
118
+ request.end();
119
+ } catch (error) {
120
+ fail(error);
121
+ request.destroy();
122
+ throw error;
123
+ }
124
+ })();
125
+ sent.catch(() => undefined);
126
+ request.on('response', (response) => {
127
+ try {
128
+ const status = response.statusCode ?? 0;
129
+ if (redirectStatuses.has(status)) throw new Error('Refused redirect.');
130
+ // Same combination rules as fetch: repeated fields join with ", ".
131
+ const responseHeaders = new Headers();
132
+ for (let index = 0; index < response.rawHeaders.length; index += 2) {
133
+ responseHeaders.append(response.rawHeaders[index], response.rawHeaders[index + 1]);
134
+ }
135
+ if (settled) return void request.destroy();
136
+ settled = true;
137
+ resolve({ status, headers: responseHeaders, body: response, sent });
138
+ } catch (error) {
139
+ fail(error);
140
+ request.destroy();
141
+ }
142
+ });
143
+ });
144
+
34
145
  /**
35
146
  * `node:https` with an owned keep-alive agent whose `ca` list replaces the
36
147
  * default trust store for these connections only. The agent unreferences idle
37
148
  * sockets, so pooled connections never keep the process alive.
38
149
  */
39
150
  export const createHttpsTransport = (caCertificates: string[]): TForgeTransport => {
40
- const agent = new plugins.https.Agent({ ca: caCertificates, keepAlive: true, timeout: idleSocketTimeoutMs });
41
- return (url, headers, signal) => new Promise<IForgeTransportResponse>((resolve, reject) => {
42
- if (signal.aborted) return reject(signal.reason);
43
- // Bodies are read as sent, so ask for them without a content coding.
44
- const request = plugins.https.request(url, { method: 'GET', headers: { ...headers, 'accept-encoding': 'identity' }, agent });
45
- // Stays attached for the request's lifetime, so a connection failure while
46
- // the body is read never becomes an unhandled 'error' event.
47
- request.on('error', reject);
48
- // Destroyed without an error on purpose: an error would be re-emitted on the
49
- // socket, which no longer has a listener once a keep-alive response completed.
50
- const abort = () => {
51
- reject(signal.reason);
52
- request.destroy();
53
- };
54
- signal.addEventListener('abort', abort, { once: true });
55
- request.once('close', () => signal.removeEventListener('abort', abort));
56
- request.on('response', (response) => {
57
- try {
58
- const status = response.statusCode ?? 0;
59
- if (redirectStatuses.has(status)) throw new Error('Refused redirect.');
60
- // Same combination rules as fetch: repeated fields join with ", ".
61
- const responseHeaders = new Headers();
62
- for (let index = 0; index < response.rawHeaders.length; index += 2) {
63
- responseHeaders.append(response.rawHeaders[index], response.rawHeaders[index + 1]);
64
- }
65
- resolve({ status, headers: responseHeaders, body: response });
66
- } catch (error) {
67
- reject(error);
68
- request.destroy();
69
- }
70
- });
71
- request.end();
72
- });
151
+ const agents = createNodeAgents(caCertificates);
152
+ return async (url, headers, signal, body) => {
153
+ const { sent: _sent, ...response } = await sendNodeRequest(url, { method: body ? 'POST' : 'GET', headers, body, signal, agents });
154
+ return response;
155
+ };
73
156
  };
@@ -0,0 +1,343 @@
1
+ import * as plugins from './plugins.js';
2
+ import * as validation from './forgeinventory.validation.js';
3
+ import type { ForgeRequestClient } from './forgeinventory.client.js';
4
+
5
+ type TMeasure = plugins.fossInterfaces.data.TForgeSourceMeasure;
6
+ type IGitCounts = Omit<plugins.fossInterfaces.data.IForgeSourceGitCounts, 'reportedSizeKiB'>;
7
+ type ILfsCounts = plugins.fossInterfaces.data.IForgeSourceLfsCounts;
8
+
9
+ export interface IGiteaMirrorSource {
10
+ /** Instance root without a trailing slash. */
11
+ baseUrl: string;
12
+ owner: string;
13
+ name: string;
14
+ /** The reader's login and token; Gitea accepts a token as the Basic password for Git and LFS. */
15
+ username: string;
16
+ token: string;
17
+ caCertificates?: string[];
18
+ /** Private directory that holds the temporary mirror. */
19
+ workDir: string;
20
+ /** The repository size Gitea reports, which sizes the mirror's scratch reservation. */
21
+ reportedSizeKiB: bigint;
22
+ /** Wall-clock limit of the mirror clone. */
23
+ timeoutMs: number;
24
+ /** Sends LFS batch reads with the reader's Basic credentials. */
25
+ lfsClient: ForgeRequestClient;
26
+ signal?: AbortSignal;
27
+ }
28
+
29
+ /** Objects per LFS batch request. */
30
+ const lfsBatchSize = 100;
31
+ const lfsMediaType = 'application/vnd.git-lfs+json';
32
+
33
+ /**
34
+ * Scratch space reserved for one mirror: twice the size Gitea reports, because
35
+ * a clone holds received and indexed packs, plus fixed headroom for refs, the
36
+ * index and a size Gitea has not updated yet.
37
+ */
38
+ export const mirrorScratchBytes = (reportedSizeKiBArg: bigint): bigint => reportedSizeKiBArg * 1024n * 2n + 64n * 1024n * 1024n;
39
+
40
+ /** Bytes an unprivileged process may still write to the file system holding `directoryArg`. */
41
+ const statfsFreeBytes = async (directoryArg: string): Promise<bigint> => {
42
+ const stats = await plugins.fs.statfs(directoryArg, { bigint: true });
43
+ return stats.bavail * stats.bsize;
44
+ };
45
+
46
+ /**
47
+ * Admits mirror clones on one file system in arrival order while the scratch
48
+ * they reserve fits its free space. A clone's reservation counts in full until
49
+ * it is released, although part of it is already written, so the check never
50
+ * admits more than fits. A clone that does not fit waits until the clones in
51
+ * flight release their reservations; one that does not fit even alone is
52
+ * refused.
53
+ */
54
+ export class GitScratchBudget {
55
+ readonly #freeBytes: (directoryArg: string) => Promise<bigint>;
56
+ #reserved = 0n;
57
+ /** Settles when the clones ahead of the next arrival have been admitted or refused. */
58
+ #queue: Promise<void> = Promise.resolve();
59
+ readonly #releaseListeners = new Set<() => void>();
60
+
61
+ constructor(freeBytesArg: (directoryArg: string) => Promise<bigint> = statfsFreeBytes) {
62
+ this.#freeBytes = freeBytesArg;
63
+ }
64
+
65
+ /**
66
+ * Reserves `bytesArg` in `directoryArg` and resolves with the function that
67
+ * releases them, or with null when they exceed the free space with no other
68
+ * clone in flight. Rejects when the free space cannot be read or `signalArg`
69
+ * aborts.
70
+ */
71
+ async reserve(directoryArg: string, bytesArg: bigint, signalArg?: AbortSignal): Promise<(() => void) | null> {
72
+ const ahead = this.#queue;
73
+ let admitted!: () => void;
74
+ const turn = new Promise<void>((resolve) => { admitted = resolve; });
75
+ // Followers wait for this turn and every turn ahead of it, even when this one ends early.
76
+ this.#queue = ahead.then(() => turn);
77
+ try {
78
+ await this.#until(ahead, signalArg);
79
+ for (;;) {
80
+ // Listens before reading the free space, so a release while it is read is not missed.
81
+ let listener!: () => void;
82
+ const released = new Promise<void>((resolve) => { listener = resolve; });
83
+ this.#releaseListeners.add(listener);
84
+ try {
85
+ const free = await this.#freeBytes(directoryArg);
86
+ if (this.#reserved + bytesArg <= free) {
87
+ this.#reserved += bytesArg;
88
+ return this.#releaser(bytesArg);
89
+ }
90
+ if (this.#reserved === 0n) return null;
91
+ await this.#until(released, signalArg);
92
+ } finally {
93
+ this.#releaseListeners.delete(listener);
94
+ }
95
+ }
96
+ } finally {
97
+ admitted();
98
+ }
99
+ }
100
+
101
+ /**
102
+ * The process-wide budget of the file system that holds `directoryArg`,
103
+ * keyed by its device; every mirror clone and every scratch GitManager in
104
+ * the process reserves through it.
105
+ */
106
+ static async forDirectory(directoryArg: string): Promise<GitScratchBudget> {
107
+ const device = (await plugins.fs.stat(directoryArg, { bigint: true })).dev;
108
+ let budget = scratchBudgets.get(device);
109
+ if (!budget) {
110
+ budget = new GitScratchBudget();
111
+ scratchBudgets.set(device, budget);
112
+ }
113
+ return budget;
114
+ }
115
+
116
+ #releaser(bytesArg: bigint): () => void {
117
+ let held = true;
118
+ return () => {
119
+ if (!held) return;
120
+ held = false;
121
+ this.#reserved -= bytesArg;
122
+ for (const listener of this.#releaseListeners) listener();
123
+ };
124
+ }
125
+
126
+ async #until(promiseArg: Promise<void>, signalArg?: AbortSignal): Promise<void> {
127
+ signalArg?.throwIfAborted();
128
+ if (!signalArg) return promiseArg;
129
+ let abort!: () => void;
130
+ const aborted = new Promise<never>((_resolve, reject) => {
131
+ abort = () => reject(signalArg.reason);
132
+ signalArg.addEventListener('abort', abort, { once: true });
133
+ });
134
+ try {
135
+ await Promise.race([promiseArg, aborted]);
136
+ } finally {
137
+ signalArg.removeEventListener('abort', abort);
138
+ }
139
+ }
140
+ }
141
+
142
+ /** One budget per file system, shared by every reader in the process. */
143
+ const scratchBudgets = new Map<bigint, GitScratchBudget>();
144
+
145
+ /**
146
+ * Reserves the scratch of one mirror clone into `directoryArg` in the
147
+ * process-wide budget of its file system and resolves with the function that
148
+ * releases it. Rejects with `git-workdir-too-small` when the clone does not
149
+ * fit even alone, `git-workdir-failed` when the directory or its free space
150
+ * cannot be read and `request-aborted` when `signalArg` aborts first.
151
+ */
152
+ export const reserveMirrorScratch = async (directoryArg: string, reportedSizeKiBArg: bigint, signalArg?: AbortSignal): Promise<() => void> => {
153
+ let release: (() => void) | null;
154
+ try {
155
+ release = await (await GitScratchBudget.forDirectory(directoryArg)).reserve(directoryArg, mirrorScratchBytes(reportedSizeKiBArg), signalArg);
156
+ } catch {
157
+ throw new validation.ForgeInventoryError(signalArg?.aborted ? 'request-aborted' : 'git-workdir-failed');
158
+ }
159
+ if (release === null) throw new validation.ForgeInventoryError('git-workdir-too-small');
160
+ return release;
161
+ };
162
+
163
+ /** The HTTPS Git URL of a Gitea repository; it never carries credentials. */
164
+ export const giteaCloneUrl = (baseUrlArg: string, ownerArg: string, nameArg: string): string =>
165
+ `${baseUrlArg}/${encodeURIComponent(ownerArg)}/${encodeURIComponent(nameArg)}.git`;
166
+
167
+ const complete = (valueArg: bigint | number): TMeasure => ({ status: 'complete', value: BigInt(valueArg).toString() });
168
+ const partial = (observedArg: bigint | number, reasonArg: string): TMeasure => ({ status: 'partial', observedValue: BigInt(observedArg).toString(), reason: reasonArg });
169
+
170
+ const failedGit = (reasonArg: string): IGitCounts => {
171
+ const failed = partial(0, reasonArg);
172
+ return { branches: failed, tags: failed, pullRequestRefs: failed, otherRefs: failed, objects: failed, objectBytes: failed };
173
+ };
174
+ const failedLfs = (reasonArg: string): ILfsCounts => {
175
+ const failed = partial(0, reasonArg);
176
+ return { objects: failed, bytes: failed, missingObjects: failed };
177
+ };
178
+
179
+ /**
180
+ * Fixed reason for a failed step. GitManager errors are never passed on: they
181
+ * are redacted, but still remote text.
182
+ */
183
+ const stepFailure = (errorArg: unknown, stepArg: string, signalArg?: AbortSignal): string =>
184
+ signalArg?.aborted ? 'request-aborted' : errorArg instanceof validation.ForgeInventoryError ? errorArg.code : stepArg;
185
+
186
+ /**
187
+ * 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
189
+ * of every reachable revision and checks through the LFS batch API which of
190
+ * their objects Gitea holds. The mirror is removed afterwards. The clone starts
191
+ * only once its scratch reservation fits the work directory's free space; a
192
+ * repository too large for it fails with `git-workdir-too-small`.
193
+ */
194
+ export const measureGiteaMirror = async (sourceArg: IGiteaMirrorSource): Promise<{ git: IGitCounts; lfs: ILfsCounts }> => {
195
+ const { signal } = sourceArg;
196
+ if (signal?.aborted) throw new validation.ForgeInventoryError('request-aborted');
197
+ let release: () => void;
198
+ try {
199
+ release = await reserveMirrorScratch(sourceArg.workDir, sourceArg.reportedSizeKiB, signal);
200
+ } catch (error) {
201
+ const failure = stepFailure(error, 'git-workdir-failed', signal);
202
+ return { git: failedGit(failure), lfs: failedLfs(failure) };
203
+ }
204
+ try {
205
+ return await measureReservedMirror(sourceArg);
206
+ } finally {
207
+ release();
208
+ }
209
+ };
210
+
211
+ const measureReservedMirror = async (sourceArg: IGiteaMirrorSource): Promise<{ git: IGitCounts; lfs: ILfsCounts }> => {
212
+ const { signal } = sourceArg;
213
+ const { GitManager } = await plugins.getGitManager();
214
+ let directory: string;
215
+ try {
216
+ directory = await plugins.fs.mkdtemp(plugins.path.join(sourceArg.workDir, 'mirror-'));
217
+ } catch {
218
+ return { git: failedGit('git-workdir-failed'), lfs: failedLfs('git-workdir-failed') };
219
+ }
220
+ const manager = new GitManager({ dataDir: directory });
221
+ // The engine serves this measurement alone; stopping it ends the ref, object
222
+ // and LFS scans in flight, which take no signal. The clone takes the signal.
223
+ const abort = () => { void manager.stop().catch(() => undefined); };
224
+ signal?.addEventListener('abort', abort, { once: true });
225
+ const selector = { org: 'source', repo: 'mirror' };
226
+ try {
227
+ try {
228
+ // An abort while the engine module loaded or the directory was created reached no listener.
229
+ signal?.throwIfAborted();
230
+ await manager.start();
231
+ await manager.importMirror({
232
+ ...selector,
233
+ remoteUrl: giteaCloneUrl(sourceArg.baseUrl, sourceArg.owner, sourceArg.name),
234
+ credential: { kind: 'basic', username: sourceArg.username, password: sourceArg.token },
235
+ caCertificates: sourceArg.caCertificates,
236
+ timeoutMs: sourceArg.timeoutMs,
237
+ }, { signal });
238
+ } catch (error) {
239
+ const failure = stepFailure(error, 'git-mirror-failed', signal);
240
+ return { git: failedGit(failure), lfs: failedLfs(failure) };
241
+ }
242
+ const git = await measureRefsAndObjects(manager, selector, signal);
243
+ let pointers: Array<{ oid: string; sizeBytes: number }>;
244
+ try {
245
+ pointers = await manager.listRepositoryLfsPointers(selector);
246
+ } catch (error) {
247
+ return { git, lfs: failedLfs(stepFailure(error, 'lfs-pointer-scan-failed', signal)) };
248
+ }
249
+ return { git, lfs: await giteaLfsPresence(sourceArg, pointers) };
250
+ } finally {
251
+ signal?.removeEventListener('abort', abort);
252
+ await manager.stop().catch(() => undefined);
253
+ await plugins.fs.rm(directory, { recursive: true, force: true });
254
+ }
255
+ };
256
+
257
+ const measureRefsAndObjects = async (
258
+ managerArg: plugins.TGitManager,
259
+ selectorArg: { org: string; repo: string },
260
+ signalArg?: AbortSignal,
261
+ ): Promise<IGitCounts> => {
262
+ const counts = { branches: 0, tags: 0, pullRequestRefs: 0, otherRefs: 0 };
263
+ let refsFailure: string | null = null;
264
+ try {
265
+ let cursor: string | undefined;
266
+ do {
267
+ const page = await managerArg.listRefs({ ...selectorArg, cursor });
268
+ // Nothing writes to the private mirror, so a changed inventory is a failure.
269
+ if (page.status === 'stale') throw new validation.ForgeInventoryError('git-refs-changed');
270
+ for (const ref of page.refs) {
271
+ if (ref.name.startsWith('refs/heads/')) counts.branches++;
272
+ else if (ref.name.startsWith('refs/tags/')) counts.tags++;
273
+ else if (ref.name.startsWith('refs/pull/')) counts.pullRequestRefs++;
274
+ else counts.otherRefs++;
275
+ }
276
+ cursor = page.pagination.status === 'more' ? page.pagination.cursor : undefined;
277
+ } while (cursor !== undefined);
278
+ } catch (error) {
279
+ refsFailure = stepFailure(error, 'git-refs-failed', signalArg);
280
+ }
281
+ const refs = (valueArg: number) => refsFailure === null ? complete(valueArg) : partial(0, refsFailure);
282
+ let objects: TMeasure;
283
+ let objectBytes: TMeasure;
284
+ try {
285
+ const size = await managerArg.getRepositorySize(selectorArg);
286
+ objects = complete(size.objectCount);
287
+ objectBytes = complete(size.objectBytes);
288
+ } catch (error) {
289
+ objects = objectBytes = partial(0, stepFailure(error, 'git-size-failed', signalArg));
290
+ }
291
+ return {
292
+ branches: refs(counts.branches), tags: refs(counts.tags), pullRequestRefs: refs(counts.pullRequestRefs), otherRefs: refs(counts.otherRefs),
293
+ objects, objectBytes,
294
+ };
295
+ };
296
+
297
+ /**
298
+ * Asks Gitea's LFS batch API, as a `download`, which referenced objects it
299
+ * holds: a held object answers with a download action, a missing one with
300
+ * error 404. Actions, their URLs and headers are never retained.
301
+ */
302
+ export const giteaLfsPresence = async (
303
+ sourceArg: Pick<IGiteaMirrorSource, 'baseUrl' | 'owner' | 'name' | 'lfsClient' | 'signal'>,
304
+ pointersArg: Array<{ oid: string; sizeBytes: number }>,
305
+ ): Promise<ILfsCounts> => {
306
+ const url = new URL(`${sourceArg.baseUrl}/${encodeURIComponent(sourceArg.owner)}/${encodeURIComponent(sourceArg.name)}.git/info/lfs/objects/batch`);
307
+ let held = 0n;
308
+ let heldBytes = 0n;
309
+ let missing = 0n;
310
+ for (let index = 0; index < pointersArg.length; index += lfsBatchSize) {
311
+ const chunk = pointersArg.slice(index, index + lfsBatchSize);
312
+ try {
313
+ const response = await sourceArg.lfsClient.request(url, {
314
+ signal: sourceArg.signal,
315
+ mediaType: lfsMediaType,
316
+ json: { operation: 'download', transfers: ['basic'], objects: chunk.map((pointerArg) => ({ oid: pointerArg.oid, size: pointerArg.sizeBytes })) },
317
+ });
318
+ const objects = validation.record(response.body).objects;
319
+ if (!Array.isArray(objects) || objects.length !== chunk.length) throw new validation.ForgeInventoryError('invalid-lfs-batch');
320
+ const requested = new Map(chunk.map((pointerArg) => [pointerArg.oid, pointerArg.sizeBytes]));
321
+ for (const objectArg of objects) {
322
+ const object = validation.record(objectArg);
323
+ const size = typeof object.oid === 'string' ? requested.get(object.oid) : undefined;
324
+ if (size === undefined || object.size !== size) throw new validation.ForgeInventoryError('invalid-lfs-batch');
325
+ requested.delete(object.oid as string);
326
+ const download = object.actions === undefined || object.actions === null ? undefined : validation.record(object.actions).download;
327
+ if (download !== undefined && download !== null && object.error === undefined) {
328
+ held++;
329
+ heldBytes += BigInt(size);
330
+ } else if (object.error !== undefined && validation.record(object.error).code === 404) {
331
+ missing++;
332
+ } else {
333
+ // A size that disagrees with the stored object answers 422.
334
+ throw new validation.ForgeInventoryError('lfs-object-unavailable');
335
+ }
336
+ }
337
+ } catch (error) {
338
+ const failure = stepFailure(error, 'lfs-batch-failed', sourceArg.signal);
339
+ return { objects: partial(held, failure), bytes: partial(heldBytes, failure), missingObjects: partial(missing, failure) };
340
+ }
341
+ }
342
+ return { objects: complete(held), bytes: complete(heldBytes), missingObjects: complete(missing) };
343
+ };
package/ts/index.ts CHANGED
@@ -488,3 +488,6 @@ export class CodeFeed {
488
488
  export * from './classes.forgeinventory.js';
489
489
  export * from './classes.forgeinventoryscanner.js';
490
490
  export * from './classes.giteasourceinventory.js';
491
+ export * from './classes.giteasourcetransfer.js';
492
+ export * from './classes.lfsbatchclient.js';
493
+ export { GitScratchBudget, mirrorScratchBytes } from './giteasourceinventory.git.js';
package/ts/plugins.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  import type * as fossInterfaces from '@foss.global/interfaces';
2
2
 
3
3
  import * as crypto from 'node:crypto';
4
+ import * as fs from 'node:fs/promises';
5
+ import * as http from 'node:http';
4
6
  import * as https from 'node:https';
5
- export { crypto, https };
7
+ import * as net from 'node:net';
8
+ import * as path from 'node:path';
9
+ import * as stream from 'node:stream';
10
+ export { crypto, fs, http, https, net, path, stream };
6
11
 
7
12
  // module
8
13
  import * as interfaces from './interfaces/index.js';
@@ -25,6 +30,7 @@ export type TGitManagerTag = import('@foss.global/gitmanager').IGitManagerTag;
25
30
  export type TGitManagerDiff = import('@foss.global/gitmanager').IGitManagerDiff;
26
31
 
27
32
  export const getSmartnpm = async () => import('@push.rocks/smartnpm');
33
+ export const getGitManager = async () => import('@foss.global/gitmanager');
28
34
 
29
35
  export {
30
36
  smarttime,