UNPKG

12.2 kBJavaScriptView Raw
1"use strict";
2Object.defineProperty(exports, "__esModule", { value: true });
3const tslib_1 = require("tslib");
4const url_1 = require("url");
5const url_join_1 = tslib_1.__importDefault(require("url-join"));
6const make_hooks_1 = require("./utils/make-hooks");
7const semver_1 = tslib_1.__importDefault(require("./semver"));
8/** Determine how deep the markdown headers are in a string */
9const getHeaderDepth = (line) => line.split('').reduce((count, char) => (char === '#' ? count + 1 : count), 0);
10/** Filter for only commits that have a specific label */
11const filterLabel = (commits, label) => commits.filter(commit => commit.labels.includes(label));
12/**
13 * Manages creating the "Release Notes" that are included in
14 * both the CHANGELOG.md and GitHub releases.
15 */
16class Changelog {
17 /** Initialize the changelog generator with default hooks and labels */
18 constructor(logger, options) {
19 this.logger = logger;
20 this.options = options;
21 this.hooks = make_hooks_1.makeChangelogHooks();
22 if (!this.options.labels.find(l => l.name === 'pushToBaseBranch'))
23 this.options.labels.push({
24 name: 'pushToBaseBranch',
25 changelogTitle: `⚠️ Pushed to ${options.baseBranch}`,
26 description: 'N/A',
27 releaseType: semver_1.default.patch
28 });
29 }
30 /** Load the default configuration */
31 loadDefaultHooks() {
32 this.hooks.renderChangelogAuthor.tap('Default', (author, commit) => this.createUserLink(author, commit));
33 this.hooks.renderChangelogAuthorLine.tap('Default', (author, user) => {
34 const authorString = author.name && user ? `${author.name} (${user})` : user;
35 return authorString ? `- ${authorString}` : undefined;
36 });
37 this.hooks.renderChangelogLine.tap('Default', ([commit, line]) => [
38 commit,
39 line
40 ]);
41 this.hooks.renderChangelogTitle.tap('Default', (label, changelogTitles) => `#### ${changelogTitles[label]}\n`);
42 this.hooks.omitReleaseNotes.tap('Renovate', commit => {
43 const names = ['renovate-pro[bot]', 'renovate-bot'];
44 if (commit.authors.find(author => Boolean((author.name && names.includes(author.name)) ||
45 (author.username && names.includes(author.username))))) {
46 return true;
47 }
48 });
49 }
50 /** Generate the release notes for a group of commits */
51 async generateReleaseNotes(commits) {
52 if (commits.length === 0) {
53 return '';
54 }
55 this.logger.verbose.info('Generating release notes for:\n', commits);
56 const split = this.splitCommits(commits);
57 this.logger.verbose.info('Split commits into groups');
58 this.logger.veryVerbose.info('\n', split);
59 const sections = [];
60 const extraNotes = (await this.hooks.addToBody.promise([], commits)) || [];
61 extraNotes.filter(Boolean).forEach(note => sections.push(note));
62 await this.createReleaseNotesSection(commits, sections);
63 this.logger.verbose.info('Added release notes to changelog');
64 this.authors = this.getAllAuthors(split);
65 await this.createLabelSection(split, sections);
66 this.logger.verbose.info('Added groups to changelog');
67 await this.createAuthorSection(sections);
68 this.logger.verbose.info('Added authors to changelog');
69 const result = sections.join('\n\n');
70 this.logger.verbose.info('Successfully generated release notes.');
71 return result;
72 }
73 /** Create a link to a user for use in the changelog */
74 createUserLink(author, commit) {
75 const githubUrl = new url_1.URL(this.options.baseUrl).origin;
76 if (author.username === 'invalid-email-address') {
77 return;
78 }
79 return author.username
80 ? `[@${author.username}](${url_join_1.default(githubUrl, author.username)})`
81 : author.email || commit.authorEmail;
82 }
83 /** Split commits into changelogTitle sections. */
84 splitCommits(commits) {
85 let currentCommits = [...commits];
86 const order = ['major', 'minor', 'patch'];
87 const sections = this.options.labels
88 .filter(label => label.changelogTitle)
89 .sort((a, b) => {
90 const bIndex = order.indexOf(b.releaseType || '') + 1 || order.length + 1;
91 const aIndex = order.indexOf(a.releaseType || '') + 1 || order.length + 1;
92 return aIndex - bIndex;
93 })
94 .reduce((acc, item) => [...acc, item], []);
95 const defaultPatchLabelName = this.getFirstLabelNameFromLabelKey(this.options.labels, 'patch');
96 commits
97 .filter(({ labels }) =>
98 // in case pr commit doesn't contain a label for section inclusion
99 !sections.some(section => labels.includes(section.name)) ||
100 // in this case we auto attached a patch when it was merged
101 (labels[0] === 'released' && labels.length === 1))
102 .map(({ labels }) => labels.push(defaultPatchLabelName));
103 return Object.assign({}, ...sections.map(label => {
104 const matchedCommits = filterLabel(currentCommits, label.name);
105 currentCommits = currentCommits.filter(commit => !matchedCommits.includes(commit));
106 return matchedCommits.length === 0
107 ? {}
108 : { [label.name]: matchedCommits };
109 }));
110 }
111 /** Get the default label for a label key */
112 getFirstLabelNameFromLabelKey(labels, labelKey) {
113 var _a;
114 return ((_a = labels.find(l => l.releaseType === labelKey)) === null || _a === void 0 ? void 0 : _a.name) || labelKey;
115 }
116 /** Create a list of users */
117 async createUserLinkList(commit) {
118 const result = new Set();
119 await Promise.all(commit.authors.map(async (rawAuthor) => {
120 const data = this.authors.find(([, commitAuthor]) => (commitAuthor.name &&
121 rawAuthor.name &&
122 commitAuthor.name === rawAuthor.name) ||
123 (commitAuthor.email &&
124 rawAuthor.email &&
125 commitAuthor.email === rawAuthor.email) ||
126 (commitAuthor.username &&
127 rawAuthor.username &&
128 commitAuthor.username === rawAuthor.username)) || [{}, rawAuthor];
129 const link = await this.hooks.renderChangelogAuthor.promise(data[1], commit, this.options);
130 if (link) {
131 result.add(link);
132 }
133 }));
134 return [...result].join(' ');
135 }
136 /** Transform a commit into a line in the changelog */
137 async generateCommitNote(commit) {
138 var _a;
139 const subject = commit.subject ? commit.subject.trim() : '';
140 let pr = '';
141 if ((_a = commit.pullRequest) === null || _a === void 0 ? void 0 : _a.number) {
142 const prLink = url_join_1.default(this.options.baseUrl, 'pull', commit.pullRequest.number.toString());
143 pr = `[#${commit.pullRequest.number}](${prLink})`;
144 }
145 const user = await this.createUserLinkList(commit);
146 return `- ${subject} ${pr}${user ? ` (${user})` : ''}`;
147 }
148 /** Get all the authors in the provided commits */
149 getAllAuthors(split) {
150 const commits = Object.values(split).reduce((labeledCommits, sectionCommits) => [...labeledCommits, ...sectionCommits], []);
151 return commits
152 .map(commit => commit.authors
153 .filter(author => author.username !== 'invalid-email-address' &&
154 (author.name || author.email || author.username))
155 .map(author => [commit, author]))
156 .reduce((all, more) => [...all, ...more], [])
157 .sort(a => ('id' in a[1] ? 0 : 1));
158 }
159 /** Create a section in the changelog to showcase contributing authors */
160 async createAuthorSection(sections) {
161 const authors = new Set();
162 const authorsWithFullData = this.authors.map(([, author]) => author).filter(author => 'id' in author);
163 await Promise.all(this.authors.map(async ([commit, author]) => {
164 const info = authorsWithFullData.find(u => (author.name && u.name === author.name) ||
165 (author.email && u.email === author.email)) || author;
166 const user = await this.hooks.renderChangelogAuthor.promise(info, commit, this.options);
167 const authorEntry = await this.hooks.renderChangelogAuthorLine.promise(info, user);
168 if (authorEntry && !authors.has(authorEntry)) {
169 authors.add(authorEntry);
170 }
171 }));
172 if (authors.size === 0) {
173 return;
174 }
175 let authorSection = `#### Authors: ${authors.size}\n\n`;
176 authorSection += [...authors].sort((a, b) => a.localeCompare(b)).join('\n');
177 sections.push(authorSection);
178 }
179 /** Create a section in the changelog to with all of the changelog notes organized by change type */
180 async createLabelSection(split, sections) {
181 const changelogTitles = this.options.labels.reduce((titles, label) => {
182 if (label.changelogTitle) {
183 titles[label.name] = label.changelogTitle;
184 }
185 return titles;
186 }, {});
187 const labelSections = await Promise.all(Object.entries(split).map(async ([label, labelCommits]) => {
188 const title = await this.hooks.renderChangelogTitle.promise(label, changelogTitles);
189 const lines = new Set();
190 await Promise.all(labelCommits.map(async (commit) => {
191 const [, line] = await this.hooks.renderChangelogLine.promise([
192 commit,
193 await this.generateCommitNote(commit)
194 ]);
195 lines.add(line);
196 }));
197 return [
198 title,
199 [...lines].sort((a, b) => a.split('\n').length - b.split('\n').length)
200 ];
201 }));
202 const mergedSections = labelSections.reduce((acc, [title, commits]) => (Object.assign(Object.assign({}, acc), { [title]: [...(acc[title] || []), ...commits] })), {});
203 Object.entries(mergedSections)
204 .map(([title, lines]) => [title, ...lines].join('\n'))
205 .map(section => sections.push(section));
206 }
207 /** Gather extra release notes to display at the top of the changelog */
208 async createReleaseNotesSection(commits, sections) {
209 if (!commits.length) {
210 return;
211 }
212 let section = '';
213 const visited = new Set();
214 const included = await Promise.all(commits.map(async (commit) => {
215 const omit = await this.hooks.omitReleaseNotes.promise(commit);
216 if (!omit) {
217 return commit;
218 }
219 }));
220 included.forEach(commit => {
221 if (!commit) {
222 return;
223 }
224 const pr = commit.pullRequest;
225 if (!pr || !pr.body) {
226 return;
227 }
228 const title = /^[#]{0,5}[ ]*[R|r]elease [N|n]otes$/;
229 const lines = pr.body.split('\n').map(line => line.replace(/\r$/, ''));
230 const notesStart = lines.findIndex(line => Boolean(line.match(title)));
231 if (notesStart === -1 || visited.has(pr.number)) {
232 return;
233 }
234 const depth = getHeaderDepth(lines[notesStart]);
235 visited.add(pr.number);
236 let notes = '';
237 for (let index = notesStart; index < lines.length; index++) {
238 const line = lines[index];
239 const isTitle = line.match(title);
240 if (line.startsWith('#') && getHeaderDepth(line) <= depth && !isTitle) {
241 break;
242 }
243 if (!isTitle) {
244 notes += `${line}\n`;
245 }
246 }
247 section += `_From #${pr.number}_\n\n${notes.trim()}\n\n`;
248 });
249 if (!section) {
250 return;
251 }
252 sections.push(`### Release Notes\n\n${section}---`);
253 }
254}
255exports.default = Changelog;
256//# sourceMappingURL=changelog.js.map
\No newline at end of file