1 | /**
|
2 | * Copyright IBM Corp. 2019, 2019
|
3 | *
|
4 | * This source code is licensed under the Apache-2.0 license found in the
|
5 | * LICENSE file in the root directory of this source tree.
|
6 | */
|
7 |
|
8 | ;
|
9 |
|
10 | const parse = require('@commitlint/parse');
|
11 | const execa = require('execa');
|
12 |
|
13 | // We keep a list of commits that are process-oriented that we never want to
|
14 | // show up in generated changelogs
|
15 | const headerDenyList = new Set([
|
16 | 'chore(project): sync generated files [skip ci]',
|
17 | 'chore(release): update package versions',
|
18 | ]);
|
19 |
|
20 | /**
|
21 | * @typedef PackageInfo
|
22 | * @property {string} name
|
23 | * @property {string} version
|
24 | * @property {boolean} private
|
25 | * @property {string} location
|
26 | */
|
27 |
|
28 | /**
|
29 | * Generate the contents of a CHANGELOG for a given list of packages between two
|
30 | * tagged git objects. For example, if updating from v1.0.0 to v1.1.0, this
|
31 | * method would generate a changelog for all commits in the range
|
32 | * v1.0.0...v1.1.0 and group them based on the given packages.
|
33 | *
|
34 | * @param {Array<PackageInfo>} packages
|
35 | * @param {string} lastTag
|
36 | * @param {string} latestTag
|
37 | * @returns {string}
|
38 | */
|
39 | async function generate(packages, lastTag, latestTag) {
|
40 | const packageCommitsInRange = await Promise.all(
|
41 | packages.map(pkg => getCommitsInRange(pkg, `${lastTag}...${latestTag}`))
|
42 | );
|
43 | const packageCommitsToInclude = packageCommitsInRange.filter(
|
44 | ({ commits }) => {
|
45 | return commits.length > 0;
|
46 | }
|
47 | );
|
48 |
|
49 | return [
|
50 | getMarkdownTitle(lastTag, latestTag),
|
51 | ...getMarkdownSections(packageCommitsToInclude),
|
52 | ].join('\n');
|
53 | }
|
54 |
|
55 | const sectionTypes = [
|
56 | {
|
57 | title: 'New features :rocket:',
|
58 | types: ['feat'],
|
59 | },
|
60 | {
|
61 | title: 'Bug fixes :bug:',
|
62 | types: ['fix'],
|
63 | },
|
64 | {
|
65 | title: 'Documentation :memo:',
|
66 | types: ['docs'],
|
67 | },
|
68 | {
|
69 | title: 'Housekeeping :house:',
|
70 | types: ['build', 'ci', 'chore', 'perf', 'refactor', 'revert', 'test'],
|
71 | },
|
72 | ];
|
73 |
|
74 | const commitUrl = 'https://github.com/carbon-design-system/carbon/commit';
|
75 |
|
76 | /**
|
77 | * Get the sections to be rendered in our changelog for the given packages and
|
78 | * their associated commits. Our plan is to group the commits for each package
|
79 | * under various section types and build up the lists accordingly.
|
80 | *
|
81 | * @param {Array} packages
|
82 | * @returns {Array}
|
83 | */
|
84 | function getMarkdownSections(packages) {
|
85 | return packages.map(({ name, version, commits }) => {
|
86 | let section = `## \`${name}@${version}\`\n`;
|
87 |
|
88 | for (const { title, types } of sectionTypes) {
|
89 | const commitsForSection = commits.filter(commit => {
|
90 | return types.includes(commit.info.type);
|
91 | });
|
92 |
|
93 | if (commitsForSection.length === 0) {
|
94 | continue;
|
95 | }
|
96 |
|
97 | let subsection = `### ${title}\n`;
|
98 |
|
99 | for (const commit of commitsForSection) {
|
100 | const { hash, info } = commit;
|
101 | const url = `${commitUrl}/${hash}`;
|
102 | subsection += `- ${info.header} ([\`${hash}\`](${url}))\n`;
|
103 | }
|
104 |
|
105 | section += '\n' + subsection;
|
106 | }
|
107 |
|
108 | return section;
|
109 | });
|
110 | }
|
111 |
|
112 | const compareUrl = 'https://github.com/carbon-design-system/carbon/compare';
|
113 |
|
114 | /**
|
115 | * Get the markdown title for the given lastTag and latestTag. This title is
|
116 | * used at the top of a given GitHub release.
|
117 | *
|
118 | * @param {string} lastTag
|
119 | * @param {string} latestTag
|
120 | * @returns {string}
|
121 | */
|
122 | function getMarkdownTitle(lastTag, latestTag) {
|
123 | const now = new Date();
|
124 | const year = now.getFullYear();
|
125 | const day = now.getDate();
|
126 | const month = now.getMonth() + 1;
|
127 | const url = `${compareUrl}/${lastTag}...${latestTag}`;
|
128 |
|
129 | return `# [${latestTag}](${url}) (${year}-${month}-${day})`;
|
130 | }
|
131 |
|
132 | /**
|
133 | * Retrieves the commits from the given range for the particular package.
|
134 | * Specifically, we're going to find commits that are in the package folder so
|
135 | * that we can group them later on when generating the changelog.
|
136 | *
|
137 | * @param {PackageInfo} pkg
|
138 | * @param {string} range
|
139 | * @returns {Array}
|
140 | */
|
141 | async function getCommitsInRange(pkg, range) {
|
142 | // Using the `rev-list` subcommand of `git` we can list out all of the commits
|
143 | // for the given range inside of the package's location. This will allow us to
|
144 | // find all the commits associated with this package that we'll display in the
|
145 | // changelog
|
146 | const { stdout } = await execa('git', [
|
147 | 'rev-list',
|
148 | range,
|
149 | '--oneline',
|
150 | '--',
|
151 | pkg.location,
|
152 | ]);
|
153 |
|
154 | // If the git sub-command returns nothing, then no commits have occurred for
|
155 | // this package in the given commit rnage
|
156 | if (stdout === '') {
|
157 | return {
|
158 | ...pkg,
|
159 | commits: [],
|
160 | };
|
161 | }
|
162 |
|
163 | const commitsInFolder = await Promise.all(
|
164 | stdout.split('\n').map(async commit => {
|
165 | // The output from `git rev-list` follows the pattern: `HASH <header>`, so
|
166 | // we will need to trim the string to get the appropriate hash and text
|
167 | // values for `parse` to consume.
|
168 | const hash = commit.slice(0, 9);
|
169 | const text = commit.slice(10);
|
170 | // Just in case, we'll trim the header value as sometimes the commit gets
|
171 | // an extra space that's unnecessary
|
172 | const info = await parse(text.trim());
|
173 |
|
174 | return {
|
175 | info,
|
176 | hash,
|
177 | text,
|
178 | };
|
179 | })
|
180 | );
|
181 |
|
182 | const headers = new Set();
|
183 | // There are certain conditions where we don't want a commit visible in the
|
184 | // CHANGELOG, namely when we cannot parse the commit info type or when the
|
185 | // commit header is in our deny list
|
186 | const commits = commitsInFolder
|
187 | .filter(commit => {
|
188 | if (commit.info.type === null) {
|
189 | return false;
|
190 | }
|
191 |
|
192 | if (headerDenyList.has(commit.info.header)) {
|
193 | return false;
|
194 | }
|
195 |
|
196 | return true;
|
197 | })
|
198 | .filter(commit => {
|
199 | // Running into an issue with duplicate headers when viewing "commited by"
|
200 | // and "commited and authored by", as a result we'll keep a set of all
|
201 | // headers and exclude the commit if we've seen it already
|
202 | if (headers.has(commit.info.header)) {
|
203 | return false;
|
204 | }
|
205 | headers.add(commit.info.header);
|
206 | return true;
|
207 | });
|
208 |
|
209 | return {
|
210 | ...pkg,
|
211 | commits,
|
212 | };
|
213 | }
|
214 |
|
215 | module.exports = {
|
216 | generate,
|
217 | };
|