@foss.global/codefeed 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.smartconfig.json +1 -1
- package/changelog.md +168 -0
- package/dist_ts/00_commitinfo_data.js +2 -2
- package/dist_ts/classes.gitmanagerpublicclient.d.ts +62 -0
- package/dist_ts/classes.gitmanagerpublicclient.js +648 -0
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/plugins.d.ts +7 -1
- package/dist_ts/plugins.js +2 -2
- package/package.json +7 -6
- package/readme.md +59 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.gitmanagerpublicclient.ts +856 -0
- package/ts/index.ts +2 -0
- package/ts/plugins.ts +8 -1
|
@@ -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
|
+
}
|