@foss.global/codefeed 3.1.0 → 4.0.0

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