@foss.global/codefeed 5.4.0 → 5.6.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 +29 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.giteasourcefreeze.d.ts +99 -0
- package/dist_ts/classes.giteasourcefreeze.js +707 -0
- package/dist_ts/classes.giteasourceinventory.d.ts +27 -15
- package/dist_ts/classes.giteasourceinventory.js +461 -70
- package/dist_ts/forgeinventory.transport.d.ts +7 -2
- package/dist_ts/forgeinventory.transport.js +33 -15
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/package.json +4 -4
- package/readme.md +92 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.giteasourcefreeze.ts +763 -0
- package/ts/classes.giteasourceinventory.ts +498 -69
- package/ts/forgeinventory.transport.ts +32 -14
- package/ts/index.ts +1 -0
|
@@ -35,6 +35,16 @@ type IPackageRecord = plugins.fossInterfaces.data.IForgeSourcePackageInventory;
|
|
|
35
35
|
type IPackageVersion = plugins.fossInterfaces.data.IForgeSourcePackageVersion;
|
|
36
36
|
type IPackageBlob = plugins.fossInterfaces.data.IForgeSourcePackageBlob;
|
|
37
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;
|
|
38
48
|
|
|
39
49
|
export interface IGiteaSourceInventoryOptions {
|
|
40
50
|
/** Operator-assigned identity of the source instance. */
|
|
@@ -76,14 +86,17 @@ export interface IGiteaSourceInventoryOptions {
|
|
|
76
86
|
}
|
|
77
87
|
|
|
78
88
|
/** A class of source data a run measures only when its discovery selects it. */
|
|
79
|
-
export type TGiteaSourceInventoryClass = 'packages';
|
|
89
|
+
export type TGiteaSourceInventoryClass = 'packages' | 'collaboration';
|
|
80
90
|
|
|
81
91
|
export interface IGiteaSourceDiscoveryOptions {
|
|
82
92
|
/**
|
|
83
93
|
* Classes beside the instance, owners and repositories to list for
|
|
84
94
|
* measurement. `packages` lists every package, so `units` measures one unit
|
|
85
95
|
* per package; without it, discovery carries no `packages` and the package
|
|
86
|
-
* 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.
|
|
87
100
|
*/
|
|
88
101
|
classes?: readonly TGiteaSourceInventoryClass[];
|
|
89
102
|
}
|
|
@@ -131,7 +144,15 @@ export const giteaSourceInventoryLimitations: readonly string[] = [
|
|
|
131
144
|
const complete = (valueArg: bigint | number): TMeasure => ({ status: 'complete', value: BigInt(valueArg).toString() });
|
|
132
145
|
const partial = (observedArg: bigint | number, reasonArg: string): TMeasure => ({ status: 'partial', observedValue: BigInt(observedArg).toString(), reason: reasonArg });
|
|
133
146
|
const notMeasured = (reasonArg: string): TMeasure => ({ status: 'notMeasured', reason: reasonArg });
|
|
134
|
-
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.';
|
|
135
156
|
|
|
136
157
|
/** The value a measure observed so far. */
|
|
137
158
|
const observed = (measureArg: TMeasure): bigint => measureArg.status === 'complete' ? BigInt(measureArg.value)
|
|
@@ -367,6 +388,172 @@ const tagProtection = (itemArg: unknown): ITagProtection => {
|
|
|
367
388
|
};
|
|
368
389
|
};
|
|
369
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
|
+
|
|
370
557
|
/** A `{ <rows>, total_count }` envelope whose rows must match its total. */
|
|
371
558
|
const countedEnvelope = (bodyArg: unknown, rowsKeyArg: string): { rows: unknown[]; failure: string | null } => {
|
|
372
559
|
const body = validation.record(bodyArg);
|
|
@@ -383,6 +570,7 @@ const resultUnit = (resultArg: TUnitResult): TUnit => {
|
|
|
383
570
|
case 'owner': return { kind: 'owner', ownerKind: resultArg.owner.kind, name: resultArg.owner.name };
|
|
384
571
|
case 'repository': return { kind: 'repository', owner: resultArg.repository.owner, name: resultArg.repository.name };
|
|
385
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 };
|
|
386
574
|
default: return unknownUnit(resultArg);
|
|
387
575
|
}
|
|
388
576
|
};
|
|
@@ -399,10 +587,18 @@ const unitKey = (unitArg: TUnit): string => {
|
|
|
399
587
|
case 'owner': return `owner:${unitArg.ownerKind}:${unitArg.name}`;
|
|
400
588
|
case 'repository': return `repository:${unitArg.owner}/${unitArg.name}`;
|
|
401
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}`;
|
|
402
592
|
default: return unknownUnit(unitArg);
|
|
403
593
|
}
|
|
404
594
|
};
|
|
405
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
|
+
|
|
406
602
|
/** A package type from a listing. */
|
|
407
603
|
const packageType = (valueArg: unknown): string => {
|
|
408
604
|
const type = requiredString(valueArg);
|
|
@@ -494,15 +690,18 @@ export class GiteaSourceInventory {
|
|
|
494
690
|
|
|
495
691
|
/**
|
|
496
692
|
* Identifies the source and its reader and lists every owner and repository
|
|
497
|
-
* with its id,
|
|
498
|
-
* administrator package listing (`packages`, absent otherwise)
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
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`.
|
|
502
699
|
*/
|
|
503
700
|
public async discover(signalArg?: AbortSignal, optionsArg: IGiteaSourceDiscoveryOptions = {}): Promise<IDiscovery> {
|
|
504
701
|
const classes = optionsArg.classes ?? [];
|
|
505
|
-
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
|
+
}
|
|
506
705
|
const reader = validation.record((await this.#get('/user', { signal: signalArg })).body);
|
|
507
706
|
if (reader.is_admin !== true) throw new validation.ForgeInventoryError('reader-not-site-admin');
|
|
508
707
|
const version = validation.record((await this.#get('/version', { signal: signalArg })).body).version;
|
|
@@ -558,6 +757,7 @@ export class GiteaSourceInventory {
|
|
|
558
757
|
owners,
|
|
559
758
|
repositories: discovered,
|
|
560
759
|
...(packages === undefined ? {} : { packages }),
|
|
760
|
+
...(classes.includes('collaboration') ? { collaboration: true as const } : {}),
|
|
561
761
|
incompleteReasons: incompleteReasons.sort(compareText),
|
|
562
762
|
};
|
|
563
763
|
}
|
|
@@ -565,7 +765,10 @@ export class GiteaSourceInventory {
|
|
|
565
765
|
/**
|
|
566
766
|
* Every unit a run over this discovery measures: the instance, then owners,
|
|
567
767
|
* then repositories, then the packages discovery lists when it selected
|
|
568
|
-
* 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.
|
|
569
772
|
*/
|
|
570
773
|
public units(discoveryArg: IDiscovery): TUnit[] {
|
|
571
774
|
return [
|
|
@@ -575,6 +778,10 @@ export class GiteaSourceInventory {
|
|
|
575
778
|
kind: 'repository', owner: repositoryArg.owner, name: repositoryArg.name, ...repositoryArg.id === undefined ? {} : { id: repositoryArg.id },
|
|
576
779
|
})),
|
|
577
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
|
+
}),
|
|
578
785
|
];
|
|
579
786
|
}
|
|
580
787
|
|
|
@@ -601,12 +808,13 @@ export class GiteaSourceInventory {
|
|
|
601
808
|
|
|
602
809
|
/**
|
|
603
810
|
* Measures one unit. Never rejects: a unit whose primary record cannot be
|
|
604
|
-
* read is `failed`, and so is a repository unit carrying an
|
|
605
|
-
* no longer answers with (`repository-identity-changed`).
|
|
606
|
-
* read whole or
|
|
607
|
-
* index that is not what its file list
|
|
608
|
-
* and a result over
|
|
609
|
-
* (`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.
|
|
610
818
|
*/
|
|
611
819
|
public async measureUnit(unitArg: TUnit, signalArg?: AbortSignal): Promise<TUnitResult> {
|
|
612
820
|
try {
|
|
@@ -617,13 +825,10 @@ export class GiteaSourceInventory {
|
|
|
617
825
|
return { kind: 'owner', owner: unitArg.ownerKind === 'organization' ? await this.#organization(unitArg.name, signalArg) : await this.#user(unitArg.name, signalArg) };
|
|
618
826
|
case 'repository':
|
|
619
827
|
return { kind: 'repository', repository: await this.#repository(unitArg.owner, unitArg.name, unitArg.id, signalArg) };
|
|
620
|
-
case 'package':
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
}
|
|
625
|
-
return result;
|
|
626
|
-
}
|
|
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) });
|
|
627
832
|
default:
|
|
628
833
|
return unknownUnit(unitArg);
|
|
629
834
|
}
|
|
@@ -695,13 +900,16 @@ export class GiteaSourceInventory {
|
|
|
695
900
|
};
|
|
696
901
|
}
|
|
697
902
|
|
|
698
|
-
|
|
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) {
|
|
699
905
|
return {
|
|
700
906
|
name: nameArg,
|
|
701
907
|
id: validation.decimal(recordArg.id),
|
|
702
908
|
visibility: ownerVisibility(recordArg.visibility),
|
|
703
|
-
repositories: await this.#count(
|
|
909
|
+
repositories: await this.#count(`${pathArg}/repos`, { signal: signalArg }),
|
|
704
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' } }),
|
|
705
913
|
};
|
|
706
914
|
}
|
|
707
915
|
|
|
@@ -709,6 +917,7 @@ export class GiteaSourceInventory {
|
|
|
709
917
|
const path = `/orgs/${encodeURIComponent(nameArg)}`;
|
|
710
918
|
const organization = validation.record((await this.#get(path, { signal: signalArg })).body);
|
|
711
919
|
if (segment(organization.name) !== nameArg) throw new validation.ForgeInventoryError('owner-identity-changed');
|
|
920
|
+
const labels = await this.#list(`${path}/labels`, { signal: signalArg });
|
|
712
921
|
const teams = await this.#list(`${path}/teams`, { signal: signalArg });
|
|
713
922
|
const memberships: TMeasure[] = [];
|
|
714
923
|
const grants: TMeasure[] = [];
|
|
@@ -729,7 +938,7 @@ export class GiteaSourceInventory {
|
|
|
729
938
|
const teamRepositoryGrants = fromTeams(grants);
|
|
730
939
|
return {
|
|
731
940
|
kind: 'organization',
|
|
732
|
-
...await this.#ownerBase(nameArg, organization,
|
|
941
|
+
...await this.#ownerBase(nameArg, organization, path, signalArg),
|
|
733
942
|
webhooks: await this.#count(`${path}/hooks`, { signal: signalArg }),
|
|
734
943
|
actionsVariables: await this.#count(`${path}/actions/variables`, { signal: signalArg }),
|
|
735
944
|
runners: await this.#runners(`${path}/actions/runners`, { signal: signalArg }),
|
|
@@ -737,12 +946,14 @@ export class GiteaSourceInventory {
|
|
|
737
946
|
teams: teamsMeasure,
|
|
738
947
|
teamMemberships,
|
|
739
948
|
teamRepositoryGrants,
|
|
740
|
-
labels:
|
|
949
|
+
labels: listingMeasure(labels),
|
|
741
950
|
actionsSecrets: await this.#count(`${path}/actions/secrets`, { signal: signalArg }),
|
|
742
951
|
// Never partial: one unfinished listing makes the records null.
|
|
743
952
|
teamRecords: [teamsMeasure, teamMemberships, teamRepositoryGrants].every((measureArg) => measureArg.status === 'complete')
|
|
744
953
|
? teamRecords.sort((aArg, bArg) => compareDecimal(aArg.id, bArg.id))
|
|
745
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,
|
|
746
957
|
};
|
|
747
958
|
}
|
|
748
959
|
|
|
@@ -752,7 +963,7 @@ export class GiteaSourceInventory {
|
|
|
752
963
|
if (segment(user.login) !== nameArg) throw new validation.ForgeInventoryError('owner-identity-changed');
|
|
753
964
|
// User-scoped settings are only readable as that user, through the administrator's Sudo header.
|
|
754
965
|
const sudo = { signal: signalArg, sudo: nameArg };
|
|
755
|
-
const base = await this.#ownerBase(nameArg, user,
|
|
966
|
+
const base = await this.#ownerBase(nameArg, user, path, signalArg);
|
|
756
967
|
const flags = {
|
|
757
968
|
admin: requiredBoolean(user.is_admin),
|
|
758
969
|
active: requiredBoolean(user.active),
|
|
@@ -1067,6 +1278,12 @@ export class GiteaSourceInventory {
|
|
|
1067
1278
|
const fork = requiredBoolean(item.fork);
|
|
1068
1279
|
const parent = item.parent === undefined || item.parent === null ? null : requiredString(validation.record(item.parent).full_name);
|
|
1069
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;
|
|
1070
1287
|
const options = { signal: signalArg };
|
|
1071
1288
|
const branchRules = await this.#unpaged(`${path}/branch_protections`, options);
|
|
1072
1289
|
const tagRules = await this.#unpaged(`${path}/tag_protections`, options);
|
|
@@ -1089,7 +1306,7 @@ export class GiteaSourceInventory {
|
|
|
1089
1306
|
description: text(item.description),
|
|
1090
1307
|
features,
|
|
1091
1308
|
...await this.#git(ownerArg, nameArg, item, signalArg),
|
|
1092
|
-
collaboration: await this.#collaboration(path,
|
|
1309
|
+
collaboration: await this.#collaboration(path, units, projectsHidden, archived, signalArg),
|
|
1093
1310
|
// Releases stay readable while their unit is disabled; a disabled wiki answers 404.
|
|
1094
1311
|
...await this.#releases(path, signalArg),
|
|
1095
1312
|
wikiPages: features.wiki ? await this.#count(`${path}/wiki/pages`, { ...options, missingIsEmpty: true }) : unitDisabled('wiki'),
|
|
@@ -1207,66 +1424,204 @@ export class GiteaSourceInventory {
|
|
|
1207
1424
|
return this.#lfsClient;
|
|
1208
1425
|
}
|
|
1209
1426
|
|
|
1210
|
-
|
|
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> {
|
|
1211
1440
|
const options = { signal: signalArg };
|
|
1212
|
-
// 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.
|
|
1213
1442
|
const labels = await this.#count(`${pathArg}/labels`, options);
|
|
1214
1443
|
const milestones = await this.#count(`${pathArg}/milestones`, { ...options, query: { state: 'all' } });
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
const hidden = unitDisabled(featuresArg.issues ? 'pull requests' : 'issues');
|
|
1218
|
-
return {
|
|
1219
|
-
issues: hidden, pullRequests: hidden, comments: hidden, reviews: hidden, reviewComments: hidden,
|
|
1220
|
-
labels, milestones, reactions: hidden, attachments: hidden, attachmentBytes: hidden,
|
|
1221
|
-
};
|
|
1222
|
-
}
|
|
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;
|
|
1223
1446
|
// Issues and pull requests share one listing and one number sequence.
|
|
1224
|
-
const
|
|
1225
|
-
const unfinished =
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
let attachments = { count: 0n, bytes: 0n };
|
|
1229
|
-
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 };
|
|
1230
1451
|
const reviews: TMeasure[] = [...unfinished];
|
|
1231
1452
|
const reviewComments: TMeasure[] = [...unfinished];
|
|
1232
|
-
for (const itemArg of
|
|
1453
|
+
for (const itemArg of items.items) {
|
|
1233
1454
|
const item = validation.record(itemArg);
|
|
1234
1455
|
const number = validation.decimal(item.number);
|
|
1235
|
-
const
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
reactions.push(await this.#count(`${pathArg}/issues/${number}/reactions`, { ...options, nullIsEmpty: true }));
|
|
1240
|
-
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
|
+
}
|
|
1241
1470
|
const pullReviews = await this.#pullList(`${pathArg}/pulls/${number}/reviews`, archivedArg, options);
|
|
1242
1471
|
reviews.push(listingMeasure(pullReviews));
|
|
1243
1472
|
const inline = pullReviews.items.reduce((sumArg: bigint, reviewArg: unknown) => sumArg + BigInt(validation.decimal(validation.record(reviewArg).comments_count)), 0n);
|
|
1244
1473
|
reviewComments.push(listingMeasure(pullReviews, inline));
|
|
1245
1474
|
}
|
|
1246
1475
|
}
|
|
1247
|
-
const comments = await this.#list(`${pathArg}/issues/comments`, options);
|
|
1476
|
+
const comments: IListing = listed ? await this.#list(`${pathArg}/issues/comments`, options) : { items: [], failure: null };
|
|
1248
1477
|
const commentUnfinished = comments.failure === null ? [] : [partial(0, comments.failure)];
|
|
1249
1478
|
for (const commentArg of comments.items) {
|
|
1250
1479
|
const comment = validation.record(commentArg);
|
|
1251
|
-
const
|
|
1252
|
-
|
|
1253
|
-
|
|
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 }));
|
|
1254
1486
|
}
|
|
1255
|
-
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);
|
|
1256
1503
|
return {
|
|
1257
|
-
issues:
|
|
1258
|
-
pullRequests:
|
|
1259
|
-
comments:
|
|
1260
|
-
reviews: sumMeasures(reviews),
|
|
1261
|
-
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)),
|
|
1262
1509
|
labels,
|
|
1263
1510
|
milestones,
|
|
1264
|
-
reactions: sumMeasures([
|
|
1265
|
-
attachments: sumMeasures([
|
|
1266
|
-
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,
|
|
1267
1528
|
};
|
|
1268
1529
|
}
|
|
1269
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 },
|
|
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
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1270
1625
|
/**
|
|
1271
1626
|
* A listing below `/pulls`. Gitea 28 answers 404 to every pull request
|
|
1272
1627
|
* route of an archived repository, reads included (since go-gitea/gitea#39155),
|
|
@@ -1375,6 +1730,23 @@ export class GiteaSourceInventory {
|
|
|
1375
1730
|
return listingMeasure(await this.#list(pathArg, optionsArg));
|
|
1376
1731
|
}
|
|
1377
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
|
+
|
|
1378
1750
|
/**
|
|
1379
1751
|
* Reads every page of a listing. It ends at an empty page or once the rows
|
|
1380
1752
|
* reach `X-Total-Count`, and is partial when the total disagrees with the
|
|
@@ -1438,6 +1810,15 @@ const indicator = (measureArg: TMeasure, predicateArg: (valueArg: bigint) => boo
|
|
|
1438
1810
|
|
|
1439
1811
|
/** A repository result from a generator that predates the deploy token count, whose `deployKeys` included the tokens. */
|
|
1440
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
|
+
};
|
|
1441
1822
|
|
|
1442
1823
|
/** Every measure of a document with its dotted path. */
|
|
1443
1824
|
const measuresOf = (valueArg: unknown, pathArg: string, outArg: Array<{ path: string; measure: TMeasure }>) => {
|
|
@@ -1465,7 +1846,11 @@ const measuresOf = (valueArg: unknown, pathArg: string, outArg: Array<{ path: st
|
|
|
1465
1846
|
* `packages` and `totals.packageContent` (`composeGiteaPackageContent`) are
|
|
1466
1847
|
* present exactly when discovery lists packages, an empty list included; a
|
|
1467
1848
|
* package without a record is left out, never truncated, and makes its
|
|
1468
|
-
* 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`.
|
|
1469
1854
|
*/
|
|
1470
1855
|
export const composeGiteaSourceInventory = (
|
|
1471
1856
|
discoveryArg: IDiscovery,
|
|
@@ -1490,11 +1875,21 @@ export const composeGiteaSourceInventory = (
|
|
|
1490
1875
|
};
|
|
1491
1876
|
}
|
|
1492
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
|
+
};
|
|
1493
1888
|
const owners: TOwner[] = [];
|
|
1494
1889
|
const failedOwners: string[] = [];
|
|
1495
1890
|
for (const owner of discoveryArg.owners) {
|
|
1496
1891
|
const result = results.get(unitKey({ kind: 'owner', ownerKind: owner.kind, name: owner.name }));
|
|
1497
|
-
if (result?.kind === 'owner') owners.push(result.owner);
|
|
1892
|
+
if (result?.kind === 'owner') owners.push(ownerOfRun(result.owner));
|
|
1498
1893
|
else {
|
|
1499
1894
|
failedOwners.push(owner.name);
|
|
1500
1895
|
addReason(`${owner.kind}: ${result?.kind === 'failed' ? `unit failed: ${result.reason}` : 'unit not measured'}`);
|
|
@@ -1522,6 +1917,24 @@ export const composeGiteaSourceInventory = (
|
|
|
1522
1917
|
}
|
|
1523
1918
|
packages.sort(comparePackages);
|
|
1524
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
|
+
}
|
|
1525
1938
|
// Without discovered ids, two names measured as one id show a rename between their units.
|
|
1526
1939
|
if (new Set(repositories.map((repositoryArg) => repositoryArg.id)).size !== repositories.length) addReason('repository: duplicate-id');
|
|
1527
1940
|
owners.sort((aArg, bArg) => compareText(aArg.kind, bArg.kind) || compareText(aArg.name, bArg.name));
|
|
@@ -1559,6 +1972,7 @@ export const composeGiteaSourceInventory = (
|
|
|
1559
1972
|
owners,
|
|
1560
1973
|
repositories,
|
|
1561
1974
|
...(packages === undefined ? {} : { packages }),
|
|
1975
|
+
...(collaboration === undefined ? {} : { collaboration }),
|
|
1562
1976
|
totals: {
|
|
1563
1977
|
organizations: instance.organizations,
|
|
1564
1978
|
users: instance.users.total,
|
|
@@ -1576,8 +1990,15 @@ export const composeGiteaSourceInventory = (
|
|
|
1576
1990
|
git: sumGroup(repositories.map((repositoryArg) => repositoryArg.git),
|
|
1577
1991
|
['reportedSizeKiB', 'branches', 'tags', 'pullRequestRefs', 'otherRefs', 'objects', 'objectBytes'], missingRepositories),
|
|
1578
1992
|
lfs: sumGroup(repositories.map((repositoryArg) => repositoryArg.lfs), ['objects', 'bytes', 'missingObjects'], missingRepositories),
|
|
1579
|
-
collaboration:
|
|
1580
|
-
|
|
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
|
+
},
|
|
1581
2002
|
releases: sumGroup(repositories.map((repositoryArg) => repositoryArg.releases), ['releases', 'drafts', 'assets', 'assetBytes'], missingRepositories),
|
|
1582
2003
|
wikiPages: sumMeasures([...repositories.map((repositoryArg) => repositoryArg.wikiPages), ...missingRepositories]),
|
|
1583
2004
|
access: sumGroup(repositories.map((repositoryArg): Required<IAccessCounts> => ({
|
|
@@ -1612,11 +2033,19 @@ export const composeGiteaSourceInventory = (
|
|
|
1612
2033
|
for (const owner of owners) {
|
|
1613
2034
|
const { name: _name, id: _id, ...measures } = owner;
|
|
1614
2035
|
measuresOf(measures, owner.kind, collected);
|
|
2036
|
+
if (owner.projects === undefined) collected.push({ path: `${owner.kind}.projects`, measure: projectsPredated });
|
|
1615
2037
|
}
|
|
1616
2038
|
for (const repository of repositories) {
|
|
1617
2039
|
measuresOf(repository, 'repository', collected);
|
|
1618
2040
|
// A stored result from a generator before deploy tokens counted them among its deploy keys.
|
|
1619
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);
|
|
1620
2049
|
}
|
|
1621
2050
|
for (const { path, measure } of collected) {
|
|
1622
2051
|
if (measure.status !== 'complete') addReason(`${path}: ${measure.reason}`);
|