@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/{npmextra.json → .smartconfig.json} +15 -7
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +30 -10
- package/dist_ts/index.js +336 -211
- package/dist_ts/interfaces/index.d.ts +29 -11
- package/dist_ts/plugins.d.ts +7 -0
- package/dist_ts/plugins.js +9 -0
- package/license.md +21 -0
- package/package.json +15 -13
- package/readme.md +87 -91
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +378 -275
- package/ts/interfaces/index.ts +33 -11
- package/ts/plugins.ts +19 -0
- package/dist_ts/codefeed.plugins.d.ts +0 -7
- package/dist_ts/codefeed.plugins.js +0 -10
- package/ts/codefeed.plugins.ts +0 -19
package/ts/index.ts
CHANGED
|
@@ -1,50 +1,310 @@
|
|
|
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
|
+
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
|
|
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
|
-
*
|
|
55
|
+
* Fetch all new commits (since lastRunTimestamp) across all orgs and repos.
|
|
21
56
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
this.
|
|
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
|
-
|
|
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[]> {
|
|
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/
|
|
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.
|
|
351
|
+
headers: this.createAuthHeaders(),
|
|
142
352
|
});
|
|
143
|
-
|
|
144
353
|
if (!resp.ok) {
|
|
145
|
-
|
|
354
|
+
console.error(`Failed to fetch tags for ${owner}/${repo}: ${resp.status} ${resp.statusText}`);
|
|
355
|
+
return { shas: taggedShas, map: tagNameBySha };
|
|
146
356
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
384
|
+
private async fetchAllOrganizations(): Promise<string[]> {
|
|
385
|
+
const headers = this.createAuthHeaders();
|
|
161
386
|
let page = 1;
|
|
162
|
-
const
|
|
163
|
-
|
|
387
|
+
const orgs: string[] = [];
|
|
164
388
|
while (true) {
|
|
165
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
425
|
+
const since = sinceTimestamp ?? this.lastRunTimestamp;
|
|
426
|
+
const headers = this.createAuthHeaders();
|
|
202
427
|
let page = 1;
|
|
203
|
-
const
|
|
204
|
-
|
|
428
|
+
const commits: plugins.interfaces.ICommit[] = [];
|
|
205
429
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
}
|