@foss.global/codefeed 2.0.1 → 2.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.
@@ -0,0 +1,882 @@
1
+ import type * 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 type { IForgeRequestMetrics, IForgeRequestOptions } from './forgeinventory.client.js';
6
+
7
+ type TMeasure = plugins.fossInterfaces.data.TForgeSourceMeasure;
8
+ type TUnit = plugins.fossInterfaces.data.TForgeSourceInventoryUnit;
9
+ type TUnitResult = plugins.fossInterfaces.data.TForgeSourceInventoryUnitResult;
10
+ type IDiscovery = plugins.fossInterfaces.data.IForgeSourceInventoryDiscovery;
11
+ type IInstance = plugins.fossInterfaces.data.IForgeSourceInstanceInventory;
12
+ type TOwner = plugins.fossInterfaces.data.TForgeSourceOwnerInventory;
13
+ type IRepository = plugins.fossInterfaces.data.IForgeSourceRepositoryInventory;
14
+ type IPackageTypeCounts = plugins.fossInterfaces.data.IForgeSourcePackageTypeCounts;
15
+ type IInventory = plugins.fossInterfaces.data.IForgeSourceInventory;
16
+
17
+ export interface IGiteaSourceInventoryOptions {
18
+ /** Operator-assigned identity of the source instance. */
19
+ instanceId: string;
20
+ /** Instance root, including an optional deployment subpath. HTTPS is required. */
21
+ baseUrl: string;
22
+ /**
23
+ * A site administrator's token, retained only in this reader's memory. It is
24
+ * sent only to `baseUrl` and never appears in results, reasons or errors.
25
+ */
26
+ token: string;
27
+ /** PEM certificates that replace the default trust store for this reader's connections. */
28
+ caCertificates?: string[];
29
+ /** Items requested per page; Gitea caps it at its `MAX_RESPONSE_ITEMS`. */
30
+ pageSize?: number;
31
+ /** Pages read per listing before the listing counts as partial. */
32
+ maxPages?: number;
33
+ requestTimeoutMs?: number;
34
+ maxResponseBytes?: number;
35
+ }
36
+
37
+ export interface IGiteaSourceMeasureOptions {
38
+ signal?: AbortSignal;
39
+ /** Units measured at the same time. Requests within one unit are sequential. */
40
+ concurrency?: number;
41
+ /** Called once per unit as soon as its result exists, for example to persist it. */
42
+ onResult?: (resultArg: TUnitResult) => void | Promise<void>;
43
+ }
44
+
45
+ interface IListing {
46
+ items: unknown[];
47
+ /** A sanitized reason when the listing did not finish. */
48
+ failure: string | null;
49
+ }
50
+
51
+ interface IListOptions {
52
+ query?: Record<string, string>;
53
+ /** Reads as this user through the administrator's `Sudo` header. */
54
+ sudo?: string;
55
+ /** A 404 on the first page means an empty listing, for example a repository without a wiki. */
56
+ missingIsEmpty?: boolean;
57
+ /** Gitea answers `null` instead of an empty array on some listings, such as reactions. */
58
+ nullIsEmpty?: boolean;
59
+ /** Extracts the rows from an enveloped page. */
60
+ rows?: (bodyArg: unknown) => unknown;
61
+ signal?: AbortSignal;
62
+ }
63
+
64
+ /** The only limitation: data Gitea exposes to no administrator read and that the migration reissues. */
65
+ export const giteaSourceInventoryLimitations: readonly string[] = [
66
+ 'Gitea has no read that lists user-scoped Actions secrets; they are reissued.',
67
+ ];
68
+
69
+ const lfsNotMeasured: TMeasure = { status: 'notMeasured', reason: 'Gitea exposes no API that lists LFS objects.' };
70
+
71
+ const complete = (valueArg: bigint | number): TMeasure => ({ status: 'complete', value: BigInt(valueArg).toString() });
72
+ const partial = (observedArg: bigint | number, reasonArg: string): TMeasure => ({ status: 'partial', observedValue: BigInt(observedArg).toString(), reason: reasonArg });
73
+ const notMeasured = (reasonArg: string): TMeasure => ({ status: 'notMeasured', reason: reasonArg });
74
+ const unitDisabled = (unitArg: string): TMeasure => notMeasured(`The ${unitArg} unit is disabled; Gitea's API hides its data.`);
75
+
76
+ /** The value a measure observed so far. */
77
+ const observed = (measureArg: TMeasure): bigint => measureArg.status === 'complete' ? BigInt(measureArg.value)
78
+ : measureArg.status === 'partial' ? BigInt(measureArg.observedValue) : 0n;
79
+
80
+ /**
81
+ * Sums measures: complete only when every addend is, not measured when any
82
+ * addend is, otherwise partial with the observed sum and the first reason.
83
+ */
84
+ const sumMeasures = (measuresArg: TMeasure[]): TMeasure => {
85
+ const missing = measuresArg.find((measureArg) => measureArg.status === 'notMeasured');
86
+ if (missing) return missing;
87
+ const total = measuresArg.reduce((sumArg, measureArg) => sumArg + observed(measureArg), 0n);
88
+ const unfinished = measuresArg.find((measureArg) => measureArg.status === 'partial');
89
+ return unfinished?.status === 'partial' ? partial(total, unfinished.reason) : complete(total);
90
+ };
91
+
92
+ const listingMeasure = (listingArg: IListing, valueArg: bigint = BigInt(listingArg.items.length)): TMeasure =>
93
+ listingArg.failure === null ? complete(valueArg) : partial(valueArg, listingArg.failure);
94
+
95
+ /** A sanitized failure reason: adapter-owned error codes only, never remote text. */
96
+ const reason = (errorArg: unknown): string => errorArg instanceof validation.ForgeInventoryError ? errorArg.code : 'unexpected-error';
97
+
98
+ const requiredBoolean = (valueArg: unknown): boolean => {
99
+ if (typeof valueArg !== 'boolean') throw new validation.ForgeInventoryError('invalid-boolean');
100
+ return valueArg;
101
+ };
102
+
103
+ const requiredString = (valueArg: unknown): string => {
104
+ const value = validation.optionalString(valueArg);
105
+ if (value === null) throw new validation.ForgeInventoryError('invalid-string');
106
+ return value;
107
+ };
108
+
109
+ /** One provider path segment, as an owner or repository name. */
110
+ const segment = (valueArg: unknown): string => {
111
+ const segments = validation.segments(valueArg);
112
+ if (segments.length !== 1) throw new validation.ForgeInventoryError('invalid-path');
113
+ return segments[0];
114
+ };
115
+
116
+ const ownerVisibility = (valueArg: unknown): plugins.fossInterfaces.data.TForgeInventoryVisibility => {
117
+ if (valueArg === 'limited') return 'internal';
118
+ const visibility = validation.visibility(valueArg);
119
+ if (visibility === null) throw new validation.ForgeInventoryError('invalid-visibility');
120
+ return visibility;
121
+ };
122
+
123
+ /** Files attached to an issue, pull request, comment or release, with their bytes. */
124
+ const assets = (valueArg: unknown): { count: bigint; bytes: bigint } => {
125
+ if (valueArg === undefined || valueArg === null) return { count: 0n, bytes: 0n };
126
+ if (!Array.isArray(valueArg)) throw new validation.ForgeInventoryError('invalid-assets');
127
+ return valueArg.reduce((sumArg: { count: bigint; bytes: bigint }, assetArg: unknown) => ({
128
+ count: sumArg.count + 1n, bytes: sumArg.bytes + BigInt(validation.decimal(validation.record(assetArg).size)),
129
+ }), { count: 0n, bytes: 0n });
130
+ };
131
+
132
+ /** A `{ <rows>, total_count }` envelope whose rows must match its total. */
133
+ const countedEnvelope = (bodyArg: unknown, rowsKeyArg: string): { rows: unknown[]; failure: string | null } => {
134
+ const body = validation.record(bodyArg);
135
+ const rows = body[rowsKeyArg] ?? [];
136
+ if (!Array.isArray(rows)) throw new validation.ForgeInventoryError('invalid-page');
137
+ return { rows, failure: validation.decimal(body.total_count) === String(rows.length) ? null : 'count-mismatch' };
138
+ };
139
+
140
+ const unitKey = (unitArg: TUnit): string => unitArg.kind === 'instance' ? 'instance'
141
+ : unitArg.kind === 'owner' ? `owner:${unitArg.ownerKind}:${unitArg.name}` : `repository:${unitArg.owner}/${unitArg.name}`;
142
+
143
+ const compareText = (aArg: string, bArg: string): number => aArg < bArg ? -1 : aArg > bArg ? 1 : 0;
144
+
145
+ /**
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.
150
+ */
151
+ export class GiteaSourceInventory {
152
+ readonly #options: { instanceId: string; baseUrl: string; pageSize: number; maxPages: number };
153
+ readonly #client: ForgeRequestClient;
154
+
155
+ constructor(optionsArg: IGiteaSourceInventoryOptions) {
156
+ const baseUrl = validation.httpUrl(optionsArg.baseUrl);
157
+ 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)) {
159
+ throw new validation.ForgeInventoryError('invalid-options');
160
+ }
161
+ const limits = {
162
+ pageSize: optionsArg.pageSize ?? 50,
163
+ maxPages: optionsArg.maxPages ?? 10_000,
164
+ requestTimeoutMs: optionsArg.requestTimeoutMs ?? 60_000,
165
+ maxResponseBytes: optionsArg.maxResponseBytes ?? 16 * 1024 * 1024,
166
+ };
167
+ for (const [name, maximum] of [
168
+ ['pageSize', 100], ['maxPages', 1_000_000], ['requestTimeoutMs', 300_000], ['maxResponseBytes', 64 * 1024 * 1024],
169
+ ] as const) {
170
+ const value = limits[name];
171
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) throw new validation.ForgeInventoryError('invalid-limits');
172
+ }
173
+ this.#options = { instanceId: optionsArg.instanceId, baseUrl: baseUrl.replace(/\/+$/, ''), pageSize: limits.pageSize, maxPages: limits.maxPages };
174
+ this.#client = new ForgeRequestClient({
175
+ transport: optionsArg.caCertificates === undefined ? transport.globalFetchTransport
176
+ : transport.createHttpsTransport(validation.caCertificates(optionsArg.caCertificates)),
177
+ headers: { authorization: `token ${optionsArg.token}` },
178
+ requestTimeoutMs: limits.requestTimeoutMs,
179
+ maxResponseBytes: limits.maxResponseBytes,
180
+ });
181
+ }
182
+
183
+ /** Validated, normalized instance root; contains no credentials or API suffix. */
184
+ public get baseUrl(): string { return this.#options.baseUrl; }
185
+
186
+ public get metrics(): IForgeRequestMetrics { return this.#client.metrics; }
187
+
188
+ /**
189
+ * Identifies the source and its reader and lists every owner and repository.
190
+ * Rejects unless the token belongs to a site administrator; a listing that
191
+ * does not finish is reported in `incompleteReasons`.
192
+ */
193
+ public async discover(signalArg?: AbortSignal): Promise<IDiscovery> {
194
+ const reader = validation.record((await this.#get('/user', { signal: signalArg })).body);
195
+ if (reader.is_admin !== true) throw new validation.ForgeInventoryError('reader-not-site-admin');
196
+ const version = validation.record((await this.#get('/version', { signal: signalArg })).body).version;
197
+ if (typeof version !== 'string' || version.length > 128 || !/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9][A-Za-z0-9.+-]*)?$/.test(version)) {
198
+ throw new validation.ForgeInventoryError('invalid-server-version');
199
+ }
200
+ const incompleteReasons: string[] = [];
201
+ const organizations = await this.#list('/admin/orgs', { signal: signalArg });
202
+ const users = await this.#list('/admin/users', { signal: signalArg });
203
+ const repositories = await this.#list('/repos/search', {
204
+ signal: signalArg, query: { private: 'true', sort: 'id', order: 'asc' },
205
+ rows: (bodyArg) => {
206
+ const envelope = validation.record(bodyArg);
207
+ if (envelope.ok !== true) throw new validation.ForgeInventoryError('search-not-successful');
208
+ return envelope.data;
209
+ },
210
+ });
211
+ for (const [name, listing] of [['organizations', organizations], ['users', users], ['repositories', repositories]] as const) {
212
+ if (listing.failure !== null) incompleteReasons.push(`discovery: ${name}: ${listing.failure}`);
213
+ }
214
+ const owners = [
215
+ ...organizations.items.map((itemArg) => ({ kind: 'organization' as const, name: segment(validation.record(itemArg).name) })),
216
+ ...users.items.map((itemArg) => ({ kind: 'user' as const, name: segment(validation.record(itemArg).login) })),
217
+ ].sort((aArg, bArg) => compareText(aArg.kind, bArg.kind) || compareText(aArg.name, bArg.name));
218
+ const discovered = repositories.items.map((itemArg) => {
219
+ const item = validation.record(itemArg);
220
+ return { owner: segment(validation.record(item.owner).login), name: segment(item.name) };
221
+ }).sort((aArg, bArg) => compareText(aArg.owner, bArg.owner) || compareText(aArg.name, bArg.name));
222
+ return {
223
+ source: { instanceId: this.#options.instanceId, kind: 'gitea', baseUrl: this.#options.baseUrl, version },
224
+ reader: { login: segment(reader.login), siteAdmin: true },
225
+ owners,
226
+ repositories: discovered,
227
+ incompleteReasons: incompleteReasons.sort(compareText),
228
+ };
229
+ }
230
+
231
+ /** Every unit a run over this discovery measures: the instance, then owners, then repositories. */
232
+ public units(discoveryArg: IDiscovery): TUnit[] {
233
+ return [
234
+ { kind: 'instance' },
235
+ ...discoveryArg.owners.map((ownerArg): TUnit => ({ kind: 'owner', ownerKind: ownerArg.kind, name: ownerArg.name })),
236
+ ...discoveryArg.repositories.map((repositoryArg): TUnit => ({ kind: 'repository', owner: repositoryArg.owner, name: repositoryArg.name })),
237
+ ];
238
+ }
239
+
240
+ /** Measures one unit. Never rejects: a unit whose primary record cannot be read is `failed`. */
241
+ public async measureUnit(unitArg: TUnit, signalArg?: AbortSignal): Promise<TUnitResult> {
242
+ try {
243
+ if (unitArg.kind === 'instance') return { kind: 'instance', instance: await this.#instance(signalArg) };
244
+ if (unitArg.kind === 'owner') {
245
+ return { kind: 'owner', owner: unitArg.ownerKind === 'organization' ? await this.#organization(unitArg.name, signalArg) : await this.#user(unitArg.name, signalArg) };
246
+ }
247
+ return { kind: 'repository', repository: await this.#repository(unitArg.owner, unitArg.name, signalArg) };
248
+ } catch (error) {
249
+ return { kind: 'failed', unit: unitArg, reason: reason(error) };
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Measures units with bounded concurrency; results keep the order of `unitsArg`.
255
+ * An aborted signal or a rejected `onResult` aborts the units in flight, hands
256
+ * over no further result and rejects once every worker has stopped.
257
+ */
258
+ public async measureUnits(unitsArg: TUnit[], optionsArg: IGiteaSourceMeasureOptions = {}): Promise<TUnitResult[]> {
259
+ const concurrency = optionsArg.concurrency ?? 4;
260
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 16) throw new validation.ForgeInventoryError('invalid-concurrency');
261
+ const results: TUnitResult[] = new Array(unitsArg.length);
262
+ // The first failure aborts the units in flight; nothing is handed over after it.
263
+ const controller = new AbortController();
264
+ const abort = () => controller.abort();
265
+ if (optionsArg.signal?.aborted) abort();
266
+ optionsArg.signal?.addEventListener('abort', abort, { once: true });
267
+ let failure: { error: unknown } | null = null;
268
+ let next = 0;
269
+ const worker = async () => {
270
+ while (failure === null && next < unitsArg.length) {
271
+ const index = next++;
272
+ try {
273
+ if (controller.signal.aborted) throw new validation.ForgeInventoryError('request-aborted');
274
+ const result = await this.measureUnit(unitsArg[index], controller.signal);
275
+ if (failure !== null) return;
276
+ if (controller.signal.aborted) throw new validation.ForgeInventoryError('request-aborted');
277
+ results[index] = result;
278
+ await optionsArg.onResult?.(result);
279
+ } catch (error) {
280
+ failure ??= { error };
281
+ abort();
282
+ }
283
+ }
284
+ };
285
+ try {
286
+ await Promise.all(Array.from({ length: Math.min(concurrency, unitsArg.length) }, worker));
287
+ } finally {
288
+ optionsArg.signal?.removeEventListener('abort', abort);
289
+ }
290
+ if (failure !== null) throw (failure as { error: unknown }).error;
291
+ return results;
292
+ }
293
+
294
+ async #instance(signalArg?: AbortSignal): Promise<IInstance> {
295
+ const users = await this.#list('/admin/users', { signal: signalArg });
296
+ const flagged = (predicateArg: (userArg: Record<string, unknown>) => boolean): TMeasure => listingMeasure(
297
+ users, BigInt(users.items.filter((itemArg) => predicateArg(validation.record(itemArg))).length));
298
+ const runners = await this.#runners('/admin/actions/runners', { signal: signalArg });
299
+ return {
300
+ users: {
301
+ total: listingMeasure(users),
302
+ active: flagged((userArg) => requiredBoolean(userArg.active)),
303
+ admins: flagged((userArg) => requiredBoolean(userArg.is_admin)),
304
+ restricted: flagged((userArg) => requiredBoolean(userArg.restricted)),
305
+ prohibitLogin: flagged((userArg) => requiredBoolean(userArg.prohibit_login)),
306
+ externalAuth: flagged((userArg) => validation.decimal(userArg.source_id) !== '0'),
307
+ twoFactor: await this.#count('/admin/users', { signal: signalArg, query: { is_2fa_enabled: 'true' } }),
308
+ },
309
+ organizations: await this.#count('/admin/orgs', { signal: signalArg }),
310
+ systemWebhooks: await this.#count('/admin/hooks', { signal: signalArg, query: { type: 'all' } }),
311
+ runners,
312
+ unadoptedRepositories: await this.#count('/admin/unadopted', { signal: signalArg }),
313
+ };
314
+ }
315
+
316
+ async #ownerBase(nameArg: string, recordArg: Record<string, unknown>, repositoriesPathArg: string, signalArg?: AbortSignal) {
317
+ return {
318
+ name: nameArg,
319
+ id: validation.decimal(recordArg.id),
320
+ visibility: ownerVisibility(recordArg.visibility),
321
+ repositories: await this.#count(repositoriesPathArg, { signal: signalArg }),
322
+ packages: await this.#packages(nameArg, signalArg),
323
+ };
324
+ }
325
+
326
+ async #organization(nameArg: string, signalArg?: AbortSignal): Promise<TOwner> {
327
+ const path = `/orgs/${encodeURIComponent(nameArg)}`;
328
+ const organization = validation.record((await this.#get(path, { signal: signalArg })).body);
329
+ if (segment(organization.name) !== nameArg) throw new validation.ForgeInventoryError('owner-identity-changed');
330
+ const teams = await this.#list(`${path}/teams`, { signal: signalArg });
331
+ const memberships: TMeasure[] = [];
332
+ const grants: TMeasure[] = [];
333
+ for (const team of teams.items) {
334
+ const teamPath = `/teams/${validation.decimal(validation.record(team).id)}`;
335
+ memberships.push(await this.#count(`${teamPath}/members`, { signal: signalArg }));
336
+ grants.push(await this.#count(`${teamPath}/repos`, { signal: signalArg }));
337
+ }
338
+ const teamsMeasure = listingMeasure(teams);
339
+ const fromTeams = (measuresArg: TMeasure[]) => sumMeasures([...measuresArg, ...(teamsMeasure.status === 'complete' ? [] : [partial(0, teams.failure!)])]);
340
+ return {
341
+ kind: 'organization',
342
+ ...await this.#ownerBase(nameArg, organization, `${path}/repos`, signalArg),
343
+ webhooks: await this.#count(`${path}/hooks`, { signal: signalArg }),
344
+ actionsVariables: await this.#count(`${path}/actions/variables`, { signal: signalArg }),
345
+ runners: await this.#runners(`${path}/actions/runners`, { signal: signalArg }),
346
+ members: await this.#count(`${path}/members`, { signal: signalArg }),
347
+ teams: teamsMeasure,
348
+ teamMemberships: fromTeams(memberships),
349
+ teamRepositoryGrants: fromTeams(grants),
350
+ labels: await this.#count(`${path}/labels`, { signal: signalArg }),
351
+ actionsSecrets: await this.#count(`${path}/actions/secrets`, { signal: signalArg }),
352
+ };
353
+ }
354
+
355
+ async #user(nameArg: string, signalArg?: AbortSignal): Promise<TOwner> {
356
+ const path = `/users/${encodeURIComponent(nameArg)}`;
357
+ const user = validation.record((await this.#get(path, { signal: signalArg })).body);
358
+ if (segment(user.login) !== nameArg) throw new validation.ForgeInventoryError('owner-identity-changed');
359
+ // User-scoped settings are only readable as that user, through the administrator's Sudo header.
360
+ const sudo = { signal: signalArg, sudo: nameArg };
361
+ return {
362
+ kind: 'user',
363
+ ...await this.#ownerBase(nameArg, user, `${path}/repos`, signalArg),
364
+ account: {
365
+ admin: requiredBoolean(user.is_admin),
366
+ active: requiredBoolean(user.active),
367
+ restricted: requiredBoolean(user.restricted),
368
+ prohibitLogin: requiredBoolean(user.prohibit_login),
369
+ externalAuth: validation.decimal(user.source_id) !== '0',
370
+ },
371
+ webhooks: await this.#count('/user/hooks', sudo),
372
+ actionsVariables: await this.#count('/user/actions/variables', sudo),
373
+ runners: await this.#runners('/user/actions/runners', sudo),
374
+ sshKeys: await this.#count(`${path}/keys`, { signal: signalArg }),
375
+ gpgKeys: await this.#count(`${path}/gpg_keys`, { signal: signalArg }),
376
+ };
377
+ }
378
+
379
+ /** Package versions by type, with their files and bytes. */
380
+ async #packages(ownerArg: string, signalArg?: AbortSignal): Promise<IPackageTypeCounts[]> {
381
+ const ownerPath = `/packages/${encodeURIComponent(ownerArg)}`;
382
+ // Gitea lists one row per package version.
383
+ const versions = await this.#list(ownerPath, { signal: signalArg });
384
+ const byType = new Map<string, { names: Set<string>; versions: number; files: TMeasure[]; bytes: TMeasure[] }>();
385
+ for (const itemArg of versions.items) {
386
+ const item = validation.record(itemArg);
387
+ const type = requiredString(item.type);
388
+ // The type is reported and names reason paths, so it must be one of Gitea's plain identifiers.
389
+ if (!/^[a-z][a-z0-9_-]{0,31}$/.test(type)) throw new validation.ForgeInventoryError('invalid-package-type');
390
+ const name = requiredString(item.name);
391
+ const version = requiredString(item.version);
392
+ const entry = byType.get(type) ?? { names: new Set<string>(), versions: 0, files: [], bytes: [] };
393
+ byType.set(type, entry);
394
+ entry.names.add(name);
395
+ entry.versions++;
396
+ try {
397
+ const files = (await this.#get(`${ownerPath}/${[type, name, version].map(encodeURIComponent).join('/')}/files`, { signal: signalArg })).body;
398
+ if (!Array.isArray(files)) throw new validation.ForgeInventoryError('invalid-page');
399
+ const bytes = files.reduce((sumArg: bigint, fileArg: unknown) => sumArg + BigInt(validation.decimal(validation.record(fileArg).size)), 0n);
400
+ entry.files.push(complete(files.length));
401
+ entry.bytes.push(complete(bytes));
402
+ } catch (error) {
403
+ entry.files.push(partial(0, reason(error)));
404
+ entry.bytes.push(partial(0, reason(error)));
405
+ }
406
+ }
407
+ const unfinished = versions.failure === null ? [] : [partial(0, versions.failure)];
408
+ const counts = [...byType.entries()].sort(([aArg], [bArg]) => compareText(aArg, bArg)).map(([type, entry]): IPackageTypeCounts => ({
409
+ type,
410
+ packages: sumMeasures([complete(entry.names.size), ...unfinished]),
411
+ versions: sumMeasures([complete(entry.versions), ...unfinished]),
412
+ files: sumMeasures([...entry.files, ...unfinished]),
413
+ bytes: sumMeasures([...entry.bytes, ...unfinished]),
414
+ }));
415
+ // A listing that failed before its first row still has to surface as partial.
416
+ if (counts.length === 0 && versions.failure !== null) {
417
+ const failed = partial(0, versions.failure);
418
+ counts.push({ type: 'unknown', packages: failed, versions: failed, files: failed, bytes: failed });
419
+ }
420
+ return counts;
421
+ }
422
+
423
+ async #repository(ownerArg: string, nameArg: string, signalArg?: AbortSignal): Promise<IRepository> {
424
+ const path = `/repos/${encodeURIComponent(ownerArg)}/${encodeURIComponent(nameArg)}`;
425
+ const item = validation.record((await this.#get(path, { signal: signalArg })).body);
426
+ const owner = validation.record(item.owner);
427
+ if (segment(owner.login) !== ownerArg || segment(item.name) !== nameArg) throw new validation.ForgeInventoryError('repository-identity-changed');
428
+ const visibility = validation.giteaRepositoryVisibility(item.private, item.internal, owner.visibility);
429
+ if (visibility === null) throw new validation.ForgeInventoryError('invalid-visibility');
430
+ const empty = requiredBoolean(item.empty);
431
+ const features = {
432
+ issues: requiredBoolean(item.has_issues),
433
+ pullRequests: requiredBoolean(item.has_pull_requests),
434
+ wiki: requiredBoolean(item.has_wiki),
435
+ releases: requiredBoolean(item.has_releases),
436
+ packages: requiredBoolean(item.has_packages),
437
+ actions: requiredBoolean(item.has_actions),
438
+ projects: requiredBoolean(item.has_projects),
439
+ };
440
+ const fork = requiredBoolean(item.fork);
441
+ const parent = item.parent === undefined || item.parent === null ? null : requiredString(validation.record(item.parent).full_name);
442
+ if (fork !== (parent !== null)) throw new validation.ForgeInventoryError('inconsistent-fork-parent');
443
+ const options = { signal: signalArg };
444
+ return {
445
+ owner: ownerArg,
446
+ name: nameArg,
447
+ id: validation.decimal(item.id),
448
+ visibility,
449
+ fork,
450
+ parent,
451
+ mirror: requiredBoolean(item.mirror),
452
+ template: requiredBoolean(item.template),
453
+ archived: requiredBoolean(item.archived),
454
+ empty,
455
+ objectFormat: requiredString(item.object_format_name),
456
+ defaultBranch: requiredString(item.default_branch),
457
+ features,
458
+ git: await this.#git(path, item, empty, signalArg),
459
+ lfs: { objects: lfsNotMeasured, bytes: lfsNotMeasured },
460
+ collaboration: await this.#collaboration(path, features, signalArg),
461
+ // Releases stay readable while their unit is disabled; a disabled wiki answers 404.
462
+ releases: await this.#releases(path, signalArg),
463
+ wikiPages: features.wiki ? await this.#count(`${path}/wiki/pages`, { ...options, missingIsEmpty: true }) : unitDisabled('wiki'),
464
+ access: {
465
+ collaborators: await this.#count(`${path}/collaborators`, options),
466
+ teams: await this.#repositoryTeams(path, options),
467
+ deployKeys: await this.#count(`${path}/keys`, options),
468
+ webhooks: await this.#count(`${path}/hooks`, options),
469
+ branchProtections: await this.#unpagedCount(`${path}/branch_protections`, options),
470
+ tagProtections: await this.#unpagedCount(`${path}/tag_protections`, options),
471
+ pushMirrors: await this.#count(`${path}/push_mirrors`, options),
472
+ },
473
+ actions: await this.#actions(path, empty, signalArg),
474
+ };
475
+ }
476
+
477
+ /** Gitea answers 405 for a repository that no organization owns, which therefore has no teams. */
478
+ async #repositoryTeams(pathArg: string, optionsArg: IListOptions): Promise<TMeasure> {
479
+ try {
480
+ const body = (await this.#get(`${pathArg}/teams`, optionsArg)).body;
481
+ if (!Array.isArray(body)) throw new validation.ForgeInventoryError('invalid-page');
482
+ return complete(body.length);
483
+ } catch (error) {
484
+ if (error instanceof validation.ForgeInventoryError && error.code === 'http-405') return complete(0);
485
+ return partial(0, reason(error));
486
+ }
487
+ }
488
+
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 };
510
+ }
511
+ }
512
+
513
+ async #collaboration(pathArg: string, featuresArg: IRepository['features'], signalArg?: AbortSignal): Promise<IRepository['collaboration']> {
514
+ const options = { signal: signalArg };
515
+ // Labels and milestones stay readable while the issue units are disabled.
516
+ const labels = await this.#count(`${pathArg}/labels`, options);
517
+ const milestones = await this.#count(`${pathArg}/milestones`, { ...options, query: { state: 'all' } });
518
+ if (!featuresArg.issues || !featuresArg.pullRequests) {
519
+ // A disabled unit silently drops its items from the shared issue listing; with both disabled the listings answer 404.
520
+ const hidden = unitDisabled(featuresArg.issues ? 'pull requests' : 'issues');
521
+ return {
522
+ issues: hidden, pullRequests: hidden, comments: hidden, reviews: hidden, reviewComments: hidden,
523
+ labels, milestones, reactions: hidden, attachments: hidden, attachmentBytes: hidden,
524
+ };
525
+ }
526
+ // Issues and pull requests share one listing and one number sequence.
527
+ const issues = await this.#list(`${pathArg}/issues`, { ...options, query: { state: 'all' } });
528
+ const unfinished = issues.failure === null ? [] : [partial(0, issues.failure)];
529
+ let issueCount = 0n;
530
+ let pullCount = 0n;
531
+ let attachments = { count: 0n, bytes: 0n };
532
+ const reactions: TMeasure[] = [...unfinished];
533
+ const reviews: TMeasure[] = [...unfinished];
534
+ const reviewComments: TMeasure[] = [...unfinished];
535
+ for (const itemArg of issues.items) {
536
+ const item = validation.record(itemArg);
537
+ const number = validation.decimal(item.number);
538
+ const isPull = item.pull_request !== undefined && item.pull_request !== null;
539
+ if (isPull) pullCount++; else issueCount++;
540
+ const itemAssets = assets(item.assets);
541
+ attachments = { count: attachments.count + itemAssets.count, bytes: attachments.bytes + itemAssets.bytes };
542
+ reactions.push(await this.#count(`${pathArg}/issues/${number}/reactions`, { ...options, nullIsEmpty: true }));
543
+ if (isPull) {
544
+ const pullReviews = await this.#list(`${pathArg}/pulls/${number}/reviews`, options);
545
+ reviews.push(listingMeasure(pullReviews));
546
+ const inline = pullReviews.items.reduce((sumArg: bigint, reviewArg: unknown) => sumArg + BigInt(validation.decimal(validation.record(reviewArg).comments_count)), 0n);
547
+ reviewComments.push(listingMeasure(pullReviews, inline));
548
+ }
549
+ }
550
+ const comments = await this.#list(`${pathArg}/issues/comments`, options);
551
+ const commentUnfinished = comments.failure === null ? [] : [partial(0, comments.failure)];
552
+ for (const commentArg of comments.items) {
553
+ const comment = validation.record(commentArg);
554
+ const commentAssets = assets(comment.assets);
555
+ attachments = { count: attachments.count + commentAssets.count, bytes: attachments.bytes + commentAssets.bytes };
556
+ reactions.push(await this.#unpagedCount(`${pathArg}/issues/comments/${validation.decimal(comment.id)}/reactions`, { ...options, nullIsEmpty: true }));
557
+ }
558
+ const attachmentUnfinished = [...unfinished, ...commentUnfinished];
559
+ return {
560
+ issues: listingMeasure(issues, issueCount),
561
+ pullRequests: listingMeasure(issues, pullCount),
562
+ comments: listingMeasure(comments),
563
+ reviews: sumMeasures(reviews),
564
+ reviewComments: sumMeasures(reviewComments),
565
+ labels,
566
+ milestones,
567
+ reactions: sumMeasures([...reactions, ...commentUnfinished]),
568
+ attachments: sumMeasures([complete(attachments.count), ...attachmentUnfinished]),
569
+ attachmentBytes: sumMeasures([complete(attachments.bytes), ...attachmentUnfinished]),
570
+ };
571
+ }
572
+
573
+ async #releases(pathArg: string, signalArg?: AbortSignal): Promise<IRepository['releases']> {
574
+ // An administrator's listing includes drafts.
575
+ const releases = await this.#list(`${pathArg}/releases`, { signal: signalArg });
576
+ let drafts = 0n;
577
+ let releaseAssets = { count: 0n, bytes: 0n };
578
+ for (const itemArg of releases.items) {
579
+ const item = validation.record(itemArg);
580
+ if (requiredBoolean(item.draft)) drafts++;
581
+ const itemAssets = assets(item.assets);
582
+ releaseAssets = { count: releaseAssets.count + itemAssets.count, bytes: releaseAssets.bytes + itemAssets.bytes };
583
+ }
584
+ return {
585
+ releases: listingMeasure(releases),
586
+ drafts: listingMeasure(releases, drafts),
587
+ assets: listingMeasure(releases, releaseAssets.count),
588
+ assetBytes: listingMeasure(releases, releaseAssets.bytes),
589
+ };
590
+ }
591
+
592
+ /** Every Actions read stays available while the repository's Actions unit is disabled. */
593
+ async #actions(pathArg: string, emptyArg: boolean, signalArg?: AbortSignal): Promise<IRepository['actions']> {
594
+ const options = { signal: signalArg };
595
+ const settings = {
596
+ secrets: await this.#count(`${pathArg}/actions/secrets`, options),
597
+ variables: await this.#count(`${pathArg}/actions/variables`, options),
598
+ runners: await this.#runners(`${pathArg}/actions/runners`, options),
599
+ };
600
+ let workflows: TMeasure;
601
+ if (emptyArg) {
602
+ workflows = complete(0);
603
+ } else {
604
+ try {
605
+ const listing = countedEnvelope((await this.#get(`${pathArg}/actions/workflows`, options)).body, 'workflows');
606
+ workflows = listing.failure === null ? complete(listing.rows.length) : partial(listing.rows.length, listing.failure);
607
+ } catch (error) {
608
+ workflows = partial(0, reason(error));
609
+ }
610
+ }
611
+ let runs: TMeasure;
612
+ try {
613
+ // The run history is archived, not migrated; its total sizes the archive without reading every run.
614
+ const body = validation.record((await this.#get(`${pathArg}/actions/runs`, { ...options, query: { limit: '1', page: '1' } })).body);
615
+ runs = complete(BigInt(validation.decimal(body.total_count)));
616
+ } catch (error) {
617
+ runs = partial(0, reason(error));
618
+ }
619
+ let artifacts: TMeasure;
620
+ let artifactBytes: TMeasure;
621
+ try {
622
+ const listing = countedEnvelope((await this.#get(`${pathArg}/actions/artifacts`, options)).body, 'artifacts');
623
+ const bytes = listing.rows.reduce((sumArg: bigint, artifactArg: unknown) => sumArg + BigInt(validation.decimal(validation.record(artifactArg).size_in_bytes)), 0n);
624
+ artifacts = listing.failure === null ? complete(listing.rows.length) : partial(listing.rows.length, listing.failure);
625
+ artifactBytes = listing.failure === null ? complete(bytes) : partial(bytes, listing.failure);
626
+ } catch (error) {
627
+ artifacts = artifactBytes = partial(0, reason(error));
628
+ }
629
+ return { workflows, ...settings, runs, artifacts, artifactBytes };
630
+ }
631
+
632
+ /** Runner listings are unpaged `{ runners, total_count }` envelopes. */
633
+ async #runners(pathArg: string, optionsArg: IListOptions): Promise<TMeasure> {
634
+ try {
635
+ const listing = countedEnvelope((await this.#get(pathArg, optionsArg)).body, 'runners');
636
+ return listing.failure === null ? complete(listing.rows.length) : partial(listing.rows.length, listing.failure);
637
+ } catch (error) {
638
+ return partial(0, reason(error));
639
+ }
640
+ }
641
+
642
+ async #unpagedCount(pathArg: string, optionsArg: IListOptions): Promise<TMeasure> {
643
+ try {
644
+ const response = (await this.#get(pathArg, optionsArg)).body;
645
+ const body = response === null && optionsArg.nullIsEmpty ? [] : response;
646
+ if (!Array.isArray(body)) throw new validation.ForgeInventoryError('invalid-page');
647
+ return complete(body.length);
648
+ } catch (error) {
649
+ return partial(0, reason(error));
650
+ }
651
+ }
652
+
653
+ async #count(pathArg: string, optionsArg: IListOptions): Promise<TMeasure> {
654
+ return listingMeasure(await this.#list(pathArg, optionsArg));
655
+ }
656
+
657
+ /**
658
+ * Reads every page of a listing. It ends at an empty page or once the rows
659
+ * reach `X-Total-Count`, and is partial when the total disagrees with the
660
+ * rows read, a page repeats, the page limit is reached or a page fails.
661
+ */
662
+ async #list(pathArg: string, optionsArg: IListOptions): Promise<IListing> {
663
+ const items: unknown[] = [];
664
+ let previousPage: string | null = null;
665
+ let total: bigint | null = null;
666
+ for (let page = 1; ; page++) {
667
+ if (page > this.#options.maxPages) return { items, failure: 'page-limit' };
668
+ let rows: unknown;
669
+ let totalHeader: string | null;
670
+ try {
671
+ const response = await this.#get(pathArg, {
672
+ ...optionsArg, query: { ...optionsArg.query, limit: String(this.#options.pageSize), page: String(page) },
673
+ });
674
+ rows = optionsArg.rows ? optionsArg.rows(response.body) : response.body;
675
+ if (rows === null && optionsArg.nullIsEmpty) rows = [];
676
+ totalHeader = response.headers.get('x-total-count');
677
+ } catch (error) {
678
+ if (page === 1 && optionsArg.missingIsEmpty && error instanceof validation.ForgeInventoryError && error.code === 'http-404') {
679
+ return { items, failure: null };
680
+ }
681
+ return { items, failure: reason(error) };
682
+ }
683
+ if (!Array.isArray(rows) || rows.length > this.#options.pageSize) return { items, failure: 'invalid-page' };
684
+ if (totalHeader !== null) {
685
+ let pageTotal: bigint;
686
+ try { pageTotal = BigInt(validation.decimal(totalHeader)); } catch { return { items, failure: 'invalid-total-count' }; }
687
+ if (total !== null && pageTotal !== total) return { items, failure: 'total-changed' };
688
+ total = pageTotal;
689
+ }
690
+ if (rows.length === 0) break;
691
+ const signature = JSON.stringify(rows);
692
+ if (signature === previousPage) return { items, failure: 'repeated-page' };
693
+ previousPage = signature;
694
+ items.push(...rows);
695
+ if (total !== null && BigInt(items.length) >= total) break;
696
+ }
697
+ if (total !== null && BigInt(items.length) !== total) return { items, failure: 'count-mismatch' };
698
+ return { items, failure: null };
699
+ }
700
+
701
+ #get(pathArg: string, optionsArg: Pick<IListOptions, 'query' | 'sudo' | 'signal'>) {
702
+ const url = new URL(`${this.#options.baseUrl}/api/v1${pathArg}`);
703
+ for (const [key, value] of Object.entries(optionsArg.query ?? {})) url.searchParams.set(key, value);
704
+ const request: IForgeRequestOptions = { signal: optionsArg.signal };
705
+ if (optionsArg.sudo !== undefined) request.headers = { sudo: optionsArg.sudo };
706
+ return this.#client.request(url, request);
707
+ }
708
+ }
709
+
710
+ /** Repository-shaped groups, summed over repositories. */
711
+ const sumGroup = <TGroup extends Record<keyof TGroup, TMeasure>>(groupsArg: TGroup[], keysArg: Array<keyof TGroup>, missingArg: TMeasure[]): TGroup =>
712
+ Object.fromEntries(keysArg.map((keyArg) => [keyArg, sumMeasures([...groupsArg.map((groupArg) => groupArg[keyArg]), ...missingArg])])) as TGroup;
713
+
714
+ const indicator = (measureArg: TMeasure, predicateArg: (valueArg: bigint) => boolean): TMeasure =>
715
+ measureArg.status === 'complete' ? complete(predicateArg(BigInt(measureArg.value)) ? 1 : 0)
716
+ : measureArg.status === 'partial' ? partial(predicateArg(BigInt(measureArg.observedValue)) ? 1 : 0, measureArg.reason) : measureArg;
717
+
718
+ /** Every measure of a document with its dotted path. */
719
+ const measuresOf = (valueArg: unknown, pathArg: string, outArg: Array<{ path: string; measure: TMeasure }>) => {
720
+ if (!valueArg || typeof valueArg !== 'object') return;
721
+ const value = valueArg as Record<string, unknown>;
722
+ if (typeof value.status === 'string' && (value.status === 'complete' || value.status === 'partial' || value.status === 'notMeasured')) {
723
+ outArg.push({ path: pathArg, measure: value as TMeasure });
724
+ return;
725
+ }
726
+ for (const [key, child] of Object.entries(value)) {
727
+ // Package counts are keyed by their type rather than their position.
728
+ const name = Array.isArray(value) && child && typeof child === 'object' && typeof (child as { type?: unknown }).type === 'string'
729
+ ? (child as { type: string }).type : key;
730
+ measuresOf(child, pathArg ? `${pathArg}.${name}` : name, outArg);
731
+ }
732
+ };
733
+
734
+ /**
735
+ * Composes the deterministic inventory from a discovery and its unit results.
736
+ * Repeated results for one unit keep the last. Units without a successful
737
+ * result, owner repository counts that disagree with discovery, and every
738
+ * partial or unmeasured value make the inventory incomplete, with reasons
739
+ * aggregated per subject kind, path and reason.
740
+ */
741
+ export const composeGiteaSourceInventory = (
742
+ discoveryArg: IDiscovery,
743
+ resultsArg: TUnitResult[],
744
+ generatorsArg: plugins.fossInterfaces.data.IForgeSourceInventoryGenerator[],
745
+ ): IInventory => {
746
+ const results = new Map<string, TUnitResult>();
747
+ for (const result of resultsArg) {
748
+ const unit: TUnit = result.kind === 'failed' ? result.unit : result.kind === 'instance' ? { kind: 'instance' }
749
+ : result.kind === 'owner' ? { kind: 'owner', ownerKind: result.owner.kind, name: result.owner.name }
750
+ : { kind: 'repository', owner: result.repository.owner, name: result.repository.name };
751
+ results.set(unitKey(unit), result);
752
+ }
753
+ const reasons = new Map<string, number>();
754
+ const addReason = (textArg: string) => reasons.set(textArg, (reasons.get(textArg) ?? 0) + 1);
755
+ for (const text of discoveryArg.incompleteReasons) addReason(text);
756
+
757
+ const instanceResult = results.get('instance');
758
+ let instance: IInstance;
759
+ if (instanceResult?.kind === 'instance') {
760
+ instance = instanceResult.instance;
761
+ } else {
762
+ const missing = notMeasured(instanceResult?.kind === 'failed' ? `The instance unit failed: ${instanceResult.reason}.` : 'The instance unit was not measured.');
763
+ instance = {
764
+ users: { total: missing, active: missing, admins: missing, restricted: missing, prohibitLogin: missing, externalAuth: missing, twoFactor: missing },
765
+ organizations: missing, systemWebhooks: missing, runners: missing, unadoptedRepositories: missing,
766
+ };
767
+ }
768
+
769
+ const owners: TOwner[] = [];
770
+ const failedOwners: string[] = [];
771
+ for (const owner of discoveryArg.owners) {
772
+ const result = results.get(unitKey({ kind: 'owner', ownerKind: owner.kind, name: owner.name }));
773
+ if (result?.kind === 'owner') owners.push(result.owner);
774
+ else {
775
+ failedOwners.push(owner.name);
776
+ addReason(`${owner.kind}: ${result?.kind === 'failed' ? `unit failed: ${result.reason}` : 'unit not measured'}`);
777
+ }
778
+ }
779
+ const repositories: IRepository[] = [];
780
+ const failedRepositories: string[] = [];
781
+ for (const repository of discoveryArg.repositories) {
782
+ const result = results.get(unitKey({ kind: 'repository', ...repository }));
783
+ if (result?.kind === 'repository') repositories.push(result.repository);
784
+ else {
785
+ failedRepositories.push(`${repository.owner}/${repository.name}`);
786
+ addReason(`repository: ${result?.kind === 'failed' ? `unit failed: ${result.reason}` : 'unit not measured'}`);
787
+ }
788
+ }
789
+ owners.sort((aArg, bArg) => compareText(aArg.kind, bArg.kind) || compareText(aArg.name, bArg.name));
790
+ repositories.sort((aArg, bArg) => compareText(aArg.owner, bArg.owner) || compareText(aArg.name, bArg.name));
791
+
792
+ // Each owner's own repository listing must agree with the instance-wide discovery.
793
+ const discoveredPerOwner = new Map<string, number>();
794
+ for (const repository of discoveryArg.repositories) discoveredPerOwner.set(repository.owner, (discoveredPerOwner.get(repository.owner) ?? 0) + 1);
795
+ for (const owner of owners) {
796
+ if (owner.repositories.status === 'complete' && owner.repositories.value !== String(discoveredPerOwner.get(owner.name) ?? 0)) {
797
+ addReason(`${owner.kind}: repositories: the owner listing disagrees with the instance-wide repository search`);
798
+ }
799
+ }
800
+ const ownerNames = new Set(discoveryArg.owners.map((ownerArg) => ownerArg.name));
801
+ for (const owner of discoveredPerOwner.keys()) {
802
+ if (!ownerNames.has(owner)) addReason('repository: owner missing from the owner discovery');
803
+ }
804
+
805
+ const missingOwners = failedOwners.length ? [partial(0, `Owner units not measured: ${failedOwners.length}.`)] : [];
806
+ const missingRepositories = failedRepositories.length ? [partial(0, `Repository units not measured: ${failedRepositories.length}.`)] : [];
807
+ const repositoryCount = (predicateArg: (repositoryArg: IRepository) => boolean) =>
808
+ sumMeasures([complete(repositories.filter(predicateArg).length), ...missingRepositories]);
809
+ const organizationOwners = owners.filter((ownerArg): ownerArg is plugins.fossInterfaces.data.IForgeSourceOrganizationInventory => ownerArg.kind === 'organization');
810
+ const userOwners = owners.filter((ownerArg): ownerArg is plugins.fossInterfaces.data.IForgeSourceUserInventory => ownerArg.kind === 'user');
811
+ const ownerSum = (measuresArg: TMeasure[]) => sumMeasures([...measuresArg, ...missingOwners]);
812
+ const packageTypes = new Map<string, IPackageTypeCounts[]>();
813
+ for (const owner of owners) for (const counts of owner.packages) packageTypes.set(counts.type, [...packageTypes.get(counts.type) ?? [], counts]);
814
+
815
+ const inventory: Omit<IInventory, 'inventoryComplete' | 'incompleteReasons'> = {
816
+ generators: [...generatorsArg].sort((aArg, bArg) => compareText(aArg.package, bArg.package)),
817
+ source: discoveryArg.source,
818
+ reader: discoveryArg.reader,
819
+ limitations: [...giteaSourceInventoryLimitations],
820
+ instance,
821
+ owners,
822
+ repositories,
823
+ totals: {
824
+ organizations: instance.organizations,
825
+ users: instance.users.total,
826
+ repositories: {
827
+ total: repositoryCount(() => true),
828
+ private: repositoryCount((repositoryArg) => repositoryArg.visibility === 'private'),
829
+ internal: repositoryCount((repositoryArg) => repositoryArg.visibility === 'internal'),
830
+ forks: repositoryCount((repositoryArg) => repositoryArg.fork),
831
+ mirrors: repositoryCount((repositoryArg) => repositoryArg.mirror),
832
+ templates: repositoryCount((repositoryArg) => repositoryArg.template),
833
+ archived: repositoryCount((repositoryArg) => repositoryArg.archived),
834
+ empty: repositoryCount((repositoryArg) => repositoryArg.empty),
835
+ withWiki: sumMeasures([...repositories.map((repositoryArg) => indicator(repositoryArg.wikiPages, (valueArg) => valueArg > 0n)), ...missingRepositories]),
836
+ },
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),
839
+ collaboration: sumGroup(repositories.map((repositoryArg) => repositoryArg.collaboration),
840
+ ['issues', 'pullRequests', 'comments', 'reviews', 'reviewComments', 'labels', 'milestones', 'reactions', 'attachments', 'attachmentBytes'], missingRepositories),
841
+ releases: sumGroup(repositories.map((repositoryArg) => repositoryArg.releases), ['releases', 'drafts', 'assets', 'assetBytes'], missingRepositories),
842
+ wikiPages: sumMeasures([...repositories.map((repositoryArg) => repositoryArg.wikiPages), ...missingRepositories]),
843
+ access: sumGroup(repositories.map((repositoryArg) => repositoryArg.access),
844
+ ['collaborators', 'teams', 'deployKeys', 'webhooks', 'branchProtections', 'tagProtections', 'pushMirrors'], missingRepositories),
845
+ actions: sumGroup(repositories.map((repositoryArg) => repositoryArg.actions),
846
+ ['workflows', 'secrets', 'variables', 'runners', 'runs', 'artifacts', 'artifactBytes'], missingRepositories),
847
+ owners: {
848
+ webhooks: ownerSum(owners.map((ownerArg) => ownerArg.webhooks)),
849
+ actionsSecrets: ownerSum(organizationOwners.map((ownerArg) => ownerArg.actionsSecrets)),
850
+ actionsVariables: ownerSum(owners.map((ownerArg) => ownerArg.actionsVariables)),
851
+ runners: ownerSum(owners.map((ownerArg) => ownerArg.runners)),
852
+ members: ownerSum(organizationOwners.map((ownerArg) => ownerArg.members)),
853
+ teams: ownerSum(organizationOwners.map((ownerArg) => ownerArg.teams)),
854
+ teamMemberships: ownerSum(organizationOwners.map((ownerArg) => ownerArg.teamMemberships)),
855
+ teamRepositoryGrants: ownerSum(organizationOwners.map((ownerArg) => ownerArg.teamRepositoryGrants)),
856
+ labels: ownerSum(organizationOwners.map((ownerArg) => ownerArg.labels)),
857
+ sshKeys: ownerSum(userOwners.map((ownerArg) => ownerArg.sshKeys)),
858
+ gpgKeys: ownerSum(userOwners.map((ownerArg) => ownerArg.gpgKeys)),
859
+ },
860
+ packages: [...packageTypes.entries()].sort(([aArg], [bArg]) => compareText(aArg, bArg)).map(([type, countsArg]) => ({
861
+ type,
862
+ ...sumGroup(countsArg.map(({ type: _type, ...measures }) => measures), ['packages', 'versions', 'files', 'bytes'], missingOwners),
863
+ })),
864
+ },
865
+ };
866
+
867
+ // Every value that is not complete is a reason, aggregated per subject kind, path and reason.
868
+ const collected: Array<{ path: string; measure: TMeasure }> = [];
869
+ measuresOf(instance, 'instance', collected);
870
+ for (const owner of owners) {
871
+ const { name: _name, id: _id, ...measures } = owner;
872
+ measuresOf(measures, owner.kind, collected);
873
+ }
874
+ for (const repository of repositories) measuresOf(repository, 'repository', collected);
875
+ for (const { path, measure } of collected) {
876
+ if (measure.status !== 'complete') addReason(`${path}: ${measure.reason}`);
877
+ }
878
+ const incompleteReasons = [...reasons.entries()]
879
+ .map(([text, count]) => count > 1 ? `${text} (${count} times)` : text)
880
+ .sort(compareText);
881
+ return { ...inventory, inventoryComplete: incompleteReasons.length === 0, incompleteReasons };
882
+ };