@foss.global/codefeed 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/readme.md CHANGED
@@ -107,13 +107,80 @@ What is measured: users with their account flags, two-factor and SSH/GPG keys; o
107
107
 
108
108
  Secrets are counted, never named: the report carries no tokens, Actions secret or variable values or names, webhook secrets or mirror remote URLs. Reasons are adapter codes or fixed text, never response bodies or URLs. Gitea has no read that lists user-scoped Actions secrets; that is listed in `limitations`, since they are reissued. User-scoped webhooks, variables and runners are read as that user through the `Sudo` header.
109
109
 
110
- Git-level measures come from a mirror clone of every ref Gitea advertises, `refs/pull/*` included, made with `@foss.global/gitmanager` in a fresh directory below `gitWorkDir` and removed afterwards. The reader's login and token travel as Basic credentials outside the URL, the Git command line and Git configuration; the private CA list applies to the clone as well. The mirror's distinct LFS pointers, from every reachable revision, are checked against Gitea in batches of 100: an object with a download action counts as held, one answered with error 404 as missing, and any other answer makes the LFS measures `partial`. A clone that fails or exceeds `gitTimeoutMs` makes every Git and LFS measure of that repository `partial`; only the API-reported size stays complete. A clone starts only once its scratch reservation, twice the reported size plus 64 MiB, fits the free space of the file system that holds `gitWorkDir`, counting the reservations of the clones already in flight from every reader in the process. A clone that does not fit waits for them, in arrival order; one that does not fit even alone makes every Git and LFS measure of its repository `partial` with `git-workdir-too-small`, so the inventory stays incomplete. Size `gitWorkDir` for the largest repositories measured at once.
110
+ Git-level measures come from a mirror clone of every ref Gitea advertises, `refs/pull/*` included, made with `@foss.global/gitmanager` in a fresh directory below `gitWorkDir` and removed afterwards. The reader's login and token form Basic credentials; the credential reaches Git only through GIT_CONFIG_* entries in the child process environment; it is never in the URL, the command line or any file. The private CA list applies to the clone as well. The mirror's distinct LFS pointers, from every reachable revision, are checked against Gitea in batches of 100: an object with a download action counts as held, one answered with error 404 as missing, and any other answer makes the LFS measures `partial`. A clone that fails or exceeds `gitTimeoutMs` makes every Git and LFS measure of that repository `partial`; only the API-reported size stays complete. A clone starts only once its scratch reservation, twice the reported size plus 64 MiB, fits the free space of the file system that holds `gitWorkDir`, counting the reservations of the clones already in flight from every reader in the process. A clone that does not fit waits for them, in arrival order; one that does not fit even alone makes every Git and LFS measure of its repository `partial` with `git-workdir-too-small`, so the inventory stays incomplete. Size `gitWorkDir` for the largest repositories measured at once.
111
111
 
112
112
  Known gaps that keep an inventory incomplete:
113
113
  - A repository with its issues or pull requests unit disabled hides both through the API, so its issues, pull requests, comments, reviews, reactions and attachments are `notMeasured`. A disabled wiki hides its pages the same way. Labels, milestones, releases and Actions stay readable while their units are disabled.
114
114
 
115
115
  Listings page until an empty page or the `x-total-count` total is reached. A total that changes mid-listing or disagrees with the rows read, a repeated page, a malformed page, a failed request or the page limit makes that measure `partial` and keeps the rows already read; only a listing that does not exist, such as the wiki of a repository without one, counts as empty.
116
116
 
