@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.
@@ -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 unitDisabled = (unitArg: string): TMeasure => notMeasured(`The ${unitArg} unit is disabled; Gitea's API hides its data.`);
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, and with `classes: ['packages']` every package from the
498
- * administrator package listing (`packages`, absent otherwise). Rejects
499
- * unless the token belongs to a site administrator, and rejects an unknown
500
- * class with `invalid-options`; a listing that does not finish, or that
501
- * lists a repository id twice, is reported in `incompleteReasons`.
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')) throw new validation.ForgeInventoryError('invalid-options');
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 id that its name
605
- * no longer answers with (`repository-identity-changed`). A package unit is
606
- * read whole or fails, never partly: a listing that does not finish, an
607
- * index that is not what its file list promises (`unexpected-manifest-shape`)
608
- * and a result over `forgeSourceInventoryUnitMaxBytes` of UTF-8 JSON
609
- * (`unit-over-bound`) fail it, and its record is never truncated.
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
- const result: TUnitResult = { kind: 'package', package: await this.#package(unitArg, signalArg) };
622
- if (Buffer.byteLength(JSON.stringify(result), 'utf8') > plugins.fossInterfaces.data.forgeSourceInventoryUnitMaxBytes) {
623
- return { kind: 'failed', unit: unitArg, reason: 'unit-over-bound' };
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
- async #ownerBase(nameArg: string, recordArg: Record<string, unknown>, repositoriesPathArg: string, signalArg?: AbortSignal) {
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(repositoriesPathArg, { signal: signalArg }),
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, `${path}/repos`, signalArg),
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: await this.#count(`${path}/labels`, { signal: signalArg }),
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, `${path}/repos`, signalArg);
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, features, archived, signalArg),
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
- async #collaboration(pathArg: string, featuresArg: IRepository['features'], archivedArg: boolean, signalArg?: AbortSignal): Promise<IRepository['collaboration']> {
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
- if (!featuresArg.issues || !featuresArg.pullRequests) {
1216
- // A disabled unit silently drops its items from the shared issue listing; with both disabled the listings answer 404.
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 issues = await this.#list(`${pathArg}/issues`, { ...options, query: { state: 'all' } });
1225
- const unfinished = issues.failure === null ? [] : [partial(0, issues.failure)];
1226
- let issueCount = 0n;
1227
- let pullCount = 0n;
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 issues.items) {
1453
+ for (const itemArg of items.items) {
1233
1454
  const item = validation.record(itemArg);
1234
1455
  const number = validation.decimal(item.number);
1235
- const isPull = item.pull_request !== undefined && item.pull_request !== null;
1236
- if (isPull) pullCount++; else issueCount++;
1237
- const itemAssets = assets(item.assets);
1238
- attachments = { count: attachments.count + itemAssets.count, bytes: attachments.bytes + itemAssets.bytes };
1239
- reactions.push(await this.#count(`${pathArg}/issues/${number}/reactions`, { ...options, nullIsEmpty: true }));
1240
- if (isPull) {
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 commentAssets = assets(comment.assets);
1252
- attachments = { count: attachments.count + commentAssets.count, bytes: attachments.bytes + commentAssets.bytes };
1253
- reactions.push(await this.#unpagedCount(`${pathArg}/issues/comments/${validation.decimal(comment.id)}/reactions`, { ...options, nullIsEmpty: true }));
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 attachmentUnfinished = [...unfinished, ...commentUnfinished];
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: listingMeasure(issues, issueCount),
1258
- pullRequests: listingMeasure(issues, pullCount),
1259
- comments: listingMeasure(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([...reactions, ...commentUnfinished]),
1265
- attachments: sumMeasures([complete(attachments.count), ...attachmentUnfinished]),
1266
- attachmentBytes: sumMeasures([complete(attachments.bytes), ...attachmentUnfinished]),
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: sumGroup(repositories.map((repositoryArg) => repositoryArg.collaboration),
1580
- ['issues', 'pullRequests', 'comments', 'reviews', 'reviewComments', 'labels', 'milestones', 'reactions', 'attachments', 'attachmentBytes'], missingRepositories),
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}`);