@swisspost/design-system-changelog-github 10.0.0-next.72 → 10.0.0-next.76
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/CHANGELOG.md +12 -0
- package/dist/index.js +252 -13
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @swisspost/design-system-changelog-github
|
|
2
2
|
|
|
3
|
+
## 10.0.0-next.76
|
|
4
|
+
|
|
5
|
+
## 10.0.0-next.75
|
|
6
|
+
|
|
7
|
+
### Minor Changes
|
|
8
|
+
|
|
9
|
+
- Updated dependencies, co-contributors are now mentioned in changelogs and added a delay when making GitHub API calls to enable exiting pre-mode with 900+ changesets. (by [@gfellerph](https://github.com/gfellerph) with [#7923](https://github.com/swisspost/design-system/pull/7923))
|
|
10
|
+
|
|
11
|
+
## 10.0.0-next.74
|
|
12
|
+
|
|
13
|
+
## 10.0.0-next.73
|
|
14
|
+
|
|
3
15
|
## 10.0.0-next.72
|
|
4
16
|
|
|
5
17
|
## 10.0.0-next.71
|
package/dist/index.js
CHANGED
|
@@ -5,8 +5,245 @@
|
|
|
5
5
|
*/
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
const dotenv_1 = require("dotenv");
|
|
8
|
-
const get_github_info_1 = require("@changesets/get-github-info");
|
|
9
8
|
(0, dotenv_1.config)();
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Configuration (override via environment variables)
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
|
|
13
|
+
const GITHUB_GRAPHQL_URL = process.env.GITHUB_GRAPHQL_URL || 'https://api.github.com/graphql';
|
|
14
|
+
const GITHUB_SERVER_URL = process.env.GITHUB_SERVER_URL || 'https://github.com';
|
|
15
|
+
// Abort a single GitHub request if it stalls, then retry on timeouts or
|
|
16
|
+
// secondary rate limits.
|
|
17
|
+
const REQUEST_TIMEOUT_MS = Number(process.env.CHANGELOG_REQUEST_TIMEOUT_MS) || 30_000;
|
|
18
|
+
const MAX_RETRIES = Number(process.env.CHANGELOG_MAX_RETRIES) || 1;
|
|
19
|
+
// Max commits to request per batched GraphQL call. Smaller batches keep each
|
|
20
|
+
// query cheap and fast so it is far less likely to time out.
|
|
21
|
+
const COMMIT_BATCH_SIZE = Number(process.env.CHANGELOG_COMMIT_BATCH_SIZE) || 25;
|
|
22
|
+
// Minimum delay between the start of any two GitHub requests, to avoid bursts
|
|
23
|
+
// that trigger GitHub's secondary rate limiting.
|
|
24
|
+
const MIN_REQUEST_INTERVAL_MS = Number(process.env.CHANGELOG_MIN_REQUEST_INTERVAL_MS) || 100;
|
|
25
|
+
const EMPTY_COMMIT_INFO = {
|
|
26
|
+
links: { commit: null, pull: null, user: null },
|
|
27
|
+
user: null,
|
|
28
|
+
coAuthors: [],
|
|
29
|
+
};
|
|
30
|
+
// Cache commit info by commit hash to avoid duplicate API calls
|
|
31
|
+
const commitInfoCache = new Map();
|
|
32
|
+
let throttleChain = Promise.resolve();
|
|
33
|
+
let lastRequestStartedAt = 0;
|
|
34
|
+
/**
|
|
35
|
+
* Resolves once it is this caller's turn to start a request, guaranteeing at
|
|
36
|
+
* least MIN_REQUEST_INTERVAL_MS between the start of any two requests. Calls are
|
|
37
|
+
* serialized through a single promise chain so concurrent callers queue in
|
|
38
|
+
* order.
|
|
39
|
+
*/
|
|
40
|
+
function throttleRequest() {
|
|
41
|
+
throttleChain = throttleChain.then(async () => {
|
|
42
|
+
const wait = lastRequestStartedAt + MIN_REQUEST_INTERVAL_MS - Date.now();
|
|
43
|
+
if (wait > 0) {
|
|
44
|
+
await new Promise(resolve => setTimeout(resolve, wait));
|
|
45
|
+
}
|
|
46
|
+
lastRequestStartedAt = Date.now();
|
|
47
|
+
});
|
|
48
|
+
return throttleChain;
|
|
49
|
+
}
|
|
50
|
+
async function githubFetch(url, token, init = {}) {
|
|
51
|
+
for (let attempt = 0;; attempt++) {
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
54
|
+
try {
|
|
55
|
+
// Space out requests so we never send more than one per
|
|
56
|
+
// MIN_REQUEST_INTERVAL_MS to GitHub.
|
|
57
|
+
await throttleRequest();
|
|
58
|
+
const response = await fetch(url, {
|
|
59
|
+
method: init.method,
|
|
60
|
+
body: init.body,
|
|
61
|
+
headers: {
|
|
62
|
+
'Authorization': `Token ${token}`,
|
|
63
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
64
|
+
'User-Agent': 'swisspost-design-system-changelog',
|
|
65
|
+
...init.headers,
|
|
66
|
+
},
|
|
67
|
+
signal: controller.signal,
|
|
68
|
+
});
|
|
69
|
+
// Honor GitHub secondary rate limit / abuse detection responses.
|
|
70
|
+
if ((response.status === 403 || response.status === 429) && attempt < MAX_RETRIES) {
|
|
71
|
+
const retryAfter = Number(response.headers.get('retry-after'));
|
|
72
|
+
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 1000;
|
|
73
|
+
process.stderr.write(`\n⏳ Changelog: rate limited (${response.status}) for ${url}, retrying in ${Math.round(waitMs / 1000)}s\n`);
|
|
74
|
+
await new Promise(resolve => setTimeout(resolve, waitMs));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
return response;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
const isTimeout = error instanceof Error && error.name === 'AbortError';
|
|
81
|
+
if (attempt < MAX_RETRIES) {
|
|
82
|
+
const waitMs = 2 ** attempt * 1000;
|
|
83
|
+
process.stderr.write(`\n⏳ Changelog: ${isTimeout ? `request timed out after ${REQUEST_TIMEOUT_MS / 1000}s` : 'request failed'} for ${url}, retrying in ${Math.round(waitMs / 1000)}s\n`);
|
|
84
|
+
await new Promise(resolve => setTimeout(resolve, waitMs));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
clearTimeout(timeout);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
let pendingCommits = [];
|
|
95
|
+
let flushScheduled = false;
|
|
96
|
+
function scheduleCommitFlush() {
|
|
97
|
+
if (flushScheduled)
|
|
98
|
+
return;
|
|
99
|
+
flushScheduled = true;
|
|
100
|
+
// Flush on the next microtask so every getReleaseLine call made in this tick
|
|
101
|
+
// is collected into a single batch.
|
|
102
|
+
queueMicrotask(() => {
|
|
103
|
+
flushScheduled = false;
|
|
104
|
+
const batch = pendingCommits;
|
|
105
|
+
pendingCommits = [];
|
|
106
|
+
void flushCommitBatch(batch);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function buildCommitInfoQuery(repoOrder, reposToCommits) {
|
|
110
|
+
const selection = `... on Commit {
|
|
111
|
+
commitUrl
|
|
112
|
+
message
|
|
113
|
+
author { user { login url } }
|
|
114
|
+
associatedPullRequests(first: 50) {
|
|
115
|
+
nodes { number url mergedAt author { login url } }
|
|
116
|
+
}
|
|
117
|
+
}`;
|
|
118
|
+
const body = repoOrder
|
|
119
|
+
.map((repo, i) => {
|
|
120
|
+
const [owner, name] = repo.split('/');
|
|
121
|
+
const objects = [...reposToCommits.get(repo)]
|
|
122
|
+
.map(oid => `c_${oid}: object(expression: ${JSON.stringify(oid)}) { ${selection} }`)
|
|
123
|
+
.join('\n');
|
|
124
|
+
return `r${i}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) {\n${objects}\n}`;
|
|
125
|
+
})
|
|
126
|
+
.join('\n');
|
|
127
|
+
return `query {\n${body}\n}`;
|
|
128
|
+
}
|
|
129
|
+
/** Parse Co-authored-by trailers from a squash merge commit message. */
|
|
130
|
+
function parseCoAuthors(message, mainUserLogin) {
|
|
131
|
+
const users = new Set();
|
|
132
|
+
const coAuthorMatches = message.matchAll(/^Co-authored-by:\s+.+<([^>]+)>/gm);
|
|
133
|
+
for (const match of coAuthorMatches) {
|
|
134
|
+
// GitHub noreply emails contain the username
|
|
135
|
+
const noreplyMatch = match[1].match(/^(\d+\+)?([^@]+)@users\.noreply\.github\.com$/);
|
|
136
|
+
if (noreplyMatch) {
|
|
137
|
+
users.add(noreplyMatch[2]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Remove the main PR author and bots
|
|
141
|
+
if (mainUserLogin)
|
|
142
|
+
users.delete(mainUserLogin);
|
|
143
|
+
for (const user of users) {
|
|
144
|
+
if (user.endsWith('[bot]') || user === 'web-flow') {
|
|
145
|
+
users.delete(user);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return [...users].map(login => `[@${login}](${GITHUB_SERVER_URL}/${login})`);
|
|
149
|
+
}
|
|
150
|
+
/** Derive links + co-authors from a commit node, mirroring getInfo's logic. */
|
|
151
|
+
function deriveCommitInfo(commit, node) {
|
|
152
|
+
if (!node)
|
|
153
|
+
return EMPTY_COMMIT_INFO;
|
|
154
|
+
let user = node.author?.user ?? null;
|
|
155
|
+
// Pick the earliest-merged associated PR (unmerged PRs rank last), matching
|
|
156
|
+
// getInfo's behavior.
|
|
157
|
+
const prNodes = node.associatedPullRequests?.nodes ?? [];
|
|
158
|
+
const mergedRank = (pr) => pr.mergedAt ? new Date(pr.mergedAt).getTime() : Infinity;
|
|
159
|
+
const associatedPullRequest = prNodes.length
|
|
160
|
+
? prNodes.reduce((best, pr) => (mergedRank(pr) < mergedRank(best) ? pr : best))
|
|
161
|
+
: null;
|
|
162
|
+
// Prefer the PR author over the commit author, matching getInfo's behavior.
|
|
163
|
+
if (associatedPullRequest) {
|
|
164
|
+
user = associatedPullRequest.author;
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
links: {
|
|
168
|
+
commit: `[\`${commit.slice(0, 7)}\`](${node.commitUrl})`,
|
|
169
|
+
pull: associatedPullRequest
|
|
170
|
+
? `[#${associatedPullRequest.number}](${associatedPullRequest.url})`
|
|
171
|
+
: null,
|
|
172
|
+
user: user ? `[@${user.login}](${user.url})` : null,
|
|
173
|
+
},
|
|
174
|
+
user: user ? user.login : null,
|
|
175
|
+
coAuthors: parseCoAuthors(node.message, user ? user.login : null),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
async function flushCommitBatch(batch) {
|
|
179
|
+
if (batch.length === 0)
|
|
180
|
+
return;
|
|
181
|
+
if (!GITHUB_TOKEN) {
|
|
182
|
+
process.stderr.write(`\n⚠️ Changelog: GITHUB_TOKEN is not set, skipping GitHub info lookup\n`);
|
|
183
|
+
for (const item of batch)
|
|
184
|
+
item.resolve(EMPTY_COMMIT_INFO);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// Process in chunks so a single GraphQL request never gets too large. Chunks
|
|
188
|
+
// run sequentially to stay gentle on the API.
|
|
189
|
+
for (let start = 0; start < batch.length; start += COMMIT_BATCH_SIZE) {
|
|
190
|
+
const chunk = batch.slice(start, start + COMMIT_BATCH_SIZE);
|
|
191
|
+
const reposToCommits = new Map();
|
|
192
|
+
for (const { repo, commit } of chunk) {
|
|
193
|
+
if (!reposToCommits.has(repo))
|
|
194
|
+
reposToCommits.set(repo, new Set());
|
|
195
|
+
reposToCommits.get(repo).add(commit);
|
|
196
|
+
}
|
|
197
|
+
const repoOrder = [...reposToCommits.keys()];
|
|
198
|
+
const query = buildCommitInfoQuery(repoOrder, reposToCommits);
|
|
199
|
+
try {
|
|
200
|
+
const response = await githubFetch(GITHUB_GRAPHQL_URL, GITHUB_TOKEN, {
|
|
201
|
+
method: 'POST',
|
|
202
|
+
headers: { 'Content-Type': 'application/json' },
|
|
203
|
+
body: JSON.stringify({ query }),
|
|
204
|
+
});
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
process.stderr.write(`\n⚠️ Changelog: GitHub GraphQL returned ${response.status} ${response.statusText} for a commit-info batch\n`);
|
|
207
|
+
for (const item of chunk)
|
|
208
|
+
item.resolve(EMPTY_COMMIT_INFO);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const json = (await response.json());
|
|
212
|
+
if (json.errors) {
|
|
213
|
+
process.stderr.write(`\n⚠️ Changelog: GitHub GraphQL returned errors for a commit-info batch: ${JSON.stringify(json.errors)}\n`);
|
|
214
|
+
}
|
|
215
|
+
const data = json.data ?? {};
|
|
216
|
+
for (const item of chunk) {
|
|
217
|
+
const repoIndex = repoOrder.indexOf(item.repo);
|
|
218
|
+
const node = data[`r${repoIndex}`]?.[`c_${item.commit}`] ?? null;
|
|
219
|
+
item.resolve(deriveCommitInfo(item.commit, node));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
process.stderr.write(`\n⚠️ Changelog: commit-info batch request failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
224
|
+
for (const item of chunk)
|
|
225
|
+
item.reject(error);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Look up PR/user links and co-authors for a commit. Lookups are batched into a
|
|
231
|
+
* single GraphQL request per tick and cached by commit hash.
|
|
232
|
+
*/
|
|
233
|
+
function loadCommitInfo(repo, commit) {
|
|
234
|
+
const cached = commitInfoCache.get(commit);
|
|
235
|
+
if (cached)
|
|
236
|
+
return cached;
|
|
237
|
+
const promise = new Promise((resolve, reject) => {
|
|
238
|
+
pendingCommits.push({ repo, commit, resolve, reject });
|
|
239
|
+
scheduleCommitFlush();
|
|
240
|
+
});
|
|
241
|
+
// Evict on failure so a later call retries instead of serving a cached
|
|
242
|
+
// rejection (otherwise one failed batch poisons every commit in it).
|
|
243
|
+
promise.catch(() => commitInfoCache.delete(commit));
|
|
244
|
+
commitInfoCache.set(commit, promise);
|
|
245
|
+
return promise;
|
|
246
|
+
}
|
|
10
247
|
const changelogFunctions = {
|
|
11
248
|
getDependencyReleaseLine: async (_changesets, dependenciesUpdated, options) => {
|
|
12
249
|
if (!options.repo) {
|
|
@@ -24,24 +261,26 @@ const changelogFunctions = {
|
|
|
24
261
|
}
|
|
25
262
|
const replacedChangelog = changeset.summary.trim();
|
|
26
263
|
const [firstLine, ...futureLines] = replacedChangelog.split('\n').map(l => l.trimEnd());
|
|
27
|
-
const
|
|
264
|
+
const info = await (async () => {
|
|
28
265
|
const commitToFetchFrom = changeset.commit;
|
|
29
266
|
if (commitToFetchFrom) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
267
|
+
try {
|
|
268
|
+
return await loadCommitInfo(options.repo, commitToFetchFrom);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
process.stderr.write(`\n⚠️ Changelog: could not resolve GitHub info for changeset "${firstLine}" (commit ${commitToFetchFrom}): ${error instanceof Error ? error.message : String(error)}\n`);
|
|
272
|
+
}
|
|
35
273
|
}
|
|
36
|
-
return
|
|
37
|
-
commit: null,
|
|
38
|
-
pull: null,
|
|
39
|
-
user: null,
|
|
40
|
-
};
|
|
274
|
+
return EMPTY_COMMIT_INFO;
|
|
41
275
|
})();
|
|
276
|
+
const { links, coAuthors } = info;
|
|
42
277
|
const users = links.user;
|
|
43
278
|
const pullOrCommit = links.pull || links.commit || null;
|
|
44
|
-
const
|
|
279
|
+
const allUsersList = [users, ...coAuthors].filter(Boolean);
|
|
280
|
+
const allUsers = allUsersList.length > 1
|
|
281
|
+
? `${allUsersList.slice(0, -1).join(', ')} and ${allUsersList[allUsersList.length - 1]}`
|
|
282
|
+
: allUsersList.join('');
|
|
283
|
+
const userString = allUsers ? `by ${allUsers}` : '';
|
|
45
284
|
const pullString = pullOrCommit !== null ? `with ${pullOrCommit}` : '';
|
|
46
285
|
const hasUserOrPull = userString && pullString;
|
|
47
286
|
const entry = [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swisspost/design-system-changelog-github",
|
|
3
|
-
"version": "10.0.0-next.
|
|
3
|
+
"version": "10.0.0-next.76",
|
|
4
4
|
"description": "A changelog entry generator for GitHub that links to PRs and users",
|
|
5
5
|
"author": "Swiss Post <design-system@post.ch>",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
},
|
|
19
19
|
"main": "./dist/index.js",
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@changesets/get-github-info": "0.
|
|
22
|
-
"@changesets/types": "6.
|
|
21
|
+
"@changesets/get-github-info": "0.8.0",
|
|
22
|
+
"@changesets/types": "6.1.0",
|
|
23
23
|
"dotenv": "17.2.3"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"@eslint/js": "9.18.0",
|
|
28
28
|
"eslint": "9.26.0",
|
|
29
29
|
"globals": "17.3.0",
|
|
30
|
-
"
|
|
31
|
-
"typescript
|
|
32
|
-
"
|
|
30
|
+
"rimraf": "6.0.1",
|
|
31
|
+
"typescript": "6.0.3",
|
|
32
|
+
"typescript-eslint": "8.62.0"
|
|
33
33
|
},
|
|
34
34
|
"keywords": [
|
|
35
35
|
"changelog",
|