@swisspost/design-system-changelog-github 10.0.0-next.75 → 10.0.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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @swisspost/design-system-changelog-github
2
2
 
3
+ ## 10.0.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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))
8
+
9
+ ## 10.0.0-next.76
10
+
3
11
  ## 10.0.0-next.75
4
12
 
5
13
  ### Minor Changes
package/dist/index.js CHANGED
@@ -5,105 +5,243 @@
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;
10
34
  /**
11
- * Simple concurrency limiter to avoid overwhelming the GitHub API.
12
- * Limits parallel requests and adds a small delay between them.
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.
13
39
  */
14
- function createRateLimiter(concurrency, delayMs) {
15
- let active = 0;
16
- const queue = [];
17
- function next() {
18
- if (queue.length > 0 && active < concurrency) {
19
- active++;
20
- const resolve = queue.shift();
21
- resolve();
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));
22
45
  }
23
- }
24
- return async function (fn) {
25
- await new Promise(resolve => {
26
- queue.push(resolve);
27
- next();
28
- });
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);
29
54
  try {
30
- const result = await fn();
31
- return result;
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;
32
88
  }
33
89
  finally {
34
- active--;
35
- // Small delay between requests to avoid hitting secondary rate limits
36
- await new Promise(resolve => setTimeout(resolve, delayMs));
37
- next();
90
+ clearTimeout(timeout);
38
91
  }
39
- };
92
+ }
40
93
  }
41
- // Cache GitHub info lookups by commit hash to avoid duplicate API calls
42
- const commitInfoCache = new Map();
43
- // Cache co-authors by commit hash
44
- const coAuthorsCache = new Map();
45
- // Allow max 5 concurrent requests with 100ms delay between them
46
- const rateLimited = createRateLimiter(5, 100);
47
- // Progress tracking
48
- let processedCount = 0;
49
- let cacheHits = 0;
50
- async function getGitHubInfo(repo, commit) {
51
- if (commitInfoCache.has(commit)) {
52
- cacheHits++;
53
- processedCount++;
54
- process.stderr.write(`\r🔗 Changelog: ${processedCount} processed (${cacheHits} cached)`);
55
- return commitInfoCache.get(commit);
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 } }
56
116
  }
57
- const promise = rateLimited(() => (0, get_github_info_1.getInfo)({ repo, commit }));
58
- commitInfoCache.set(commit, promise);
59
- processedCount++;
60
- process.stderr.write(`\r🔗 Changelog: ${processedCount} processed (${cacheHits} cached)`);
61
- return promise;
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}`;
62
128
  }
63
- /**
64
- * Fetch co-authors from the squash merge commit's Co-authored-by trailers.
65
- * With squash merging, the merge commit contains all co-author information.
66
- */
67
- async function getCoAuthors(repo, commit, mainUser) {
68
- if (coAuthorsCache.has(commit)) {
69
- return coAuthorsCache.get(commit);
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
+ }
70
139
  }
71
- const promise = rateLimited(async () => {
72
- const token = process.env.GITHUB_TOKEN;
73
- if (!token)
74
- return [];
75
- const serverUrl = process.env.GITHUB_SERVER_URL || 'https://github.com';
76
- const apiUrl = process.env.GITHUB_API_URL || 'https://api.github.com';
77
- const response = await fetch(`${apiUrl}/repos/${repo}/commits/${commit}`, {
78
- headers: {
79
- Authorization: `Token ${token}`,
80
- Accept: 'application/vnd.github.v3+json',
81
- },
82
- });
83
- if (!response.ok)
84
- return [];
85
- const commitData = (await response.json());
86
- const users = new Set();
87
- // Parse Co-authored-by trailers from the squash merge commit
88
- const coAuthorMatches = commitData.commit.message.matchAll(/^Co-authored-by:\s+.+<([^>]+)>/gm);
89
- for (const match of coAuthorMatches) {
90
- // GitHub noreply emails contain the username
91
- const noreplyMatch = match[1].match(/^(\d+\+)?([^@]+)@users\.noreply\.github\.com$/);
92
- if (noreplyMatch) {
93
- users.add(noreplyMatch[2]);
94
- }
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);
95
146
  }
96
- // Remove the main PR author and bots
97
- if (mainUser)
98
- users.delete(mainUser);
99
- for (const user of users) {
100
- if (user.endsWith('[bot]') || user === 'web-flow') {
101
- users.delete(user);
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;
102
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);
103
226
  }
104
- return [...users].map(login => `[@${login}](${serverUrl}/${login})`);
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();
105
240
  });
106
- coAuthorsCache.set(commit, promise);
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);
107
245
  return promise;
108
246
  }
109
247
  const changelogFunctions = {
@@ -123,25 +261,20 @@ const changelogFunctions = {
123
261
  }
124
262
  const replacedChangelog = changeset.summary.trim();
125
263
  const [firstLine, ...futureLines] = replacedChangelog.split('\n').map(l => l.trimEnd());
126
- const links = await (async () => {
264
+ const info = await (async () => {
127
265
  const commitToFetchFrom = changeset.commit;
128
266
  if (commitToFetchFrom) {
129
- const { links } = await getGitHubInfo(options.repo, commitToFetchFrom);
130
- 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
+ }
131
273
  }
132
- return {
133
- commit: null,
134
- pull: null,
135
- user: null,
136
- };
274
+ return EMPTY_COMMIT_INFO;
137
275
  })();
276
+ const { links, coAuthors } = info;
138
277
  const users = links.user;
139
- // Fetch co-authors from the squash merge commit
140
- let coAuthors = [];
141
- if (changeset.commit) {
142
- const mainUser = links.user ? (links.user.match(/@([\w-]+)/)?.[1] ?? null) : null;
143
- coAuthors = await getCoAuthors(options.repo, changeset.commit, mainUser);
144
- }
145
278
  const pullOrCommit = links.pull || links.commit || null;
146
279
  const allUsersList = [users, ...coAuthors].filter(Boolean);
147
280
  const allUsers = allUsersList.length > 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swisspost/design-system-changelog-github",
3
- "version": "10.0.0-next.75",
3
+ "version": "10.0.0",
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",
@@ -28,8 +28,8 @@
28
28
  "eslint": "9.26.0",
29
29
  "globals": "17.3.0",
30
30
  "rimraf": "6.0.1",
31
- "typescript": "5.9.3",
32
- "typescript-eslint": "8.59.2"
31
+ "typescript": "6.0.3",
32
+ "typescript-eslint": "8.62.0"
33
33
  },
34
34
  "keywords": [
35
35
  "changelog",