@foss.global/codefeed 1.9.0 → 1.10.1

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.
@@ -0,0 +1,856 @@
1
+ import * as plugins from './plugins.js';
2
+
3
+ export interface IGitManagerPublicReadClientOptions {
4
+ gitManager: plugins.TGitManager;
5
+ publicBaseUrl?: string;
6
+ smartHttpBaseUrl?: string;
7
+ sshBaseUrl?: string;
8
+ allowAllRepositories?: boolean;
9
+ isRepositoryPublic?: (repositoryArg: plugins.TGitManagerRepositoryInfo) => Promise<boolean>;
10
+ organizationMetadata?: (
11
+ orgArg: string,
12
+ ) => Promise<Partial<plugins.fossInterfaces.IPublicOrg> | undefined>;
13
+ repositoryMetadata?: (
14
+ repositoryArg: plugins.TGitManagerRepositoryInfo,
15
+ ) => Promise<Partial<plugins.fossInterfaces.IPublicRepo> | undefined>;
16
+ newsProvider?: (limitArg: number) => Promise<plugins.fossInterfaces.IPublicNewsItem[]>;
17
+ maxCommits?: number;
18
+ maxReleases?: number;
19
+ maxLanguageFiles?: number;
20
+ maxConcurrency?: number;
21
+ }
22
+
23
+ export class GitManagerPublicReadClient {
24
+ private readonly gitManager: plugins.TGitManager;
25
+ private readonly publicBaseUrl: string;
26
+ private readonly smartHttpBaseUrl: string;
27
+ private readonly sshBaseUrl: string;
28
+ private readonly allowAllRepositories: boolean;
29
+ private readonly isRepositoryPublic?: (
30
+ repositoryArg: plugins.TGitManagerRepositoryInfo,
31
+ ) => Promise<boolean>;
32
+ private readonly organizationMetadata?: (
33
+ orgArg: string,
34
+ ) => Promise<Partial<plugins.fossInterfaces.IPublicOrg> | undefined>;
35
+ private readonly repositoryMetadata?: (
36
+ repositoryArg: plugins.TGitManagerRepositoryInfo,
37
+ ) => Promise<Partial<plugins.fossInterfaces.IPublicRepo> | undefined>;
38
+ private readonly newsProvider?: (
39
+ limitArg: number,
40
+ ) => Promise<plugins.fossInterfaces.IPublicNewsItem[]>;
41
+ private readonly maxCommits: number;
42
+ private readonly maxReleases: number;
43
+ private readonly maxLanguageFiles: number;
44
+ private readonly maxConcurrency: number;
45
+
46
+ constructor(optionsArg: IGitManagerPublicReadClientOptions) {
47
+ if (!optionsArg.allowAllRepositories && !optionsArg.isRepositoryPublic) {
48
+ throw new Error(
49
+ 'GitManagerPublicReadClient requires allowAllRepositories or an authoritative isRepositoryPublic predicate',
50
+ );
51
+ }
52
+ this.gitManager = optionsArg.gitManager;
53
+ this.publicBaseUrl = this.normalizeBaseUrl(
54
+ optionsArg.publicBaseUrl ?? 'https://foss.global',
55
+ ['http:', 'https:'],
56
+ );
57
+ this.smartHttpBaseUrl = this.normalizeBaseUrl(
58
+ optionsArg.smartHttpBaseUrl ?? this.publicBaseUrl,
59
+ ['http:', 'https:'],
60
+ );
61
+ this.sshBaseUrl = this.normalizeBaseUrl(
62
+ optionsArg.sshBaseUrl ?? 'ssh://git@code.foss.global:29419',
63
+ ['ssh:'],
64
+ );
65
+ this.allowAllRepositories = optionsArg.allowAllRepositories ?? false;
66
+ this.isRepositoryPublic = optionsArg.isRepositoryPublic;
67
+ this.organizationMetadata = optionsArg.organizationMetadata;
68
+ this.repositoryMetadata = optionsArg.repositoryMetadata;
69
+ this.newsProvider = optionsArg.newsProvider;
70
+ this.maxCommits = this.validateLimit(optionsArg.maxCommits ?? 40, 'maxCommits', 1, 1000);
71
+ this.maxReleases = this.validateLimit(optionsArg.maxReleases ?? 50, 'maxReleases', 1, 1000);
72
+ this.maxLanguageFiles = this.validateLimit(
73
+ optionsArg.maxLanguageFiles ?? 1000,
74
+ 'maxLanguageFiles',
75
+ 1,
76
+ 10_000,
77
+ );
78
+ this.maxConcurrency = this.validateLimit(
79
+ optionsArg.maxConcurrency ?? 8,
80
+ 'maxConcurrency',
81
+ 1,
82
+ 32,
83
+ );
84
+ }
85
+
86
+ public async getOverview(
87
+ queryArg = '',
88
+ ): Promise<plugins.fossInterfaces.IReq_PublicOverview['response']> {
89
+ const query = queryArg.trim().toLowerCase();
90
+ if (query.length > 160) {
91
+ throw new Error('Query exceeds maximum length of 160');
92
+ }
93
+ const repositoryInfos = await this.listVisibleRepositories();
94
+ const filteredInfos = query
95
+ ? repositoryInfos.filter((repository) =>
96
+ `${repository.org}/${repository.repo}`.toLowerCase().includes(query),
97
+ )
98
+ : repositoryInfos;
99
+ const repos = await this.mapWithConcurrency(
100
+ filteredInfos.slice(0, query ? 100 : 36),
101
+ (repository) => this.mapRepository(repository),
102
+ );
103
+ const orgs = await this.mapOrganizations(repositoryInfos);
104
+ const commitFeed = await this.getCommitFeed(filteredInfos.slice(0, 12), 3);
105
+ const activity = commitFeed.slice(0, 24).map((commit) => this.mapActivity(commit));
106
+ const news = query ? [] : ((await this.newsProvider?.(6)) ?? []);
107
+ return {
108
+ orgs: query
109
+ ? orgs.filter((org) => org.name.toLowerCase().includes(query))
110
+ : orgs,
111
+ repos,
112
+ activity,
113
+ commitFeed,
114
+ news,
115
+ stats: this.getStats(orgs, repos),
116
+ };
117
+ }
118
+
119
+ public async getOrg(
120
+ orgArg: string,
121
+ ): Promise<plugins.fossInterfaces.IReq_PublicOrg['response']> {
122
+ const org = this.validateSlug(orgArg, 'organization', 120);
123
+ const repositoryInfos = (await this.listVisibleRepositories()).filter(
124
+ (repository) => repository.org === org,
125
+ );
126
+ if (repositoryInfos.length === 0) {
127
+ return {
128
+ org: null,
129
+ repos: [],
130
+ members: [],
131
+ activity: [],
132
+ commitFeed: [],
133
+ stats: {
134
+ orgCount: 0,
135
+ repoCount: 0,
136
+ releaseCount: 0,
137
+ openIssueCount: 0,
138
+ },
139
+ };
140
+ }
141
+ const repos = await this.mapWithConcurrency(repositoryInfos, (repository) =>
142
+ this.mapRepository(repository),
143
+ );
144
+ const [mappedOrg] = await this.mapOrganizations(repositoryInfos, [org]);
145
+ const commitFeed = await this.getCommitFeed(repositoryInfos.slice(0, 12), 3);
146
+ const activity = commitFeed.slice(0, 24).map((commit) => this.mapActivity(commit));
147
+ return {
148
+ org: mappedOrg ?? null,
149
+ repos,
150
+ members: [],
151
+ activity,
152
+ commitFeed,
153
+ stats: this.getStats(mappedOrg ? [mappedOrg] : [], repos),
154
+ };
155
+ }
156
+
157
+ public async getRepo(
158
+ orgArg: string,
159
+ repoArg: string,
160
+ pathArg = '',
161
+ _issueNumberArg = 0,
162
+ ): Promise<plugins.fossInterfaces.IReq_PublicRepo['response']> {
163
+ const repository = await this.findVisibleRepository(orgArg, repoArg);
164
+ if (!repository) {
165
+ return {
166
+ repo: null,
167
+ contents: [],
168
+ readme: '',
169
+ releases: [],
170
+ activity: [],
171
+ issues: [],
172
+ selectedIssue: null,
173
+ selectedIssueComments: [],
174
+ commits: [],
175
+ languages: {},
176
+ };
177
+ }
178
+ const path = this.validatePath(pathArg);
179
+ const [tree, readme, tags, commitSummaries, languages] = await Promise.all([
180
+ this.withFallback(
181
+ () => this.gitManager.listTree({
182
+ org: repository.org,
183
+ repo: repository.repo,
184
+ ref: repository.defaultBranch,
185
+ path: path || undefined,
186
+ }),
187
+ [],
188
+ ),
189
+ path ? Promise.resolve('') : this.readReadme(repository),
190
+ this.withFallback(
191
+ () => this.gitManager.listTags({
192
+ org: repository.org,
193
+ repo: repository.repo,
194
+ limit: this.maxReleases,
195
+ }),
196
+ [],
197
+ ),
198
+ this.withFallback(
199
+ () => this.gitManager.listCommits({
200
+ org: repository.org,
201
+ repo: repository.repo,
202
+ ref: repository.defaultBranch,
203
+ limit: Math.min(this.maxCommits, 12),
204
+ }),
205
+ [],
206
+ ),
207
+ this.scanLanguages(repository),
208
+ ]);
209
+ const commits = commitSummaries.map((commit) => this.mapCommit(commit));
210
+ const latestCommit = commits[0];
211
+ const releases = tags.map((tag) => this.mapRelease(repository, tag));
212
+ const repo = await this.mapRepository(repository);
213
+ repo.releaseCount = releases.length;
214
+ repo.language = this.getPrimaryLanguage(languages);
215
+ return {
216
+ repo,
217
+ contents: tree
218
+ .filter((entry) => entry.entryType !== 'other')
219
+ .map((entry) => this.mapContent(repository, entry, latestCommit)),
220
+ readme,
221
+ releases,
222
+ activity: commits.map((commit) => this.mapActivity(commit)),
223
+ issues: [],
224
+ selectedIssue: null,
225
+ selectedIssueComments: [],
226
+ commits,
227
+ languages,
228
+ };
229
+ }
230
+
231
+ public async getFile(
232
+ orgArg: string,
233
+ repoArg: string,
234
+ pathArg: string,
235
+ ): Promise<plugins.fossInterfaces.IReq_PublicFile['response']> {
236
+ const repository = await this.findVisibleRepository(orgArg, repoArg);
237
+ if (!repository) {
238
+ return { repo: null, file: null, activity: [], commits: [] };
239
+ }
240
+ const path = this.validatePath(pathArg, false);
241
+ const [file, commitSummaries, repo] = await Promise.all([
242
+ this.withFallback(
243
+ () => this.gitManager.readFile({
244
+ org: repository.org,
245
+ repo: repository.repo,
246
+ ref: repository.defaultBranch,
247
+ path,
248
+ }),
249
+ null,
250
+ ),
251
+ this.withFallback(
252
+ () => this.gitManager.listCommits({
253
+ org: repository.org,
254
+ repo: repository.repo,
255
+ ref: repository.defaultBranch,
256
+ limit: Math.min(this.maxCommits, 8),
257
+ }),
258
+ [],
259
+ ),
260
+ this.mapRepository(repository),
261
+ ]);
262
+ const commits = commitSummaries.map((commit) => this.mapCommit(commit));
263
+ return {
264
+ repo,
265
+ file: file
266
+ ? {
267
+ path: file.path,
268
+ name: file.path.split('/').pop() ?? file.path,
269
+ size: file.size,
270
+ htmlUrl: this.repoPathUrl(repository, `src/${repository.defaultBranch}/${file.path}`),
271
+ downloadUrl: '',
272
+ content: file.isBinary
273
+ ? ''
274
+ : Buffer.from(file.contentBase64, 'base64').toString('utf8'),
275
+ isBinary: file.isBinary,
276
+ truncated: false,
277
+ }
278
+ : null,
279
+ activity: commits.map((commit) => this.mapActivity(commit)),
280
+ commits,
281
+ };
282
+ }
283
+
284
+ public async getCommits(
285
+ orgArg: string,
286
+ repoArg: string,
287
+ shaArg = '',
288
+ ): Promise<plugins.fossInterfaces.IReq_PublicCommits['response']> {
289
+ const repository = await this.findVisibleRepository(orgArg, repoArg);
290
+ if (!repository) {
291
+ return { repo: null, commits: [], selectedCommit: null };
292
+ }
293
+ const sha = shaArg.trim();
294
+ if (sha && !/^[0-9a-fA-F]{40,64}$/.test(sha)) {
295
+ throw new Error('Invalid commit SHA');
296
+ }
297
+ const [repo, commitSummaries, selectedCommit] = await Promise.all([
298
+ this.mapRepository(repository),
299
+ this.withFallback(
300
+ () => this.gitManager.listCommits({
301
+ org: repository.org,
302
+ repo: repository.repo,
303
+ ref: repository.defaultBranch,
304
+ limit: this.maxCommits,
305
+ }),
306
+ [],
307
+ ),
308
+ sha
309
+ ? this.withFallback(() => this.getCommitDetail(repository, sha), null)
310
+ : Promise.resolve(null),
311
+ ]);
312
+ return {
313
+ repo,
314
+ commits: commitSummaries.map((commit) => this.mapCommit(commit)),
315
+ selectedCommit,
316
+ };
317
+ }
318
+
319
+ private async listVisibleRepositories(): Promise<plugins.TGitManagerRepositoryInfo[]> {
320
+ const repositories = await this.gitManager.listRepositories();
321
+ if (this.allowAllRepositories) {
322
+ return repositories;
323
+ }
324
+ const visibility = await this.mapWithConcurrency(repositories, async (repository) => ({
325
+ repository,
326
+ visible: await this.isRepositoryPublic!(repository),
327
+ }));
328
+ return visibility
329
+ .filter((entry) => entry.visible)
330
+ .map((entry) => entry.repository);
331
+ }
332
+
333
+ private async findVisibleRepository(
334
+ orgArg: string,
335
+ repoArg: string,
336
+ ): Promise<plugins.TGitManagerRepositoryInfo | undefined> {
337
+ const org = this.validateSlug(orgArg, 'organization', 120);
338
+ const repo = this.validateSlug(repoArg, 'repository', 160);
339
+ return (await this.listVisibleRepositories()).find(
340
+ (repository) => repository.org === org && repository.repo === repo,
341
+ );
342
+ }
343
+
344
+ private async mapOrganizations(
345
+ repositoriesArg: plugins.TGitManagerRepositoryInfo[],
346
+ explicitOrgsArg?: string[],
347
+ ): Promise<plugins.fossInterfaces.IPublicOrg[]> {
348
+ const orgNames = explicitOrgsArg
349
+ ?? [...new Set(repositoriesArg.map((repository) => repository.org))];
350
+ const orgs = await this.mapWithConcurrency(orgNames, async (name) => {
351
+ const repositories = repositoriesArg.filter((repository) => repository.org === name);
352
+ const metadata = await this.organizationMetadata?.(name);
353
+ const base: plugins.fossInterfaces.IPublicOrg = {
354
+ name,
355
+ fullName: name,
356
+ description: '',
357
+ website: '',
358
+ location: '',
359
+ avatarUrl: '',
360
+ htmlUrl: `${this.publicBaseUrl}/${name}`,
361
+ repoCount: repositories.length,
362
+ starsCount: 0,
363
+ memberCount: 0,
364
+ languages: {},
365
+ };
366
+ return {
367
+ ...base,
368
+ ...metadata,
369
+ name,
370
+ repoCount: repositories.length,
371
+ };
372
+ });
373
+ return orgs.sort((orgA, orgB) => orgA.name.localeCompare(orgB.name));
374
+ }
375
+
376
+ private async mapRepository(
377
+ repositoryArg: plugins.TGitManagerRepositoryInfo,
378
+ ): Promise<plugins.fossInterfaces.IPublicRepo> {
379
+ const [latestCommit] = await this.withFallback(
380
+ () => this.gitManager.listCommits({
381
+ org: repositoryArg.org,
382
+ repo: repositoryArg.repo,
383
+ ref: repositoryArg.defaultBranch,
384
+ limit: 1,
385
+ }),
386
+ [],
387
+ );
388
+ const metadata = await this.repositoryMetadata?.(repositoryArg);
389
+ const slug = `${repositoryArg.org}/${repositoryArg.repo}`;
390
+ const base: plugins.fossInterfaces.IPublicRepo = {
391
+ id: this.stableId(slug),
392
+ org: repositoryArg.org,
393
+ name: repositoryArg.repo,
394
+ slug,
395
+ orgAvatarUrl: '',
396
+ description: '',
397
+ language: '',
398
+ htmlUrl: `${this.publicBaseUrl}/${slug}`,
399
+ cloneUrl: `${this.smartHttpBaseUrl}/${slug}.git`,
400
+ sshUrl: `${this.sshBaseUrl}/${slug}.git`,
401
+ starsCount: 0,
402
+ forksCount: 0,
403
+ watchersCount: 0,
404
+ openIssuesCount: 0,
405
+ openPrCount: 0,
406
+ releaseCount: 0,
407
+ size: 0,
408
+ defaultBranch: repositoryArg.defaultBranch,
409
+ updatedAt: latestCommit?.createdAt ?? '',
410
+ createdAt: latestCommit?.createdAt ?? '',
411
+ archived: false,
412
+ topics: [],
413
+ };
414
+ return {
415
+ ...base,
416
+ ...metadata,
417
+ id: this.stableId(slug),
418
+ org: repositoryArg.org,
419
+ name: repositoryArg.repo,
420
+ slug,
421
+ defaultBranch: repositoryArg.defaultBranch,
422
+ };
423
+ }
424
+
425
+ private async getCommitFeed(
426
+ repositoriesArg: plugins.TGitManagerRepositoryInfo[],
427
+ perRepositoryLimitArg: number,
428
+ ): Promise<plugins.fossInterfaces.IPublicCommit[]> {
429
+ const groups = await this.mapWithConcurrency(repositoriesArg, async (repository) => {
430
+ const commits = await this.withFallback(
431
+ () => this.gitManager.listCommits({
432
+ org: repository.org,
433
+ repo: repository.repo,
434
+ ref: repository.defaultBranch,
435
+ limit: Math.min(this.maxCommits, perRepositoryLimitArg),
436
+ }),
437
+ [],
438
+ );
439
+ return commits.map((commit) => this.mapCommit(commit));
440
+ });
441
+ return groups
442
+ .flat()
443
+ .sort((commitA, commitB) => commitB.createdAt.localeCompare(commitA.createdAt))
444
+ .slice(0, this.maxCommits);
445
+ }
446
+
447
+ private mapCommit(
448
+ commitArg: plugins.TGitManagerCommitSummary,
449
+ ): plugins.fossInterfaces.IPublicCommit {
450
+ return {
451
+ sha: commitArg.sha,
452
+ shortSha: commitArg.shortSha,
453
+ message: commitArg.subject,
454
+ title: commitArg.subject,
455
+ body: '',
456
+ htmlUrl: this.repoPathUrl(
457
+ { org: commitArg.org, repo: commitArg.repo },
458
+ `commit/${commitArg.sha}`,
459
+ ),
460
+ createdAt: commitArg.createdAt,
461
+ authorName: commitArg.authorName,
462
+ authorLogin: '',
463
+ authorAvatarUrl: '',
464
+ repoSlug: `${commitArg.org}/${commitArg.repo}`,
465
+ files: [],
466
+ additions: 0,
467
+ deletions: 0,
468
+ verified: false,
469
+ kind: this.getActivityKind(commitArg.subject),
470
+ };
471
+ }
472
+
473
+ private async getCommitDetail(
474
+ repositoryArg: plugins.TGitManagerRepositoryInfo,
475
+ shaArg: string,
476
+ ): Promise<plugins.fossInterfaces.IPublicCommitDetail> {
477
+ const [commit, diff] = await Promise.all([
478
+ this.gitManager.getCommit({
479
+ org: repositoryArg.org,
480
+ repo: repositoryArg.repo,
481
+ ref: shaArg,
482
+ }),
483
+ this.gitManager.getDiff({
484
+ org: repositoryArg.org,
485
+ repo: repositoryArg.repo,
486
+ headRef: shaArg,
487
+ }),
488
+ ]);
489
+ return {
490
+ sha: commit.sha,
491
+ shortSha: commit.shortSha,
492
+ message: commit.message,
493
+ title: commit.subject,
494
+ body: commit.body,
495
+ htmlUrl: this.repoPathUrl(repositoryArg, `commit/${commit.sha}`),
496
+ createdAt: commit.createdAt,
497
+ authorName: commit.author.name,
498
+ authorLogin: '',
499
+ authorAvatarUrl: '',
500
+ verified: false,
501
+ parents: commit.parents.map((parent) => parent.sha),
502
+ additions: diff.additions,
503
+ deletions: diff.deletions,
504
+ files: diff.files.map((file) => ({
505
+ path: file.newPath || file.oldPath,
506
+ previousPath: file.oldPath,
507
+ status: this.mapDiffStatus(file.status),
508
+ language: this.languageForPath(file.newPath || file.oldPath),
509
+ additions: file.additions,
510
+ deletions: file.deletions,
511
+ isBinary: file.isBinary,
512
+ hunks: file.hunks.map((hunk) => ({
513
+ header: hunk.header,
514
+ lines: hunk.lines.map((line) => ({
515
+ kind: line.kind,
516
+ content: line.content,
517
+ oldNumber: line.oldNumber,
518
+ newNumber: line.newNumber,
519
+ })),
520
+ })),
521
+ })),
522
+ diffTruncated: diff.truncated,
523
+ };
524
+ }
525
+
526
+ private mapContent(
527
+ repositoryArg: plugins.TGitManagerRepositoryInfo,
528
+ entryArg: plugins.TGitManagerTreeEntry,
529
+ latestCommitArg?: plugins.fossInterfaces.IPublicCommit,
530
+ ): plugins.fossInterfaces.IPublicContentItem {
531
+ const type = entryArg.entryType === 'other' ? 'file' : entryArg.entryType;
532
+ return {
533
+ name: entryArg.name,
534
+ path: entryArg.path,
535
+ type,
536
+ size: entryArg.size ?? 0,
537
+ htmlUrl: this.repoPathUrl(
538
+ repositoryArg,
539
+ `src/${repositoryArg.defaultBranch}/${entryArg.path}`,
540
+ ),
541
+ downloadUrl: '',
542
+ lastCommitSha: latestCommitArg?.sha ?? '',
543
+ lastAuthorDate: latestCommitArg?.createdAt ?? '',
544
+ lastCommitMessage: latestCommitArg?.title ?? '',
545
+ };
546
+ }
547
+
548
+ private mapRelease(
549
+ repositoryArg: plugins.TGitManagerRepositoryInfo,
550
+ tagArg: plugins.TGitManagerTag,
551
+ ): plugins.fossInterfaces.IPublicRelease {
552
+ return {
553
+ tagName: tagArg.name,
554
+ name: tagArg.subject || tagArg.name,
555
+ body: tagArg.message,
556
+ htmlUrl: this.repoPathUrl(repositoryArg, `releases/tag/${tagArg.name}`),
557
+ publishedAt: tagArg.taggedAt,
558
+ prerelease: /(?:^|[-.])(alpha|beta|rc)(?:[-.]|$)/i.test(tagArg.name),
559
+ draft: false,
560
+ author: {
561
+ login: '',
562
+ fullName: tagArg.taggerName,
563
+ description: '',
564
+ website: '',
565
+ location: '',
566
+ avatarUrl: '',
567
+ htmlUrl: '',
568
+ createdAt: '',
569
+ },
570
+ assets: [],
571
+ };
572
+ }
573
+
574
+ private mapActivity(
575
+ commitArg: plugins.fossInterfaces.IPublicCommit,
576
+ ): plugins.fossInterfaces.IPublicActivityEntry {
577
+ return {
578
+ id: commitArg.sha,
579
+ org: commitArg.repoSlug.split('/')[0] ?? '',
580
+ repo: commitArg.repoSlug.split('/')[1] ?? '',
581
+ repoSlug: commitArg.repoSlug,
582
+ tagName: '',
583
+ version: '',
584
+ kind: commitArg.kind,
585
+ title: commitArg.title,
586
+ body: commitArg.body,
587
+ createdAt: commitArg.createdAt,
588
+ };
589
+ }
590
+
591
+ private async readReadme(repositoryArg: plugins.TGitManagerRepositoryInfo): Promise<string> {
592
+ for (const path of ['README.md', 'readme.md', 'Readme.md']) {
593
+ try {
594
+ const file = await this.gitManager.readFile({
595
+ org: repositoryArg.org,
596
+ repo: repositoryArg.repo,
597
+ ref: repositoryArg.defaultBranch,
598
+ path,
599
+ });
600
+ if (!file.isBinary) {
601
+ return Buffer.from(file.contentBase64, 'base64').toString('utf8');
602
+ }
603
+ } catch {
604
+ continue;
605
+ }
606
+ }
607
+ return '';
608
+ }
609
+
610
+ private async scanLanguages(
611
+ repositoryArg: plugins.TGitManagerRepositoryInfo,
612
+ ): Promise<Record<string, number>> {
613
+ const languages: Record<string, number> = {};
614
+ const pendingPaths = [''];
615
+ let fileCount = 0;
616
+ let scannedEntryCount = 0;
617
+ while (pendingPaths.length > 0 && scannedEntryCount < this.maxLanguageFiles) {
618
+ const path = pendingPaths.shift()!;
619
+ const entries = await this.withFallback(
620
+ () => this.gitManager.listTree({
621
+ org: repositoryArg.org,
622
+ repo: repositoryArg.repo,
623
+ ref: repositoryArg.defaultBranch,
624
+ path: path || undefined,
625
+ }),
626
+ [],
627
+ );
628
+ for (const entry of entries) {
629
+ if (scannedEntryCount >= this.maxLanguageFiles) {
630
+ break;
631
+ }
632
+ scannedEntryCount += 1;
633
+ if (entry.entryType === 'dir') {
634
+ if (entry.path.split('/').length <= 20) {
635
+ pendingPaths.push(entry.path);
636
+ }
637
+ continue;
638
+ }
639
+ if (entry.entryType !== 'file' || fileCount >= this.maxLanguageFiles) {
640
+ continue;
641
+ }
642
+ fileCount += 1;
643
+ const language = this.languageForPath(entry.path);
644
+ languages[language] = (languages[language] ?? 0) + (entry.size ?? 0);
645
+ }
646
+ }
647
+ return languages;
648
+ }
649
+
650
+ private getStats(
651
+ orgsArg: plugins.fossInterfaces.IPublicOrg[],
652
+ reposArg: plugins.fossInterfaces.IPublicRepo[],
653
+ ): plugins.fossInterfaces.IPublicStats {
654
+ return {
655
+ orgCount: orgsArg.length,
656
+ repoCount: reposArg.length,
657
+ releaseCount: reposArg.reduce((sum, repo) => sum + repo.releaseCount, 0),
658
+ openIssueCount: reposArg.reduce((sum, repo) => sum + repo.openIssuesCount, 0),
659
+ };
660
+ }
661
+
662
+ private getPrimaryLanguage(languagesArg: Record<string, number>): string {
663
+ return Object.entries(languagesArg)
664
+ .sort((entryA, entryB) => entryB[1] - entryA[1] || entryA[0].localeCompare(entryB[0]))[0]?.[0] ?? '';
665
+ }
666
+
667
+ private languageForPath(pathArg: string): string {
668
+ const extension = pathArg.toLowerCase().split('.').pop() ?? '';
669
+ const languages: Record<string, string> = {
670
+ ts: 'TypeScript',
671
+ tsx: 'TypeScript',
672
+ js: 'JavaScript',
673
+ jsx: 'JavaScript',
674
+ mjs: 'JavaScript',
675
+ cjs: 'JavaScript',
676
+ rs: 'Rust',
677
+ go: 'Go',
678
+ py: 'Python',
679
+ java: 'Java',
680
+ kt: 'Kotlin',
681
+ rb: 'Ruby',
682
+ php: 'PHP',
683
+ c: 'C',
684
+ h: 'C',
685
+ cc: 'C++',
686
+ cpp: 'C++',
687
+ hpp: 'C++',
688
+ cs: 'C#',
689
+ swift: 'Swift',
690
+ sh: 'Shell',
691
+ bash: 'Shell',
692
+ zsh: 'Shell',
693
+ html: 'HTML',
694
+ css: 'CSS',
695
+ scss: 'SCSS',
696
+ vue: 'Vue',
697
+ svelte: 'Svelte',
698
+ };
699
+ return languages[extension] ?? 'Other';
700
+ }
701
+
702
+ private getActivityKind(messageArg: string): plugins.fossInterfaces.TPublicActivityKind {
703
+ const prefix = messageArg.trim().toLowerCase().match(/^([a-z]+)(?:\([^)]*\))?[!:]/)?.[1];
704
+ switch (prefix) {
705
+ case 'feat':
706
+ case 'fix':
707
+ case 'perf':
708
+ case 'docs':
709
+ case 'test':
710
+ case 'refactor':
711
+ case 'build':
712
+ case 'ci':
713
+ case 'chore':
714
+ return prefix;
715
+ case 'release':
716
+ case 'rel':
717
+ return 'rel';
718
+ default:
719
+ return 'code';
720
+ }
721
+ }
722
+
723
+ private mapDiffStatus(
724
+ statusArg: plugins.TGitManagerDiff['files'][number]['status'],
725
+ ): plugins.fossInterfaces.TPublicDiffFileStatus {
726
+ switch (statusArg) {
727
+ case 'added':
728
+ case 'deleted':
729
+ case 'renamed':
730
+ return statusArg;
731
+ default:
732
+ return 'modified';
733
+ }
734
+ }
735
+
736
+ private repoPathUrl(
737
+ repositoryArg: Pick<plugins.TGitManagerRepositoryInfo, 'org' | 'repo'>,
738
+ suffixArg: string,
739
+ ): string {
740
+ const encodedSuffix = suffixArg
741
+ .split('/')
742
+ .map((segment) => encodeURIComponent(segment))
743
+ .join('/');
744
+ return `${this.publicBaseUrl}/${repositoryArg.org}/${repositoryArg.repo}/${encodedSuffix}`;
745
+ }
746
+
747
+ private stableId(valueArg: string): number {
748
+ let hash = 2166136261;
749
+ for (const char of valueArg) {
750
+ hash ^= char.charCodeAt(0);
751
+ hash = Math.imul(hash, 16777619);
752
+ }
753
+ return hash >>> 0;
754
+ }
755
+
756
+ private normalizeBaseUrl(valueArg: string, allowedProtocolsArg: string[]): string {
757
+ const value = valueArg.trim().replace(/\/+$/, '');
758
+ if (
759
+ !value
760
+ || value.length > 2048
761
+ || /[\u0000-\u0020\u007f]/.test(value)
762
+ ) {
763
+ throw new Error('Invalid base URL');
764
+ }
765
+ let parsed: URL;
766
+ try {
767
+ parsed = new URL(value);
768
+ } catch {
769
+ throw new Error('Invalid base URL');
770
+ }
771
+ if (
772
+ !allowedProtocolsArg.includes(parsed.protocol)
773
+ || parsed.password
774
+ || parsed.search
775
+ || parsed.hash
776
+ ) {
777
+ throw new Error('Invalid base URL');
778
+ }
779
+ return value;
780
+ }
781
+
782
+ private validateSlug(valueArg: string, labelArg: string, maxLengthArg: number): string {
783
+ const value = valueArg.trim();
784
+ if (
785
+ !value
786
+ || value.length > maxLengthArg
787
+ || value === '.'
788
+ || value === '..'
789
+ || !/^[A-Za-z0-9._-]+$/.test(value)
790
+ ) {
791
+ throw new Error(`Invalid ${labelArg}`);
792
+ }
793
+ return value;
794
+ }
795
+
796
+ private validatePath(valueArg: string, allowEmptyArg = true): string {
797
+ const path = valueArg.trim().replace(/^\/+|\/+$/g, '');
798
+ if (!path) {
799
+ if (allowEmptyArg) {
800
+ return '';
801
+ }
802
+ throw new Error('Path is required');
803
+ }
804
+ if (
805
+ path.length > 500
806
+ || path.includes('\\')
807
+ || path.split('/').some((segment) => !segment || segment === '.' || segment === '..')
808
+ ) {
809
+ throw new Error('Invalid path');
810
+ }
811
+ return path;
812
+ }
813
+
814
+ private validateLimit(
815
+ valueArg: number,
816
+ labelArg: string,
817
+ minimumArg: number,
818
+ maximumArg: number,
819
+ ): number {
820
+ if (!Number.isSafeInteger(valueArg) || valueArg < minimumArg || valueArg > maximumArg) {
821
+ throw new Error(`${labelArg} must be an integer between ${minimumArg} and ${maximumArg}`);
822
+ }
823
+ return valueArg;
824
+ }
825
+
826
+ private async withFallback<TValue>(
827
+ operationArg: () => Promise<TValue>,
828
+ fallbackArg: TValue,
829
+ ): Promise<TValue> {
830
+ try {
831
+ return await operationArg();
832
+ } catch {
833
+ return fallbackArg;
834
+ }
835
+ }
836
+
837
+ private async mapWithConcurrency<TInput, TOutput>(
838
+ itemsArg: TInput[],
839
+ mapperArg: (itemArg: TInput) => Promise<TOutput>,
840
+ ): Promise<TOutput[]> {
841
+ const results = new Array<TOutput>(itemsArg.length);
842
+ let nextIndex = 0;
843
+ const workers = Array.from(
844
+ { length: Math.min(this.maxConcurrency, itemsArg.length) },
845
+ async () => {
846
+ while (nextIndex < itemsArg.length) {
847
+ const index = nextIndex;
848
+ nextIndex += 1;
849
+ results[index] = await mapperArg(itemsArg[index]);
850
+ }
851
+ },
852
+ );
853
+ await Promise.all(workers);
854
+ return results;
855
+ }
856
+ }