@foss.global/codefeed 5.0.0 → 5.2.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/changelog.md +39 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.giteasourceinventory.d.ts +35 -9
- package/dist_ts/classes.giteasourceinventory.js +448 -45
- package/dist_ts/classes.giteasourcetransfer.d.ts +114 -6
- package/dist_ts/classes.giteasourcetransfer.js +326 -9
- package/dist_ts/forgeinventory.client.d.ts +22 -4
- package/dist_ts/forgeinventory.client.js +32 -13
- package/dist_ts/giteasourceinventory.packages.d.ts +46 -0
- package/dist_ts/giteasourceinventory.packages.js +139 -0
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/plugins.d.ts +2 -2
- package/dist_ts/plugins.js +3 -1
- package/package.json +3 -3
- package/readme.md +37 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.giteasourceinventory.ts +465 -44
- package/ts/classes.giteasourcetransfer.ts +395 -8
- package/ts/forgeinventory.client.ts +63 -12
- package/ts/giteasourceinventory.packages.ts +160 -0
- package/ts/index.ts +1 -0
- package/ts/plugins.ts +2 -2
|
@@ -4,6 +4,9 @@ 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
6
|
import { measureGiteaMirror } from './giteasourceinventory.git.js';
|
|
7
|
+
import {
|
|
8
|
+
cargoIndexPath, comparePackages, composeGiteaPackageContent, containerIndexMediaTypes, npmPackagePath, packageKey, packageTypePattern,
|
|
9
|
+
} from './giteasourceinventory.packages.js';
|
|
7
10
|
|
|
8
11
|
type TMeasure = plugins.fossInterfaces.data.TForgeSourceMeasure;
|
|
9
12
|
type TUnit = plugins.fossInterfaces.data.TForgeSourceInventoryUnit;
|
|
@@ -17,6 +20,18 @@ type TRelease = plugins.fossInterfaces.data.TForgeSourceRelease;
|
|
|
17
20
|
type IBranchProtection = plugins.fossInterfaces.data.IForgeSourceBranchProtection;
|
|
18
21
|
type ITagProtection = plugins.fossInterfaces.data.IForgeSourceTagProtection;
|
|
19
22
|
type IInventory = plugins.fossInterfaces.data.IForgeSourceInventory;
|
|
23
|
+
type TAccessMode = plugins.fossInterfaces.data.TForgeSourceAccessMode;
|
|
24
|
+
type TTeamVisibility = plugins.fossInterfaces.data.TForgeSourceTeamVisibility;
|
|
25
|
+
type ITeamRecord = plugins.fossInterfaces.data.IForgeSourceTeamRecord;
|
|
26
|
+
type IUserSshKey = plugins.fossInterfaces.data.IForgeSourceUserSshKey;
|
|
27
|
+
type ICollaborator = plugins.fossInterfaces.data.IForgeSourceCollaborator;
|
|
28
|
+
type IDeployKey = plugins.fossInterfaces.data.IForgeSourceDeployKey;
|
|
29
|
+
type IDeployToken = plugins.fossInterfaces.data.IForgeSourceDeployToken;
|
|
30
|
+
type IAccessCounts = plugins.fossInterfaces.data.IForgeSourceAccessCounts;
|
|
31
|
+
type IPackageRef = plugins.fossInterfaces.data.IForgeSourcePackageRef;
|
|
32
|
+
type IPackageRecord = plugins.fossInterfaces.data.IForgeSourcePackageInventory;
|
|
33
|
+
type IPackageVersion = plugins.fossInterfaces.data.IForgeSourcePackageVersion;
|
|
34
|
+
type IPackageBlob = plugins.fossInterfaces.data.IForgeSourcePackageBlob;
|
|
20
35
|
|
|
21
36
|
export interface IGiteaSourceInventoryOptions {
|
|
22
37
|
/** Operator-assigned identity of the source instance. */
|
|
@@ -47,6 +62,19 @@ export interface IGiteaSourceInventoryOptions {
|
|
|
47
62
|
maxResponseBytes?: number;
|
|
48
63
|
}
|
|
49
64
|
|
|
65
|
+
/** A class of source data a run measures only when its discovery selects it. */
|
|
66
|
+
export type TGiteaSourceInventoryClass = 'packages';
|
|
67
|
+
|
|
68
|
+
export interface IGiteaSourceDiscoveryOptions {
|
|
69
|
+
/**
|
|
70
|
+
* Classes beside the instance, owners and repositories to list for
|
|
71
|
+
* measurement. `packages` lists every package, so `units` measures one unit
|
|
72
|
+
* per package; without it, discovery carries no `packages` and the package
|
|
73
|
+
* class is unread.
|
|
74
|
+
*/
|
|
75
|
+
classes?: readonly TGiteaSourceInventoryClass[];
|
|
76
|
+
}
|
|
77
|
+
|
|
50
78
|
export interface IGiteaSourceMeasureOptions {
|
|
51
79
|
signal?: AbortSignal;
|
|
52
80
|
/** Units measured at the same time. Requests within one unit are sequential. */
|
|
@@ -212,6 +240,71 @@ const releaseRecord = (itemArg: unknown): TRelease => {
|
|
|
212
240
|
: { ...release, draft: false, publishedAt: requiredTimestamp(item.published_at) };
|
|
213
241
|
};
|
|
214
242
|
|
|
243
|
+
const accessModes: ReadonlySet<string> = new Set<TAccessMode>(['none', 'read', 'write', 'admin', 'owner']);
|
|
244
|
+
const isAccessMode = (valueArg: string): valueArg is TAccessMode => accessModes.has(valueArg);
|
|
245
|
+
/** An access mode as Gitea reports it (`AccessMode.ToString`). */
|
|
246
|
+
const accessMode = (valueArg: unknown): TAccessMode => {
|
|
247
|
+
if (typeof valueArg !== 'string' || !isAccessMode(valueArg)) throw new validation.ForgeInventoryError('invalid-access-mode');
|
|
248
|
+
return valueArg;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const teamVisibilities: ReadonlySet<string> = new Set<TTeamVisibility>(['public', 'limited', 'private']);
|
|
252
|
+
const isTeamVisibility = (valueArg: string): valueArg is TTeamVisibility => teamVisibilities.has(valueArg);
|
|
253
|
+
|
|
254
|
+
/** An account as a listing names it: its id and login. */
|
|
255
|
+
const account = (itemArg: unknown): { id: string; login: string } => {
|
|
256
|
+
const item = validation.record(itemArg);
|
|
257
|
+
return { id: validation.decimal(item.id), login: segment(item.login) };
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/** A user's public SSH key; its key type is the first field of the public key Gitea stores. */
|
|
261
|
+
const userSshKey = (itemArg: unknown): IUserSshKey => {
|
|
262
|
+
const item = validation.record(itemArg);
|
|
263
|
+
const publicKey = requiredString(item.key);
|
|
264
|
+
const keyType = publicKey.split(' ')[0];
|
|
265
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9@._+-]{0,127}$/.test(keyType)) throw new validation.ForgeInventoryError('invalid-ssh-key');
|
|
266
|
+
return { id: validation.decimal(item.id), title: text(item.title), fingerprint: requiredString(item.fingerprint), keyType, publicKey };
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* A team as Gitea 28 reads it, with the members and repositories its listings
|
|
271
|
+
* returned. A whole-repository team has a null units map; units sort by name.
|
|
272
|
+
*/
|
|
273
|
+
const teamRecord = (teamArg: Record<string, unknown>, membersArg: unknown[], repositoriesArg: unknown[]): ITeamRecord => {
|
|
274
|
+
if (typeof teamArg.visibility !== 'string' || !isTeamVisibility(teamArg.visibility)) throw new validation.ForgeInventoryError('invalid-visibility');
|
|
275
|
+
return {
|
|
276
|
+
id: validation.decimal(teamArg.id),
|
|
277
|
+
name: requiredString(teamArg.name),
|
|
278
|
+
permission: accessMode(teamArg.permission),
|
|
279
|
+
unitsMap: teamArg.units_map === null ? null : Object.fromEntries(Object.entries(validation.record(teamArg.units_map))
|
|
280
|
+
.map(([unitArg, modeArg]) => [unitArg, accessMode(modeArg)] as const)
|
|
281
|
+
.sort(([aArg], [bArg]) => compareText(aArg, bArg))),
|
|
282
|
+
includesAllRepositories: requiredBoolean(teamArg.includes_all_repositories),
|
|
283
|
+
canCreateOrgRepo: requiredBoolean(teamArg.can_create_org_repo),
|
|
284
|
+
visibility: teamArg.visibility,
|
|
285
|
+
members: membersArg.map(account).sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id)),
|
|
286
|
+
repositoryIds: repositoriesArg.map((repositoryArg) => validation.decimal(validation.record(repositoryArg).id)).sort(compareDecimal),
|
|
287
|
+
};
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* One entry of a repository's deploy key listing, which on Gitea 28 holds SSH
|
|
292
|
+
* deploy keys and deploy tokens (`key_type`). A token's key and fingerprint
|
|
293
|
+
* (its mask) are never read.
|
|
294
|
+
*/
|
|
295
|
+
const deployKeyEntry = (itemArg: unknown): { kind: 'ssh'; key: IDeployKey } | { kind: 'token'; token: IDeployToken } => {
|
|
296
|
+
const item = validation.record(itemArg);
|
|
297
|
+
const id = validation.decimal(item.id);
|
|
298
|
+
const title = text(item.title);
|
|
299
|
+
const readOnly = requiredBoolean(item.read_only);
|
|
300
|
+
if (item.key_type === 'token') return { kind: 'token', token: { id, title, readOnly } };
|
|
301
|
+
if (item.key_type !== 'ssh') throw new validation.ForgeInventoryError('invalid-deploy-key-type');
|
|
302
|
+
return {
|
|
303
|
+
kind: 'ssh',
|
|
304
|
+
key: { id, keyId: validation.decimal(item.key_id), title, fingerprint: requiredString(item.fingerprint), publicKey: requiredString(item.key), readOnly },
|
|
305
|
+
};
|
|
306
|
+
};
|
|
307
|
+
|
|
215
308
|
const allowlist = (usernamesArg: unknown, teamsArg: unknown) => ({ usernames: texts(usernamesArg), teams: texts(teamsArg) });
|
|
216
309
|
|
|
217
310
|
/** A branch protection rule with every restriction Gitea reads back. */
|
|
@@ -266,16 +359,56 @@ const countedEnvelope = (bodyArg: unknown, rowsKeyArg: string): { rows: unknown[
|
|
|
266
359
|
return { rows, failure: validation.decimal(body.total_count) === String(rows.length) ? null : 'count-mismatch' };
|
|
267
360
|
};
|
|
268
361
|
|
|
269
|
-
|
|
270
|
-
|
|
362
|
+
/** The unit a result answers. */
|
|
363
|
+
const resultUnit = (resultArg: TUnitResult): TUnit => {
|
|
364
|
+
switch (resultArg.kind) {
|
|
365
|
+
case 'failed': return resultArg.unit;
|
|
366
|
+
case 'instance': return { kind: 'instance' };
|
|
367
|
+
case 'owner': return { kind: 'owner', ownerKind: resultArg.owner.kind, name: resultArg.owner.name };
|
|
368
|
+
case 'repository': return { kind: 'repository', owner: resultArg.repository.owner, name: resultArg.repository.name };
|
|
369
|
+
case 'package': return { kind: 'package', owner: resultArg.package.owner, type: resultArg.package.type, name: resultArg.package.name };
|
|
370
|
+
default: return unknownUnit(resultArg);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/** Refuses a unit kind this generator does not know, which a newer contract may add. */
|
|
375
|
+
const unknownUnit = (unitArg: never): never => {
|
|
376
|
+
void unitArg;
|
|
377
|
+
throw new validation.ForgeInventoryError('unit-kind-unsupported');
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const unitKey = (unitArg: TUnit): string => {
|
|
381
|
+
switch (unitArg.kind) {
|
|
382
|
+
case 'instance': return 'instance';
|
|
383
|
+
case 'owner': return `owner:${unitArg.ownerKind}:${unitArg.name}`;
|
|
384
|
+
case 'repository': return `repository:${unitArg.owner}/${unitArg.name}`;
|
|
385
|
+
case 'package': return `package:${packageKey(unitArg)}`;
|
|
386
|
+
default: return unknownUnit(unitArg);
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/** A package type from a listing. */
|
|
391
|
+
const packageType = (valueArg: unknown): string => {
|
|
392
|
+
const type = requiredString(valueArg);
|
|
393
|
+
if (!packageTypePattern.test(type)) throw new validation.ForgeInventoryError('invalid-package-type');
|
|
394
|
+
return type;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/** A package name as Gitea stores it; container and npm names may hold `/`, so it is no single path segment. */
|
|
398
|
+
const packageName = (valueArg: unknown): string => {
|
|
399
|
+
const name = requiredString(valueArg);
|
|
400
|
+
if (name.length > 512 || /[\u0000-\u001f\u007f]/.test(name)) throw new validation.ForgeInventoryError('invalid-package-name');
|
|
401
|
+
return name;
|
|
402
|
+
};
|
|
271
403
|
|
|
272
404
|
const compareText = (aArg: string, bArg: string): number => aArg < bArg ? -1 : aArg > bArg ? 1 : 0;
|
|
273
405
|
|
|
274
406
|
/**
|
|
275
407
|
* Authenticated, read-only inventory of a Gitea instance, read with a site
|
|
276
|
-
* administrator's token: GET requests to its REST API
|
|
277
|
-
* repository for refs, objects and LFS pointers, and
|
|
278
|
-
* requests that check which referenced objects Gitea
|
|
408
|
+
* administrator's token: GET requests to its REST API and package registries,
|
|
409
|
+
* a mirror clone of every repository for refs, objects and LFS pointers, and
|
|
410
|
+
* LFS batch `download` requests that check which referenced objects Gitea
|
|
411
|
+
* holds. It counts secrets
|
|
279
412
|
* and variables without retaining their values, and reports every read that
|
|
280
413
|
* did not finish as a partial or unmeasured value instead of a count.
|
|
281
414
|
*/
|
|
@@ -339,11 +472,15 @@ export class GiteaSourceInventory {
|
|
|
339
472
|
|
|
340
473
|
/**
|
|
341
474
|
* Identifies the source and its reader and lists every owner and repository
|
|
342
|
-
* with its id
|
|
343
|
-
*
|
|
344
|
-
*
|
|
475
|
+
* with its id, and with `classes: ['packages']` every package from the
|
|
476
|
+
* administrator package listing (`packages`, absent otherwise). Rejects
|
|
477
|
+
* unless the token belongs to a site administrator, and rejects an unknown
|
|
478
|
+
* class with `invalid-options`; a listing that does not finish, or that
|
|
479
|
+
* lists a repository id twice, is reported in `incompleteReasons`.
|
|
345
480
|
*/
|
|
346
|
-
public async discover(signalArg?: AbortSignal): Promise<IDiscovery> {
|
|
481
|
+
public async discover(signalArg?: AbortSignal, optionsArg: IGiteaSourceDiscoveryOptions = {}): Promise<IDiscovery> {
|
|
482
|
+
const classes = optionsArg.classes ?? [];
|
|
483
|
+
if (!Array.isArray(classes) || classes.some((classArg) => classArg !== 'packages')) throw new validation.ForgeInventoryError('invalid-options');
|
|
347
484
|
const reader = validation.record((await this.#get('/user', { signal: signalArg })).body);
|
|
348
485
|
if (reader.is_admin !== true) throw new validation.ForgeInventoryError('reader-not-site-admin');
|
|
349
486
|
const version = validation.record((await this.#get('/version', { signal: signalArg })).body).version;
|
|
@@ -380,16 +517,34 @@ export class GiteaSourceInventory {
|
|
|
380
517
|
}
|
|
381
518
|
if (ids.size !== repositories.items.length) incompleteReasons.push('discovery: repositories: duplicate-id');
|
|
382
519
|
discovered.sort((aArg, bArg) => compareText(aArg.owner, bArg.owner) || compareText(aArg.name, bArg.name));
|
|
520
|
+
let packages: IPackageRef[] | undefined;
|
|
521
|
+
if (classes.includes('packages')) {
|
|
522
|
+
// One row per package version, of every owner, user-owned packages included.
|
|
523
|
+
const versions = await this.#list('/admin/packages', { signal: signalArg });
|
|
524
|
+
if (versions.failure !== null) incompleteReasons.push(`discovery: packages: ${versions.failure}`);
|
|
525
|
+
const refs = new Map<string, IPackageRef>();
|
|
526
|
+
for (const itemArg of versions.items) {
|
|
527
|
+
const item = validation.record(itemArg);
|
|
528
|
+
const ref = { owner: segment(validation.record(item.owner).login), type: packageType(item.type), name: packageName(item.name) };
|
|
529
|
+
refs.set(packageKey(ref), ref);
|
|
530
|
+
}
|
|
531
|
+
packages = [...refs.values()].sort(comparePackages);
|
|
532
|
+
}
|
|
383
533
|
return {
|
|
384
534
|
source: { instanceId: this.#options.instanceId, kind: 'gitea', baseUrl: this.#options.baseUrl, version },
|
|
385
535
|
reader: { login: segment(reader.login), siteAdmin: true },
|
|
386
536
|
owners,
|
|
387
537
|
repositories: discovered,
|
|
538
|
+
...(packages === undefined ? {} : { packages }),
|
|
388
539
|
incompleteReasons: incompleteReasons.sort(compareText),
|
|
389
540
|
};
|
|
390
541
|
}
|
|
391
542
|
|
|
392
|
-
/**
|
|
543
|
+
/**
|
|
544
|
+
* Every unit a run over this discovery measures: the instance, then owners,
|
|
545
|
+
* then repositories, then the packages discovery lists when it selected
|
|
546
|
+
* them.
|
|
547
|
+
*/
|
|
393
548
|
public units(discoveryArg: IDiscovery): TUnit[] {
|
|
394
549
|
return [
|
|
395
550
|
{ kind: 'instance' },
|
|
@@ -397,6 +552,7 @@ export class GiteaSourceInventory {
|
|
|
397
552
|
...discoveryArg.repositories.map((repositoryArg): TUnit => ({
|
|
398
553
|
kind: 'repository', owner: repositoryArg.owner, name: repositoryArg.name, ...repositoryArg.id === undefined ? {} : { id: repositoryArg.id },
|
|
399
554
|
})),
|
|
555
|
+
...(discoveryArg.packages ?? []).map((packageArg): TUnit => ({ kind: 'package', owner: packageArg.owner, type: packageArg.type, name: packageArg.name })),
|
|
400
556
|
];
|
|
401
557
|
}
|
|
402
558
|
|
|
@@ -424,15 +580,31 @@ export class GiteaSourceInventory {
|
|
|
424
580
|
/**
|
|
425
581
|
* Measures one unit. Never rejects: a unit whose primary record cannot be
|
|
426
582
|
* read is `failed`, and so is a repository unit carrying an id that its name
|
|
427
|
-
* no longer answers with (`repository-identity-changed`).
|
|
583
|
+
* no longer answers with (`repository-identity-changed`). A package unit is
|
|
584
|
+
* read whole or fails, never partly: a listing that does not finish, an
|
|
585
|
+
* index that is not what its file list promises (`unexpected-manifest-shape`)
|
|
586
|
+
* and a result over `forgeSourceInventoryUnitMaxBytes` of UTF-8 JSON
|
|
587
|
+
* (`unit-over-bound`) fail it, and its record is never truncated.
|
|
428
588
|
*/
|
|
429
589
|
public async measureUnit(unitArg: TUnit, signalArg?: AbortSignal): Promise<TUnitResult> {
|
|
430
590
|
try {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
591
|
+
switch (unitArg.kind) {
|
|
592
|
+
case 'instance':
|
|
593
|
+
return { kind: 'instance', instance: await this.#instance(signalArg) };
|
|
594
|
+
case 'owner':
|
|
595
|
+
return { kind: 'owner', owner: unitArg.ownerKind === 'organization' ? await this.#organization(unitArg.name, signalArg) : await this.#user(unitArg.name, signalArg) };
|
|
596
|
+
case 'repository':
|
|
597
|
+
return { kind: 'repository', repository: await this.#repository(unitArg.owner, unitArg.name, unitArg.id, signalArg) };
|
|
598
|
+
case 'package': {
|
|
599
|
+
const result: TUnitResult = { kind: 'package', package: await this.#package(unitArg, signalArg) };
|
|
600
|
+
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > plugins.fossInterfaces.data.forgeSourceInventoryUnitMaxBytes) {
|
|
601
|
+
return { kind: 'failed', unit: unitArg, reason: 'unit-over-bound' };
|
|
602
|
+
}
|
|
603
|
+
return result;
|
|
604
|
+
}
|
|
605
|
+
default:
|
|
606
|
+
return unknownUnit(unitArg);
|
|
434
607
|
}
|
|
435
|
-
return { kind: 'repository', repository: await this.#repository(unitArg.owner, unitArg.name, unitArg.id, signalArg) };
|
|
436
608
|
} catch (error) {
|
|
437
609
|
return { kind: 'failed', unit: unitArg, reason: reason(error) };
|
|
438
610
|
}
|
|
@@ -518,13 +690,21 @@ export class GiteaSourceInventory {
|
|
|
518
690
|
const teams = await this.#list(`${path}/teams`, { signal: signalArg });
|
|
519
691
|
const memberships: TMeasure[] = [];
|
|
520
692
|
const grants: TMeasure[] = [];
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
693
|
+
const teamRecords: ITeamRecord[] = [];
|
|
694
|
+
for (const teamArg of teams.items) {
|
|
695
|
+
const team = validation.record(teamArg);
|
|
696
|
+
const teamPath = `/teams/${validation.decimal(team.id)}`;
|
|
697
|
+
// An includes-all team lists every repository it holds like any other team.
|
|
698
|
+
const members = await this.#list(`${teamPath}/members`, { signal: signalArg });
|
|
699
|
+
const repositories = await this.#list(`${teamPath}/repos`, { signal: signalArg });
|
|
700
|
+
memberships.push(listingMeasure(members));
|
|
701
|
+
grants.push(listingMeasure(repositories));
|
|
702
|
+
teamRecords.push(teamRecord(team, members.items, repositories.items));
|
|
525
703
|
}
|
|
526
704
|
const teamsMeasure = listingMeasure(teams);
|
|
527
705
|
const fromTeams = (measuresArg: TMeasure[]) => sumMeasures([...measuresArg, ...(teamsMeasure.status === 'complete' ? [] : [partial(0, teams.failure!)])]);
|
|
706
|
+
const teamMemberships = fromTeams(memberships);
|
|
707
|
+
const teamRepositoryGrants = fromTeams(grants);
|
|
528
708
|
return {
|
|
529
709
|
kind: 'organization',
|
|
530
710
|
...await this.#ownerBase(nameArg, organization, `${path}/repos`, signalArg),
|
|
@@ -533,10 +713,14 @@ export class GiteaSourceInventory {
|
|
|
533
713
|
runners: await this.#runners(`${path}/actions/runners`, { signal: signalArg }),
|
|
534
714
|
members: await this.#count(`${path}/members`, { signal: signalArg }),
|
|
535
715
|
teams: teamsMeasure,
|
|
536
|
-
teamMemberships
|
|
537
|
-
teamRepositoryGrants
|
|
716
|
+
teamMemberships,
|
|
717
|
+
teamRepositoryGrants,
|
|
538
718
|
labels: await this.#count(`${path}/labels`, { signal: signalArg }),
|
|
539
719
|
actionsSecrets: await this.#count(`${path}/actions/secrets`, { signal: signalArg }),
|
|
720
|
+
// Never partial: one unfinished listing makes the records null.
|
|
721
|
+
teamRecords: [teamsMeasure, teamMemberships, teamRepositoryGrants].every((measureArg) => measureArg.status === 'complete')
|
|
722
|
+
? teamRecords.sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id))
|
|
723
|
+
: null,
|
|
540
724
|
};
|
|
541
725
|
}
|
|
542
726
|
|
|
@@ -546,21 +730,28 @@ export class GiteaSourceInventory {
|
|
|
546
730
|
if (segment(user.login) !== nameArg) throw new validation.ForgeInventoryError('owner-identity-changed');
|
|
547
731
|
// User-scoped settings are only readable as that user, through the administrator's Sudo header.
|
|
548
732
|
const sudo = { signal: signalArg, sudo: nameArg };
|
|
733
|
+
const base = await this.#ownerBase(nameArg, user, `${path}/repos`, signalArg);
|
|
734
|
+
const flags = {
|
|
735
|
+
admin: requiredBoolean(user.is_admin),
|
|
736
|
+
active: requiredBoolean(user.active),
|
|
737
|
+
restricted: requiredBoolean(user.restricted),
|
|
738
|
+
prohibitLogin: requiredBoolean(user.prohibit_login),
|
|
739
|
+
};
|
|
740
|
+
// As an administrator this lists every key of the user.
|
|
741
|
+
const sshKeys = await this.#list(`${path}/keys`, { signal: signalArg });
|
|
742
|
+
const keyRecords = sshKeys.items.map(userSshKey).sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id));
|
|
549
743
|
return {
|
|
550
744
|
kind: 'user',
|
|
551
|
-
...
|
|
552
|
-
account: {
|
|
553
|
-
admin: requiredBoolean(user.is_admin),
|
|
554
|
-
active: requiredBoolean(user.active),
|
|
555
|
-
restricted: requiredBoolean(user.restricted),
|
|
556
|
-
prohibitLogin: requiredBoolean(user.prohibit_login),
|
|
557
|
-
externalAuth: validation.decimal(user.source_id) !== '0',
|
|
558
|
-
},
|
|
745
|
+
...base,
|
|
746
|
+
account: { ...flags, externalAuth: validation.decimal(user.source_id) !== '0' },
|
|
559
747
|
webhooks: await this.#count('/user/hooks', sudo),
|
|
560
748
|
actionsVariables: await this.#count('/user/actions/variables', sudo),
|
|
561
749
|
runners: await this.#runners('/user/actions/runners', sudo),
|
|
562
|
-
sshKeys:
|
|
750
|
+
sshKeys: listingMeasure(sshKeys),
|
|
563
751
|
gpgKeys: await this.#count(`${path}/gpg_keys`, { signal: signalArg }),
|
|
752
|
+
userRecord: sshKeys.failure === null
|
|
753
|
+
? { id: base.id, login: nameArg, active: flags.active, admin: flags.admin, restricted: flags.restricted, prohibitLogin: flags.prohibitLogin, visibility: base.visibility, sshKeys: keyRecords }
|
|
754
|
+
: null,
|
|
564
755
|
};
|
|
565
756
|
}
|
|
566
757
|
|
|
@@ -608,6 +799,164 @@ export class GiteaSourceInventory {
|
|
|
608
799
|
return counts;
|
|
609
800
|
}
|
|
610
801
|
|
|
802
|
+
/**
|
|
803
|
+
* One package as Gitea's v1 package API lists it: every version with its
|
|
804
|
+
* files, each distinct content once. Container index candidates, versions
|
|
805
|
+
* whose only file is `manifest.json`, are read by digest for their
|
|
806
|
+
* references; no other manifest is read. npm packages add the dist-tags Gitea
|
|
807
|
+
* serves (a derived `latest` included) and each version's deprecation,
|
|
808
|
+
* Cargo crates each version's yank from the sparse index. Every read is a
|
|
809
|
+
* GET; reading an index counts a download of its version, an accepted read
|
|
810
|
+
* stamp (S10 ruling, Addendum 1).
|
|
811
|
+
*/
|
|
812
|
+
async #package(refArg: IPackageRef, signalArg?: AbortSignal): Promise<IPackageRecord> {
|
|
813
|
+
const packagePath = `/packages/${encodeURIComponent(refArg.owner)}/${encodeURIComponent(refArg.type)}/${encodeURIComponent(refArg.name)}`;
|
|
814
|
+
const listing = await this.#list(packagePath, { signal: signalArg });
|
|
815
|
+
if (listing.failure !== null) throw new validation.ForgeInventoryError(listing.failure);
|
|
816
|
+
if (listing.items.length === 0) throw new validation.ForgeInventoryError('package-without-versions');
|
|
817
|
+
let ownerId: string | null = null;
|
|
818
|
+
let repositoryId: string | null | undefined;
|
|
819
|
+
const versions: IPackageVersion[] = [];
|
|
820
|
+
const ids = new Set<string>();
|
|
821
|
+
for (const itemArg of listing.items) {
|
|
822
|
+
const item = validation.record(itemArg);
|
|
823
|
+
const owner = validation.record(item.owner);
|
|
824
|
+
const repository = item.repository === null || item.repository === undefined ? null : validation.decimal(validation.record(item.repository).id);
|
|
825
|
+
if (segment(owner.login) !== refArg.owner || packageType(item.type) !== refArg.type || packageName(item.name) !== refArg.name) {
|
|
826
|
+
throw new validation.ForgeInventoryError('package-identity-changed');
|
|
827
|
+
}
|
|
828
|
+
// Every version names the package's owner and its one repository link.
|
|
829
|
+
if ((ownerId ?? validation.decimal(owner.id)) !== validation.decimal(owner.id) || (repositoryId !== undefined && repositoryId !== repository)) {
|
|
830
|
+
throw new validation.ForgeInventoryError('package-identity-changed');
|
|
831
|
+
}
|
|
832
|
+
ownerId = validation.decimal(owner.id);
|
|
833
|
+
repositoryId = repository;
|
|
834
|
+
const id = validation.decimal(item.id);
|
|
835
|
+
if (ids.has(id)) throw new validation.ForgeInventoryError('duplicate-id');
|
|
836
|
+
ids.add(id);
|
|
837
|
+
versions.push({ id, version: requiredString(item.version), createdAt: requiredTimestamp(item.created_at) });
|
|
838
|
+
}
|
|
839
|
+
versions.sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id));
|
|
840
|
+
const blobs = new Map<string, IPackageBlob>();
|
|
841
|
+
const filesByVersion = new Map<string, Array<{ name: string; sha256: string }>>();
|
|
842
|
+
for (const version of versions) {
|
|
843
|
+
const files = (await this.#get(`${packagePath}/${encodeURIComponent(version.version)}/files`, { signal: signalArg })).body;
|
|
844
|
+
if (!Array.isArray(files)) throw new validation.ForgeInventoryError('invalid-page');
|
|
845
|
+
const read: Array<{ name: string; sha256: string }> = [];
|
|
846
|
+
for (const fileArg of files) {
|
|
847
|
+
const file = validation.record(fileArg);
|
|
848
|
+
const sha256 = requiredString(file.sha256);
|
|
849
|
+
if (!/^[0-9a-f]{64}$/.test(sha256)) throw new validation.ForgeInventoryError('invalid-sha256');
|
|
850
|
+
const size = validation.decimal(file.size);
|
|
851
|
+
const blob = blobs.get(sha256) ?? { sha256, size, files: [] };
|
|
852
|
+
if (blob.size !== size) throw new validation.ForgeInventoryError('package-blob-size-conflict');
|
|
853
|
+
const name = requiredString(file.name);
|
|
854
|
+
blob.files.push({ versionId: version.id, id: validation.decimal(file.id), name });
|
|
855
|
+
blobs.set(sha256, blob);
|
|
856
|
+
read.push({ name, sha256 });
|
|
857
|
+
}
|
|
858
|
+
filesByVersion.set(version.id, read);
|
|
859
|
+
}
|
|
860
|
+
const record: IPackageRecord = {
|
|
861
|
+
owner: refArg.owner, ownerId: ownerId!, type: refArg.type, name: refArg.name, repositoryId: repositoryId ?? null, versions,
|
|
862
|
+
blobs: [...blobs.values()].sort((aArg, bArg) => compareText(aArg.sha256, bArg.sha256)).map((blobArg) => ({
|
|
863
|
+
...blobArg, files: blobArg.files.sort((aArg, bArg) => compareDecimal(aArg.versionId, bArg.versionId) || compareDecimal(aArg.id, bArg.id)),
|
|
864
|
+
})),
|
|
865
|
+
};
|
|
866
|
+
if (record.type === 'container') await this.#containerReferences(record, filesByVersion, signalArg);
|
|
867
|
+
if (record.type === 'npm') await this.#npmState(record, signalArg);
|
|
868
|
+
if (record.type === 'cargo') await this.#cargoYanks(record, signalArg);
|
|
869
|
+
return record;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Reads the references of every index candidate, a version whose file list
|
|
874
|
+
* is exactly `manifest.json` (Gitea stores an index's manifest and nothing
|
|
875
|
+
* else), by digest from the registry with the reader's Basic credentials. The
|
|
876
|
+
* bytes must hash to the listed SHA-256 and the response must be an index or
|
|
877
|
+
* a manifest list, else the unit fails with `unexpected-manifest-shape`.
|
|
878
|
+
*/
|
|
879
|
+
async #containerReferences(
|
|
880
|
+
recordArg: IPackageRecord, filesArg: ReadonlyMap<string, Array<{ name: string; sha256: string }>>, signalArg?: AbortSignal,
|
|
881
|
+
): Promise<void> {
|
|
882
|
+
if (!/^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*$/.test(recordArg.name)) {
|
|
883
|
+
throw new validation.ForgeInventoryError('invalid-image-name');
|
|
884
|
+
}
|
|
885
|
+
for (const version of recordArg.versions) {
|
|
886
|
+
const files = filesArg.get(version.id)!;
|
|
887
|
+
if (files.length !== 1 || files[0].name !== 'manifest.json') continue;
|
|
888
|
+
const digest = `sha256:${files[0].sha256}`;
|
|
889
|
+
const client = this.#basic(await this.#login(signalArg));
|
|
890
|
+
const response = await client.requestBytes(
|
|
891
|
+
new URL(`/v2/${recordArg.owner.toLowerCase()}/${recordArg.name}/manifests/${digest}`, this.#options.baseUrl),
|
|
892
|
+
{ accept: containerIndexMediaTypes.join(', '), signal: signalArg },
|
|
893
|
+
);
|
|
894
|
+
if (plugins.crypto.createHash('sha256').update(response.bytes).digest('hex') !== files[0].sha256
|
|
895
|
+
|| response.mediaType === null || !containerIndexMediaTypes.includes(response.mediaType)) {
|
|
896
|
+
throw new validation.ForgeInventoryError('unexpected-manifest-shape');
|
|
897
|
+
}
|
|
898
|
+
let entries: unknown;
|
|
899
|
+
try {
|
|
900
|
+
entries = validation.record(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(response.bytes)) as unknown).manifests;
|
|
901
|
+
} catch {
|
|
902
|
+
throw new validation.ForgeInventoryError('unexpected-manifest-shape');
|
|
903
|
+
}
|
|
904
|
+
if (!Array.isArray(entries)) throw new validation.ForgeInventoryError('unexpected-manifest-shape');
|
|
905
|
+
const references = entries.map((entryArg: unknown) => {
|
|
906
|
+
const entry = entryArg !== null && typeof entryArg === 'object' && !Array.isArray(entryArg) ? (entryArg as Record<string, unknown>).digest : undefined;
|
|
907
|
+
if (typeof entry !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(entry)) throw new validation.ForgeInventoryError('unexpected-manifest-shape');
|
|
908
|
+
return entry;
|
|
909
|
+
});
|
|
910
|
+
version.references = [...new Set(references)].sort(compareText);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** Every dist-tag Gitea serves, a `latest` it derives included, and each version's deprecation from the package document. */
|
|
915
|
+
async #npmState(recordArg: IPackageRecord, signalArg?: AbortSignal): Promise<void> {
|
|
916
|
+
const registry = `${this.#options.baseUrl}/api/packages/${encodeURIComponent(recordArg.owner)}/npm`;
|
|
917
|
+
const name = npmPackagePath(recordArg.name);
|
|
918
|
+
const tags = validation.record((await this.#client.request(new URL(`${registry}/-/package/${name}/dist-tags`), { signal: signalArg })).body);
|
|
919
|
+
recordArg.distTags = Object.entries(tags).map(([tag, version]) => ({ tag, version: requiredString(version) }))
|
|
920
|
+
.sort((aArg, bArg) => compareText(aArg.tag, bArg.tag));
|
|
921
|
+
const versions = validation.record(validation.record((await this.#client.request(new URL(`${registry}/${name}`), { signal: signalArg })).body).versions);
|
|
922
|
+
for (const version of recordArg.versions) {
|
|
923
|
+
const entry = versions[version.version];
|
|
924
|
+
if (entry === undefined) throw new validation.ForgeInventoryError('npm-version-missing');
|
|
925
|
+
const deprecated = validation.record(entry).deprecated;
|
|
926
|
+
version.deprecated = deprecated === undefined ? '' : text(deprecated);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** Each version's yank from the crate's line in the Cargo sparse index. */
|
|
931
|
+
async #cargoYanks(recordArg: IPackageRecord, signalArg?: AbortSignal): Promise<void> {
|
|
932
|
+
const response = await this.#client.requestBytes(
|
|
933
|
+
new URL(`${this.#options.baseUrl}/api/packages/${encodeURIComponent(recordArg.owner)}/cargo/${cargoIndexPath(recordArg.name)}`),
|
|
934
|
+
{ accept: 'text/plain', signal: signalArg },
|
|
935
|
+
);
|
|
936
|
+
if (response.mediaType !== 'text/plain') throw new validation.ForgeInventoryError('invalid-content-type');
|
|
937
|
+
const yanked = new Map<string, boolean>();
|
|
938
|
+
let lines: string[];
|
|
939
|
+
try {
|
|
940
|
+
lines = new TextDecoder('utf-8', { fatal: true }).decode(response.bytes).split('\n').filter((lineArg) => lineArg !== '');
|
|
941
|
+
} catch {
|
|
942
|
+
throw new validation.ForgeInventoryError('invalid-cargo-index');
|
|
943
|
+
}
|
|
944
|
+
for (const line of lines) {
|
|
945
|
+
let entry: Record<string, unknown>;
|
|
946
|
+
try {
|
|
947
|
+
entry = validation.record(JSON.parse(line) as unknown);
|
|
948
|
+
} catch {
|
|
949
|
+
throw new validation.ForgeInventoryError('invalid-cargo-index');
|
|
950
|
+
}
|
|
951
|
+
yanked.set(requiredString(entry.vers), requiredBoolean(entry.yanked));
|
|
952
|
+
}
|
|
953
|
+
for (const version of recordArg.versions) {
|
|
954
|
+
const value = yanked.get(version.version);
|
|
955
|
+
if (value === undefined) throw new validation.ForgeInventoryError('cargo-index-incomplete');
|
|
956
|
+
version.yanked = value;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
611
960
|
async #repository(ownerArg: string, nameArg: string, idArg: string | undefined, signalArg?: AbortSignal): Promise<IRepository> {
|
|
612
961
|
const path = `/repos/${encodeURIComponent(ownerArg)}/${encodeURIComponent(nameArg)}`;
|
|
613
962
|
const item = validation.record((await this.#get(path, { signal: signalArg })).body);
|
|
@@ -635,6 +984,8 @@ export class GiteaSourceInventory {
|
|
|
635
984
|
const options = { signal: signalArg };
|
|
636
985
|
const branchRules = await this.#unpaged(`${path}/branch_protections`, options);
|
|
637
986
|
const tagRules = await this.#unpaged(`${path}/tag_protections`, options);
|
|
987
|
+
const collaborators = await this.#collaborators(path, signalArg);
|
|
988
|
+
const deployKeys = await this.#deployKeys(path, signalArg);
|
|
638
989
|
return {
|
|
639
990
|
owner: ownerArg,
|
|
640
991
|
name: nameArg,
|
|
@@ -657,14 +1008,21 @@ export class GiteaSourceInventory {
|
|
|
657
1008
|
...await this.#releases(path, signalArg),
|
|
658
1009
|
wikiPages: features.wiki ? await this.#count(`${path}/wiki/pages`, { ...options, missingIsEmpty: true }) : unitDisabled('wiki'),
|
|
659
1010
|
access: {
|
|
660
|
-
collaborators:
|
|
1011
|
+
collaborators: collaborators.measure,
|
|
661
1012
|
teams: await this.#repositoryTeams(path, options),
|
|
662
|
-
deployKeys:
|
|
1013
|
+
deployKeys: deployKeys.keys,
|
|
1014
|
+
deployTokens: deployKeys.tokens,
|
|
663
1015
|
webhooks: await this.#count(`${path}/hooks`, options),
|
|
664
1016
|
branchProtections: branchRules.measure,
|
|
665
1017
|
tagProtections: tagRules.measure,
|
|
666
1018
|
pushMirrors: await this.#count(`${path}/push_mirrors`, options),
|
|
667
1019
|
},
|
|
1020
|
+
// Never partial: an unfinished listing or permission read makes the records null.
|
|
1021
|
+
accessRecords: collaborators.records === null || deployKeys.records === null ? null : {
|
|
1022
|
+
collaborators: collaborators.records,
|
|
1023
|
+
deployKeys: deployKeys.records.keys,
|
|
1024
|
+
deployTokens: deployKeys.records.tokens,
|
|
1025
|
+
},
|
|
668
1026
|
protections: branchRules.rows === null || tagRules.rows === null ? null : {
|
|
669
1027
|
branches: branchRules.rows.map(branchProtection).sort((aArg, bArg) => compareText(aArg.ruleName, bArg.ruleName)),
|
|
670
1028
|
tags: tagRules.rows.map(tagProtection).sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id)),
|
|
@@ -673,6 +1031,49 @@ export class GiteaSourceInventory {
|
|
|
673
1031
|
};
|
|
674
1032
|
}
|
|
675
1033
|
|
|
1034
|
+
/**
|
|
1035
|
+
* The repository's collaborators, each with one read of the effective
|
|
1036
|
+
* permission Gitea reports for them, never the collaboration's own mode,
|
|
1037
|
+
* which no REST endpoint reports. The count is complete only when every
|
|
1038
|
+
* permission was read; the first failed read ends the reads.
|
|
1039
|
+
*/
|
|
1040
|
+
async #collaborators(pathArg: string, signalArg?: AbortSignal): Promise<{ measure: TMeasure; records: ICollaborator[] | null }> {
|
|
1041
|
+
const listing = await this.#list(`${pathArg}/collaborators`, { signal: signalArg });
|
|
1042
|
+
const listed = listing.items.map(account);
|
|
1043
|
+
if (listing.failure !== null) return { measure: partial(listed.length, listing.failure), records: null };
|
|
1044
|
+
const records: ICollaborator[] = [];
|
|
1045
|
+
for (const collaborator of listed) {
|
|
1046
|
+
try {
|
|
1047
|
+
const read = validation.record((await this.#get(`${pathArg}/collaborators/${encodeURIComponent(collaborator.login)}/permission`, { signal: signalArg })).body);
|
|
1048
|
+
if (validation.decimal(validation.record(read.user).id) !== collaborator.id) throw new validation.ForgeInventoryError('collaborator-identity-changed');
|
|
1049
|
+
records.push({ userId: collaborator.id, login: collaborator.login, effectivePermission: accessMode(read.permission) });
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
return { measure: partial(listed.length, `collaborator-permission: ${reason(error)}`), records: null };
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return { measure: complete(listed.length), records: records.sort((aArg, bArg) => compareDecimal(aArg.userId, bArg.userId)) };
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** SSH deploy keys and deploy tokens, which Gitea 28 lists together, counted and recorded apart. */
|
|
1058
|
+
async #deployKeys(pathArg: string, signalArg?: AbortSignal): Promise<{
|
|
1059
|
+
keys: TMeasure; tokens: TMeasure; records: { keys: IDeployKey[]; tokens: IDeployToken[] } | null;
|
|
1060
|
+
}> {
|
|
1061
|
+
const listing = await this.#list(`${pathArg}/keys`, { signal: signalArg });
|
|
1062
|
+
const keys: IDeployKey[] = [];
|
|
1063
|
+
const tokens: IDeployToken[] = [];
|
|
1064
|
+
for (const entry of listing.items.map(deployKeyEntry)) {
|
|
1065
|
+
if (entry.kind === 'ssh') keys.push(entry.key); else tokens.push(entry.token);
|
|
1066
|
+
}
|
|
1067
|
+
return {
|
|
1068
|
+
keys: listingMeasure(listing, BigInt(keys.length)),
|
|
1069
|
+
tokens: listingMeasure(listing, BigInt(tokens.length)),
|
|
1070
|
+
records: listing.failure === null ? {
|
|
1071
|
+
keys: keys.sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id)),
|
|
1072
|
+
tokens: tokens.sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id)),
|
|
1073
|
+
} : null,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
676
1077
|
/** Gitea answers 405 for a repository that no organization owns, which therefore has no teams. */
|
|
677
1078
|
async #repositoryTeams(pathArg: string, optionsArg: IListOptions): Promise<TMeasure> {
|
|
678
1079
|
try {
|
|
@@ -693,7 +1094,7 @@ export class GiteaSourceInventory {
|
|
|
693
1094
|
baseUrl: this.#options.baseUrl, owner: ownerArg, name: nameArg, username, token: this.#options.token,
|
|
694
1095
|
caCertificates: this.#options.caCertificates, workDir: this.#options.gitWorkDir, reportedSizeKiB: sizeKiB,
|
|
695
1096
|
timeoutMs: this.#options.gitTimeoutMs,
|
|
696
|
-
lfsClient: this.#
|
|
1097
|
+
lfsClient: this.#basic(username), signal: signalArg,
|
|
697
1098
|
});
|
|
698
1099
|
return { git: { reportedSizeKiB: complete(sizeKiB), ...measured.git }, lfs: measured.lfs, head: measured.head, refDigests: measured.refDigests };
|
|
699
1100
|
}
|
|
@@ -709,8 +1110,8 @@ export class GiteaSourceInventory {
|
|
|
709
1110
|
return this.#readerLogin;
|
|
710
1111
|
}
|
|
711
1112
|
|
|
712
|
-
/** LFS routes accept the token as a Basic password, not as a `token` authorization. */
|
|
713
|
-
#
|
|
1113
|
+
/** LFS and registry (`/v2`) routes accept the token as a Basic password, not as a `token` authorization. */
|
|
1114
|
+
#basic(usernameArg: string): ForgeRequestClient {
|
|
714
1115
|
this.#lfsClient ??= new ForgeRequestClient({
|
|
715
1116
|
transport: this.#transport,
|
|
716
1117
|
headers: { authorization: `Basic ${Buffer.from(`${usernameArg}:${this.#options.token}`).toString('base64')}` },
|
|
@@ -949,6 +1350,9 @@ const indicator = (measureArg: TMeasure, predicateArg: (valueArg: bigint) => boo
|
|
|
949
1350
|
measureArg.status === 'complete' ? complete(predicateArg(BigInt(measureArg.value)) ? 1 : 0)
|
|
950
1351
|
: measureArg.status === 'partial' ? partial(predicateArg(BigInt(measureArg.observedValue)) ? 1 : 0, measureArg.reason) : measureArg;
|
|
951
1352
|
|
|
1353
|
+
/** A repository result from a generator that predates the deploy token count, whose `deployKeys` included the tokens. */
|
|
1354
|
+
const deployTokensPredated = notMeasured('Measured by a generator that predates the deploy token count.');
|
|
1355
|
+
|
|
952
1356
|
/** Every measure of a document with its dotted path. */
|
|
953
1357
|
const measuresOf = (valueArg: unknown, pathArg: string, outArg: Array<{ path: string; measure: TMeasure }>) => {
|
|
954
1358
|
if (!valueArg || typeof valueArg !== 'object') return;
|
|
@@ -972,6 +1376,10 @@ const measuresOf = (valueArg: unknown, pathArg: string, outArg: Array<{ path: st
|
|
|
972
1376
|
* repositories measured with one id, owner repository counts that disagree
|
|
973
1377
|
* with discovery, and every partial or unmeasured value make the inventory
|
|
974
1378
|
* incomplete, with reasons aggregated per subject kind, path and reason.
|
|
1379
|
+
* `packages` and `totals.packageContent` (`composeGiteaPackageContent`) are
|
|
1380
|
+
* present exactly when discovery lists packages, an empty list included; a
|
|
1381
|
+
* package without a record is left out, never truncated, and makes its
|
|
1382
|
+
* content measures partial.
|
|
975
1383
|
*/
|
|
976
1384
|
export const composeGiteaSourceInventory = (
|
|
977
1385
|
discoveryArg: IDiscovery,
|
|
@@ -979,12 +1387,7 @@ export const composeGiteaSourceInventory = (
|
|
|
979
1387
|
generatorsArg: plugins.fossInterfaces.data.IForgeSourceInventoryGenerator[],
|
|
980
1388
|
): IInventory => {
|
|
981
1389
|
const results = new Map<string, TUnitResult>();
|
|
982
|
-
for (const result of resultsArg)
|
|
983
|
-
const unit: TUnit = result.kind === 'failed' ? result.unit : result.kind === 'instance' ? { kind: 'instance' }
|
|
984
|
-
: result.kind === 'owner' ? { kind: 'owner', ownerKind: result.owner.kind, name: result.owner.name }
|
|
985
|
-
: { kind: 'repository', owner: result.repository.owner, name: result.repository.name };
|
|
986
|
-
results.set(unitKey(unit), result);
|
|
987
|
-
}
|
|
1390
|
+
for (const result of resultsArg) results.set(unitKey(resultUnit(result)), result);
|
|
988
1391
|
const reasons = new Map<string, number>();
|
|
989
1392
|
const addReason = (textArg: string) => reasons.set(textArg, (reasons.get(textArg) ?? 0) + 1);
|
|
990
1393
|
for (const text of discoveryArg.incompleteReasons) addReason(text);
|
|
@@ -1022,6 +1425,17 @@ export const composeGiteaSourceInventory = (
|
|
|
1022
1425
|
addReason(`repository: ${result?.kind === 'failed' ? `unit failed: ${result.reason}` : result?.kind === 'repository' ? 'unit failed: repository-identity-changed' : 'unit not measured'}`);
|
|
1023
1426
|
}
|
|
1024
1427
|
}
|
|
1428
|
+
// Packages only when discovery listed them; a package without a record leaves the class incomplete, never truncated.
|
|
1429
|
+
let packages: IPackageRecord[] | undefined;
|
|
1430
|
+
if (discoveryArg.packages !== undefined) {
|
|
1431
|
+
packages = [];
|
|
1432
|
+
for (const ref of discoveryArg.packages) {
|
|
1433
|
+
const result = results.get(unitKey({ kind: 'package', ...ref }));
|
|
1434
|
+
if (result?.kind === 'package') packages.push(result.package);
|
|
1435
|
+
else addReason(`package: ${result?.kind === 'failed' ? `unit failed: ${result.reason}` : 'unit not measured'}`);
|
|
1436
|
+
}
|
|
1437
|
+
packages.sort(comparePackages);
|
|
1438
|
+
}
|
|
1025
1439
|
// Without discovered ids, two names measured as one id show a rename between their units.
|
|
1026
1440
|
if (new Set(repositories.map((repositoryArg) => repositoryArg.id)).size !== repositories.length) addReason('repository: duplicate-id');
|
|
1027
1441
|
owners.sort((aArg, bArg) => compareText(aArg.kind, bArg.kind) || compareText(aArg.name, bArg.name));
|
|
@@ -1058,6 +1472,7 @@ export const composeGiteaSourceInventory = (
|
|
|
1058
1472
|
instance,
|
|
1059
1473
|
owners,
|
|
1060
1474
|
repositories,
|
|
1475
|
+
...(packages === undefined ? {} : { packages }),
|
|
1061
1476
|
totals: {
|
|
1062
1477
|
organizations: instance.organizations,
|
|
1063
1478
|
users: instance.users.total,
|
|
@@ -1079,8 +1494,9 @@ export const composeGiteaSourceInventory = (
|
|
|
1079
1494
|
['issues', 'pullRequests', 'comments', 'reviews', 'reviewComments', 'labels', 'milestones', 'reactions', 'attachments', 'attachmentBytes'], missingRepositories),
|
|
1080
1495
|
releases: sumGroup(repositories.map((repositoryArg) => repositoryArg.releases), ['releases', 'drafts', 'assets', 'assetBytes'], missingRepositories),
|
|
1081
1496
|
wikiPages: sumMeasures([...repositories.map((repositoryArg) => repositoryArg.wikiPages), ...missingRepositories]),
|
|
1082
|
-
access: sumGroup(repositories.map((repositoryArg) =>
|
|
1083
|
-
|
|
1497
|
+
access: sumGroup(repositories.map((repositoryArg): Required<IAccessCounts> => ({
|
|
1498
|
+
...repositoryArg.access, deployTokens: repositoryArg.access.deployTokens ?? deployTokensPredated,
|
|
1499
|
+
})), ['collaborators', 'teams', 'deployKeys', 'deployTokens', 'webhooks', 'branchProtections', 'tagProtections', 'pushMirrors'], missingRepositories),
|
|
1084
1500
|
actions: sumGroup(repositories.map((repositoryArg) => repositoryArg.actions),
|
|
1085
1501
|
['workflows', 'secrets', 'variables', 'runners', 'runs', 'artifacts', 'artifactBytes'], missingRepositories),
|
|
1086
1502
|
owners: {
|
|
@@ -1100,6 +1516,7 @@ export const composeGiteaSourceInventory = (
|
|
|
1100
1516
|
type,
|
|
1101
1517
|
...sumGroup(countsArg.map(({ type: _type, ...measures }) => measures), ['packages', 'versions', 'files', 'bytes'], missingOwners),
|
|
1102
1518
|
})),
|
|
1519
|
+
...(packages === undefined ? {} : { packageContent: composeGiteaPackageContent(discoveryArg.packages!, packages) }),
|
|
1103
1520
|
},
|
|
1104
1521
|
};
|
|
1105
1522
|
|
|
@@ -1110,7 +1527,11 @@ export const composeGiteaSourceInventory = (
|
|
|
1110
1527
|
const { name: _name, id: _id, ...measures } = owner;
|
|
1111
1528
|
measuresOf(measures, owner.kind, collected);
|
|
1112
1529
|
}
|
|
1113
|
-
for (const repository of repositories)
|
|
1530
|
+
for (const repository of repositories) {
|
|
1531
|
+
measuresOf(repository, 'repository', collected);
|
|
1532
|
+
// A stored result from a generator before deploy tokens counted them among its deploy keys.
|
|
1533
|
+
if (repository.access.deployTokens === undefined) collected.push({ path: 'repository.access.deployTokens', measure: deployTokensPredated });
|
|
1534
|
+
}
|
|
1114
1535
|
for (const { path, measure } of collected) {
|
|
1115
1536
|
if (measure.status !== 'complete') addReason(`${path}: ${measure.reason}`);
|
|
1116
1537
|
}
|