@foss.global/codefeed 1.6.4 → 1.7.2

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