@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.
package/readme.md CHANGED
@@ -80,7 +80,7 @@ The implementation has synthetic protocol fixtures based on the [Gitea 1.24.7 AP
80
80
 
81
81
  ### Authenticated Gitea source inventory
82
82
 
83
- `GiteaSourceInventory` measures a whole Gitea instance before its one-time migration, read with a site administrator's token. It returns the `IForgeSourceInventory` contract from `@foss.global/interfaces`: per-instance, per-owner and per-repository counts with totals, where every measure is `complete`, `partial` (attempted, did not finish) or `notMeasured` (not attempted). The inventory is complete only when discovery finished and every measure is complete; otherwise `inventoryComplete` is false and `incompleteReasons` names each gap. It only sends GET requests.
83
+ `GiteaSourceInventory` measures a whole Gitea instance before its one-time migration, read with a site administrator's token. It returns the `IForgeSourceInventory` contract from `@foss.global/interfaces`: per-instance, per-owner and per-repository counts with totals, where every measure is `complete`, `partial` (attempted, did not finish) or `notMeasured` (not attempted). The inventory is complete only when discovery finished and every measure is complete; otherwise `inventoryComplete` is false and `incompleteReasons` names each gap. It never writes to the forge: it sends REST GET requests, clones each repository with Git, and asks the LFS batch API, as a `download`, which objects Gitea holds.
84
84
 
85
85
  ```ts
86
86
  import { GiteaSourceInventory, composeGiteaSourceInventory } from '@foss.global/codefeed';
@@ -89,7 +89,8 @@ const reader = new GiteaSourceInventory({
89
89
  instanceId: 'code-foss-global',
90
90
  baseUrl: 'https://gitea.example.com', // HTTPS only
91
91
  token: siteAdminToken, // read it from a file; never pass it on a command line
92
- // caCertificates: [privateCaPem], pageSize: 50, maxPages: 10_000, requestTimeoutMs: 60_000, maxResponseBytes: 16 * 1024 * 1024,
92
+ gitWorkDir: '/var/lib/inventory/mirrors', // absolute, private; holds one temporary mirror per repository in flight
93
+ // caCertificates: [privateCaPem], gitTimeoutMs: 30 * 60 * 1000, pageSize: 50, maxPages: 10_000, requestTimeoutMs: 60_000, maxResponseBytes: 16 * 1024 * 1024,
93
94
  });
94
95
 
95
96
  const discovery = await reader.discover(); // rejects with `reader-not-site-admin` for other tokens
@@ -102,16 +103,84 @@ const inventory = composeGiteaSourceInventory(discovery, results, [{ package: '@
102
103
 
103
104
  A run is split into units: the instance, each owner and each repository. `measureUnit` never rejects; a unit whose primary record cannot be read becomes `failed` with a sanitized reason. A resumed run measures failed units, and units holding a partial measure, again and passes all results to `composeGiteaSourceInventory`, where the last result per unit wins. The composed document is deterministic: it carries no timestamps, sorts owners, repositories, packages and reasons, and does not depend on the order of the results. An aborted signal or a rejected `onResult` aborts the units in flight, hands over no further result, and rejects `measureUnits` once every worker has stopped.
104
105
 
105
- What is measured: users with their account flags, two-factor and SSH/GPG keys; organizations, members, teams, team memberships and repository grants; repositories with their flags, Git refs split into branches, tags, pull request refs and other refs, issues, pull requests, comments, reviews, review comments, labels, milestones, reactions and attachments; releases including drafts, assets and asset bytes; wiki pages; collaborators, team grants, deploy keys, webhooks, branch and tag protections and push mirrors; packages per type with versions, files and bytes; system webhooks, Actions runners, workflows, runs, artifacts and the number of Actions secrets and variables; unadopted repositories.
106
+ What is measured: users with their account flags, two-factor and SSH/GPG keys; organizations, members, teams, team memberships and repository grants; repositories with their flags, Git refs split into branches, tags, pull request refs and other refs, Git objects and their uncompressed bytes, LFS objects referenced anywhere in history with the bytes Gitea holds and the number it lacks, issues, pull requests, comments, reviews, review comments, labels, milestones, reactions and attachments; releases including drafts, assets and asset bytes; wiki pages; collaborators, team grants, deploy keys, webhooks, branch and tag protections and push mirrors; packages per type with versions, files and bytes; system webhooks, Actions runners, workflows, runs, artifacts and the number of Actions secrets and variables; unadopted repositories.
106
107
 
107
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.
108
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 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
+
109
112
  Known gaps that keep an inventory incomplete:
110
- - LFS objects are `notMeasured`: Gitea's API does not list them.
111
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.
112
114
 
113
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.
114
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
+
115
184
  ## Quick Start
116
185
 
117
186
  ```ts
@@ -261,6 +330,7 @@ The canonical public result contract is exported as `ICodefeedCommitResult` from
261
330
 
262
331
  - Gitea base URL and an optional token with read access
263
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`
264
334
  - For public GitManager reads, an authoritative repository-visibility source or an explicit all-public declaration
265
335
  - Node.js 22+ (global `fetch`; dependencies such as `file-type` 22 through `@push.rocks/smartnpm` declare Node.js 22)
266
336
 
@@ -271,7 +341,8 @@ The repo contains:
271
341
  - A mocked pagination test that does not require network.
272
342
  - A GitManager provider mapping test using a local test stub.
273
343
  - A default-deny GitManager public-client mapping test using a local test stub.
274
- - A mocked `GiteaSourceInventory` test, 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.
275
346
 
276
347
  Run tests:
277
348
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@foss.global/codefeed',
6
- version: '2.1.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
  }
@@ -1,8 +1,9 @@
1
- import type * as plugins from './plugins.js';
1
+ import * as plugins from './plugins.js';
2
2
  import * as transport from './forgeinventory.transport.js';
3
3
  import * as validation from './forgeinventory.validation.js';
4
4
  import { ForgeRequestClient } from './forgeinventory.client.js';
5
5
  import type { IForgeRequestMetrics, IForgeRequestOptions } from './forgeinventory.client.js';
6
+ import { measureGiteaMirror } from './giteasourceinventory.git.js';
6
7
 
7
8
  type TMeasure = plugins.fossInterfaces.data.TForgeSourceMeasure;
8
9
  type TUnit = plugins.fossInterfaces.data.TForgeSourceInventoryUnit;
@@ -24,8 +25,17 @@ export interface IGiteaSourceInventoryOptions {
24
25
  * sent only to `baseUrl` and never appears in results, reasons or errors.
25
26
  */
26
27
  token: string;
27
- /** PEM certificates that replace the default trust store for this reader's connections. */
28
+ /** PEM certificates that replace the default trust store for this reader's connections, Git transfers included. */
28
29
  caCertificates?: string[];
30
+ /**
31
+ * Absolute path of a private directory for temporary mirrors. Each
32
+ * repository is cloned into it with every ref, measured at the Git level and
33
+ * removed again, so it needs room for the largest repositories measured at
34
+ * the same time.
35
+ */
36
+ gitWorkDir: string;
37
+ /** Wall-clock limit of one repository's mirror clone; defaults to 30 minutes, at most six hours. */
38
+ gitTimeoutMs?: number;
29
39
  /** Items requested per page; Gitea caps it at its `MAX_RESPONSE_ITEMS`. */
30
40
  pageSize?: number;
31
41
  /** Pages read per listing before the listing counts as partial. */
@@ -66,8 +76,6 @@ export const giteaSourceInventoryLimitations: readonly string[] = [
66
76
  'Gitea has no read that lists user-scoped Actions secrets; they are reissued.',
67
77
  ];
68
78
 
69
- const lfsNotMeasured: TMeasure = { status: 'notMeasured', reason: 'Gitea exposes no API that lists LFS objects.' };
70
-
71
79
  const complete = (valueArg: bigint | number): TMeasure => ({ status: 'complete', value: BigInt(valueArg).toString() });
72
80
  const partial = (observedArg: bigint | number, reasonArg: string): TMeasure => ({ status: 'partial', observedValue: BigInt(observedArg).toString(), reason: reasonArg });
73
81
  const notMeasured = (reasonArg: string): TMeasure => ({ status: 'notMeasured', reason: reasonArg });
@@ -143,19 +151,28 @@ const unitKey = (unitArg: TUnit): string => unitArg.kind === 'instance' ? 'insta
143
151
  const compareText = (aArg: string, bArg: string): number => aArg < bArg ? -1 : aArg > bArg ? 1 : 0;
144
152
 
145
153
  /**
146
- * Authenticated, read-only inventory of a Gitea instance through its REST API,
147
- * read with a site administrator's token. It only sends GET requests, counts
148
- * secrets and variables without retaining their values, and reports every
149
- * read that did not finish as a partial or unmeasured value instead of a count.
154
+ * Authenticated, read-only inventory of a Gitea instance, read with a site
155
+ * administrator's token: GET requests to its REST API, a mirror clone of every
156
+ * repository for refs, objects and LFS pointers, and LFS batch `download`
157
+ * requests that check which referenced objects Gitea holds. It counts secrets
158
+ * and variables without retaining their values, and reports every read that
159
+ * did not finish as a partial or unmeasured value instead of a count.
150
160
  */
151
161
  export class GiteaSourceInventory {
152
- readonly #options: { instanceId: string; baseUrl: string; pageSize: number; maxPages: number };
162
+ readonly #options: {
163
+ instanceId: string; baseUrl: string; pageSize: number; maxPages: number; requestTimeoutMs: number; maxResponseBytes: number;
164
+ token: string; caCertificates?: string[]; gitWorkDir: string; gitTimeoutMs: number;
165
+ };
166
+ readonly #transport: transport.TForgeTransport;
153
167
  readonly #client: ForgeRequestClient;
168
+ #lfsClient: ForgeRequestClient | null = null;
169
+ #readerLogin: Promise<string> | null = null;
154
170
 
155
171
  constructor(optionsArg: IGiteaSourceInventoryOptions) {
156
172
  const baseUrl = validation.httpUrl(optionsArg.baseUrl);
157
173
  if (!baseUrl || !baseUrl.startsWith('https:') || typeof optionsArg.instanceId !== 'string' || !optionsArg.instanceId.trim() ||
158
- typeof optionsArg.token !== 'string' || !optionsArg.token || /[\s\u0000-\u001f\u007f]/.test(optionsArg.token)) {
174
+ typeof optionsArg.token !== 'string' || !optionsArg.token || /[\s\u0000-\u001f\u007f]/.test(optionsArg.token) ||
175
+ typeof optionsArg.gitWorkDir !== 'string' || !plugins.path.isAbsolute(optionsArg.gitWorkDir)) {
159
176
  throw new validation.ForgeInventoryError('invalid-options');
160
177
  }
161
178
  const limits = {
@@ -163,17 +180,22 @@ export class GiteaSourceInventory {
163
180
  maxPages: optionsArg.maxPages ?? 10_000,
164
181
  requestTimeoutMs: optionsArg.requestTimeoutMs ?? 60_000,
165
182
  maxResponseBytes: optionsArg.maxResponseBytes ?? 16 * 1024 * 1024,
183
+ gitTimeoutMs: optionsArg.gitTimeoutMs ?? 30 * 60 * 1000,
166
184
  };
167
185
  for (const [name, maximum] of [
168
- ['pageSize', 100], ['maxPages', 1_000_000], ['requestTimeoutMs', 300_000], ['maxResponseBytes', 64 * 1024 * 1024],
186
+ ['pageSize', 100], ['maxPages', 1_000_000], ['requestTimeoutMs', 300_000], ['maxResponseBytes', 64 * 1024 * 1024], ['gitTimeoutMs', 6 * 60 * 60 * 1000],
169
187
  ] as const) {
170
188
  const value = limits[name];
171
189
  if (!Number.isSafeInteger(value) || value < 1 || value > maximum) throw new validation.ForgeInventoryError('invalid-limits');
172
190
  }
173
- this.#options = { instanceId: optionsArg.instanceId, baseUrl: baseUrl.replace(/\/+$/, ''), pageSize: limits.pageSize, maxPages: limits.maxPages };
191
+ const caCertificates = optionsArg.caCertificates === undefined ? undefined : validation.caCertificates(optionsArg.caCertificates);
192
+ this.#options = {
193
+ instanceId: optionsArg.instanceId, baseUrl: baseUrl.replace(/\/+$/, ''), ...limits,
194
+ token: optionsArg.token, caCertificates, gitWorkDir: optionsArg.gitWorkDir,
195
+ };
196
+ this.#transport = caCertificates === undefined ? transport.globalFetchTransport : transport.createHttpsTransport(caCertificates);
174
197
  this.#client = new ForgeRequestClient({
175
- transport: optionsArg.caCertificates === undefined ? transport.globalFetchTransport
176
- : transport.createHttpsTransport(validation.caCertificates(optionsArg.caCertificates)),
198
+ transport: this.#transport,
177
199
  headers: { authorization: `token ${optionsArg.token}` },
178
200
  requestTimeoutMs: limits.requestTimeoutMs,
179
201
  maxResponseBytes: limits.maxResponseBytes,
@@ -183,7 +205,16 @@ export class GiteaSourceInventory {
183
205
  /** Validated, normalized instance root; contains no credentials or API suffix. */
184
206
  public get baseUrl(): string { return this.#options.baseUrl; }
185
207
 
186
- public get metrics(): IForgeRequestMetrics { return this.#client.metrics; }
208
+ /** REST and LFS batch requests together. */
209
+ public get metrics(): IForgeRequestMetrics {
210
+ const rest = this.#client.metrics;
211
+ const lfs = this.#lfsClient?.metrics ?? { requests: 0, responseBytes: 0, rateLimitedRequests: 0 };
212
+ return {
213
+ requests: rest.requests + lfs.requests,
214
+ responseBytes: rest.responseBytes + lfs.responseBytes,
215
+ rateLimitedRequests: rest.rateLimitedRequests + lfs.rateLimitedRequests,
216
+ };
217
+ }
187
218
 
188
219
  /**
189
220
  * Identifies the source and its reader and lists every owner and repository.
@@ -455,8 +486,7 @@ export class GiteaSourceInventory {
455
486
  objectFormat: requiredString(item.object_format_name),
456
487
  defaultBranch: requiredString(item.default_branch),
457
488
  features,
458
- git: await this.#git(path, item, empty, signalArg),
459
- lfs: { objects: lfsNotMeasured, bytes: lfsNotMeasured },
489
+ ...await this.#git(ownerArg, nameArg, item, signalArg),
460
490
  collaboration: await this.#collaboration(path, features, signalArg),
461
491
  // Releases stay readable while their unit is disabled; a disabled wiki answers 404.
462
492
  releases: await this.#releases(path, signalArg),
@@ -486,28 +516,39 @@ export class GiteaSourceInventory {
486
516
  }
487
517
  }
488
518
 
489
- async #git(pathArg: string, itemArg: Record<string, unknown>, emptyArg: boolean, signalArg?: AbortSignal): Promise<IRepository['git']> {
490
- const reportedSizeKiB = complete(BigInt(validation.decimal(itemArg.size)));
491
- if (emptyArg) return { reportedSizeKiB, branches: complete(0), tags: complete(0), pullRequestRefs: complete(0), otherRefs: complete(0) };
492
- try {
493
- const refs = (await this.#get(`${pathArg}/git/refs`, { signal: signalArg })).body;
494
- if (!Array.isArray(refs)) throw new validation.ForgeInventoryError('invalid-page');
495
- const counts = { branches: 0, tags: 0, pullRequestRefs: 0, otherRefs: 0 };
496
- for (const refArg of refs) {
497
- const ref = requiredString(validation.record(refArg).ref);
498
- if (ref.startsWith('refs/heads/')) counts.branches++;
499
- else if (ref.startsWith('refs/tags/')) counts.tags++;
500
- else if (ref.startsWith('refs/pull/')) counts.pullRequestRefs++;
501
- else counts.otherRefs++;
502
- }
503
- return {
504
- reportedSizeKiB, branches: complete(counts.branches), tags: complete(counts.tags),
505
- pullRequestRefs: complete(counts.pullRequestRefs), otherRefs: complete(counts.otherRefs),
506
- };
507
- } catch (error) {
508
- const failed = partial(0, reason(error));
509
- return { reportedSizeKiB, branches: failed, tags: failed, pullRequestRefs: failed, otherRefs: failed };
519
+ /** Refs, objects and LFS objects, read from a mirror clone; only the reported size comes from the API. */
520
+ async #git(ownerArg: string, nameArg: string, itemArg: Record<string, unknown>, signalArg?: AbortSignal): Promise<Pick<IRepository, 'git' | 'lfs'>> {
521
+ const sizeKiB = BigInt(validation.decimal(itemArg.size));
522
+ const username = await this.#login(signalArg);
523
+ const measured = await measureGiteaMirror({
524
+ baseUrl: this.#options.baseUrl, owner: ownerArg, name: nameArg, username, token: this.#options.token,
525
+ caCertificates: this.#options.caCertificates, workDir: this.#options.gitWorkDir, reportedSizeKiB: sizeKiB,
526
+ timeoutMs: this.#options.gitTimeoutMs,
527
+ lfsClient: this.#lfs(username), signal: signalArg,
528
+ });
529
+ return { git: { reportedSizeKiB: complete(sizeKiB), ...measured.git }, lfs: measured.lfs };
530
+ }
531
+
532
+ /** The reader's login, the user name of its Git and LFS credentials. */
533
+ #login(signalArg?: AbortSignal): Promise<string> {
534
+ if (this.#readerLogin === null) {
535
+ const login = this.#get('/user', { signal: signalArg }).then((responseArg) => segment(validation.record(responseArg.body).login));
536
+ // A failed read is retried by the next repository.
537
+ login.catch(() => { if (this.#readerLogin === login) this.#readerLogin = null; });
538
+ this.#readerLogin = login;
510
539
  }
540
+ return this.#readerLogin;
541
+ }
542
+
543
+ /** LFS routes accept the token as a Basic password, not as a `token` authorization. */
544
+ #lfs(usernameArg: string): ForgeRequestClient {
545
+ this.#lfsClient ??= new ForgeRequestClient({
546
+ transport: this.#transport,
547
+ headers: { authorization: `Basic ${Buffer.from(`${usernameArg}:${this.#options.token}`).toString('base64')}` },
548
+ requestTimeoutMs: this.#options.requestTimeoutMs,
549
+ maxResponseBytes: this.#options.maxResponseBytes,
550
+ });
551
+ return this.#lfsClient;
511
552
  }
512
553
 
513
554
  async #collaboration(pathArg: string, featuresArg: IRepository['features'], signalArg?: AbortSignal): Promise<IRepository['collaboration']> {
@@ -834,8 +875,9 @@ export const composeGiteaSourceInventory = (
834
875
  empty: repositoryCount((repositoryArg) => repositoryArg.empty),
835
876
  withWiki: sumMeasures([...repositories.map((repositoryArg) => indicator(repositoryArg.wikiPages, (valueArg) => valueArg > 0n)), ...missingRepositories]),
836
877
  },
837
- git: sumGroup(repositories.map((repositoryArg) => repositoryArg.git), ['reportedSizeKiB', 'branches', 'tags', 'pullRequestRefs', 'otherRefs'], missingRepositories),
838
- lfs: sumGroup(repositories.map((repositoryArg) => repositoryArg.lfs), ['objects', 'bytes'], missingRepositories),
878
+ git: sumGroup(repositories.map((repositoryArg) => repositoryArg.git),
879
+ ['reportedSizeKiB', 'branches', 'tags', 'pullRequestRefs', 'otherRefs', 'objects', 'objectBytes'], missingRepositories),
880
+ lfs: sumGroup(repositories.map((repositoryArg) => repositoryArg.lfs), ['objects', 'bytes', 'missingObjects'], missingRepositories),
839
881
  collaboration: sumGroup(repositories.map((repositoryArg) => repositoryArg.collaboration),
840
882
  ['issues', 'pullRequests', 'comments', 'reviews', 'reviewComments', 'labels', 'milestones', 'reactions', 'attachments', 'attachmentBytes'], missingRepositories),
841
883
  releases: sumGroup(repositories.map((repositoryArg) => repositoryArg.releases), ['releases', 'drafts', 'assets', 'assetBytes'], missingRepositories),
@@ -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
+ }