Skip to content

Commit 20a42c8

Browse files
Add 'resources/gen-changelog.js' missing from #337 (#338)
1 parent 0ea457a commit 20a42c8

File tree

1 file changed

+320
-0
lines changed

1 file changed

+320
-0
lines changed

resources/gen-changelog.js

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
'use strict';
2+
3+
const util = require('util');
4+
const https = require('https');
5+
6+
const packageJSON = require('../package.json');
7+
8+
const { exec } = require('./utils');
9+
10+
const graphqlRequest = util.promisify(graphqlRequestImpl);
11+
const labelsConfig = {
12+
'PR: breaking change 💥': {
13+
section: 'Breaking Change 💥',
14+
},
15+
'PR: feature 🚀': {
16+
section: 'New Feature 🚀',
17+
},
18+
'PR: bug fix 🐞': {
19+
section: 'Bug Fix 🐞',
20+
},
21+
'PR: docs 📝': {
22+
section: 'Docs 📝',
23+
fold: true,
24+
},
25+
'PR: polish 💅': {
26+
section: 'Polish 💅',
27+
fold: true,
28+
},
29+
'PR: internal 🏠': {
30+
section: 'Internal 🏠',
31+
fold: true,
32+
},
33+
'PR: dependency 📦': {
34+
section: 'Dependency 📦',
35+
fold: true,
36+
},
37+
};
38+
const { GH_TOKEN } = process.env;
39+
40+
if (!GH_TOKEN) {
41+
console.error('Must provide GH_TOKEN as environment variable!');
42+
process.exit(1);
43+
}
44+
45+
if (!packageJSON.repository || typeof packageJSON.repository.url !== 'string') {
46+
console.error('package.json is missing repository.url string!');
47+
process.exit(1);
48+
}
49+
50+
const repoURLMatch =
51+
/https:\/\/github.com\/(?<githubOrg>[^/]+)\/(?<githubRepo>[^/]+).git/.exec(
52+
packageJSON.repository.url,
53+
);
54+
if (repoURLMatch == null) {
55+
console.error('Cannot extract organization and repo name from repo URL!');
56+
process.exit(1);
57+
}
58+
const { githubOrg, githubRepo } = repoURLMatch.groups;
59+
60+
getChangeLog()
61+
.then((changelog) => process.stdout.write(changelog))
62+
.catch((error) => {
63+
console.error(error);
64+
process.exit(1);
65+
});
66+
67+
function getChangeLog() {
68+
const { version } = packageJSON;
69+
70+
let tag = null;
71+
let commitsList = exec(`git rev-list --reverse v${version}..`);
72+
if (commitsList === '') {
73+
const parentPackageJSON = exec('git cat-file blob HEAD~1:package.json');
74+
const parentVersion = JSON.parse(parentPackageJSON).version;
75+
commitsList = exec(`git rev-list --reverse v${parentVersion}..HEAD~1`);
76+
tag = `v${version}`;
77+
}
78+
79+
const date = exec('git log -1 --format=%cd --date=short');
80+
return getCommitsInfo(commitsList.split('\n'))
81+
.then((commitsInfo) => getPRsInfo(commitsInfoToPRs(commitsInfo)))
82+
.then((prsInfo) => genChangeLog(tag, date, prsInfo));
83+
}
84+
85+
function genChangeLog(tag, date, allPRs) {
86+
const byLabel = {};
87+
const committersByLogin = {};
88+
89+
for (const pr of allPRs) {
90+
const labels = pr.labels.nodes
91+
.map((label) => label.name)
92+
.filter((label) => label.startsWith('PR: '));
93+
94+
if (labels.length === 0) {
95+
throw new Error(`PR is missing label. See ${pr.url}`);
96+
}
97+
if (labels.length > 1) {
98+
throw new Error(
99+
`PR has conflicting labels: ${labels.join('\n')}\nSee ${pr.url}`,
100+
);
101+
}
102+
103+
const label = labels[0];
104+
if (!labelsConfig[label]) {
105+
throw new Error(`Unknown label: ${label}. See ${pr.url}`);
106+
}
107+
byLabel[label] = byLabel[label] || [];
108+
byLabel[label].push(pr);
109+
committersByLogin[pr.author.login] = pr.author;
110+
}
111+
112+
let changelog = `## ${tag || 'Unreleased'} (${date})\n`;
113+
for (const [label, config] of Object.entries(labelsConfig)) {
114+
const prs = byLabel[label];
115+
if (prs) {
116+
const shouldFold = config.fold && prs.length > 1;
117+
118+
changelog += `\n#### ${config.section}\n`;
119+
if (shouldFold) {
120+
changelog += '<details>\n';
121+
changelog += `<summary> ${prs.length} PRs were merged </summary>\n\n`;
122+
}
123+
124+
for (const pr of prs) {
125+
const { number, url, author } = pr;
126+
changelog += `* [#${number}](${url}) ${pr.title} ([@${author.login}](${author.url}))\n`;
127+
}
128+
129+
if (shouldFold) {
130+
changelog += '</details>\n';
131+
}
132+
}
133+
}
134+
135+
const committers = Object.values(committersByLogin).sort((a, b) =>
136+
(a.name || a.login).localeCompare(b.name || b.login),
137+
);
138+
changelog += `\n#### Committers: ${committers.length}\n`;
139+
for (const committer of committers) {
140+
changelog += `* ${committer.name}([@${committer.login}](${committer.url}))\n`;
141+
}
142+
143+
return changelog;
144+
}
145+
146+
function graphqlRequestImpl(query, variables, cb) {
147+
const resultCB = typeof variables === 'function' ? variables : cb;
148+
149+
const req = https.request('https://api.github.com/graphql', {
150+
method: 'POST',
151+
headers: {
152+
Authorization: 'bearer ' + GH_TOKEN,
153+
'Content-Type': 'application/json',
154+
'User-Agent': 'gen-changelog',
155+
},
156+
});
157+
158+
req.on('response', (res) => {
159+
let responseBody = '';
160+
161+
res.setEncoding('utf8');
162+
res.on('data', (d) => (responseBody += d));
163+
res.on('error', (error) => resultCB(error));
164+
165+
res.on('end', () => {
166+
if (res.statusCode !== 200) {
167+
return resultCB(
168+
new Error(
169+
`GitHub responded with ${res.statusCode}: ${res.statusMessage}\n` +
170+
responseBody,
171+
),
172+
);
173+
}
174+
175+
let json;
176+
try {
177+
json = JSON.parse(responseBody);
178+
} catch (error) {
179+
return resultCB(error);
180+
}
181+
182+
if (json.errors) {
183+
return resultCB(
184+
new Error('Errors: ' + JSON.stringify(json.errors, null, 2)),
185+
);
186+
}
187+
188+
resultCB(undefined, json.data);
189+
});
190+
});
191+
192+
req.on('error', (error) => resultCB(error));
193+
req.write(JSON.stringify({ query, variables }));
194+
req.end();
195+
}
196+
197+
async function batchCommitInfo(commits) {
198+
let commitsSubQuery = '';
199+
for (const oid of commits) {
200+
commitsSubQuery += `
201+
commit_${oid}: object(oid: "${oid}") {
202+
... on Commit {
203+
oid
204+
message
205+
associatedPullRequests(first: 10) {
206+
nodes {
207+
number
208+
repository {
209+
nameWithOwner
210+
}
211+
}
212+
}
213+
}
214+
}
215+
`;
216+
}
217+
218+
const response = await graphqlRequest(`
219+
{
220+
repository(owner: "${githubOrg}", name: "${githubRepo}") {
221+
${commitsSubQuery}
222+
}
223+
}
224+
`);
225+
226+
const commitsInfo = [];
227+
for (const oid of commits) {
228+
commitsInfo.push(response.repository['commit_' + oid]);
229+
}
230+
return commitsInfo;
231+
}
232+
233+
async function batchPRInfo(prs) {
234+
let prsSubQuery = '';
235+
for (const number of prs) {
236+
prsSubQuery += `
237+
pr_${number}: pullRequest(number: ${number}) {
238+
number
239+
title
240+
url
241+
author {
242+
login
243+
url
244+
... on User {
245+
name
246+
}
247+
}
248+
labels(first: 10) {
249+
nodes {
250+
name
251+
}
252+
}
253+
}
254+
`;
255+
}
256+
257+
const response = await graphqlRequest(`
258+
{
259+
repository(owner: "${githubOrg}", name: "${githubRepo}") {
260+
${prsSubQuery}
261+
}
262+
}
263+
`);
264+
265+
const prsInfo = [];
266+
for (const number of prs) {
267+
prsInfo.push(response.repository['pr_' + number]);
268+
}
269+
return prsInfo;
270+
}
271+
272+
function commitsInfoToPRs(commits) {
273+
const prs = {};
274+
for (const commit of commits) {
275+
const associatedPRs = commit.associatedPullRequests.nodes.filter(
276+
(pr) => pr.repository.nameWithOwner === `${githubOrg}/${githubRepo}`,
277+
);
278+
if (associatedPRs.length === 0) {
279+
const match = / \(#(?<prNumber>[0-9]+)\)$/m.exec(commit.message);
280+
if (match) {
281+
prs[parseInt(match.groups.prNumber, 10)] = true;
282+
continue;
283+
}
284+
throw new Error(
285+
`Commit ${commit.oid} has no associated PR: ${commit.message}`,
286+
);
287+
}
288+
if (associatedPRs.length > 1) {
289+
throw new Error(
290+
`Commit ${commit.oid} is associated with multiple PRs: ${commit.message}`,
291+
);
292+
}
293+
294+
prs[associatedPRs[0].number] = true;
295+
}
296+
297+
return Object.keys(prs);
298+
}
299+
300+
async function getPRsInfo(commits) {
301+
// Split pr into batches of 50 to prevent timeouts
302+
const prInfoPromises = [];
303+
for (let i = 0; i < commits.length; i += 50) {
304+
const batch = commits.slice(i, i + 50);
305+
prInfoPromises.push(batchPRInfo(batch));
306+
}
307+
308+
return (await Promise.all(prInfoPromises)).flat();
309+
}
310+
311+
async function getCommitsInfo(commits) {
312+
// Split commits into batches of 50 to prevent timeouts
313+
const commitInfoPromises = [];
314+
for (let i = 0; i < commits.length; i += 50) {
315+
const batch = commits.slice(i, i + 50);
316+
commitInfoPromises.push(batchCommitInfo(batch));
317+
}
318+
319+
return (await Promise.all(commitInfoPromises)).flat();
320+
}

0 commit comments

Comments
 (0)