117
+ ### Gitea source transfer
118
+
119
+ `GiteaSourceTransfer` reads one repository's Git data and LFS objects for the one-time migration, with the same reader credentials as the inventory. It never writes to Gitea: Git is read only through `git-upload-pack`, LFS only through batch `download` requests and their download actions.
120
+
121
+ ```ts
122
+ import { GitManager } from '@foss.global/gitmanager';
123
+ import { GiteaSourceTransfer } from '@foss.global/codefeed';
124
+
125
+ const source = new GiteaSourceTransfer({
126
+ baseUrl: 'https://gitea.example.com', // HTTPS only
127
+ login: readerLogin,
128
+ token: readerToken, // read it from a file; never pass it on a command line
129
+ // caCertificates: [privateCaPem], gitTimeoutMs: 30 * 60 * 1000, requestTimeoutMs: 60_000, maxResponseBytes: 16 * 1024 * 1024, lfsTransferTimeoutMs: 30 * 60 * 1000,
130
+ });
131
+
132
+ // A started GitManager rooted in the caller's own scratch space; codefeed creates no scratch roots.
133
+ const scratch = new GitManager({ dataDir: scratchDir });
134
+ await scratch.start();
135
+ const { repository, reportedSizeKiB } = await source.importMirror({ owner: 'team', name: 'app' }, scratch, {
136
+ target: { org: 'team', repo: 'app' },
137
+ signal,
138
+ });
139
+
140
+ const download = await source.downloadLfsObject({ owner: 'team', name: 'app' }, oid, size, { signal });
141
+ if (download.status === 'missing') {
142
+ // Gitea answered error 404: the object is not held at the source.
143
+ } else {
144
+ await pipeline(download.stream, destination); // errors unless the bytes are exactly the object
145
+ }
146
+ ```
147
+
148
+ `importMirror` reads the repository's size from the REST API, reserves its scratch in the process-wide `GitScratchBudget` of the file system holding the GitManager's data directory (twice the reported size plus 64 MiB, admitted in arrival order), and then clones every ref into `target` with `importMirror` of `@foss.global/gitmanager`. The clone URL is formed from `baseUrl` and the repository's owner and name; the reader's login and token reach Git as a Basic credential only through GIT_CONFIG_* entries in the child process environment, never in the URL, the command line or any file. A repository that does not fit even alone rejects with `git-workdir-too-small` without cloning. The reservation is released when the clone ends. Failures reject with a fixed `ForgeInventoryError` code (`invalid-repository`, `invalid-target`, `gitmanager-not-running`, a REST code such as `http-404`, `git-workdir-failed`, `git-workdir-too-small`, `git-mirror-failed`, `request-aborted`); GitManager error text is never passed on. An abort rejects with `request-aborted`. While the clone runs, the abort reaches the GitManager import through its own `signal` (`@foss.global/gitmanager` 2.4.0 or later): Git is ended, the call settles once it has exited, no repository is left at `target`, and the reservation is released then. The GitManager keeps serving its other work, so one engine can serve several imports and other commands at once; a `stop()` of it during the clone also rejects the import with `request-aborted`.
149
+
150
+ While the import runs, GitManager holds `target`: writes to it, such as `initBareRepository`, `restoreRepositoryBundle`, `setDefaultBranch` or a Smart HTTP push, fail with a `GitManagerError` whose code is `repositoryBusy`, and a second import into the same `target` is refused at once (codefeed rejects it with `git-mirror-failed`). Reads, of `target` as of other repositories, proceed.
151
+
152
+ `downloadLfsObject` sends one batch `download` request with the reader's Basic credentials, then a GET of the action's href with the action's headers only. A missing object resolves as `{ status: 'missing' }`, so a caller can list the missing oids; any other object error rejects with `lfs-object-error-<code>`, such as `lfs-object-error-422` for a size Gitea disagrees with. The returned stream never holds the whole object: it counts and hashes the bytes as they pass and holds back the last chunk until the body ended with exactly `size` bytes whose SHA-256 is `oid`. Otherwise it errors with `lfs-size-overrun`, `lfs-short-body` or `lfs-digest-mismatch`; a `content-length` other than `size` rejects with `lfs-size-mismatch` before streaming. An abort errors it with `request-aborted`, a transfer longer than `lfsTransferTimeoutMs` or idle for `requestTimeoutMs` with `lfs-transfer-timed-out`. Destroying the stream ends the download.
153
+
154
+ ### Git LFS batch client
155
+
156
+ `LfsBatchClient` is a provider-neutral Git LFS batch API client with the `basic` transfer adapter, bound to one LFS endpoint and its credential. `GiteaSourceTransfer` downloads through it; the migration uploads to the native Git service through it.
157
+
158
+ ```ts
159
+ import { LfsBatchClient } from '@foss.global/codefeed';
160
+
161
+ const lfs = new LfsBatchClient({
162
+ endpoint: 'https://git.example.com/team/app.git/info/lfs', // https, or http only to 127.0.0.0/8 or [::1]
163
+ credential: { kind: 'authorization', value: `Bearer ${grantToken}` }, // or { kind: 'basic', username, password }
164
+ // caCertificates, requestTimeoutMs: 60_000, transferTimeoutMs: 30 * 60 * 1000, maxResponseBytes: 4 * 1024 * 1024, batchSize: 100,
165
+ });
166
+ const uploaded = await lfs.upload([{ oid, size, open: (signalArg) => openObjectStream(oid, signalArg) }], { signal });
167
+ // [{ oid, size, outcome: 'uploaded' | 'present' }]
168
+ const presence = await lfs.checkPresence([{ oid, size }], { signal }); // [{ oid, size, status: 'held' | 'missing' }]
169
+ const download = await lfs.download({ oid, size }, { signal }); // as for downloadLfsObject
170
+ lfs.close(); // optional: idle pooled connections close on their own
171
+ ```
172
+
173
+ - `upload` sends one batch `upload` request per `batchSize` objects (at most 100). An object without an `upload` action is already present; for each other one it opens the source, PUTs exactly `size` bytes with the action's headers and `Content-Length: size`, and then calls the `verify` action when the server sent one. The bytes are hashed while they stream and the last chunk is held back, so a source that is not the object is never sent complete (`lfs-size-overrun`, `lfs-short-body`, `lfs-digest-mismatch`). Any object error rejects with `lfs-object-error-<code>`.
174
+ - Action expiry: `expires_in` (whole seconds after the batch answer arrived) is preferred over `expires_at` (RFC 3339), as the batch API specifies; like the git-lfs client, an `expires_in` of 0 and Go's zero time mean no expiry, and any other malformed value fails with `invalid-lfs-batch`. An upload, verify or download action is used only while it stays valid for the expiry margin, 30 s or `requestTimeoutMs` when that is shorter. Objects are transferred one at a time after their batch answer, so a later object whose action would expire within the margin is asked for again in a batch of its own, once; when the fresh answer is again too short-lived the call fails with `lfs-action-expired`. A verify action that expires during its PUT is asked for the same way; a fresh answer without actions means the server holds the object.
175
+ - `checkPresence` sends batch `download` requests and downloads nothing: a download action means `held`, error 404 `missing`. It uses no action, so expiry is validated but not enforced there.
176
+ - The credential is sent only as the `Authorization` header of batch requests to the endpoint, never to action hrefs and never in a URL, error or result; hrefs get the action's headers only. Action headers may not set connection-level fields (`host`, `content-length`, `transfer-encoding` and similar).
177
+ - URL policy, enforced for the endpoint and every action href: `https:`, or `http:` only to an IP literal in 127.0.0.0/8 or `[::1]`; `localhost` and every other host name over HTTP are refused (`unsafe-lfs-url`), as is user information. An href must keep the endpoint's scheme, so an HTTPS server cannot move a transfer to plain HTTP (`unsafe-lfs-href`). Redirects are never followed.
178
+ - Every request is bounded: batch answers by `maxResponseBytes`, batch and verify requests by `requestTimeoutMs`, object transfers by `transferTimeoutMs` and an idle limit of `requestTimeoutMs`, everything by the caller's `AbortSignal`. Errors are fixed `ForgeInventoryError` codes.
179
+
180
+ ### Shared scratch budget
181
+
182
+ `GitScratchBudget` and `mirrorScratchBytes` are exported so that every scratch GitManager in a process admits its work through the same budget as the inventory and the source transfer. `GitScratchBudget.forDirectory(directory)` returns the process-wide budget of the file system holding `directory`; `reserve(directory, bytes, signal)` resolves with a release function once the bytes fit its free space next to the reservations in flight, in arrival order, or with `null` when they do not fit even alone. `mirrorScratchBytes(reportedSizeKiB)` is the reservation of one mirror clone: twice the reported size plus 64 MiB.
183
+
117
184
  ## Quick Start
