@foss.global/codefeed 5.3.0 → 5.5.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 +41 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.giteasourceinventory.d.ts +38 -16
- package/dist_ts/classes.giteasourceinventory.js +537 -79
- package/dist_ts/classes.giteasourcetransfer.js +8 -103
- package/dist_ts/forgeinventory.transfer.d.ts +24 -0
- package/dist_ts/forgeinventory.transfer.js +77 -1
- package/dist_ts/giteasourceinventory.packages.d.ts +49 -0
- package/dist_ts/giteasourceinventory.packages.js +180 -1
- package/dist_ts/giteasourcetransfer.documents.d.ts +19 -4
- package/dist_ts/giteasourcetransfer.documents.js +49 -5
- package/package.json +4 -4
- package/readme.md +28 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.giteasourceinventory.ts +598 -83
- package/ts/classes.giteasourcetransfer.ts +10 -104
- package/ts/forgeinventory.transfer.ts +88 -0
- package/ts/giteasourceinventory.packages.ts +168 -0
- package/ts/giteasourcetransfer.documents.ts +47 -4
|
@@ -3,9 +3,11 @@ import * as transport from './forgeinventory.transport.js';
|
|
|
3
3
|
import * as validation from './forgeinventory.validation.js';
|
|
4
4
|
import { ForgeRequestClient } from './forgeinventory.client.js';
|
|
5
5
|
import type { IForgeRequestMetrics, IForgeRequestOptions } from './forgeinventory.client.js';
|
|
6
|
+
import { verifiedPackageDownload } from './forgeinventory.transfer.js';
|
|
6
7
|
import { measureGiteaMirror } from './giteasourceinventory.git.js';
|
|
7
8
|
import {
|
|
8
9
|
cargoIndexPath, comparePackages, composeGiteaPackageContent, containerIndexMediaTypes, npmPackagePath, packageKey, packageTypePattern,
|
|
10
|
+
readSwiftManifest, swiftManifestCandidates, swiftPackageParts,
|
|
9
11
|
} from './giteasourceinventory.packages.js';
|
|
10
12
|
|
|
11
13
|
type TMeasure = plugins.fossInterfaces.data.TForgeSourceMeasure;
|
|
@@ -32,6 +34,17 @@ type IPackageRef = plugins.fossInterfaces.data.IForgeSourcePackageRef;
|
|
|
32
34
|
type IPackageRecord = plugins.fossInterfaces.data.IForgeSourcePackageInventory;
|
|
33
35
|
type IPackageVersion = plugins.fossInterfaces.data.IForgeSourcePackageVersion;
|
|
34
36
|
type IPackageBlob = plugins.fossInterfaces.data.IForgeSourcePackageBlob;
|
|
37
|
+
type ISwiftManifest = plugins.fossInterfaces.data.IForgeSourceSwiftManifest;
|
|
38
|
+
type ICollaborationCounts = plugins.fossInterfaces.data.IForgeSourceCollaborationCounts;
|
|
39
|
+
type ICollaborationKindCounts = plugins.fossInterfaces.data.IForgeSourceCollaborationKindCounts;
|
|
40
|
+
type ICollaboration = plugins.fossInterfaces.data.IForgeSourceCollaborationInventory;
|
|
41
|
+
type IIssue = plugins.fossInterfaces.data.IForgeSourceIssue;
|
|
42
|
+
type IIssueComment = plugins.fossInterfaces.data.IForgeSourceIssueComment;
|
|
43
|
+
type ILabel = plugins.fossInterfaces.data.IForgeSourceLabel;
|
|
44
|
+
type IMilestone = plugins.fossInterfaces.data.IForgeSourceMilestone;
|
|
45
|
+
type IAccountRef = plugins.fossInterfaces.data.IForgeSourceAccountRef;
|
|
46
|
+
type IOriginalAuthor = plugins.fossInterfaces.data.IForgeSourceOriginalAuthor;
|
|
47
|
+
type TCollaborationState = plugins.fossInterfaces.data.TForgeSourceCollaborationState;
|
|
35
48
|
|
|
36
49
|
export interface IGiteaSourceInventoryOptions {
|
|
37
50
|
/** Operator-assigned identity of the source instance. */
|
|
@@ -58,19 +71,32 @@ export interface IGiteaSourceInventoryOptions {
|
|
|
58
71
|
pageSize?: number;
|
|
59
72
|
/** Pages read per listing before the listing counts as partial. */
|
|
60
73
|
maxPages?: number;
|
|
74
|
+
/** Deadline of one REST, registry or LFS batch request, and the idle limit of a Swift archive download; defaults to 60 s, at most 300 s. */
|
|
61
75
|
requestTimeoutMs?: number;
|
|
62
76
|
maxResponseBytes?: number;
|
|
77
|
+
/**
|
|
78
|
+
* The largest Swift source archive, in bytes, that the inventory reads for
|
|
79
|
+
* the version's `manifests`; defaults to 128 MiB, at most 1 GiB. A version
|
|
80
|
+
* whose recorded archive is larger is not read and records `manifests:
|
|
81
|
+
* null`. A consumer passes the cap its own store puts on an archive.
|
|
82
|
+
*/
|
|
83
|
+
maxSwiftArchiveBytes?: number;
|
|
84
|
+
/** Wall-clock limit of one Swift archive download; defaults to 30 minutes, at most six hours. */
|
|
85
|
+
packageTransferTimeoutMs?: number;
|
|
63
86
|
}
|
|
64
87
|
|
|
65
88
|
/** A class of source data a run measures only when its discovery selects it. */
|
|
66
|
-
export type TGiteaSourceInventoryClass = 'packages';
|
|
89
|
+
export type TGiteaSourceInventoryClass = 'packages' | 'collaboration';
|
|
67
90
|
|
|
68
91
|
export interface IGiteaSourceDiscoveryOptions {
|
|
69
92
|
/**
|
|
70
93
|
* Classes beside the instance, owners and repositories to list for
|
|
71
94
|
* measurement. `packages` lists every package, so `units` measures one unit
|
|
72
95
|
* per package; without it, discovery carries no `packages` and the package
|
|
73
|
-
* class is unread.
|
|
96
|
+
* class is unread. `collaboration` sets discovery's `collaboration: true`,
|
|
97
|
+
* so `units` measures one collaboration unit per repository, and the
|
|
98
|
+
* composed inventory carries the records and every organization's
|
|
99
|
+
* `labelRecords`; without it, collaboration records are unread.
|
|
74
100
|
*/
|
|
75
101
|
classes?: readonly TGiteaSourceInventoryClass[];
|
|
76
102
|
}
|
|
@@ -88,6 +114,9 @@ export type TGiteaSourceRepositoryLookup =
|
|
|
88
114
|
| { status: 'present'; id: string; owner: string; name: string }
|
|
89
115
|
| { status: 'absent'; id: string };
|
|
90
116
|
|
|
117
|
+
/** A file of a version as its listing reports it: the name, and the content's SHA-256 and size as a canonical decimal. */
|
|
118
|
+
interface IVersionFile { name: string; sha256: string; size: string }
|
|
119
|
+
|
|
91
120
|
interface IListing {
|
|
92
121
|
items: unknown[];
|
|
93
122
|
/** A sanitized reason when the listing did not finish. */
|
|
@@ -115,7 +144,15 @@ export const giteaSourceInventoryLimitations: readonly string[] = [
|
|
|
115
144
|
const complete = (valueArg: bigint | number): TMeasure => ({ status: 'complete', value: BigInt(valueArg).toString() });
|
|
116
145
|
const partial = (observedArg: bigint | number, reasonArg: string): TMeasure => ({ status: 'partial', observedValue: BigInt(observedArg).toString(), reason: reasonArg });
|
|
117
146
|
const notMeasured = (reasonArg: string): TMeasure => ({ status: 'notMeasured', reason: reasonArg });
|
|
118
|
-
const
|
|
147
|
+
const unitDisabledReason = (unitArg: string): string => `The ${unitArg} unit is disabled; Gitea's API hides its data.`;
|
|
148
|
+
const unitDisabled = (unitArg: string): TMeasure => notMeasured(unitDisabledReason(unitArg));
|
|
149
|
+
/** Why Gitea's API hides issue state kept outside the issue, which makes a repository's issue records unread. */
|
|
150
|
+
const timeTrackingDisabled = 'Time tracking is disabled; Gitea\'s API hides tracked times.';
|
|
151
|
+
const timeTrackingDisabledInstanceWide = 'Time tracking is disabled instance-wide; Gitea\'s API hides tracked times.';
|
|
152
|
+
const dependenciesDisabled = 'Issue dependencies are disabled; Gitea\'s API hides them.';
|
|
153
|
+
/** Why an imported repository's issues are unread: the poster Gitea's API names is not the author (S8 U3 ruling, Q1). */
|
|
154
|
+
const importedIssues = 'Gitea imported this repository (original_url is set); its API names the importing account as poster and never reports the original author.';
|
|
155
|
+
const projectsOwnerOnly = 'Repository project boards are disabled (projects mode owner); Gitea\'s API hides them.';
|
|
119
156
|
|
|
120
157
|
/** The value a measure observed so far. */
|
|
121
158
|
const observed = (measureArg: TMeasure): bigint => measureArg.status === 'complete' ? BigInt(measureArg.value)
|
|
@@ -351,6 +388,172 @@ const tagProtection = (itemArg: unknown): ITagProtection => {
|
|
|
351
388
|
};
|
|
352
389
|
};
|
|
353
390
|
|
|
391
|
+
/** An RFC 3339 time the forge may report as `null`; a missing field still fails. */
|
|
392
|
+
const nullableTimestamp = (valueArg: unknown): string | null => valueArg === null ? null : requiredTimestamp(valueArg);
|
|
393
|
+
|
|
394
|
+
/** A list Gitea answers as `null` when it is empty. */
|
|
395
|
+
const listed = (valueArg: unknown): unknown[] => {
|
|
396
|
+
if (valueArg === null) return [];
|
|
397
|
+
if (!Array.isArray(valueArg)) throw new validation.ForgeInventoryError('invalid-page');
|
|
398
|
+
return valueArg;
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
/** Orders signed decimal strings, such as account ids with Gitea's `-1` placeholder, numerically. */
|
|
402
|
+
const compareSigned = (aArg: string, bArg: string): number => {
|
|
403
|
+
const a = BigInt(aArg);
|
|
404
|
+
const b = BigInt(bArg);
|
|
405
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
406
|
+
};
|
|
407
|
+
const byId = (aArg: { id: string }, bArg: { id: string }): number => compareDecimal(aArg.id, bArg.id);
|
|
408
|
+
|
|
409
|
+
const collaborationState = (valueArg: unknown): TCollaborationState => {
|
|
410
|
+
if (valueArg !== 'open' && valueArg !== 'closed') throw new validation.ForgeInventoryError('invalid-state');
|
|
411
|
+
return valueArg;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
/** An account as a collaboration record names it, verbatim; a deleted account reads as Gitea's placeholder, id -1. */
|
|
415
|
+
const accountRef = (itemArg: unknown): IAccountRef => {
|
|
416
|
+
const item = validation.record(itemArg);
|
|
417
|
+
return { id: accountId(item.id), login: requiredString(item.login) };
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Gitea's `original_author` and `original_author_id`: null when it reports
|
|
422
|
+
* none, an empty name with id 0. An id without a name fails.
|
|
423
|
+
*/
|
|
424
|
+
const originalAuthor = (nameArg: unknown, idArg: unknown): IOriginalAuthor | null => {
|
|
425
|
+
const login = text(nameArg);
|
|
426
|
+
const id = validation.decimal(idArg);
|
|
427
|
+
if (login !== '') return { id, login };
|
|
428
|
+
if (id !== '0') throw new validation.ForgeInventoryError('invalid-original-author');
|
|
429
|
+
return null;
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
/** A repository or organization label as Gitea reads it: name and description verbatim, colour without `#` as served. */
|
|
433
|
+
const labelRecord = (itemArg: unknown): ILabel => {
|
|
434
|
+
const item = validation.record(itemArg);
|
|
435
|
+
return {
|
|
436
|
+
id: validation.decimal(item.id),
|
|
437
|
+
name: text(item.name),
|
|
438
|
+
color: requiredString(item.color),
|
|
439
|
+
description: text(item.description),
|
|
440
|
+
exclusive: requiredBoolean(item.exclusive),
|
|
441
|
+
archived: requiredBoolean(item.is_archived),
|
|
442
|
+
};
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
/** A milestone as Gitea reads it; a time it reports as `null` stays null. */
|
|
446
|
+
const milestoneRecord = (itemArg: unknown): IMilestone => {
|
|
447
|
+
const item = validation.record(itemArg);
|
|
448
|
+
return {
|
|
449
|
+
id: validation.decimal(item.id),
|
|
450
|
+
title: text(item.title),
|
|
451
|
+
description: text(item.description),
|
|
452
|
+
state: collaborationState(item.state),
|
|
453
|
+
createdAt: requiredTimestamp(item.created_at),
|
|
454
|
+
updatedAt: nullableTimestamp(item.updated_at),
|
|
455
|
+
closedAt: nullableTimestamp(item.closed_at),
|
|
456
|
+
dueOn: nullableTimestamp(item.due_on),
|
|
457
|
+
};
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
/** An issue's conversation comment as Gitea reads it, with the number of its reactions. */
|
|
461
|
+
const commentRecord = (itemArg: Record<string, unknown>, reactionsArg: number): IIssueComment => ({
|
|
462
|
+
id: validation.decimal(itemArg.id),
|
|
463
|
+
body: text(itemArg.body),
|
|
464
|
+
createdAt: requiredTimestamp(itemArg.created_at),
|
|
465
|
+
updatedAt: requiredTimestamp(itemArg.updated_at),
|
|
466
|
+
author: accountRef(itemArg.user),
|
|
467
|
+
originalAuthor: originalAuthor(itemArg.original_author, itemArg.original_author_id),
|
|
468
|
+
attachments: assets(itemArg.assets).count.toString(),
|
|
469
|
+
reactions: String(reactionsArg),
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* An issue as Gitea's issue listing reads it, with what it keeps outside the
|
|
474
|
+
* issue: its comments, reactions, tracked times and dependencies. Title, body
|
|
475
|
+
* and ref stay verbatim; Gitea's `content_version` is deliberately not
|
|
476
|
+
* recorded (S8 U1 ruling 8).
|
|
477
|
+
*/
|
|
478
|
+
const issueRecord = (itemArg: Record<string, unknown>, readsArg: {
|
|
479
|
+
comments: IIssueComment[]; reactions: number; trackedTimes: number; dependencies: number;
|
|
480
|
+
}): IIssue => ({
|
|
481
|
+
id: validation.decimal(itemArg.id),
|
|
482
|
+
number: validation.decimal(itemArg.number),
|
|
483
|
+
title: text(itemArg.title),
|
|
484
|
+
body: text(itemArg.body),
|
|
485
|
+
state: collaborationState(itemArg.state),
|
|
486
|
+
createdAt: requiredTimestamp(itemArg.created_at),
|
|
487
|
+
updatedAt: requiredTimestamp(itemArg.updated_at),
|
|
488
|
+
closedAt: nullableTimestamp(itemArg.closed_at),
|
|
489
|
+
author: accountRef(itemArg.user),
|
|
490
|
+
originalAuthor: originalAuthor(itemArg.original_author, itemArg.original_author_id),
|
|
491
|
+
assignees: listed(itemArg.assignees).map(accountRef).sort((aArg, bArg) => compareSigned(aArg.id, bArg.id)),
|
|
492
|
+
labelIds: listed(itemArg.labels).map((labelArg) => validation.decimal(validation.record(labelArg).id)).sort(compareDecimal),
|
|
493
|
+
milestoneId: itemArg.milestone === null ? null : validation.decimal(validation.record(itemArg.milestone).id),
|
|
494
|
+
locked: requiredBoolean(itemArg.is_locked),
|
|
495
|
+
pinOrder: validation.decimal(itemArg.pin_order),
|
|
496
|
+
dueDate: nullableTimestamp(itemArg.due_date),
|
|
497
|
+
ref: text(itemArg.ref),
|
|
498
|
+
projectIds: listed(itemArg.projects).map((projectArg) => validation.decimal(validation.record(projectArg).id)).sort(compareDecimal),
|
|
499
|
+
timeEstimate: validation.decimal(itemArg.time_estimate),
|
|
500
|
+
trackedTimes: String(readsArg.trackedTimes),
|
|
501
|
+
dependencies: String(readsArg.dependencies),
|
|
502
|
+
attachments: assets(itemArg.assets).count.toString(),
|
|
503
|
+
reactions: String(readsArg.reactions),
|
|
504
|
+
comments: readsArg.comments.sort(byId),
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const isPullRequest = (itemArg: Record<string, unknown>): boolean => itemArg.pull_request !== undefined && itemArg.pull_request !== null;
|
|
508
|
+
|
|
509
|
+
/** What a repository's issue and comment listings hold of one kind, issues or pull requests. */
|
|
510
|
+
interface IKindTally {
|
|
511
|
+
items: bigint;
|
|
512
|
+
comments: bigint;
|
|
513
|
+
/** One measure per listed item and comment. */
|
|
514
|
+
reactions: TMeasure[];
|
|
515
|
+
attachments: bigint;
|
|
516
|
+
attachmentBytes: bigint;
|
|
517
|
+
add(assetsArg: { count: bigint; bytes: bigint }): void;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const kindTally = (): IKindTally => ({
|
|
521
|
+
items: 0n, comments: 0n, reactions: [], attachments: 0n, attachmentBytes: 0n,
|
|
522
|
+
add(assetsArg) {
|
|
523
|
+
this.attachments += assetsArg.count;
|
|
524
|
+
this.attachmentBytes += assetsArg.bytes;
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Which kinds a repository's issue listing holds. Gitea lists the issues and
|
|
530
|
+
* pull requests whose unit is readable, and the issue unit is readable only
|
|
531
|
+
* as Gitea's own tracker: with an external tracker `has_issues` stays true
|
|
532
|
+
* but no issue is listed. Each kind is null when listed, else the reason it is
|
|
533
|
+
* hidden; `tracker` holds the switches of Gitea's own tracker.
|
|
534
|
+
*/
|
|
535
|
+
interface ICollaborationUnits {
|
|
536
|
+
issues: string | null;
|
|
537
|
+
pullRequests: string | null;
|
|
538
|
+
tracker: { timeTracking: boolean; dependencies: boolean } | null;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const collaborationUnits = (itemArg: Record<string, unknown>): ICollaborationUnits => {
|
|
542
|
+
const hasIssues = requiredBoolean(itemArg.has_issues);
|
|
543
|
+
const internal = itemArg.internal_tracker === undefined || itemArg.internal_tracker === null ? null : validation.record(itemArg.internal_tracker);
|
|
544
|
+
const external = itemArg.external_tracker !== undefined && itemArg.external_tracker !== null;
|
|
545
|
+
if (hasIssues !== (internal !== null || external) || (internal !== null && external)) throw new validation.ForgeInventoryError('inconsistent-issue-unit');
|
|
546
|
+
return {
|
|
547
|
+
issues: !hasIssues ? unitDisabledReason('issues')
|
|
548
|
+
: internal === null ? 'The issues unit is an external tracker; Gitea\'s API lists none of the repository\'s issues.' : null,
|
|
549
|
+
pullRequests: requiredBoolean(itemArg.has_pull_requests) ? null : unitDisabledReason('pull requests'),
|
|
550
|
+
tracker: internal === null ? null : {
|
|
551
|
+
timeTracking: requiredBoolean(internal.enable_time_tracker),
|
|
552
|
+
dependencies: requiredBoolean(internal.enable_issue_dependencies),
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
};
|
|
556
|
+
|
|
354
557
|
/** A `{ <rows>, total_count }` envelope whose rows must match its total. */
|
|
355
558
|
const countedEnvelope = (bodyArg: unknown, rowsKeyArg: string): { rows: unknown[]; failure: string | null } => {
|
|
356
559
|
const body = validation.record(bodyArg);
|
|
@@ -367,6 +570,7 @@ const resultUnit = (resultArg: TUnitResult): TUnit => {
|
|
|
367
570
|
case 'owner': return { kind: 'owner', ownerKind: resultArg.owner.kind, name: resultArg.owner.name };
|
|
368
571
|
case 'repository': return { kind: 'repository', owner: resultArg.repository.owner, name: resultArg.repository.name };
|
|
369
572
|
case 'package': return { kind: 'package', owner: resultArg.package.owner, type: resultArg.package.type, name: resultArg.package.name };
|
|
573
|
+
case 'collaboration': return { kind: 'collaboration', owner: resultArg.collaboration.owner, name: resultArg.collaboration.name, id: resultArg.collaboration.repositoryId };
|
|
370
574
|
default: return unknownUnit(resultArg);
|
|
371
575
|
}
|
|
372
576
|
};
|
|
@@ -383,10 +587,18 @@ const unitKey = (unitArg: TUnit): string => {
|
|
|
383
587
|
case 'owner': return `owner:${unitArg.ownerKind}:${unitArg.name}`;
|
|
384
588
|
case 'repository': return `repository:${unitArg.owner}/${unitArg.name}`;
|
|
385
589
|
case 'package': return `package:${packageKey(unitArg)}`;
|
|
590
|
+
// Keyed by the discovered repository id, never by name (S8 ruling D12).
|
|
591
|
+
case 'collaboration': return `collaboration:${unitArg.id}`;
|
|
386
592
|
default: return unknownUnit(unitArg);
|
|
387
593
|
}
|
|
388
594
|
};
|
|
389
595
|
|
|
596
|
+
/** A result over `forgeSourceInventoryUnitMaxBytes` of UTF-8 JSON is a failed unit, never a truncated record. */
|
|
597
|
+
const bounded = (unitArg: TUnit, resultArg: TUnitResult): TUnitResult =>
|
|
598
|
+
Buffer.byteLength(JSON.stringify(resultArg), 'utf8') > plugins.fossInterfaces.data.forgeSourceInventoryUnitMaxBytes
|
|
599
|
+
? { kind: 'failed', unit: unitArg, reason: 'unit-over-bound' }
|
|
600
|
+
: resultArg;
|
|
601
|
+
|
|
390
602
|
/** A package type from a listing. */
|
|
391
603
|
const packageType = (valueArg: unknown): string => {
|
|
392
604
|
const type = requiredString(valueArg);
|
|
@@ -415,11 +627,13 @@ const compareText = (aArg: string, bArg: string): number => aArg < bArg ? -1 : a
|
|
|
415
627
|
export class GiteaSourceInventory {
|
|
416
628
|
readonly #options: {
|
|
417
629
|
instanceId: string; baseUrl: string; pageSize: number; maxPages: number; requestTimeoutMs: number; maxResponseBytes: number;
|
|
418
|
-
token: string; caCertificates?: string[]; gitWorkDir: string; gitTimeoutMs: number;
|
|
630
|
+
token: string; caCertificates?: string[]; gitWorkDir: string; gitTimeoutMs: number; maxSwiftArchiveBytes: number; packageTransferTimeoutMs: number;
|
|
419
631
|
};
|
|
420
632
|
readonly #transport: transport.TForgeTransport;
|
|
421
633
|
readonly #client: ForgeRequestClient;
|
|
422
634
|
#lfsClient: ForgeRequestClient | null = null;
|
|
635
|
+
/** The Swift archive GETs, which go through `verifiedPackageDownload` instead of a request client. */
|
|
636
|
+
readonly #archiveMetrics: IForgeRequestMetrics = { requests: 0, responseBytes: 0, rateLimitedRequests: 0 };
|
|
423
637
|
#readerLogin: Promise<string> | null = null;
|
|
424
638
|
|
|
425
639
|
constructor(optionsArg: IGiteaSourceInventoryOptions) {
|
|
@@ -435,9 +649,12 @@ export class GiteaSourceInventory {
|
|
|
435
649
|
requestTimeoutMs: optionsArg.requestTimeoutMs ?? 60_000,
|
|
436
650
|
maxResponseBytes: optionsArg.maxResponseBytes ?? 16 * 1024 * 1024,
|
|
437
651
|
gitTimeoutMs: optionsArg.gitTimeoutMs ?? 30 * 60 * 1000,
|
|
652
|
+
maxSwiftArchiveBytes: optionsArg.maxSwiftArchiveBytes ?? 128 * 1024 * 1024,
|
|
653
|
+
packageTransferTimeoutMs: optionsArg.packageTransferTimeoutMs ?? 30 * 60 * 1000,
|
|
438
654
|
};
|
|
439
655
|
for (const [name, maximum] of [
|
|
440
656
|
['pageSize', 100], ['maxPages', 1_000_000], ['requestTimeoutMs', 300_000], ['maxResponseBytes', 64 * 1024 * 1024], ['gitTimeoutMs', 6 * 60 * 60 * 1000],
|
|
657
|
+
['maxSwiftArchiveBytes', 1024 * 1024 * 1024], ['packageTransferTimeoutMs', 6 * 60 * 60 * 1000],
|
|
441
658
|
] as const) {
|
|
442
659
|
const value = limits[name];
|
|
443
660
|
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) throw new validation.ForgeInventoryError('invalid-limits');
|
|
@@ -459,28 +676,32 @@ export class GiteaSourceInventory {
|
|
|
459
676
|
/** Validated, normalized instance root; contains no credentials or API suffix. */
|
|
460
677
|
public get baseUrl(): string { return this.#options.baseUrl; }
|
|
461
678
|
|
|
462
|
-
/** REST and LFS batch requests together. */
|
|
679
|
+
/** REST, registry and LFS batch requests and Swift archive GETs together. */
|
|
463
680
|
public get metrics(): IForgeRequestMetrics {
|
|
464
681
|
const rest = this.#client.metrics;
|
|
465
682
|
const lfs = this.#lfsClient?.metrics ?? { requests: 0, responseBytes: 0, rateLimitedRequests: 0 };
|
|
683
|
+
const archives = this.#archiveMetrics;
|
|
466
684
|
return {
|
|
467
|
-
requests: rest.requests + lfs.requests,
|
|
468
|
-
responseBytes: rest.responseBytes + lfs.responseBytes,
|
|
469
|
-
rateLimitedRequests: rest.rateLimitedRequests + lfs.rateLimitedRequests,
|
|
685
|
+
requests: rest.requests + lfs.requests + archives.requests,
|
|
686
|
+
responseBytes: rest.responseBytes + lfs.responseBytes + archives.responseBytes,
|
|
687
|
+
rateLimitedRequests: rest.rateLimitedRequests + lfs.rateLimitedRequests + archives.rateLimitedRequests,
|
|
470
688
|
};
|
|
471
689
|
}
|
|
472
690
|
|
|
473
691
|
/**
|
|
474
692
|
* Identifies the source and its reader and lists every owner and repository
|
|
475
|
-
* with its id,
|
|
476
|
-
* administrator package listing (`packages`, absent otherwise)
|
|
477
|
-
*
|
|
478
|
-
*
|
|
479
|
-
*
|
|
693
|
+
* with its id, with `classes: ['packages']` every package from the
|
|
694
|
+
* administrator package listing (`packages`, absent otherwise), and with
|
|
695
|
+
* `classes: ['collaboration']` sets `collaboration: true`. Rejects unless
|
|
696
|
+
* the token belongs to a site administrator, and rejects an unknown class
|
|
697
|
+
* with `invalid-options`; a listing that does not finish, or that lists a
|
|
698
|
+
* repository id twice, is reported in `incompleteReasons`.
|
|
480
699
|
*/
|
|
481
700
|
public async discover(signalArg?: AbortSignal, optionsArg: IGiteaSourceDiscoveryOptions = {}): Promise<IDiscovery> {
|
|
482
701
|
const classes = optionsArg.classes ?? [];
|
|
483
|
-
if (!Array.isArray(classes) || classes.some((classArg) => classArg !== 'packages'
|
|
702
|
+
if (!Array.isArray(classes) || classes.some((classArg) => classArg !== 'packages' && classArg !== 'collaboration')) {
|
|
703
|
+
throw new validation.ForgeInventoryError('invalid-options');
|
|
704
|
+
}
|
|
484
705
|
const reader = validation.record((await this.#get('/user', { signal: signalArg })).body);
|
|
485
706
|
if (reader.is_admin !== true) throw new validation.ForgeInventoryError('reader-not-site-admin');
|
|
486
707
|
const version = validation.record((await this.#get('/version', { signal: signalArg })).body).version;
|
|
@@ -536,6 +757,7 @@ export class GiteaSourceInventory {
|
|
|
536
757
|
owners,
|
|
537
758
|
repositories: discovered,
|
|
538
759
|
...(packages === undefined ? {} : { packages }),
|
|
760
|
+
...(classes.includes('collaboration') ? { collaboration: true as const } : {}),
|
|
539
761
|
incompleteReasons: incompleteReasons.sort(compareText),
|
|
540
762
|
};
|
|
541
763
|
}
|
|
@@ -543,7 +765,10 @@ export class GiteaSourceInventory {
|
|
|
543
765
|
/**
|
|
544
766
|
* Every unit a run over this discovery measures: the instance, then owners,
|
|
545
767
|
* then repositories, then the packages discovery lists when it selected
|
|
546
|
-
* them
|
|
768
|
+
* them, then, when it selected collaboration, one collaboration unit per
|
|
769
|
+
* repository, keyed by the repository's id. A discovery that selects
|
|
770
|
+
* collaboration while a repository has no id rejects with
|
|
771
|
+
* `repository-id-missing`; it never falls back to keying by name.
|
|
547
772
|
*/
|
|
548
773
|
public units(discoveryArg: IDiscovery): TUnit[] {
|
|
549
774
|
return [
|
|
@@ -553,6 +778,10 @@ export class GiteaSourceInventory {
|
|
|
553
778
|
kind: 'repository', owner: repositoryArg.owner, name: repositoryArg.name, ...repositoryArg.id === undefined ? {} : { id: repositoryArg.id },
|
|
554
779
|
})),
|
|
555
780
|
...(discoveryArg.packages ?? []).map((packageArg): TUnit => ({ kind: 'package', owner: packageArg.owner, type: packageArg.type, name: packageArg.name })),
|
|
781
|
+
...(discoveryArg.collaboration === true ? discoveryArg.repositories : []).map((repositoryArg): TUnit => {
|
|
782
|
+
if (repositoryArg.id === undefined) throw new validation.ForgeInventoryError('repository-id-missing');
|
|
783
|
+
return { kind: 'collaboration', owner: repositoryArg.owner, name: repositoryArg.name, id: repositoryArg.id };
|
|
784
|
+
}),
|
|
556
785
|
];
|
|
557
786
|
}
|
|
558
787
|
|
|
@@ -579,12 +808,13 @@ export class GiteaSourceInventory {
|
|
|
579
808
|
|
|
580
809
|
/**
|
|
581
810
|
* Measures one unit. Never rejects: a unit whose primary record cannot be
|
|
582
|
-
* read is `failed`, and so is a repository unit carrying an
|
|
583
|
-
* no longer answers with (`repository-identity-changed`).
|
|
584
|
-
* read whole or
|
|
585
|
-
* index that is not what its file list
|
|
586
|
-
* and a result over
|
|
587
|
-
* (`unit-over-bound`) fail
|
|
811
|
+
* read is `failed`, and so is a repository or collaboration unit carrying an
|
|
812
|
+
* id that its name no longer answers with (`repository-identity-changed`).
|
|
813
|
+
* Package and collaboration units are read whole or fail, never partly: a
|
|
814
|
+
* listing that does not finish, an index that is not what its file list
|
|
815
|
+
* promises (`unexpected-manifest-shape`) and a result over
|
|
816
|
+
* `forgeSourceInventoryUnitMaxBytes` of UTF-8 JSON (`unit-over-bound`) fail
|
|
817
|
+
* it, and its records are never truncated.
|
|
588
818
|
*/
|
|
589
819
|
public async measureUnit(unitArg: TUnit, signalArg?: AbortSignal): Promise<TUnitResult> {
|
|
590
820
|
try {
|
|
@@ -595,13 +825,10 @@ export class GiteaSourceInventory {
|
|
|
595
825
|
return { kind: 'owner', owner: unitArg.ownerKind === 'organization' ? await this.#organization(unitArg.name, signalArg) : await this.#user(unitArg.name, signalArg) };
|
|
596
826
|
case 'repository':
|
|
597
827
|
return { kind: 'repository', repository: await this.#repository(unitArg.owner, unitArg.name, unitArg.id, signalArg) };
|
|
598
|
-
case 'package':
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
}
|
|
603
|
-
return result;
|
|
604
|
-
}
|
|
828
|
+
case 'package':
|
|
829
|
+
return bounded(unitArg, { kind: 'package', package: await this.#package(unitArg, signalArg) });
|
|
830
|
+
case 'collaboration':
|
|
831
|
+
return bounded(unitArg, { kind: 'collaboration', collaboration: await this.#collaborationRecords(unitArg, signalArg) });
|
|
605
832
|
default:
|
|
606
833
|
return unknownUnit(unitArg);
|
|
607
834
|
}
|
|
@@ -673,13 +900,16 @@ export class GiteaSourceInventory {
|
|
|
673
900
|
};
|
|
674
901
|
}
|
|
675
902
|
|
|
676
|
-
|
|
903
|
+
/** Fields every owner has; `pathArg` is the owner's `/orgs/{org}` or `/users/{user}` route, which lists its repositories and project boards. */
|
|
904
|
+
async #ownerBase(nameArg: string, recordArg: Record<string, unknown>, pathArg: string, signalArg?: AbortSignal) {
|
|
677
905
|
return {
|
|
678
906
|
name: nameArg,
|
|
679
907
|
id: validation.decimal(recordArg.id),
|
|
680
908
|
visibility: ownerVisibility(recordArg.visibility),
|
|
681
|
-
repositories: await this.#count(
|
|
909
|
+
repositories: await this.#count(`${pathArg}/repos`, { signal: signalArg }),
|
|
682
910
|
packages: await this.#packages(nameArg, signalArg),
|
|
911
|
+
// Open and closed boards; a site administrator reads those of private owners too.
|
|
912
|
+
projects: await this.#count(`${pathArg}/projects`, { signal: signalArg, query: { state: 'all' } }),
|
|
683
913
|
};
|
|
684
914
|
}
|
|
685
915
|
|
|
@@ -687,6 +917,7 @@ export class GiteaSourceInventory {
|
|
|
687
917
|
const path = `/orgs/${encodeURIComponent(nameArg)}`;
|
|
688
918
|
const organization = validation.record((await this.#get(path, { signal: signalArg })).body);
|
|
689
919
|
if (segment(organization.name) !== nameArg) throw new validation.ForgeInventoryError('owner-identity-changed');
|
|
920
|
+
const labels = await this.#list(`${path}/labels`, { signal: signalArg });
|
|
690
921
|
const teams = await this.#list(`${path}/teams`, { signal: signalArg });
|
|
691
922
|
const memberships: TMeasure[] = [];
|
|
692
923
|
const grants: TMeasure[] = [];
|
|
@@ -707,7 +938,7 @@ export class GiteaSourceInventory {
|
|
|
707
938
|
const teamRepositoryGrants = fromTeams(grants);
|
|
708
939
|
return {
|
|
709
940
|
kind: 'organization',
|
|
710
|
-
...await this.#ownerBase(nameArg, organization,
|
|
941
|
+
...await this.#ownerBase(nameArg, organization, path, signalArg),
|
|
711
942
|
webhooks: await this.#count(`${path}/hooks`, { signal: signalArg }),
|
|
712
943
|
actionsVariables: await this.#count(`${path}/actions/variables`, { signal: signalArg }),
|
|
713
944
|
runners: await this.#runners(`${path}/actions/runners`, { signal: signalArg }),
|
|
@@ -715,12 +946,14 @@ export class GiteaSourceInventory {
|
|
|
715
946
|
teams: teamsMeasure,
|
|
716
947
|
teamMemberships,
|
|
717
948
|
teamRepositoryGrants,
|
|
718
|
-
labels:
|
|
949
|
+
labels: listingMeasure(labels),
|
|
719
950
|
actionsSecrets: await this.#count(`${path}/actions/secrets`, { signal: signalArg }),
|
|
720
951
|
// Never partial: one unfinished listing makes the records null.
|
|
721
952
|
teamRecords: [teamsMeasure, teamMemberships, teamRepositoryGrants].every((measureArg) => measureArg.status === 'complete')
|
|
722
953
|
? teamRecords.sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id))
|
|
723
954
|
: null,
|
|
955
|
+
// Recorded by every owner unit; the composed inventory keeps them exactly when discovery selects collaboration.
|
|
956
|
+
labelRecords: labels.failure === null ? labels.items.map(labelRecord).sort(byId) : null,
|
|
724
957
|
};
|
|
725
958
|
}
|
|
726
959
|
|
|
@@ -730,7 +963,7 @@ export class GiteaSourceInventory {
|
|
|
730
963
|
if (segment(user.login) !== nameArg) throw new validation.ForgeInventoryError('owner-identity-changed');
|
|
731
964
|
// User-scoped settings are only readable as that user, through the administrator's Sudo header.
|
|
732
965
|
const sudo = { signal: signalArg, sudo: nameArg };
|
|
733
|
-
const base = await this.#ownerBase(nameArg, user,
|
|
966
|
+
const base = await this.#ownerBase(nameArg, user, path, signalArg);
|
|
734
967
|
const flags = {
|
|
735
968
|
admin: requiredBoolean(user.is_admin),
|
|
736
969
|
active: requiredBoolean(user.active),
|
|
@@ -805,9 +1038,11 @@ export class GiteaSourceInventory {
|
|
|
805
1038
|
* whose only file is `manifest.json`, are read by digest for their
|
|
806
1039
|
* references; no other manifest is read. npm packages add the dist-tags Gitea
|
|
807
1040
|
* serves (a derived `latest` included) and each version's deprecation,
|
|
808
|
-
* Cargo crates each version's yank from the sparse index
|
|
809
|
-
*
|
|
810
|
-
*
|
|
1041
|
+
* Cargo crates each version's yank from the sparse index, Swift packages
|
|
1042
|
+
* each version's manifests, read from its archive's file names and Gitea's
|
|
1043
|
+
* manifest route. Every read is a GET; reading an index or a Swift archive
|
|
1044
|
+
* counts a download of its version, an accepted read stamp (S10 ruling,
|
|
1045
|
+
* Addendum 1; S10 C5 ruling, Addendum 2).
|
|
811
1046
|
*/
|
|
812
1047
|
async #package(refArg: IPackageRef, signalArg?: AbortSignal): Promise<IPackageRecord> {
|
|
813
1048
|
const packagePath = `/packages/${encodeURIComponent(refArg.owner)}/${encodeURIComponent(refArg.type)}/${encodeURIComponent(refArg.name)}`;
|
|
@@ -838,11 +1073,11 @@ export class GiteaSourceInventory {
|
|
|
838
1073
|
}
|
|
839
1074
|
versions.sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id));
|
|
840
1075
|
const blobs = new Map<string, IPackageBlob>();
|
|
841
|
-
const filesByVersion = new Map<string,
|
|
1076
|
+
const filesByVersion = new Map<string, IVersionFile[]>();
|
|
842
1077
|
for (const version of versions) {
|
|
843
1078
|
const files = (await this.#get(`${packagePath}/${encodeURIComponent(version.version)}/files`, { signal: signalArg })).body;
|
|
844
1079
|
if (!Array.isArray(files)) throw new validation.ForgeInventoryError('invalid-page');
|
|
845
|
-
const read:
|
|
1080
|
+
const read: IVersionFile[] = [];
|
|
846
1081
|
for (const fileArg of files) {
|
|
847
1082
|
const file = validation.record(fileArg);
|
|
848
1083
|
const sha256 = requiredString(file.sha256);
|
|
@@ -853,7 +1088,7 @@ export class GiteaSourceInventory {
|
|
|
853
1088
|
const name = requiredString(file.name);
|
|
854
1089
|
blob.files.push({ versionId: version.id, id: validation.decimal(file.id), name });
|
|
855
1090
|
blobs.set(sha256, blob);
|
|
856
|
-
read.push({ name, sha256 });
|
|
1091
|
+
read.push({ name, sha256, size });
|
|
857
1092
|
}
|
|
858
1093
|
filesByVersion.set(version.id, read);
|
|
859
1094
|
}
|
|
@@ -866,6 +1101,7 @@ export class GiteaSourceInventory {
|
|
|
866
1101
|
if (record.type === 'container') await this.#containerReferences(record, filesByVersion, signalArg);
|
|
867
1102
|
if (record.type === 'npm') await this.#npmState(record, signalArg);
|
|
868
1103
|
if (record.type === 'cargo') await this.#cargoYanks(record, signalArg);
|
|
1104
|
+
if (record.type === 'swift') await this.#swiftManifests(record, filesByVersion, signalArg);
|
|
869
1105
|
return record;
|
|
870
1106
|
}
|
|
871
1107
|
|
|
@@ -876,9 +1112,7 @@ export class GiteaSourceInventory {
|
|
|
876
1112
|
* bytes must hash to the listed SHA-256 and the response must be an index or
|
|
877
1113
|
* a manifest list, else the unit fails with `unexpected-manifest-shape`.
|
|
878
1114
|
*/
|
|
879
|
-
async #containerReferences(
|
|
880
|
-
recordArg: IPackageRecord, filesArg: ReadonlyMap<string, Array<{ name: string; sha256: string }>>, signalArg?: AbortSignal,
|
|
881
|
-
): Promise<void> {
|
|
1115
|
+
async #containerReferences(recordArg: IPackageRecord, filesArg: ReadonlyMap<string, IVersionFile[]>, signalArg?: AbortSignal): Promise<void> {
|
|
882
1116
|
if (!/^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*$/.test(recordArg.name)) {
|
|
883
1117
|
throw new validation.ForgeInventoryError('invalid-image-name');
|
|
884
1118
|
}
|
|
@@ -911,6 +1145,69 @@ export class GiteaSourceInventory {
|
|
|
911
1145
|
}
|
|
912
1146
|
}
|
|
913
1147
|
|
|
1148
|
+
/**
|
|
1149
|
+
* Each Swift version's `manifests`. Gitea stores one file per version, its
|
|
1150
|
+
* source archive, and anything else fails the unit with
|
|
1151
|
+
* `swift-version-files-unexpected`. An archive over `maxSwiftArchiveBytes`
|
|
1152
|
+
* by its recorded size is not read and records `null`. Otherwise the
|
|
1153
|
+
* archive is read once and verified against its recorded size and SHA-256,
|
|
1154
|
+
* its central directory gives the candidate keys (`swiftManifestCandidates`),
|
|
1155
|
+
* and the manifest route is asked for each: a 200 is recorded with the size
|
|
1156
|
+
* and SHA-256 of the bytes, the 303 to the release's `Package.swift` means
|
|
1157
|
+
* Gitea holds no such manifest, and `''` must answer 200. A failed read
|
|
1158
|
+
* fails the unit; it never records `null`.
|
|
1159
|
+
*/
|
|
1160
|
+
async #swiftManifests(recordArg: IPackageRecord, filesArg: ReadonlyMap<string, IVersionFile[]>, signalArg?: AbortSignal): Promise<void> {
|
|
1161
|
+
const parts = swiftPackageParts(recordArg.name);
|
|
1162
|
+
for (const version of recordArg.versions) {
|
|
1163
|
+
const files = filesArg.get(version.id)!;
|
|
1164
|
+
if (files.length !== 1) throw new validation.ForgeInventoryError('swift-version-files-unexpected');
|
|
1165
|
+
// The native store's comparison: an archive of exactly the bound is held.
|
|
1166
|
+
if (BigInt(files[0].size) > BigInt(this.#options.maxSwiftArchiveBytes)) {
|
|
1167
|
+
version.manifests = null;
|
|
1168
|
+
continue;
|
|
1169
|
+
}
|
|
1170
|
+
const keys = swiftManifestCandidates(await this.#swiftArchive(recordArg, parts, version, files[0], signalArg));
|
|
1171
|
+
const manifests: ISwiftManifest[] = [];
|
|
1172
|
+
for (const key of keys) {
|
|
1173
|
+
const manifest = await readSwiftManifest(this.#client, this.#options.baseUrl, recordArg.owner, parts, version.version, key === '' ? null : key, signalArg);
|
|
1174
|
+
if (manifest === null) continue;
|
|
1175
|
+
manifests.push({ key, size: manifest.bytes.byteLength, sha256: plugins.crypto.createHash('sha256').update(manifest.bytes).digest('hex') });
|
|
1176
|
+
}
|
|
1177
|
+
version.manifests = manifests;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* A Swift version's source archive, read once with the reader's `token`
|
|
1183
|
+
* authorization through `verifiedPackageDownload` (exact size, SHA-256
|
|
1184
|
+
* before the last chunk) into one buffer of the recorded size. The read
|
|
1185
|
+
* counts a download of the version, an accepted read stamp (S10 ruling
|
|
1186
|
+
* item 8; S10 C5 ruling, Addendum 2).
|
|
1187
|
+
*/
|
|
1188
|
+
async #swiftArchive(
|
|
1189
|
+
recordArg: IPackageRecord, partsArg: { scope: string; name: string }, versionArg: IPackageVersion, fileArg: IVersionFile, signalArg?: AbortSignal,
|
|
1190
|
+
): Promise<Buffer> {
|
|
1191
|
+
const url = new URL(`${this.#options.baseUrl}/api/packages/${encodeURIComponent(recordArg.owner)}/swift/${encodeURIComponent(partsArg.scope)}/${
|
|
1192
|
+
encodeURIComponent(partsArg.name)}/${encodeURIComponent(versionArg.version)}.zip`);
|
|
1193
|
+
const bytes = Number(fileArg.size);
|
|
1194
|
+
const stream = await verifiedPackageDownload(
|
|
1195
|
+
{
|
|
1196
|
+
caCertificates: this.#options.caCertificates, packageTransferTimeoutMs: this.#options.packageTransferTimeoutMs,
|
|
1197
|
+
requestTimeoutMs: this.#options.requestTimeoutMs, metrics: this.#archiveMetrics,
|
|
1198
|
+
},
|
|
1199
|
+
url, { authorization: `token ${this.#options.token}`, accept: 'application/vnd.swift.registry.v1+zip' },
|
|
1200
|
+
{ sha256: fileArg.sha256, size: fileArg.size, bytes }, signalArg, null,
|
|
1201
|
+
);
|
|
1202
|
+
const archive = Buffer.alloc(bytes);
|
|
1203
|
+
let offset = 0;
|
|
1204
|
+
for await (const chunk of stream as AsyncIterable<Uint8Array>) {
|
|
1205
|
+
archive.set(chunk, offset);
|
|
1206
|
+
offset += chunk.byteLength;
|
|
1207
|
+
}
|
|
1208
|
+
return archive;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
914
1211
|
/** Every dist-tag Gitea serves, a `latest` it derives included, and each version's deprecation from the package document. */
|
|
915
1212
|
async #npmState(recordArg: IPackageRecord, signalArg?: AbortSignal): Promise<void> {
|
|
916
1213
|
const registry = `${this.#options.baseUrl}/api/packages/${encodeURIComponent(recordArg.owner)}/npm`;
|
|
@@ -981,6 +1278,12 @@ export class GiteaSourceInventory {
|
|
|
981
1278
|
const fork = requiredBoolean(item.fork);
|
|
982
1279
|
const parent = item.parent === undefined || item.parent === null ? null : requiredString(validation.record(item.parent).full_name);
|
|
983
1280
|
if (fork !== (parent !== null)) throw new validation.ForgeInventoryError('inconsistent-fork-parent');
|
|
1281
|
+
const units = collaborationUnits(item);
|
|
1282
|
+
const projectsMode = requiredString(item.projects_mode);
|
|
1283
|
+
if (!['repo', 'owner', 'all'].includes(projectsMode)) throw new validation.ForgeInventoryError('invalid-projects-mode');
|
|
1284
|
+
// Gitea 28.2.0 serves a repository's boards while its projects unit is disabled (it then reports `projects_mode` `all`), and hides
|
|
1285
|
+
// them only while the unit allows owner boards alone.
|
|
1286
|
+
const projectsHidden = projectsMode === 'owner' ? projectsOwnerOnly : null;
|
|
984
1287
|
const options = { signal: signalArg };
|
|
985
1288
|
const branchRules = await this.#unpaged(`${path}/branch_protections`, options);
|
|
986
1289
|
const tagRules = await this.#unpaged(`${path}/tag_protections`, options);
|
|
@@ -1003,7 +1306,7 @@ export class GiteaSourceInventory {
|
|
|
1003
1306
|
description: text(item.description),
|
|
1004
1307
|
features,
|
|
1005
1308
|
...await this.#git(ownerArg, nameArg, item, signalArg),
|
|
1006
|
-
collaboration: await this.#collaboration(path,
|
|
1309
|
+
collaboration: await this.#collaboration(path, units, projectsHidden, archived, signalArg),
|
|
1007
1310
|
// Releases stay readable while their unit is disabled; a disabled wiki answers 404.
|
|
1008
1311
|
...await this.#releases(path, signalArg),
|
|
1009
1312
|
wikiPages: features.wiki ? await this.#count(`${path}/wiki/pages`, { ...options, missingIsEmpty: true }) : unitDisabled('wiki'),
|
|
@@ -1121,64 +1424,202 @@ export class GiteaSourceInventory {
|
|
|
1121
1424
|
return this.#lfsClient;
|
|
1122
1425
|
}
|
|
1123
1426
|
|
|
1124
|
-
|
|
1427
|
+
/**
|
|
1428
|
+
* A repository's issue and pull request measures, read apart (S8 ruling,
|
|
1429
|
+
* G2). Gitea's issue listing and repository comment listing leave out the
|
|
1430
|
+
* kind whose unit is not readable (`ListIssues`, `ListRepoIssueComments` of
|
|
1431
|
+
* Gitea 28.2.0) and answer 404 only when neither is, so a disabled pull
|
|
1432
|
+
* request unit leaves every issue measure measured, and the reverse. A
|
|
1433
|
+
* comment's kind is the one of its two URL fields Gitea fills; neither URL
|
|
1434
|
+
* is kept. Each mixed measure is the sum of its two kinds, so it is not
|
|
1435
|
+
* measured while either kind is hidden.
|
|
1436
|
+
*/
|
|
1437
|
+
async #collaboration(
|
|
1438
|
+
pathArg: string, unitsArg: ICollaborationUnits, projectsHiddenArg: string | null, archivedArg: boolean, signalArg?: AbortSignal,
|
|
1439
|
+
): Promise<ICollaborationCounts> {
|
|
1125
1440
|
const options = { signal: signalArg };
|
|
1126
|
-
// Labels and milestones stay readable while the issue units are disabled.
|
|
1441
|
+
// Labels and milestones stay readable while the issue and pull request units are disabled.
|
|
1127
1442
|
const labels = await this.#count(`${pathArg}/labels`, options);
|
|
1128
1443
|
const milestones = await this.#count(`${pathArg}/milestones`, { ...options, query: { state: 'all' } });
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
const hidden = unitDisabled(featuresArg.issues ? 'pull requests' : 'issues');
|
|
1132
|
-
return {
|
|
1133
|
-
issues: hidden, pullRequests: hidden, comments: hidden, reviews: hidden, reviewComments: hidden,
|
|
1134
|
-
labels, milestones, reactions: hidden, attachments: hidden, attachmentBytes: hidden,
|
|
1135
|
-
};
|
|
1136
|
-
}
|
|
1444
|
+
const projects = projectsHiddenArg === null ? await this.#count(`${pathArg}/projects`, { ...options, query: { state: 'all' } }) : notMeasured(projectsHiddenArg);
|
|
1445
|
+
const listed = unitsArg.issues === null || unitsArg.pullRequests === null;
|
|
1137
1446
|
// Issues and pull requests share one listing and one number sequence.
|
|
1138
|
-
const
|
|
1139
|
-
const unfinished =
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
let attachments = { count: 0n, bytes: 0n };
|
|
1143
|
-
const reactions: TMeasure[] = [...unfinished];
|
|
1447
|
+
const items: IListing = listed ? await this.#list(`${pathArg}/issues`, { ...options, query: { state: 'all' } }) : { items: [], failure: null };
|
|
1448
|
+
const unfinished = items.failure === null ? [] : [partial(0, items.failure)];
|
|
1449
|
+
const tallies = { issue: kindTally(), pull: kindTally() };
|
|
1450
|
+
const states = { open: 0n, closed: 0n, merged: 0n };
|
|
1144
1451
|
const reviews: TMeasure[] = [...unfinished];
|
|
1145
1452
|
const reviewComments: TMeasure[] = [...unfinished];
|
|
1146
|
-
for (const itemArg of
|
|
1453
|
+
for (const itemArg of items.items) {
|
|
1147
1454
|
const item = validation.record(itemArg);
|
|
1148
1455
|
const number = validation.decimal(item.number);
|
|
1149
|
-
const
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
reactions.push(await this.#count(`${pathArg}/issues/${number}/reactions`, { ...options, nullIsEmpty: true }));
|
|
1154
|
-
if (
|
|
1456
|
+
const pull = isPullRequest(item);
|
|
1457
|
+
const tally = pull ? tallies.pull : tallies.issue;
|
|
1458
|
+
tally.items++;
|
|
1459
|
+
tally.add(assets(item.assets));
|
|
1460
|
+
tally.reactions.push(await this.#count(`${pathArg}/issues/${number}/reactions`, { ...options, nullIsEmpty: true }));
|
|
1461
|
+
if (pull) {
|
|
1462
|
+
const state = collaborationState(item.state);
|
|
1463
|
+
// A merged pull request is closed.
|
|
1464
|
+
if (requiredBoolean(validation.record(item.pull_request).merged)) {
|
|
1465
|
+
if (state === 'open') throw new validation.ForgeInventoryError('inconsistent-pull-request-state');
|
|
1466
|
+
states.merged++;
|
|
1467
|
+
} else {
|
|
1468
|
+
states[state]++;
|
|
1469
|
+
}
|
|
1155
1470
|
const pullReviews = await this.#pullList(`${pathArg}/pulls/${number}/reviews`, archivedArg, options);
|
|
1156
1471
|
reviews.push(listingMeasure(pullReviews));
|
|
1157
1472
|
const inline = pullReviews.items.reduce((sumArg: bigint, reviewArg: unknown) => sumArg + BigInt(validation.decimal(validation.record(reviewArg).comments_count)), 0n);
|
|
1158
1473
|
reviewComments.push(listingMeasure(pullReviews, inline));
|
|
1159
1474
|
}
|
|
1160
1475
|
}
|
|
1161
|
-
const comments = await this.#list(`${pathArg}/issues/comments`, options);
|
|
1476
|
+
const comments: IListing = listed ? await this.#list(`${pathArg}/issues/comments`, options) : { items: [], failure: null };
|
|
1162
1477
|
const commentUnfinished = comments.failure === null ? [] : [partial(0, comments.failure)];
|
|
1163
1478
|
for (const commentArg of comments.items) {
|
|
1164
1479
|
const comment = validation.record(commentArg);
|
|
1165
|
-
const
|
|
1166
|
-
|
|
1167
|
-
|
|
1480
|
+
const onIssue = text(comment.issue_url) !== '';
|
|
1481
|
+
if (onIssue === (text(comment.pull_request_url) !== '')) throw new validation.ForgeInventoryError('invalid-comment-kind');
|
|
1482
|
+
const tally = onIssue ? tallies.issue : tallies.pull;
|
|
1483
|
+
tally.comments++;
|
|
1484
|
+
tally.add(assets(comment.assets));
|
|
1485
|
+
tally.reactions.push(await this.#unpagedCount(`${pathArg}/issues/comments/${validation.decimal(comment.id)}/reactions`, { ...options, nullIsEmpty: true }));
|
|
1168
1486
|
}
|
|
1169
|
-
const
|
|
1487
|
+
const measures = (hiddenArg: string | null, tallyArg: IKindTally) => {
|
|
1488
|
+
if (hiddenArg !== null) {
|
|
1489
|
+
const hidden = notMeasured(hiddenArg);
|
|
1490
|
+
return { items: hidden, comments: hidden, reactions: hidden, attachments: hidden, attachmentBytes: hidden };
|
|
1491
|
+
}
|
|
1492
|
+
return {
|
|
1493
|
+
items: listingMeasure(items, tallyArg.items),
|
|
1494
|
+
comments: listingMeasure(comments, tallyArg.comments),
|
|
1495
|
+
reactions: sumMeasures([...unfinished, ...tallyArg.reactions, ...commentUnfinished]),
|
|
1496
|
+
attachments: sumMeasures([complete(tallyArg.attachments), ...unfinished, ...commentUnfinished]),
|
|
1497
|
+
attachmentBytes: sumMeasures([complete(tallyArg.attachmentBytes), ...unfinished, ...commentUnfinished]),
|
|
1498
|
+
};
|
|
1499
|
+
};
|
|
1500
|
+
const issues = measures(unitsArg.issues, tallies.issue);
|
|
1501
|
+
const pulls = measures(unitsArg.pullRequests, tallies.pull);
|
|
1502
|
+
const pullOnly = (measureArg: () => TMeasure): TMeasure => unitsArg.pullRequests === null ? measureArg() : notMeasured(unitsArg.pullRequests);
|
|
1170
1503
|
return {
|
|
1171
|
-
issues:
|
|
1172
|
-
pullRequests:
|
|
1173
|
-
comments:
|
|
1174
|
-
reviews: sumMeasures(reviews),
|
|
1175
|
-
reviewComments: sumMeasures(reviewComments),
|
|
1504
|
+
issues: issues.items,
|
|
1505
|
+
pullRequests: pulls.items,
|
|
1506
|
+
comments: sumMeasures([issues.comments, pulls.comments]),
|
|
1507
|
+
reviews: pullOnly(() => sumMeasures(reviews)),
|
|
1508
|
+
reviewComments: pullOnly(() => sumMeasures(reviewComments)),
|
|
1176
1509
|
labels,
|
|
1177
1510
|
milestones,
|
|
1178
|
-
reactions: sumMeasures([
|
|
1179
|
-
attachments: sumMeasures([
|
|
1180
|
-
attachmentBytes: sumMeasures([
|
|
1511
|
+
reactions: sumMeasures([issues.reactions, pulls.reactions]),
|
|
1512
|
+
attachments: sumMeasures([issues.attachments, pulls.attachments]),
|
|
1513
|
+
attachmentBytes: sumMeasures([issues.attachmentBytes, pulls.attachmentBytes]),
|
|
1514
|
+
byKind: {
|
|
1515
|
+
issueComments: issues.comments,
|
|
1516
|
+
pullRequestComments: pulls.comments,
|
|
1517
|
+
issueReactions: issues.reactions,
|
|
1518
|
+
pullRequestReactions: pulls.reactions,
|
|
1519
|
+
issueAttachments: issues.attachments,
|
|
1520
|
+
issueAttachmentBytes: issues.attachmentBytes,
|
|
1521
|
+
pullRequestAttachments: pulls.attachments,
|
|
1522
|
+
pullRequestAttachmentBytes: pulls.attachmentBytes,
|
|
1523
|
+
openPullRequests: pullOnly(() => listingMeasure(items, states.open)),
|
|
1524
|
+
closedPullRequests: pullOnly(() => listingMeasure(items, states.closed)),
|
|
1525
|
+
mergedPullRequests: pullOnly(() => listingMeasure(items, states.merged)),
|
|
1526
|
+
},
|
|
1527
|
+
projects,
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* One repository's labels, milestones and issues with their comments, the
|
|
1533
|
+
* collaboration unit (S8 ruling D12, U3), all read with GETs as the
|
|
1534
|
+
* administrator. The repository must still answer its name with the
|
|
1535
|
+
* discovered id, else `repository-identity-changed`. Labels and milestones
|
|
1536
|
+
* stay readable while the issue and pull request units are disabled. One
|
|
1537
|
+
* issue listing gives `highestNumber` over both kinds Gitea lists; it is not
|
|
1538
|
+
* measured when it lists neither. The issue records are `notMeasured`,
|
|
1539
|
+
* without any read of them, when the issue unit is not readable, or when
|
|
1540
|
+
* Gitea's API would refuse the tracked times or dependencies of every issue
|
|
1541
|
+
* (time tracking off for the repository or the instance, dependencies off),
|
|
1542
|
+
* since a refused read must never count as none (S8 U1 ruling, Conditions).
|
|
1543
|
+
* They are also `notMeasured`, without any read of an issue, when Gitea
|
|
1544
|
+
* imported the repository (`original_url` set) and the listing holds an
|
|
1545
|
+
* issue: Gitea 28.2.0's API names the importing account as every imported
|
|
1546
|
+
* issue's and comment's poster and never reports the original author, so
|
|
1547
|
+
* recording them would credit the wrong account (S8 U3 ruling, Q1). An
|
|
1548
|
+
* imported repository without issues stays a complete empty list. Any
|
|
1549
|
+
* failed read fails the unit; a resumed run measures it again.
|
|
1550
|
+
*/
|
|
1551
|
+
async #collaborationRecords(unitArg: { owner: string; name: string; id: string }, signalArg?: AbortSignal): Promise<ICollaboration> {
|
|
1552
|
+
const path = `/repos/${encodeURIComponent(unitArg.owner)}/${encodeURIComponent(unitArg.name)}`;
|
|
1553
|
+
const options = { signal: signalArg };
|
|
1554
|
+
const item = validation.record((await this.#get(path, options)).body);
|
|
1555
|
+
if (segment(validation.record(item.owner).login) !== unitArg.owner || segment(item.name) !== unitArg.name || validation.decimal(item.id) !== unitArg.id) {
|
|
1556
|
+
throw new validation.ForgeInventoryError('repository-identity-changed');
|
|
1557
|
+
}
|
|
1558
|
+
// Gitea sanitizes it, but it is still never recorded: only whether it is set decides.
|
|
1559
|
+
const imported = text(item.original_url) !== '';
|
|
1560
|
+
const units = collaborationUnits(item);
|
|
1561
|
+
const labels = (await this.#rows(`${path}/labels`, options)).map(labelRecord).sort(byId);
|
|
1562
|
+
const milestones = (await this.#rows(`${path}/milestones`, { ...options, query: { state: 'all' } })).map(milestoneRecord).sort(byId);
|
|
1563
|
+
const unit = {
|
|
1564
|
+
owner: unitArg.owner, name: unitArg.name, repositoryId: unitArg.id,
|
|
1565
|
+
labels: { status: 'complete' as const, records: labels }, milestones: { status: 'complete' as const, records: milestones },
|
|
1181
1566
|
};
|
|
1567
|
+
if (units.issues !== null && units.pullRequests !== null) {
|
|
1568
|
+
return {
|
|
1569
|
+
...unit,
|
|
1570
|
+
highestNumber: notMeasured('The issues and pull requests units are disabled; Gitea\'s API lists neither.'),
|
|
1571
|
+
issues: { status: 'notMeasured', reason: units.issues },
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
// Issues and pull requests share one listing and one number sequence; a number listed twice means the listing shifted.
|
|
1575
|
+
const items = (await this.#rows(`${path}/issues`, { ...options, query: { state: 'all' } })).map(validation.record);
|
|
1576
|
+
const numbers = new Set(items.map((itemArg) => validation.decimal(itemArg.number)));
|
|
1577
|
+
if (numbers.size !== items.length) throw new validation.ForgeInventoryError('duplicate-number');
|
|
1578
|
+
const highestNumber = complete([...numbers].reduce((maximumArg, numberArg) => BigInt(numberArg) > maximumArg ? BigInt(numberArg) : maximumArg, 0n));
|
|
1579
|
+
const hidden = units.issues
|
|
1580
|
+
?? (imported && items.some((itemArg) => !isPullRequest(itemArg)) ? importedIssues : null)
|
|
1581
|
+
?? await this.#hiddenIssueState(units.tracker!, options);
|
|
1582
|
+
if (hidden !== null) return { ...unit, highestNumber, issues: { status: 'notMeasured', reason: hidden } };
|
|
1583
|
+
const milestoneIds = new Set(milestones.map((milestoneArg) => milestoneArg.id));
|
|
1584
|
+
const issues: IIssue[] = [];
|
|
1585
|
+
for (const issueItem of items) {
|
|
1586
|
+
if (isPullRequest(issueItem)) continue;
|
|
1587
|
+
const issue = await this.#issue(path, issueItem, options);
|
|
1588
|
+
if (issue.milestoneId !== null && !milestoneIds.has(issue.milestoneId)) throw new validation.ForgeInventoryError('issue-milestone-unlisted');
|
|
1589
|
+
issues.push(issue);
|
|
1590
|
+
}
|
|
1591
|
+
return { ...unit, highestNumber, issues: { status: 'complete', records: issues.sort((aArg, bArg) => compareDecimal(aArg.number, bArg.number)) } };
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/**
|
|
1595
|
+
* Why Gitea's API would refuse every issue's tracked times or dependencies,
|
|
1596
|
+
* null when it serves both: its `/issues/{index}/times` answers 404 while
|
|
1597
|
+
* time tracking is off for the repository or the instance, and
|
|
1598
|
+
* `/issues/{index}/dependencies` while dependencies are off.
|
|
1599
|
+
*/
|
|
1600
|
+
async #hiddenIssueState(trackerArg: { timeTracking: boolean; dependencies: boolean }, optionsArg: IListOptions): Promise<string | null> {
|
|
1601
|
+
if (!trackerArg.dependencies) return dependenciesDisabled;
|
|
1602
|
+
if (!trackerArg.timeTracking) return timeTrackingDisabled;
|
|
1603
|
+
const settings = validation.record((await this.#get('/settings/repository', optionsArg)).body);
|
|
1604
|
+
return requiredBoolean(settings.time_tracking_disabled) ? timeTrackingDisabledInstanceWide : null;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/** One issue with its comments, each comment's reactions, and its reactions, tracked times and dependencies. */
|
|
1608
|
+
async #issue(pathArg: string, itemArg: Record<string, unknown>, optionsArg: IListOptions): Promise<IIssue> {
|
|
1609
|
+
const issuePath = `${pathArg}/issues/${validation.decimal(itemArg.number)}`;
|
|
1610
|
+
const comments: IIssueComment[] = [];
|
|
1611
|
+
// Gitea answers an issue's comments and a comment's reactions whole, ignoring pages.
|
|
1612
|
+
for (const commentArg of await this.#wholeRows(`${issuePath}/comments`, optionsArg)) {
|
|
1613
|
+
const comment = validation.record(commentArg);
|
|
1614
|
+
const reactions = await this.#wholeRows(`${pathArg}/issues/comments/${validation.decimal(comment.id)}/reactions`, { ...optionsArg, nullIsEmpty: true });
|
|
1615
|
+
comments.push(commentRecord(comment, reactions.length));
|
|
1616
|
+
}
|
|
1617
|
+
return issueRecord(itemArg, {
|
|
1618
|
+
comments,
|
|
1619
|
+
reactions: (await this.#rows(`${issuePath}/reactions`, { ...optionsArg, nullIsEmpty: true })).length,
|
|
1620
|
+
trackedTimes: (await this.#rows(`${issuePath}/times`, optionsArg)).length,
|
|
1621
|
+
dependencies: (await this.#rows(`${issuePath}/dependencies`, optionsArg)).length,
|
|
1622
|
+
});
|
|
1182
1623
|
}
|
|
1183
1624
|
|
|
1184
1625
|
/**
|
|
@@ -1289,6 +1730,23 @@ export class GiteaSourceInventory {
|
|
|
1289
1730
|
return listingMeasure(await this.#list(pathArg, optionsArg));
|
|
1290
1731
|
}
|
|
1291
1732
|
|
|
1733
|
+
/** Every row of a paged listing; a listing that does not finish rejects with its reason, so records are never partial. */
|
|
1734
|
+
async #rows(pathArg: string, optionsArg: IListOptions): Promise<unknown[]> {
|
|
1735
|
+
const listing = await this.#list(pathArg, optionsArg);
|
|
1736
|
+
if (listing.failure !== null) throw new validation.ForgeInventoryError(listing.failure);
|
|
1737
|
+
return listing.items;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/** A listing Gitea answers whole on one request, ignoring pages; its rows must match the `X-Total-Count` Gitea sends with it. */
|
|
1741
|
+
async #wholeRows(pathArg: string, optionsArg: IListOptions): Promise<unknown[]> {
|
|
1742
|
+
const response = await this.#get(pathArg, optionsArg);
|
|
1743
|
+
const rows = response.body === null && optionsArg.nullIsEmpty ? [] : response.body;
|
|
1744
|
+
if (!Array.isArray(rows)) throw new validation.ForgeInventoryError('invalid-page');
|
|
1745
|
+
const total = response.headers.get('x-total-count');
|
|
1746
|
+
if (total !== null && validation.decimal(total) !== String(rows.length)) throw new validation.ForgeInventoryError('count-mismatch');
|
|
1747
|
+
return rows;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1292
1750
|
/**
|
|
1293
1751
|
* Reads every page of a listing. It ends at an empty page or once the rows
|
|
1294
1752
|
* reach `X-Total-Count`, and is partial when the total disagrees with the
|
|
@@ -1352,6 +1810,15 @@ const indicator = (measureArg: TMeasure, predicateArg: (valueArg: bigint) => boo
|
|
|
1352
1810
|
|
|
1353
1811
|
/** A repository result from a generator that predates the deploy token count, whose `deployKeys` included the tokens. */
|
|
1354
1812
|
const deployTokensPredated = notMeasured('Measured by a generator that predates the deploy token count.');
|
|
1813
|
+
/** A repository or owner result from a generator that predates the project board count. */
|
|
1814
|
+
const projectsPredated = notMeasured('Measured by a generator that predates the project board count.');
|
|
1815
|
+
/** A repository result from a generator that predates the collaboration measures by kind. */
|
|
1816
|
+
const kindPredated = notMeasured('Measured by a generator that predates the collaboration measures by kind.');
|
|
1817
|
+
const kindsPredated: ICollaborationKindCounts = {
|
|
1818
|
+
issueComments: kindPredated, pullRequestComments: kindPredated, issueReactions: kindPredated, pullRequestReactions: kindPredated,
|
|
1819
|
+
issueAttachments: kindPredated, issueAttachmentBytes: kindPredated, pullRequestAttachments: kindPredated, pullRequestAttachmentBytes: kindPredated,
|
|
1820
|
+
openPullRequests: kindPredated, closedPullRequests: kindPredated, mergedPullRequests: kindPredated,
|
|
1821
|
+
};
|
|
1355
1822
|
|
|
1356
1823
|
/** Every measure of a document with its dotted path. */
|
|
1357
1824
|
const measuresOf = (valueArg: unknown, pathArg: string, outArg: Array<{ path: string; measure: TMeasure }>) => {
|
|
@@ -1379,7 +1846,11 @@ const measuresOf = (valueArg: unknown, pathArg: string, outArg: Array<{ path: st
|
|
|
1379
1846
|
* `packages` and `totals.packageContent` (`composeGiteaPackageContent`) are
|
|
1380
1847
|
* present exactly when discovery lists packages, an empty list included; a
|
|
1381
1848
|
* package without a record is left out, never truncated, and makes its
|
|
1382
|
-
* content measures partial.
|
|
1849
|
+
* content measures partial. `collaboration` and every organization's
|
|
1850
|
+
* `labelRecords` are present exactly when discovery selects collaboration: a
|
|
1851
|
+
* repository without a collaboration result for its discovered id is left
|
|
1852
|
+
* out and named in the reasons, and a record list that is not measured is a
|
|
1853
|
+
* reason by its path, such as `collaboration.issues`.
|
|
1383
1854
|
*/
|
|
1384
1855
|
export const composeGiteaSourceInventory = (
|
|
1385
1856
|
discoveryArg: IDiscovery,
|
|
@@ -1404,11 +1875,21 @@ export const composeGiteaSourceInventory = (
|
|
|
1404
1875
|
};
|
|
1405
1876
|
}
|
|
1406
1877
|
|
|
1878
|
+
const collaborationSelected = discoveryArg.collaboration === true;
|
|
1879
|
+
/** An organization keeps its label records exactly when discovery selects collaboration. */
|
|
1880
|
+
const ownerOfRun = (ownerArg: TOwner): TOwner => {
|
|
1881
|
+
if (ownerArg.kind !== 'organization') return ownerArg;
|
|
1882
|
+
const { labelRecords, ...organization } = ownerArg;
|
|
1883
|
+
if (!collaborationSelected) return organization;
|
|
1884
|
+
if (labelRecords !== undefined) return { ...organization, labelRecords };
|
|
1885
|
+
addReason('organization.labelRecords: Measured by a generator that predates organization label records.');
|
|
1886
|
+
return { ...organization, labelRecords: null };
|
|
1887
|
+
};
|
|
1407
1888
|
const owners: TOwner[] = [];
|
|
1408
1889
|
const failedOwners: string[] = [];
|
|
1409
1890
|
for (const owner of discoveryArg.owners) {
|
|
1410
1891
|
const result = results.get(unitKey({ kind: 'owner', ownerKind: owner.kind, name: owner.name }));
|
|
1411
|
-
if (result?.kind === 'owner') owners.push(result.owner);
|
|
1892
|
+
if (result?.kind === 'owner') owners.push(ownerOfRun(result.owner));
|
|
1412
1893
|
else {
|
|
1413
1894
|
failedOwners.push(owner.name);
|
|
1414
1895
|
addReason(`${owner.kind}: ${result?.kind === 'failed' ? `unit failed: ${result.reason}` : 'unit not measured'}`);
|
|
@@ -1436,6 +1917,24 @@ export const composeGiteaSourceInventory = (
|
|
|
1436
1917
|
}
|
|
1437
1918
|
packages.sort(comparePackages);
|
|
1438
1919
|
}
|
|
1920
|
+
// Collaborations only when discovery selected them, keyed by the discovered repository id, never by name.
|
|
1921
|
+
let collaboration: ICollaboration[] | undefined;
|
|
1922
|
+
if (collaborationSelected) {
|
|
1923
|
+
collaboration = [];
|
|
1924
|
+
for (const repository of discoveryArg.repositories) {
|
|
1925
|
+
if (repository.id === undefined) {
|
|
1926
|
+
addReason('collaboration: repository-id-missing');
|
|
1927
|
+
continue;
|
|
1928
|
+
}
|
|
1929
|
+
const result = results.get(unitKey({ kind: 'collaboration', owner: repository.owner, name: repository.name, id: repository.id }));
|
|
1930
|
+
if (result?.kind === 'collaboration' && result.collaboration.owner === repository.owner && result.collaboration.name === repository.name) {
|
|
1931
|
+
collaboration.push(result.collaboration);
|
|
1932
|
+
} else {
|
|
1933
|
+
addReason(`collaboration: ${result?.kind === 'failed' ? `unit failed: ${result.reason}` : result?.kind === 'collaboration' ? 'unit failed: repository-identity-changed' : 'unit not measured'}`);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
collaboration.sort((aArg, bArg) => compareText(aArg.owner, bArg.owner) || compareText(aArg.name, bArg.name));
|
|
1937
|
+
}
|
|
1439
1938
|
// Without discovered ids, two names measured as one id show a rename between their units.
|
|
1440
1939
|
if (new Set(repositories.map((repositoryArg) => repositoryArg.id)).size !== repositories.length) addReason('repository: duplicate-id');
|
|
1441
1940
|
owners.sort((aArg, bArg) => compareText(aArg.kind, bArg.kind) || compareText(aArg.name, bArg.name));
|
|
@@ -1473,6 +1972,7 @@ export const composeGiteaSourceInventory = (
|
|
|
1473
1972
|
owners,
|
|
1474
1973
|
repositories,
|
|
1475
1974
|
...(packages === undefined ? {} : { packages }),
|
|
1975
|
+
...(collaboration === undefined ? {} : { collaboration }),
|
|
1476
1976
|
totals: {
|
|
1477
1977
|
organizations: instance.organizations,
|
|
1478
1978
|
users: instance.users.total,
|
|
@@ -1490,8 +1990,15 @@ export const composeGiteaSourceInventory = (
|
|
|
1490
1990
|
git: sumGroup(repositories.map((repositoryArg) => repositoryArg.git),
|
|
1491
1991
|
['reportedSizeKiB', 'branches', 'tags', 'pullRequestRefs', 'otherRefs', 'objects', 'objectBytes'], missingRepositories),
|
|
1492
1992
|
lfs: sumGroup(repositories.map((repositoryArg) => repositoryArg.lfs), ['objects', 'bytes', 'missingObjects'], missingRepositories),
|
|
1493
|
-
collaboration:
|
|
1494
|
-
|
|
1993
|
+
collaboration: {
|
|
1994
|
+
...sumGroup(repositories.map(({ collaboration: { byKind: _byKind, projects: _projects, ...counts } }) => counts),
|
|
1995
|
+
['issues', 'pullRequests', 'comments', 'reviews', 'reviewComments', 'labels', 'milestones', 'reactions', 'attachments', 'attachmentBytes'], missingRepositories),
|
|
1996
|
+
byKind: sumGroup(repositories.map((repositoryArg) => repositoryArg.collaboration.byKind ?? kindsPredated), [
|
|
1997
|
+
'issueComments', 'pullRequestComments', 'issueReactions', 'pullRequestReactions', 'issueAttachments', 'issueAttachmentBytes',
|
|
1998
|
+
'pullRequestAttachments', 'pullRequestAttachmentBytes', 'openPullRequests', 'closedPullRequests', 'mergedPullRequests',
|
|
1999
|
+
], missingRepositories),
|
|
2000
|
+
projects: sumMeasures([...repositories.map((repositoryArg) => repositoryArg.collaboration.projects ?? projectsPredated), ...missingRepositories]),
|
|
2001
|
+
},
|
|
1495
2002
|
releases: sumGroup(repositories.map((repositoryArg) => repositoryArg.releases), ['releases', 'drafts', 'assets', 'assetBytes'], missingRepositories),
|
|
1496
2003
|
wikiPages: sumMeasures([...repositories.map((repositoryArg) => repositoryArg.wikiPages), ...missingRepositories]),
|
|
1497
2004
|
access: sumGroup(repositories.map((repositoryArg): Required<IAccessCounts> => ({
|
|
@@ -1526,11 +2033,19 @@ export const composeGiteaSourceInventory = (
|
|
|
1526
2033
|
for (const owner of owners) {
|
|
1527
2034
|
const { name: _name, id: _id, ...measures } = owner;
|
|
1528
2035
|
measuresOf(measures, owner.kind, collected);
|
|
2036
|
+
if (owner.projects === undefined) collected.push({ path: `${owner.kind}.projects`, measure: projectsPredated });
|
|
1529
2037
|
}
|
|
1530
2038
|
for (const repository of repositories) {
|
|
1531
2039
|
measuresOf(repository, 'repository', collected);
|
|
1532
2040
|
// A stored result from a generator before deploy tokens counted them among its deploy keys.
|
|
1533
2041
|
if (repository.access.deployTokens === undefined) collected.push({ path: 'repository.access.deployTokens', measure: deployTokensPredated });
|
|
2042
|
+
if (repository.collaboration.byKind === undefined) collected.push({ path: 'repository.collaboration.byKind', measure: kindPredated });
|
|
2043
|
+
if (repository.collaboration.projects === undefined) collected.push({ path: 'repository.collaboration.projects', measure: projectsPredated });
|
|
2044
|
+
}
|
|
2045
|
+
// A record list that is not measured is a gap by its path, such as `collaboration.issues`; a complete one is never read as a value.
|
|
2046
|
+
for (const entry of collaboration ?? []) {
|
|
2047
|
+
const { owner: _owner, name: _name, repositoryId: _repositoryId, ...measures } = entry;
|
|
2048
|
+
measuresOf(measures, 'collaboration', collected);
|
|
1534
2049
|
}
|
|
1535
2050
|
for (const { path, measure } of collected) {
|
|
1536
2051
|
if (measure.status !== 'complete') addReason(`${path}: ${measure.reason}`);
|