Skip to content
This repository was archived by the owner on Mar 20, 2023. It is now read-only.

Commit 45454c6

Browse files
committed
Copy 'gen-changelog' from 'graphql-js'
1 parent fd7f467 commit 45454c6

File tree

2 files changed

+283
-0
lines changed

2 files changed

+283
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"check": "flow check",
3838
"check:ts": "dtslint types",
3939
"build": "node resources/build.js",
40+
"changelog": "node resources/gen-changelog.js",
4041
"preversion": ". ./resources/checkgit.sh",
4142
"start": "node -r @babel/register examples/index.js"
4243
},

resources/gen-changelog.js

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

0 commit comments

Comments
 (0)