118
185
 
119
186
  ```ts
@@ -263,6 +330,7 @@ The canonical public result contract is exported as `ICodefeedCommitResult` from
263
330
 
264
331
  - Gitea base URL and an optional token with read access
265
332
  - For GitManager-backed feeds, a started `@foss.global/gitmanager` 2.x `GitManager` instance with imported or local repositories
333
+ - For `GiteaSourceTransfer.importMirror`, a started `@foss.global/gitmanager` 2.4.0 or later `GitManager`, whose transfers take an abort `signal`
266
334
  - For public GitManager reads, an authoritative repository-visibility source or an explicit all-public declaration
267
335
  - Node.js 22+ (global `fetch`; dependencies such as `file-type` 22 through `@push.rocks/smartnpm` declare Node.js 22)
268
336
 
@@ -273,7 +341,8 @@ The repo contains:
273
341
  - A mocked pagination test that does not require network.
274
342
  - A GitManager provider mapping test using a local test stub.
275
343
  - A default-deny GitManager public-client mapping test using a local test stub.
276
- - A mocked `GiteaSourceInventory` test, a Git-level one against a local HTTPS Git, LFS batch and REST server (needs `openssl` and `git http-backend`), and an opt-in one against a Docker Gitea from `@foss.global/forgefixtures`, run with `FORGEFIXTURES=gitea pnpm test` (Docker required).
344
+ - A mocked `GiteaSourceInventory` test, Git-level `GiteaSourceInventory` and `GiteaSourceTransfer` tests against a local HTTPS Git, LFS batch and REST server (needs `openssl` and `git http-backend`), and an opt-in one against a Docker Gitea from `@foss.global/forgefixtures`, run with `FORGEFIXTURES=gitea pnpm test` (Docker required).
345
+ - An `LfsBatchClient` test against local loopback LFS servers over HTTP and over HTTPS with a private CA.
277
346
 
278
347
  Run tests:
279
348
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@foss.global/codefeed',
6
- version: '3.0.0',
6
+ version: '3.1.0',
7
7
  description: 'The @foss.global/codefeed module generates commit/activity feeds from forge providers including Gitea and GitManager.'
8
8
  }
@@ -0,0 +1,213 @@
1
+ import * as plugins from './plugins.js';
2
+ import * as transport from './forgeinventory.transport.js';
3
+ import * as validation from './forgeinventory.validation.js';
4
+ import { ForgeRequestClient } from './forgeinventory.client.js';
5
+ import { LfsBatchClient } from './classes.lfsbatchclient.js';
6
+ import type { ILfsRequestOptions, TLfsDownload } from './classes.lfsbatchclient.js';
7
+ import { giteaCloneUrl, reserveMirrorScratch } from './giteasourceinventory.git.js';
8
+
9
+ export interface IGiteaSourceTransferOptions {
10
+ /** Instance root, including an optional deployment subpath. HTTPS is required. */
11
+ baseUrl: string;
12
+ /** The reader's login, the user name of its Git and LFS credentials. */
13
+ login: string;
14
+ /**
15
+ * The reader's token, retained only in this reader's memory. It is sent only
16
+ * to `baseUrl`, and to Git only through `GIT_CONFIG_*` entries in the child
17
+ * process environment; it never appears in results, reasons or errors.
18
+ */
19
+ token: string;
20
+ /** PEM certificates that replace the default trust store for this reader's connections, Git transfers included. */
21
+ caCertificates?: string[];
22
+ /** Wall-clock limit of one mirror clone; defaults to 30 minutes, at most six hours. */
23
+ gitTimeoutMs?: number;
24
+ /** Deadline of one REST or LFS batch request, and the idle limit of an LFS download; defaults to 60 s, at most 300 s. */
25
+ requestTimeoutMs?: number;
26
+ /** Response-size budget of one REST or LFS batch answer; defaults to 16 MiB, at most 64 MiB. */
27
+ maxResponseBytes?: number;
28
+ /** Wall-clock limit of one LFS object download; defaults to 30 minutes, at most six hours. */
29
+ lfsTransferTimeoutMs?: number;
30
+ }
31
+
32
+ /** A source repository, by its owner's and its own name. */
33
+ export interface IGiteaSourceRepository {
34
+ owner: string;
35
+ name: string;
36
+ }
37
+
38
+ export interface IGiteaMirrorImportOptions {
39
+ /** The repository the mirror becomes in the supplied GitManager. */
40
+ target: plugins.fossInterfaces.data.IGitManagerRepositorySelector;
41
+ /**
42
+ * Aborts the import with `request-aborted`. An abort while the clone runs
43
+ * cancels it through the GitManager transfer's own signal: the call settles
44
+ * once Git has exited, leaves no repository at `target`, and the GitManager
45
+ * keeps serving its other work.
46
+ */
47
+ signal?: AbortSignal;
48
+ }
49
+
50
+ export interface IGiteaMirrorImport {
51
+ repository: plugins.TGitManagerRepositoryInfo;
52
+ /** The repository size Gitea reported, in KiB, which sized the scratch reservation. */
53
+ reportedSizeKiB: string;
54
+ }
55
+
56
+ /**
57
+ * Reads a Gitea repository's Git data and LFS objects for its one-time
58
+ * migration, with the reader's login and token: a mirror clone into a
59
+ * GitManager the caller supplies, and verified LFS object downloads. It never
60
+ * writes to Gitea: Git is read only through `git-upload-pack`, LFS only
61
+ * through batch `download` requests and their download actions.
62
+ */
63
+ export class GiteaSourceTransfer {
64
+ readonly #options: {
65
+ baseUrl: string; login: string; token: string; caCertificates?: string[];
66
+ gitTimeoutMs: number; requestTimeoutMs: number; maxResponseBytes: number; lfsTransferTimeoutMs: number;
67
+ };
68
+ readonly #client: ForgeRequestClient;
69
+
70
+ constructor(optionsArg: IGiteaSourceTransferOptions) {
71
+ const baseUrl = validation.httpUrl(optionsArg.baseUrl);
72
+ const unsafe = (valueArg: unknown) => typeof valueArg !== 'string' || !valueArg || /[\s\u0000-\u001f\u007f]/.test(valueArg);
73
+ if (!baseUrl || !baseUrl.startsWith('https:') || unsafe(optionsArg.token) || unsafe(optionsArg.login) || optionsArg.login.includes(':')) {
74
+ throw new validation.ForgeInventoryError('invalid-options');
75
+ }
76
+ const limits = {
77
+ gitTimeoutMs: optionsArg.gitTimeoutMs ?? 30 * 60 * 1000,
78
+ requestTimeoutMs: optionsArg.requestTimeoutMs ?? 60_000,
79
+ maxResponseBytes: optionsArg.maxResponseBytes ?? 16 * 1024 * 1024,
80
+ lfsTransferTimeoutMs: optionsArg.lfsTransferTimeoutMs ?? 30 * 60 * 1000,
81
+ };
82
+ for (const [name, maximum] of [
83
+ ['gitTimeoutMs', 6 * 60 * 60 * 1000], ['requestTimeoutMs', 300_000], ['maxResponseBytes', 64 * 1024 * 1024], ['lfsTransferTimeoutMs', 6 * 60 * 60 * 1000],
84
+ ] as const) {
85
+ const value = limits[name];
86
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) throw new validation.ForgeInventoryError('invalid-limits');
87
+ }
88
+ const caCertificates = optionsArg.caCertificates === undefined ? undefined : validation.caCertificates(optionsArg.caCertificates);
89
+ this.#options = { baseUrl: baseUrl.replace(/\/+$/, ''), login: optionsArg.login, token: optionsArg.token, caCertificates, ...limits };
90
+ this.#client = new ForgeRequestClient({
91
+ transport: caCertificates === undefined ? transport.globalFetchTransport : transport.createHttpsTransport(caCertificates),
92
+ headers: { authorization: `token ${optionsArg.token}` },
93
+ requestTimeoutMs: limits.requestTimeoutMs,
94
+ maxResponseBytes: limits.maxResponseBytes,
95
+ });
96
+ }
97
+
98
+ /** Validated, normalized instance root; contains no credentials or API suffix. */
99
+ public get baseUrl(): string { return this.#options.baseUrl; }
100
+
101
+ /**
102
+ * Clones every ref of the repository into `options.target` of `gitManager`,
103
+ * a started GitManager the caller roots in its own scratch space. The size
104
+ * Gitea reports for the repository is read first; the clone starts only once
105
+ * its scratch reservation fits the free space of the file system holding
106
+ * the GitManager's data directory, in the process-wide `GitScratchBudget`,
107
+ * and the reservation is released once the clone has ended. While it runs
108
+ * the import holds `target`: writes to it through the GitManager fail there
109
+ * with `repositoryBusy`. Rejects with a fixed `ForgeInventoryError` code:
110
+ * `invalid-repository`, `invalid-target`, `gitmanager-not-running`, a REST
111
+ * code such as `http-404`, `git-workdir-failed`, `git-workdir-too-small`,
112
+ * `git-mirror-failed` (also when GitManager refuses `target`, such as with
113
+ * `repositoryBusy` while another transfer holds it) or `request-aborted`
114
+ * (an abort, or a GitManager stop that cancelled the clone). GitManager
115
+ * error text is never passed on.
116
+ */
117
+ public async importMirror(
118
+ repositoryArg: IGiteaSourceRepository,
119
+ gitManagerArg: plugins.TGitManager,
120
+ optionsArg: IGiteaMirrorImportOptions,
121
+ ): Promise<IGiteaMirrorImport> {
122
+ const { owner, name } = this.#repository(repositoryArg);
123
+ const target = optionsArg?.target;
124
+ if (typeof target?.org !== 'string' || typeof target?.repo !== 'string') throw new validation.ForgeInventoryError('invalid-target');
125
+ const { signal } = optionsArg;
126
+ if (signal?.aborted) throw new validation.ForgeInventoryError('request-aborted');
127
+ if (!gitManagerArg.running) throw new validation.ForgeInventoryError('gitmanager-not-running');
128
+ let reportedSizeKiB: bigint;
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
+ }
138
+ let dataDir: string;
139
+ try {
140
+ dataDir = (await gitManagerArg.getStatus()).dataDir;
141
+ } catch {
142
+ throw new validation.ForgeInventoryError('git-workdir-failed');
143
+ }
144
+ const release = await reserveMirrorScratch(dataDir, reportedSizeKiB, signal);
145
+ try {
146
+ const imported = await gitManagerArg.importMirror({
147
+ org: target.org,
148
+ repo: target.repo,
149
+ remoteUrl: giteaCloneUrl(this.#options.baseUrl, owner, name),
150
+ credential: { kind: 'basic', username: this.#options.login, password: this.#options.token },
151
+ caCertificates: this.#options.caCertificates,
152
+ timeoutMs: this.#options.gitTimeoutMs,
153
+ }, { signal });
154
+ return { repository: imported, reportedSizeKiB: reportedSizeKiB.toString() };
155
+ } catch (error) {
156
+ const { GitManagerError } = await plugins.getGitManager();
157
+ const aborted = signal?.aborted || (error instanceof GitManagerError && error.code === 'transferAborted');
158
+ // GitManager errors are redacted, but still remote text.
159
+ throw new validation.ForgeInventoryError(aborted ? 'request-aborted' : 'git-mirror-failed');
160
+ } finally {
161
+ // The import settles only after Git has exited, so the scratch is free again.
162
+ release();
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Downloads one LFS object of the repository: a batch `download` request
168
+ * with the reader's Basic credentials, then a GET of the action's href with
169
+ * the action's headers only. Resolves with `{ status: 'missing' }` when
170
+ * Gitea answers error 404, otherwise with a stream of the object verified
171
+ * against `oid` and `size` as described for `LfsBatchClient.download`.
172
+ * Rejects with a fixed code, such as `lfs-object-error-422` for a size Gitea
173
+ * disagrees with.
174
+ */
175
+ public async downloadLfsObject(repositoryArg: IGiteaSourceRepository, oidArg: string, sizeArg: number, optionsArg: ILfsRequestOptions = {}): Promise<TLfsDownload> {
176
+ const { owner, name } = this.#repository(repositoryArg);
177
+ const client = new LfsBatchClient({
178
+ endpoint: `${giteaCloneUrl(this.#options.baseUrl, owner, name)}/info/lfs`,
179
+ // Gitea's LFS routes take the token as a Basic password, not as a `token` authorization.
180
+ credential: { kind: 'basic', username: this.#options.login, password: this.#options.token },
181
+ caCertificates: this.#options.caCertificates,
182
+ requestTimeoutMs: this.#options.requestTimeoutMs,
183
+ transferTimeoutMs: this.#options.lfsTransferTimeoutMs,
184
+ maxResponseBytes: this.#options.maxResponseBytes,
185
+ batchSize: 1,
186
+ });
187
+ try {
188
+ const download = await client.download({ oid: oidArg, size: sizeArg }, optionsArg);
189
+ if (download.status === 'available') {
190
+ download.stream.once('close', () => client.close());
191
+ } else {
192
+ client.close();
193
+ }
194
+ return download;
195
+ } catch (error) {
196
+ client.close();
197
+ throw error;
198
+ }
199
+ }
200
+
201
+ #repository(repositoryArg: IGiteaSourceRepository): IGiteaSourceRepository {
202
+ const single = (valueArg: unknown): string => {
203
+ const segments = validation.segments(valueArg);
204
+ if (segments.length !== 1) throw new validation.ForgeInventoryError('invalid-repository');
205
+ return segments[0];
206
+ };
207
+ try {
208
+ return { owner: single(repositoryArg?.owner), name: single(repositoryArg?.name) };
209
+ } catch {
210
+ throw new validation.ForgeInventoryError('invalid-repository');
211
+ }
212
+ }
213
+ }