@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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +36 -10
- package/dist_ts/index.js +309 -211
- package/dist_ts/interfaces/index.d.ts +1 -0
- package/dist_ts/plugins.d.ts +8 -0
- package/dist_ts/plugins.js +11 -0
- package/package.json +11 -10
- package/readme.md +70 -101
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +358 -276
- package/ts/interfaces/index.ts +1 -0
- package/ts/{codefeed.plugins.ts → plugins.ts} +2 -0
package/ts/index.ts
CHANGED
|
@@ -1,50 +1,301 @@
|
|
|
1
|
-
import * as plugins from './
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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
|
-
*
|
|
67
|
+
* Fetch all new commits (since lastRunTimestamp) across all orgs and repos.
|
|
21
68
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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/
|
|
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
|
-
|
|
345
|
+
console.error(`Failed to fetch tags for ${owner}/${repo}: ${resp.status} ${resp.statusText}`);
|
|
346
|
+
return { shas: taggedShas, map: tagNameBySha };
|
|
146
347
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
363
|
+
private async fetchAllOrganizations(): Promise<string[]> {
|
|
364
|
+
const headers = this.token ? { Authorization: `token ${this.token}` } : {};
|
|
161
365
|
let page = 1;
|
|
162
|
-
const
|
|
163
|
-
|
|
366
|
+
const orgs: string[] = [];
|
|
164
367
|
while (true) {
|
|
165
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
404
|
+
const since = sinceTimestamp ?? this.lastRunTimestamp;
|
|
405
|
+
const headers = this.token ? { Authorization: `token ${this.token}` } : {};
|
|
202
406
|
let page = 1;
|
|
203
|
-
const
|
|
204
|
-
|
|
407
|
+
const commits: plugins.interfaces.ICommit[] = [];
|
|
205
408
|
while (true) {
|
|
206
|
-
const url = `/api/v1/repos/${owner}/${repo}/commits?limit
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
}
|