UNPKG

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