UNPKG

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