@foss.global/codefeed 5.3.0 → 5.5.0

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