Skip to content

Commit 9750a78

Browse files
authored
chore: add release notes script (#174)
1 parent c1598b3 commit 9750a78

File tree

1 file changed

+257
-0
lines changed

1 file changed

+257
-0
lines changed

scripts/generateReleaseNotes.js

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Create release notes.
5+
*
6+
* Example
7+
* ./scripts/generateReleaseNotes.js --from from_tag --to to_tag --compact
8+
*
9+
* When --to is not given HEAD is selected.
10+
* When --from is not given latest tag is selected.
11+
* When --compact is set, components are not listed, default is false
12+
*/
13+
14+
const exec = require('util').promisify(require('child_process').exec);
15+
const version = require('../lerna.json').version;
16+
17+
let from, to, compact;
18+
19+
const keyName = {
20+
bfp: ':rotating_light: [Warranty Fixes](https://vaadin.com/support/for-business#warranty)',
21+
break: ':boom: Breaking Changes',
22+
feat: ':rocket: New Features',
23+
fix: ':bug: Bug Fixes',
24+
refactor: ':nail_care: Polish',
25+
docs: ':memo: Documentation',
26+
test: ':microscope: Tests',
27+
chore: ':house: Internal'
28+
};
29+
30+
async function run(cmd) {
31+
const { stdout } = await exec(cmd, { maxBuffer: 5000 * 1024 });
32+
return stdout.trim();
33+
}
34+
35+
// Compute tags used for commit delimiters
36+
async function getReleases() {
37+
for (let i = 2; process.argv[i]; i++) {
38+
switch (process.argv[i]) {
39+
case '--from':
40+
from = process.argv[++i];
41+
break;
42+
case '--to':
43+
to = process.argv[++i];
44+
break;
45+
case '--compact':
46+
compact = true;
47+
break;
48+
}
49+
}
50+
51+
if (!from) {
52+
const branch = await run(`git rev-parse --abbrev-ref HEAD`);
53+
await run(`git pull origin ${branch} --tags`);
54+
const tags = await run(`git tag --merged ${branch} --sort=-committerdate`);
55+
from = tags.split('\n')[0];
56+
}
57+
58+
if (!to) {
59+
to = 'HEAD';
60+
}
61+
}
62+
63+
// Parse git log string and return an array of parsed commits as a JS object.
64+
function parseLog(log) {
65+
let commits = [];
66+
let commit, pos, result;
67+
log.split('\n').forEach((line) => {
68+
switch (pos) {
69+
case 'head':
70+
if (!line.trim()) {
71+
pos = 'title';
72+
break;
73+
}
74+
result = /^(\w+): +(.+)$/.exec(line);
75+
if (result) {
76+
commit.head[result[1]] = result[2];
77+
break;
78+
}
79+
break;
80+
case 'title':
81+
if (!line.trim()) {
82+
pos = 'body';
83+
break;
84+
}
85+
result = /^ +(\w+)(!?): +(.*)$/.exec(line);
86+
if (result) {
87+
commit.type = result[1].toLowerCase();
88+
commit.breaking = !!result[2];
89+
commit.isBreaking = commit.breaking;
90+
commit.skip = !commit.breaking && !/(feat|fix|perf)/.test(commit.type);
91+
commit.isIncluded = !commit.skip;
92+
commit.title += result[3];
93+
} else {
94+
commit.title += line;
95+
}
96+
break;
97+
case 'body':
98+
result = /^ +([A-Z][\w-]+): +(.*)$/.exec(line);
99+
if (result) {
100+
let k = result[1].toLowerCase();
101+
if (k == 'warranty') {
102+
commit.bfp = true;
103+
}
104+
if (/(fixes|fix|related-to|connected-to|warranty)/i.test(k)) {
105+
commit.footers.fixes = commit.footers.fixes || [];
106+
commit.footers.fixes.push(...result[2].split(/[, ]+/).filter((s) => /\d+/.test(s)));
107+
} else {
108+
commit.footers[k] = commit.footers[k] || [];
109+
commit.footers[k].push(result[2]);
110+
}
111+
break;
112+
}
113+
// eslint-disable-next-line no-fallthrough
114+
default:
115+
result = /^commit (.+)$/.exec(line);
116+
if (result) {
117+
if (commit) {
118+
commit.body = commit.body.trim();
119+
}
120+
commit = {
121+
head: {},
122+
title: '',
123+
body: '',
124+
isIncluded: false,
125+
isBreaking: false,
126+
components: [],
127+
footers: { fixes: [] },
128+
commits: []
129+
};
130+
commits.push(commit);
131+
commit.commit = result[1];
132+
pos = 'head';
133+
} else {
134+
if (line.startsWith(' ')) {
135+
commit.body += `${line}\n`;
136+
} else if (/^packages\/vaadin-.*/.test(line)) {
137+
const wc = line.split('/')[1];
138+
if (!commit.components.includes(wc)) {
139+
commit.components.push(wc);
140+
}
141+
}
142+
}
143+
}
144+
});
145+
return commits;
146+
}
147+
148+
// return absolute link to GH given a path
149+
function createGHLink(path) {
150+
return `https://github.com/vaadin/${path}`;
151+
}
152+
153+
// create link to low-components repo given a type or id
154+
function createLink(type, id, char) {
155+
return id ? `[${char ? char : id}](${createGHLink(`vaadin-web-components/${type}/${id})`)}` : '';
156+
}
157+
158+
// convert GH internal links to absolute links
159+
function parseLinks(message) {
160+
message = message.trim();
161+
message = message.replace(/^([\da-f]+) /, `${createLink('commit', '$1', '⧉')} `);
162+
message = message.replace(/ *\(#(\d+)\)$/g, ` (${createLink('pull', '$1', '#$1')})`);
163+
return message;
164+
}
165+
166+
// return web-components affected by this commit
167+
function getComponents(c) {
168+
if (c.components[0]) {
169+
return `- ${c.components.map((k) => '`' + k + '`').join(', ')}`;
170+
}
171+
}
172+
173+
// return ticket links for this commit
174+
function getTickets(c) {
175+
if (c.footers['fixes'] && c.footers['fixes'][0]) {
176+
const ticket = `Ticket${c.footers['fixes'].length > 1 ? 's' : ''}`;
177+
const links = c.footers['fixes'].reduce((prev, f) => {
178+
let link = f;
179+
if (/^#?\d/.test(f)) {
180+
f = f.replace(/^#/, '');
181+
link = `${createLink('issues', f)}`;
182+
} else if (/^(vaadin\/|https:\/\/github.com\/vaadin\/).*\d+$/.test(f)) {
183+
const n = f.replace(/^.*?(\d+)$/, '$1');
184+
f = f.replace(/^(https:\/\/github.com\/vaadin|vaadin)\//, '').replace('#', '/issues/');
185+
link = `[${n}](${createGHLink(f)})`;
186+
}
187+
return (prev ? `${prev}, ` : '') + link;
188+
}, '');
189+
return `**${ticket}:**${links}`;
190+
}
191+
}
192+
193+
// log a commit for release notes
194+
function logCommit(c) {
195+
let log = '';
196+
let indent = '';
197+
if (!compact) {
198+
const components = getComponents(c);
199+
components && (log += `${components}\n`);
200+
indent = ' ';
201+
}
202+
log += `${indent}- ` + parseLinks(c.commit.substring(0, 7) + ' ' + c.title[0].toUpperCase() + c.title.slice(1));
203+
const tickets = getTickets(c);
204+
tickets && (log += `. ${tickets}`);
205+
c.body && (log += `\n\n _${c.body}_`);
206+
console.log(log);
207+
}
208+
209+
// log a set of commits, and group by types
210+
function logCommitsByType(commits) {
211+
if (!commits[0]) return;
212+
const byType = {};
213+
commits.forEach((commit) => {
214+
const type = commit.bfp ? 'bfp' : commit.breaking ? 'break' : commit.type;
215+
byType[type] = [...(byType[type] || []), commit];
216+
});
217+
Object.keys(keyName).forEach((k) => {
218+
if (byType[k]) {
219+
console.log(`\n#### ${keyName[k]}`);
220+
221+
if (compact) {
222+
byType[k].forEach((c) => logCommit(c));
223+
} else {
224+
byType[k].filter((c) => c.components.length).forEach((c) => logCommit(c));
225+
226+
const other = byType[k].filter((c) => !c.components.length);
227+
if (other.length) {
228+
console.log('- Other');
229+
other.forEach((c) => logCommit(c));
230+
}
231+
}
232+
}
233+
});
234+
}
235+
236+
// Output the release notes for the set of commits
237+
function generateReleaseNotes(commits) {
238+
console.log(`[API Documentation →](https://cdn.vaadin.com/vaadin-web-components/${version}/)\n`);
239+
if (commits.length) {
240+
console.log(`### Changes Since [${from}](https://github.com/vaadin/vaadin-web-components/releases/tag/${from})`);
241+
} else {
242+
console.log(
243+
`### No Changes Since [${from}](https://github.com/vaadin/vaadin-web-components/releases/tag/${from})})`
244+
);
245+
}
246+
logCommitsByType(commits);
247+
}
248+
249+
// MAIN
250+
async function main() {
251+
await getReleases();
252+
const gitLog = await run(`git log ${from}..${to} --name-only`);
253+
const commits = parseLog(gitLog);
254+
generateReleaseNotes(commits);
255+
}
256+
257+
main();

0 commit comments

Comments
 (0)