Skip to content

Commit b571812

Browse files
Add script to generate changelog (#1966)
1 parent 2c7224c commit b571812

File tree

1 file changed

+253
-0
lines changed

1 file changed

+253
-0
lines changed

resources/gen-changelog.js

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/* @noflow */
2+
3+
'use strict';
4+
5+
const util = require('util');
6+
const https = require('https');
7+
const { exec } = require('./utils');
8+
const packageJSON = require('../package.json');
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 lastTag = `v${packageJSON.version}`;
39+
const GH_TOKEN = process.env['GH_TOKEN'];
40+
41+
if (!GH_TOKEN) {
42+
console.error('Must provide GH_TOKEN as enviroment variable!');
43+
process.exit(1);
44+
}
45+
46+
getCommitsInfo(lastTag)
47+
.then(genChangeLog)
48+
.then(changelog => process.stdout.write(changelog))
49+
.catch(error => console.error(error));
50+
51+
function genChangeLog(commitsInfo) {
52+
const allPRs = commitsInfoToPRs(commitsInfo);
53+
const byLabel = {};
54+
const commitersByLogin = {};
55+
56+
for (const pr of allPRs) {
57+
if (!labelsConfig[pr.label]) {
58+
throw new Error('Unknown label: ' + pr.label + pr.number);
59+
}
60+
byLabel[pr.label] = byLabel[pr.label] || [];
61+
byLabel[pr.label].push(pr);
62+
commitersByLogin[pr.author.login] = pr.author;
63+
}
64+
65+
let changelog = '';
66+
for (const [label, config] of Object.entries(labelsConfig)) {
67+
const prs = byLabel[label];
68+
if (prs) {
69+
const shouldFold = config.fold && prs.length > 1;
70+
71+
changelog += `\n#### ${config.section}\n`;
72+
if (shouldFold) {
73+
changelog += '<details>\n';
74+
changelog += `<summary> ${prs.length} PRs were merged </summary>\n\n`;
75+
}
76+
77+
for (const pr of prs) {
78+
const { number, url, author } = pr;
79+
changelog += `* [#${number}](${url}) ${pr.title} ([@${author.login}](${author.url}))\n`;
80+
}
81+
82+
if (shouldFold) {
83+
changelog += '</details>\n';
84+
}
85+
}
86+
}
87+
88+
const commiters = Object.values(commitersByLogin).sort((a, b) =>
89+
(a.name || a.login).localeCompare(b.name || b.login),
90+
);
91+
changelog += `\n#### Committers: ${commiters.length}\n`;
92+
for (const commiter of commiters) {
93+
changelog += `* ${commiter.name}([@${commiter.login}](${commiter.url}))\n`;
94+
}
95+
96+
return changelog;
97+
}
98+
99+
function graphqlRequestImpl(query, variables, cb) {
100+
const resultCB = typeof variables === 'function' ? variables : cb;
101+
102+
const req = https.request('https://api.github.com/graphql', {
103+
method: 'POST',
104+
headers: {
105+
Authorization: 'bearer ' + GH_TOKEN,
106+
'Content-Type': 'application/json',
107+
'User-Agent': 'graphql-js-changelog',
108+
},
109+
});
110+
111+
req.on('response', res => {
112+
let responseBody = '';
113+
114+
res.setEncoding('utf8');
115+
res.on('data', d => (responseBody += d));
116+
res.on('error', error => resultCB(error));
117+
118+
res.on('end', () => {
119+
if (res.statusCode !== 200) {
120+
return resultCB(
121+
new Error(
122+
`GitHub responded with ${res.statusCode}: ${res.statusMessage}\n` +
123+
responseBody,
124+
),
125+
);
126+
}
127+
128+
let json;
129+
try {
130+
json = JSON.parse(responseBody);
131+
} catch (error) {
132+
return resultCB(error);
133+
}
134+
135+
if (json.errors) {
136+
return resultCB(
137+
new Error('Errors: ' + JSON.stringify(json.errors, null, 2)),
138+
);
139+
}
140+
141+
resultCB(undefined, json.data);
142+
});
143+
});
144+
145+
req.on('error', error => cb(error));
146+
req.write(JSON.stringify({ query, variables }));
147+
req.end();
148+
}
149+
150+
async function batchCommitInfo(commits) {
151+
let commitsSubQuery = '';
152+
for (const oid of commits) {
153+
commitsSubQuery += `
154+
commit_${oid}: object(oid: "${oid}") {
155+
... on Commit {
156+
oid
157+
message
158+
associatedPullRequests(first: 10) {
159+
nodes {
160+
number
161+
title
162+
url
163+
author {
164+
login
165+
url
166+
... on User {
167+
name
168+
}
169+
}
170+
repository {
171+
nameWithOwner
172+
}
173+
labels(first: 10) {
174+
nodes {
175+
name
176+
}
177+
}
178+
}
179+
}
180+
}
181+
}
182+
`;
183+
}
184+
185+
const response = await graphqlRequest(`
186+
{
187+
repository(owner: "graphql", name: "graphql-js") {
188+
${commitsSubQuery}
189+
}
190+
}
191+
`);
192+
193+
const commitsInfo = [];
194+
for (const oid of commits) {
195+
commitsInfo.push(response.repository['commit_' + oid]);
196+
}
197+
return commitsInfo;
198+
}
199+
200+
function commitsInfoToPRs(commits) {
201+
const prs = [];
202+
for (const commit of commits) {
203+
const associatedPRs = commit.associatedPullRequests.nodes.filter(
204+
pr => pr.repository.nameWithOwner === 'graphql/graphql-js',
205+
);
206+
if (associatedPRs.length === 0) {
207+
throw new Error(
208+
`Commit ${commit.oid} has no associated PR: ${commit.message}`,
209+
);
210+
}
211+
if (associatedPRs.length > 1) {
212+
throw new Error(
213+
`Commit ${commit.oid} is associated with multiple PRs: ${commit.message}`,
214+
);
215+
}
216+
217+
const pr = associatedPRs[0];
218+
const labels = pr.labels.nodes
219+
.map(label => label.name)
220+
.filter(label => label.startsWith('PR: '));
221+
222+
if (labels.length === 0) {
223+
throw new Error(`PR #${pr.number} missing label`);
224+
}
225+
if (labels.length > 1) {
226+
throw new Error(
227+
`PR #${pr.number} has conflicting labels: ` + labels.join('\n'),
228+
);
229+
}
230+
prs.push({
231+
number: pr.number,
232+
title: pr.title,
233+
url: pr.url,
234+
author: pr.author,
235+
label: labels[0],
236+
});
237+
}
238+
239+
return prs;
240+
}
241+
242+
async function getCommitsInfo(tag) {
243+
const commits = exec(`git rev-list --reverse ${tag}..`).split('\n');
244+
245+
// Split commits into batches of 50 to prevent timeouts
246+
const commitInfoPromises = [];
247+
for (let i = 0; i < commits.length; i += 50) {
248+
const batch = commits.slice(i, i + 50);
249+
commitInfoPromises.push(batchCommitInfo(batch));
250+
}
251+
252+
return (await Promise.all(commitInfoPromises)).flat();
253+
}

0 commit comments

Comments
 (0)