@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.
Files changed (3) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/index.js +252 -13
  3. 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 links = await (async () => {
264
+ const info = await (async () => {
28
265
  const commitToFetchFrom = changeset.commit;
29
266
  if (commitToFetchFrom) {
30
- const { links } = await (0, get_github_info_1.getInfo)({
31
- repo: options.repo,
32
- commit: commitToFetchFrom,
33
- });
34
- return links;
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 userString = users !== null ? `by ${users}` : '';
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.72",
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.6.0",
22
- "@changesets/types": "6.0.0",
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
- "typescript": "5.9.3",
31
- "typescript-eslint": "8.59.2",
32
- "rimraf": "6.0.1"
30
+ "rimraf": "6.0.1",
31
+ "typescript": "6.0.3",
32
+ "typescript-eslint": "8.62.0"
33
33
  },
34
34
  "keywords": [
35
35
  "changelog",