@foss.global/codefeed 1.6.5 → 1.8.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/ts/index.ts CHANGED
@@ -1,50 +1,310 @@
1
- import * as plugins from './codefeed.plugins.js';
1
+ import * as plugins from './plugins.js';
2
2
 
3
3
  export class CodeFeed {
4
4
  private baseUrl: string;
5
5
  private token?: string;
6
- private npmRegistry = new plugins.smartnpm.NpmRegistry();
7
- private smartxmlInstance = new plugins.smartxml.SmartXml();
8
6
  private lastRunTimestamp: string;
9
- private changelogContent: string;
10
-
11
- constructor(baseUrl: string, token?: string, lastRunTimestamp?: string) {
7
+ private pageLimit = 50;
8
+ // Raw changelog content for the current repository
9
+ private changelogContent: string = '';
10
+ private npmRegistry?: plugins.TNpmRegistry;
11
+ // In-memory stateful cache of commits
12
+ private enableCache: boolean = false;
13
+ private cacheWindowMs?: number;
14
+ private cache: plugins.interfaces.ICommitResult[] = [];
15
+ private provider: plugins.interfaces.ICodeFeedProvider;
16
+ // enable or disable npm publishedOnNpm checks (true by default)
17
+ private enableNpmCheck: boolean = true;
18
+ // return only tagged commits (false by default)
19
+ private enableTaggedOnly: boolean = false;
20
+ // allow/deny filters
21
+ private orgAllowlist?: string[];
22
+ private orgDenylist?: string[];
23
+ private repoAllowlist?: string[]; // entries like "org/repo"
24
+ private repoDenylist?: string[]; // entries like "org/repo"
25
+ private untilTimestamp?: string; // optional upper bound on commit timestamps
26
+ private verbose?: boolean; // optional metrics logging
27
+
28
+ constructor(
29
+ baseUrl: string,
30
+ token?: string,
31
+ lastRunTimestamp?: string,
32
+ options?: plugins.interfaces.ICodeFeedOptions
33
+ ) {
12
34
  this.baseUrl = baseUrl;
13
35
  this.token = token;
14
36
  this.lastRunTimestamp =
15
- lastRunTimestamp || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
37
+ lastRunTimestamp ?? new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
38
+ // configure stateful caching
39
+ this.enableCache = options?.enableCache ?? false;
40
+ this.cacheWindowMs = options?.cacheWindowMs;
41
+ this.enableNpmCheck = options?.enableNpmCheck ?? true;
42
+ this.enableTaggedOnly = options?.taggedOnly ?? false;
43
+ this.orgAllowlist = options?.orgAllowlist;
44
+ this.orgDenylist = options?.orgDenylist;
45
+ this.repoAllowlist = options?.repoAllowlist;
46
+ this.repoDenylist = options?.repoDenylist;
47
+ this.untilTimestamp = options?.untilTimestamp;
48
+ this.verbose = options?.verbose ?? false;
49
+ this.cache = [];
50
+ this.provider = options?.provider ?? this.createDefaultProvider();
16
51
  console.log('CodeFeed initialized with last run timestamp:', this.lastRunTimestamp);
17
52
  }
18
53
 
19
54
  /**
20
- * Load the changelog directly from the Gitea repository.
55
+ * Fetch all new commits (since lastRunTimestamp) across all orgs and repos.
21
56
  */
22
- private async loadChangelogFromRepo(owner: string, repo: string): Promise<void> {
23
- const url = `/api/v1/repos/${owner}/${repo}/contents/changelog.md`;
24
- const headers: Record<string, string> = {};
25
- if (this.token) {
26
- headers['Authorization'] = `token ${this.token}`;
57
+ public async fetchAllCommitsFromInstance(): Promise<plugins.interfaces.ICommitResult[]> {
58
+ // Controlled concurrency with AsyncExecutionStack
59
+ const stack = new plugins.lik.AsyncExecutionStack();
60
+ stack.setNonExclusiveMaxConcurrency(20);
61
+ // determine since timestamp for this run (stateful caching)
62
+ let effectiveSince = this.lastRunTimestamp;
63
+ if (this.enableCache && this.cache.length > 0) {
64
+ // use newest timestamp in cache to fetch only tail
65
+ effectiveSince = this.cache.reduce(
66
+ (max, c) => (c.timestamp > max ? c.timestamp : max),
67
+ effectiveSince
68
+ );
69
+ }
70
+
71
+ // 1) get all organizations
72
+ let orgs = await this.provider.listOrganizations();
73
+ // apply allow/deny filters
74
+ if (this.orgAllowlist && this.orgAllowlist.length > 0) {
75
+ orgs = orgs.filter((o) => this.orgAllowlist!.includes(o));
76
+ }
77
+ if (this.orgDenylist && this.orgDenylist.length > 0) {
78
+ orgs = orgs.filter((o) => !this.orgDenylist!.includes(o));
27
79
  }
28
80
 
29
- const response = await this.fetchFunction(url, { headers });
81
+ // 2) fetch repos per org in parallel
82
+ const repoLists = await Promise.all(
83
+ orgs.map((org) =>
84
+ stack.getNonExclusiveExecutionSlot(() => this.provider.listRepositoriesForOrg(org))
85
+ )
86
+ );
87
+ // flatten to [{ owner, name }]
88
+ let allRepos = orgs.flatMap((org, i) =>
89
+ repoLists[i].map((r) => ({ owner: org, name: r.name }))
90
+ );
91
+ // apply repo allow/deny filters using slug "org/repo"
92
+ if (this.repoAllowlist && this.repoAllowlist.length > 0) {
93
+ const allow = new Set(this.repoAllowlist.map((s) => s.toLowerCase()));
94
+ allRepos = allRepos.filter(({ owner, name }) => allow.has(`${owner}/${name}`.toLowerCase()));
95
+ }
96
+ if (this.repoDenylist && this.repoDenylist.length > 0) {
97
+ const deny = new Set(this.repoDenylist.map((s) => s.toLowerCase()));
98
+ allRepos = allRepos.filter(({ owner, name }) => !deny.has(`${owner}/${name}`.toLowerCase()));
99
+ }
30
100
 
31
- if (!response.ok) {
32
- console.error(
33
- `Could not fetch CHANGELOG.md from ${owner}/${repo}: ${response.status} ${response.statusText}`
101
+ // 3) probe latest commit per repo and fetch full list only if new commits exist
102
+ const commitJobs = allRepos.map(({ owner, name }) =>
103
+ stack.getNonExclusiveExecutionSlot(async () => {
104
+ try {
105
+ const latestCommit = await this.provider.getLatestCommitForRepo(owner, name);
106
+ // If no commits or no new commits since last run, skip
107
+ if (
108
+ !latestCommit ||
109
+ new Date(latestCommit.commit.author.date).getTime() <= new Date(effectiveSince).getTime()
110
+ ) {
111
+ return { owner, name, commits: [] };
112
+ }
113
+ // 3b) Fetch commits since last run
114
+ const commits = await this.provider.listCommitsForRepo(
115
+ owner,
116
+ name,
117
+ effectiveSince
118
+ );
119
+ return { owner, name, commits };
120
+ } catch (e: any) {
121
+ console.error(`Failed to fetch commits for ${owner}/${name}:`, e.message);
122
+ return { owner, name, commits: [] };
123
+ }
124
+ })
125
+ );
126
+ const commitResults = await Promise.all(commitJobs);
127
+
128
+ // 4) build new commit entries with tagging, npm and changelog support
129
+ const newResults: plugins.interfaces.ICommitResult[] = [];
130
+ let reposWithNewCommits = 0;
131
+ for (const { owner, name, commits } of commitResults) {
132
+ // skip repos with no new commits
133
+ if (commits.length === 0) {
134
+ this.changelogContent = '';
135
+ continue;
136
+ }
137
+ reposWithNewCommits++;
138
+ // load changelog for this repo
139
+ this.changelogContent = await this.provider.readChangelogForRepo(owner, name);
140
+ // fetch tags for this repo
141
+ let taggedShas: Set<string>;
142
+ let tagNameBySha: Map<string, string>;
143
+ try {
144
+ const tagInfo = await this.provider.listTagsForRepo(owner, name);
145
+ taggedShas = tagInfo.shas;
146
+ tagNameBySha = tagInfo.map;
147
+ } catch (e: any) {
148
+ console.error(`Failed to fetch tags for ${owner}/${name}:`, e.message);
149
+ taggedShas = new Set<string>();
150
+ tagNameBySha = new Map<string, string>();
151
+ }
152
+ // fetch npm package info only if any new commits correspond to a tag
153
+ const hasTaggedCommit = commits.some((c) => taggedShas.has(c.sha));
154
+ let pkgInfo: { allVersions: Array<{ version: string }> } | null = null;
155
+ if (hasTaggedCommit && this.enableNpmCheck) {
156
+ try {
157
+ const npmRegistry = await this.getNpmRegistry();
158
+ pkgInfo = await npmRegistry.getPackageInfo(`@${owner}/${name}`);
159
+ } catch (e: any) {
160
+ console.error(`Failed to fetch package info for ${owner}/${name}:`, e.message);
161
+ pkgInfo = null;
162
+ }
163
+ }
164
+ // build commit entries
165
+ for (const c of commits) {
166
+ const isTagged = taggedShas.has(c.sha);
167
+ // derive version from tag name if present (strip leading 'v')
168
+ let versionFromTag: string | undefined;
169
+ if (isTagged) {
170
+ const tagName = tagNameBySha.get(c.sha);
171
+ if (tagName) {
172
+ versionFromTag = tagName.startsWith('v') ? tagName.substring(1) : tagName;
173
+ }
174
+ }
175
+ const publishedOnNpm = isTagged && pkgInfo && versionFromTag
176
+ ? pkgInfo.allVersions.some((v) => v.version === versionFromTag)
177
+ : false;
178
+ let changelogEntry: string | undefined;
179
+ if (this.changelogContent) {
180
+ if (versionFromTag) {
181
+ changelogEntry = this.getChangelogForVersion(versionFromTag);
182
+ }
183
+ }
184
+ // optionally enforce an upper bound on commit timestamps
185
+ if (this.untilTimestamp) {
186
+ const ts = new Date(c.commit.author.date).getTime();
187
+ if (ts > new Date(this.untilTimestamp).getTime()) {
188
+ continue;
189
+ }
190
+ }
191
+ newResults.push({
192
+ baseUrl: this.baseUrl,
193
+ org: owner,
194
+ repo: name,
195
+ timestamp: c.commit.author.date,
196
+ prettyAgoTime: plugins.smarttime.getMilliSecondsAsHumanReadableAgoTime(
197
+ new Date(c.commit.author.date).getTime()
198
+ ),
199
+ hash: c.sha,
200
+ commitMessage: c.commit.message,
201
+ tagged: isTagged,
202
+ publishedOnNpm,
203
+ changelog: changelogEntry,
204
+ });
205
+ }
206
+ }
207
+ // if caching is enabled, merge into in-memory cache and return full cache
208
+ if (this.enableCache) {
209
+ const existingHashes = new Set(this.cache.map((c) => c.hash));
210
+ const uniqueNew = newResults.filter((c) => !existingHashes.has(c.hash));
211
+ this.cache.push(...uniqueNew);
212
+ // trim commits older than window
213
+ if (this.cacheWindowMs !== undefined) {
214
+ const cutoff = Date.now() - this.cacheWindowMs;
215
+ this.cache = this.cache.filter((c) => new Date(c.timestamp).getTime() >= cutoff);
216
+ }
217
+ // advance lastRunTimestamp to now
218
+ this.lastRunTimestamp = new Date().toISOString();
219
+ // sort descending by timestamp
220
+ this.cache.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
221
+ // apply tagged-only filter if requested
222
+ if (this.enableTaggedOnly) {
223
+ return this.cache.filter((c) => c.tagged === true);
224
+ }
225
+ if (this.verbose) {
226
+ console.log(
227
+ `[CodeFeed] orgs=${orgs.length} repos=${allRepos.length} reposWithNew=${reposWithNewCommits} commits=${this.cache.length} (cached)`
228
+ );
229
+ }
230
+ return this.cache;
231
+ }
232
+ // no caching: apply tagged-only filter if requested
233
+ // sort and dedupe
234
+ const seen = new Set<string>();
235
+ const unique = newResults.filter((c) => {
236
+ if (seen.has(c.hash)) return false;
237
+ seen.add(c.hash);
238
+ return true;
239
+ });
240
+ unique.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
241
+ const result = this.enableTaggedOnly ? unique.filter((c) => c.tagged === true) : unique;
242
+ if (this.verbose) {
243
+ console.log(
244
+ `[CodeFeed] orgs=${orgs.length} repos=${allRepos.length} reposWithNew=${reposWithNewCommits} commits=${result.length}`
34
245
  );
35
- this.changelogContent = '';
36
- return;
37
246
  }
247
+ return result;
248
+ }
249
+
250
+ /**
251
+ * Load the changelog directly from the Gitea repository.
252
+ */
253
+ private createDefaultProvider(): plugins.interfaces.ICodeFeedProvider {
254
+ return {
255
+ listOrganizations: () => this.fetchAllOrganizations(),
256
+ listRepositoriesForOrg: (org) => this.fetchRepositoriesForOrg(org),
257
+ getLatestCommitForRepo: (owner, repo) => this.fetchLatestCommitForRepo(owner, repo),
258
+ listCommitsForRepo: (owner, repo, sinceTimestamp) => this.fetchRecentCommitsForRepo(owner, repo, sinceTimestamp),
259
+ listTagsForRepo: (owner, repo) => this.fetchTags(owner, repo),
260
+ readChangelogForRepo: async (owner, repo) => {
261
+ await this.loadChangelogFromRepo(owner, repo);
262
+ return this.changelogContent;
263
+ },
264
+ };
265
+ }
38
266
 
39
- const data = await response.json();
40
- if (!data.content) {
41
- console.warn(`No content field found in response for ${owner}/${repo}/changelog.md`);
42
- this.changelogContent = '';
43
- return;
267
+ private createAuthHeaders(): Record<string, string> {
268
+ const headers: Record<string, string> = {};
269
+ if (this.token) {
270
+ headers.Authorization = `token ${this.token}`;
44
271
  }
272
+ return headers;
273
+ }
45
274
 
46
- const decodedContent = Buffer.from(data.content, 'base64').toString('utf8');
47
- this.changelogContent = decodedContent;
275
+ private async getNpmRegistry(): Promise<plugins.TNpmRegistry> {
276
+ if (!this.npmRegistry) {
277
+ const smartnpm = await plugins.getSmartnpm();
278
+ this.npmRegistry = new smartnpm.NpmRegistry();
279
+ }
280
+ return this.npmRegistry;
281
+ }
282
+
283
+ private async loadChangelogFromRepo(owner: string, repo: string): Promise<void> {
284
+ const headers = this.createAuthHeaders();
285
+ const candidates = [
286
+ 'CHANGELOG.md',
287
+ 'changelog.md',
288
+ 'Changelog.md',
289
+ 'docs/CHANGELOG.md',
290
+ ];
291
+ for (const path of candidates) {
292
+ const url = `/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`;
293
+ const response = await this.fetchFunction(url, { headers });
294
+ if (!response.ok) {
295
+ continue;
296
+ }
297
+ try {
298
+ const data = await response.json();
299
+ if (data && data.content) {
300
+ this.changelogContent = Buffer.from(data.content, 'base64').toString('utf8');
301
+ return;
302
+ }
303
+ } catch {
304
+ // continue trying others
305
+ }
306
+ }
307
+ this.changelogContent = '';
48
308
  }
49
309
 
50
310
  /**
@@ -78,294 +338,137 @@ export class CodeFeed {
78
338
 
79
339
  return changelogLines.join('\n').trim();
80
340
  }
81
-
82
- private async fetchAllOrganizations(): Promise<string[]> {
83
- const url = `/api/v1/orgs`;
84
- const response = await this.fetchFunction(url, {
85
- headers: this.token ? { Authorization: `token ${this.token}` } : {},
86
- });
87
-
88
- if (!response.ok) {
89
- throw new Error(`Failed to fetch organizations: ${response.statusText}`);
90
- }
91
-
92
- const data: { username: string }[] = await response.json();
93
- return data.map((org) => org.username);
94
- }
95
-
96
- private async fetchOrgRssFeed(optionsArg: {
97
- orgName: string;
98
- repoName?: string;
99
- }): Promise<any[]> {
100
- let rssUrl: string;
101
- if (optionsArg.orgName && !optionsArg.repoName) {
102
- rssUrl = `/${optionsArg.orgName}.atom`;
103
- } else if (optionsArg.orgName && optionsArg.repoName) {
104
- rssUrl = `/${optionsArg.orgName}/${optionsArg.repoName}.atom`;
105
- } else {
106
- throw new Error('Invalid arguments provided to fetchOrgRssFeed.');
107
- }
108
-
109
- const response = await this.fetchFunction(rssUrl, {});
110
- if (!response.ok) {
111
- throw new Error(
112
- `Failed to fetch RSS feed for organization ${optionsArg.orgName}/${optionsArg.repoName}: ${response.statusText}`
113
- );
114
- }
115
-
116
- const rssText = await response.text();
117
- const rssData = this.smartxmlInstance.parseXmlToObject(rssText);
118
- return rssData.feed.entry || [];
119
- }
120
-
121
- private async hasNewActivity(optionsArg: {
122
- orgName: string;
123
- repoName?: string;
124
- }): Promise<boolean> {
125
- const entries = await this.fetchOrgRssFeed(optionsArg);
126
-
127
- return entries.some((entry: any) => {
128
- const updated = new Date(entry.updated);
129
- return updated > new Date(this.lastRunTimestamp);
130
- });
131
- }
132
-
133
- private async fetchAllRepositories(): Promise<plugins.interfaces.IRepository[]> {
341
+ /**
342
+ * Fetch all tags for a given repo and return the set of tagged commit SHAs
343
+ */
344
+ private async fetchTags(owner: string, repo: string): Promise<plugins.interfaces.ICodeFeedTagInfo> {
345
+ const taggedShas = new Set<string>();
346
+ const tagNameBySha = new Map<string, string>();
134
347
  let page = 1;
135
- const allRepos: plugins.interfaces.IRepository[] = [];
136
-
137
348
  while (true) {
138
- const url = `/api/v1/repos/search?limit=50&page=${page.toString()}`;
139
-
349
+ const url = `/api/v1/repos/${owner}/${repo}/tags?limit=${this.pageLimit}&page=${page}`;
140
350
  const resp = await this.fetchFunction(url, {
141
- headers: this.token ? { Authorization: `token ${this.token}` } : {},
351
+ headers: this.createAuthHeaders(),
142
352
  });
143
-
144
353
  if (!resp.ok) {
145
- throw new Error(`Failed to fetch repositories: ${resp.statusText}`);
354
+ console.error(`Failed to fetch tags for ${owner}/${repo}: ${resp.status} ${resp.statusText}`);
355
+ return { shas: taggedShas, map: tagNameBySha };
146
356
  }
147
-
148
- const data: plugins.interfaces.IRepoSearchResponse = await resp.json();
149
- allRepos.push(...data.data);
150
-
151
- if (data.data.length < 50) {
152
- break;
357
+ const data: plugins.interfaces.ITag[] = await resp.json();
358
+ if (data.length === 0) break;
359
+ for (const t of data) {
360
+ const sha = t.commit?.sha;
361
+ if (sha) {
362
+ taggedShas.add(sha);
363
+ if (t.name) tagNameBySha.set(sha, t.name);
364
+ }
153
365
  }
366
+ if (data.length < this.pageLimit) break;
154
367
  page++;
155
368
  }
369
+ return { shas: taggedShas, map: tagNameBySha };
370
+ }
156
371
 
157
- return allRepos;
372
+ private async fetchLatestCommitForRepo(owner: string, repo: string): Promise<plugins.interfaces.ICommit | undefined> {
373
+ const resp = await this.fetchFunction(
374
+ `/api/v1/repos/${owner}/${repo}/commits?limit=1`,
375
+ { headers: this.createAuthHeaders() }
376
+ );
377
+ if (!resp.ok) {
378
+ throw new Error(`Probe failed for ${owner}/${repo}: ${resp.statusText}`);
379
+ }
380
+ const data: plugins.interfaces.ICommit[] = await resp.json();
381
+ return data[0];
158
382
  }
159
383
 
160
- private async fetchTags(owner: string, repo: string): Promise<Set<string>> {
384
+ private async fetchAllOrganizations(): Promise<string[]> {
385
+ const headers = this.createAuthHeaders();
161
386
  let page = 1;
162
- const tags: plugins.interfaces.ITag[] = [];
163
-
387
+ const orgs: string[] = [];
164
388
  while (true) {
165
- const url = `/api/v1/repos/${owner}/${repo}/tags?limit=50&page=${page.toString()}`;
166
-
167
- const resp = await this.fetchFunction(url, {
168
- headers: this.token ? { Authorization: `token ${this.token}` } : {},
169
- });
170
-
389
+ const resp = await this.fetchFunction(`/api/v1/orgs?limit=${this.pageLimit}&page=${page}`, { headers });
171
390
  if (!resp.ok) {
172
- console.error(
173
- `Failed to fetch tags for ${owner}/${repo}: ${resp.status} ${resp.statusText} at ${url}`
174
- );
175
- throw new Error(`Failed to fetch tags for ${owner}/${repo}: ${resp.statusText}`);
176
- }
177
-
178
- const data: plugins.interfaces.ITag[] = await resp.json();
179
- tags.push(...data);
180
-
181
- if (data.length < 50) {
182
- break;
391
+ throw new Error(`Failed to fetch organizations: ${resp.status} ${resp.statusText}`);
183
392
  }
393
+ const data: { username: string }[] = await resp.json();
394
+ if (data.length === 0) break;
395
+ orgs.push(...data.map((o) => o.username));
396
+ if (data.length < this.pageLimit) break;
184
397
  page++;
185
398
  }
399
+ return orgs;
400
+ }
186
401
 
187
- const taggedCommitShas = new Set<string>();
188
- for (const t of tags) {
189
- if (t.commit?.sha) {
190
- taggedCommitShas.add(t.commit.sha);
402
+ private async fetchRepositoriesForOrg(org: string): Promise<plugins.interfaces.IRepository[]> {
403
+ const headers = this.createAuthHeaders();
404
+ let page = 1;
405
+ const repos: plugins.interfaces.IRepository[] = [];
406
+ while (true) {
407
+ const resp = await this.fetchFunction(`/api/v1/orgs/${org}/repos?limit=${this.pageLimit}&page=${page}`, { headers });
408
+ if (!resp.ok) {
409
+ throw new Error(`Failed to fetch repositories for ${org}: ${resp.status} ${resp.statusText}`);
191
410
  }
411
+ const data: plugins.interfaces.IRepository[] = await resp.json();
412
+ if (data.length === 0) break;
413
+ repos.push(...data);
414
+ if (data.length < this.pageLimit) break;
415
+ page++;
192
416
  }
193
-
194
- return taggedCommitShas;
417
+ return repos;
195
418
  }
196
419
 
197
420
  private async fetchRecentCommitsForRepo(
198
421
  owner: string,
199
- repo: string
422
+ repo: string,
423
+ sinceTimestamp?: string
200
424
  ): Promise<plugins.interfaces.ICommit[]> {
201
- const commitTimeframe = new Date(Date.now() - (7 * 24 * 60 * 60 * 1000));
425
+ const since = sinceTimestamp ?? this.lastRunTimestamp;
426
+ const headers = this.createAuthHeaders();
202
427
  let page = 1;
203
- const recentCommits: plugins.interfaces.ICommit[] = [];
204
-
428
+ const commits: plugins.interfaces.ICommit[] = [];
205
429
  while (true) {
206
- const url = `/api/v1/repos/${owner}/${repo}/commits?limit=50&page=${page.toString()}`;
207
-
208
- const resp = await this.fetchFunction(url, {
209
- headers: this.token ? { Authorization: `token ${this.token}` } : {},
210
- });
211
-
430
+ const url = `/api/v1/repos/${owner}/${repo}/commits?since=${encodeURIComponent(since)}&limit=${this.pageLimit}&page=${page}`;
431
+ const resp = await this.fetchFunction(url, { headers });
212
432
  if (!resp.ok) {
213
- console.error(
214
- `Failed to fetch commits for ${owner}/${repo}: ${resp.status} ${resp.statusText} at ${url}`
215
- );
216
- throw new Error(`Failed to fetch commits for ${owner}/${repo}: ${resp.statusText}`);
433
+ throw new Error(`Failed to fetch commits for ${owner}/${repo}: ${resp.status} ${resp.statusText}`);
217
434
  }
218
-
219
435
  const data: plugins.interfaces.ICommit[] = await resp.json();
220
- if (data.length === 0) {
221
- break;
222
- }
223
-
224
- for (const commit of data) {
225
- const commitDate = new Date(commit.commit.author.date);
226
- if (commitDate > commitTimeframe) {
227
- recentCommits.push(commit);
228
- } else {
229
- return recentCommits;
230
- }
231
- }
232
-
436
+ if (data.length === 0) break;
437
+ commits.push(...data);
438
+ if (data.length < this.pageLimit) break;
233
439
  page++;
234
440
  }
235
-
236
- return recentCommits;
441
+ return commits;
237
442
  }
238
443
 
239
- public async fetchAllCommitsFromInstance(): Promise<plugins.interfaces.ICommitResult[]> {
240
- const orgs = await this.fetchAllOrganizations();
241
- console.log(`Found ${orgs.length} organizations`);
242
- let allCommits: plugins.interfaces.ICommitResult[] = [];
243
-
244
- for (const orgName of orgs) {
245
- console.log(`Checking activity for organization: ${orgName}`);
246
-
444
+ public async fetchFunction(
445
+ urlArg: string,
446
+ optionsArg: RequestInit = {}
447
+ ): Promise<Response> {
448
+ const maxAttempts = 4;
449
+ let attempt = 0;
450
+ let lastError: any;
451
+ while (attempt < maxAttempts) {
247
452
  try {
248
- const hasActivity = await this.hasNewActivity({
249
- orgName,
250
- });
251
- if (!hasActivity) {
252
- console.log(`No new activity for organization: ${orgName}`);
253
- continue;
254
- }
255
- } catch (error: any) {
256
- console.error(`Error fetching activity for organization ${orgName}:`, error.message);
257
- continue;
258
- }
259
-
260
- console.log(`New activity detected for organization: ${orgName}. Processing repositories...`);
261
-
262
- const repos = await this.fetchAllRepositories();
263
- for (const r of repos.filter((repo) => repo.owner.login === orgName)) {
264
- try {
265
- const hasActivity = await this.hasNewActivity({
266
- orgName,
267
- repoName: r.name,
268
- });
269
- if (!hasActivity) {
270
- console.log(`No new activity for repository: ${orgName}/${r.name}`);
271
- continue;
272
- }
273
- } catch (error: any) {
274
- console.error(
275
- `Error fetching activity for repository ${orgName}/${r.name}:`,
276
- error.message
277
- );
453
+ const resp = await fetch(`${this.baseUrl}${urlArg}`, optionsArg);
454
+ // retry on 429 and 5xx
455
+ if (resp.status === 429 || resp.status >= 500) {
456
+ const retryAfter = Number(resp.headers.get('retry-after'));
457
+ const backoffMs = retryAfter
458
+ ? retryAfter * 1000
459
+ : Math.min(32000, 1000 * Math.pow(2, attempt)) + Math.floor(Math.random() * 250);
460
+ await new Promise((r) => setTimeout(r, backoffMs));
461
+ attempt++;
278
462
  continue;
279
463
  }
280
-
281
- const org = r.owner.login;
282
- const repo = r.name;
283
- console.log(`Processing repository ${org}/${repo}`);
284
-
285
- try {
286
- const taggedCommitShas = await this.fetchTags(org, repo);
287
- const commits = await this.fetchRecentCommitsForRepo(org, repo);
288
-
289
- // Load the changelog from this repo.
290
- await this.loadChangelogFromRepo(org, repo);
291
-
292
- const commitResults = commits.map((c) => {
293
- const commit: plugins.interfaces.ICommitResult = {
294
- baseUrl: this.baseUrl,
295
- org,
296
- repo,
297
- timestamp: c.commit.author.date,
298
- prettyAgoTime: plugins.smarttime.getMilliSecondsAsHumanReadableAgoTime(
299
- new Date(c.commit.author.date).getTime()
300
- ),
301
- hash: c.sha,
302
- commitMessage: c.commit.message,
303
- tagged: taggedCommitShas.has(c.sha),
304
- publishedOnNpm: false,
305
- changelog: undefined,
306
- };
307
- return commit;
308
- });
309
-
310
- if (commitResults.length > 0) {
311
- try {
312
- const packageInfo = await this.npmRegistry.getPackageInfo(`@${org}/${repo}`);
313
- for (const commitResult of commitResults.filter((c) => c.tagged)) {
314
- const versionCandidate = commitResult.commitMessage.replace('\n', '').trim();
315
- const correspondingVersion = packageInfo.allVersions.find((versionArg) => {
316
- return versionArg.version === versionCandidate;
317
- });
318
- if (correspondingVersion) {
319
- commitResult.publishedOnNpm = true;
320
- }
321
- }
322
- } catch (error: any) {
323
- console.error(`Failed to fetch package info for ${org}/${repo}:`, error.message);
324
- }
325
-
326
- try {
327
- for (const commitResult of commitResults.filter((c) => c.tagged)) {
328
- const versionCandidate = commitResult.commitMessage.replace('\n', '').trim();
329
- const changelogEntry = this.getChangelogForVersion(versionCandidate);
330
- if (changelogEntry) {
331
- commitResult.changelog = changelogEntry;
332
- }
333
- }
334
- } catch (error: any) {
335
- console.error(`Failed to fetch changelog info for ${org}/${repo}:`, error.message);
336
- }
337
- }
338
-
339
- allCommits.push(...commitResults);
340
- } catch (error: any) {
341
- console.error(`Skipping repository ${org}/${repo} due to error:`, error.message);
342
- }
464
+ return resp;
465
+ } catch (e: any) {
466
+ lastError = e;
467
+ const backoffMs = Math.min(32000, 1000 * Math.pow(2, attempt)) + Math.floor(Math.random() * 250);
468
+ await new Promise((r) => setTimeout(r, backoffMs));
469
+ attempt++;
343
470
  }
344
471
  }
345
-
346
- console.log(`Processed ${allCommits.length} commits in total.`);
347
-
348
- allCommits = allCommits
349
- .filter((commitArg) => commitArg.tagged)
350
- .sort((a, b) => b.timestamp.localeCompare(a.timestamp));
351
-
352
- console.log(`Filtered to ${allCommits.length} commits with tagged statuses.`);
353
-
354
- for (const c of allCommits) {
355
- console.log(` ==========================================================================
356
- ${c.prettyAgoTime} ago:
357
- ${c.org}/${c.repo}
358
- ${c.commitMessage}
359
- Published on npm: ${c.publishedOnNpm}
360
- ${c.changelog ? `Changelog:\n${c.changelog}\n` : ''}
361
- `);
362
- }
363
-
364
- return allCommits;
365
- }
366
-
367
- public async fetchFunction(urlArg: string, optionsArg: RequestInit): Promise<Response> {
368
- const response = await fetch(`${this.baseUrl}${urlArg}`, optionsArg);
369
- return response;
472
+ throw new Error(`fetchFunction failed after retries for ${urlArg}: ${lastError?.message ?? 'unknown error'}`);
370
473
  }
371
474
  }