UNPKG

21 kBJavaScriptView Raw
1"use strict";
2Object.defineProperty(exports, "__esModule", { value: true });
3const tslib_1 = require("tslib");
4const await_to_js_1 = tslib_1.__importDefault(require("await-to-js"));
5const fs = tslib_1.__importStar(require("fs"));
6const lodash_chunk_1 = tslib_1.__importDefault(require("lodash.chunk"));
7const semver_1 = require("semver");
8const util_1 = require("util");
9const t = tslib_1.__importStar(require("io-ts"));
10const typescript_memoize_1 = require("typescript-memoize");
11const changelog_1 = tslib_1.__importDefault(require("./changelog"));
12const log_parse_1 = tslib_1.__importDefault(require("./log-parse"));
13const semver_2 = tslib_1.__importStar(require("./semver"));
14const exec_promise_1 = tslib_1.__importDefault(require("./utils/exec-promise"));
15const logger_1 = require("./utils/logger");
16const make_hooks_1 = require("./utils/make-hooks");
17const child_process_1 = require("child_process");
18const match_sha_to_pr_1 = require("./match-sha-to-pr");
19exports.releaseLabels = [
20 semver_2.default.major,
21 semver_2.default.minor,
22 semver_2.default.patch,
23 "skip",
24 "release",
25];
26/** Determine if a label is a label used for versioning */
27exports.isVersionLabel = (label) => exports.releaseLabels.includes(label);
28const labelDefinitionRequired = t.type({
29 /** The label text */
30 name: t.string,
31});
32const labelDefinitionOptional = t.partial({
33 /** A title to put in the changelog for the label */
34 changelogTitle: t.string,
35 /** The color of the label */
36 color: t.string,
37 /** The description of the label */
38 description: t.string,
39 /** What type of release this label signifies */
40 releaseType: t.union([
41 t.literal("none"),
42 t.literal("skip"),
43 ...exports.releaseLabels.map((l) => t.literal(l)),
44 ]),
45 /** Whether to overwrite the base label */
46 overwrite: t.boolean,
47});
48exports.labelDefinition = t.intersection([
49 labelDefinitionOptional,
50 labelDefinitionRequired,
51]);
52exports.defaultLabels = [
53 {
54 name: "major",
55 changelogTitle: "💥 Breaking Change",
56 description: "Increment the major version when merged",
57 releaseType: semver_2.default.major,
58 },
59 {
60 name: "minor",
61 changelogTitle: "🚀 Enhancement",
62 description: "Increment the minor version when merged",
63 releaseType: semver_2.default.minor,
64 },
65 {
66 name: "patch",
67 changelogTitle: "🐛 Bug Fix",
68 description: "Increment the patch version when merged",
69 releaseType: semver_2.default.patch,
70 },
71 {
72 name: "skip-release",
73 description: "Preserve the current version when merged",
74 releaseType: "skip",
75 },
76 {
77 name: "release",
78 description: "Create a release when this pr is merged",
79 releaseType: "release",
80 },
81 {
82 name: "internal",
83 changelogTitle: "🏠 Internal",
84 description: "Changes only affect the internal API",
85 releaseType: "none",
86 },
87 {
88 name: "documentation",
89 changelogTitle: "📝 Documentation",
90 description: "Changes only affect the documentation",
91 releaseType: "none",
92 },
93];
94/** Construct a map of label => semver label */
95exports.getVersionMap = (labels = exports.defaultLabels) => labels.reduce((semVer, { releaseType: type, name }) => {
96 if (type && (exports.isVersionLabel(type) || type === "none")) {
97 const list = semVer.get(type) || [];
98 semVer.set(type, [...list, name]);
99 }
100 return semVer;
101}, new Map());
102const readFile = util_1.promisify(fs.readFile);
103const writeFile = util_1.promisify(fs.writeFile);
104/**
105 * A class for interacting with the git remote
106 */
107class Release {
108 /** Initialize the release manager */
109 constructor(git, config = {
110 baseBranch: "master",
111 prereleaseBranches: ["next"],
112 labels: exports.defaultLabels,
113 }, logger = logger_1.dummyLog()) {
114 this.config = config;
115 this.logger = logger;
116 this.hooks = make_hooks_1.makeReleaseHooks();
117 this.versionLabels = exports.getVersionMap(config.labels);
118 this.git = git;
119 }
120 /** Make the class that will generate changelogs for the project */
121 async makeChangelog(version) {
122 const project = await this.git.getProject();
123 const changelog = new changelog_1.default(this.logger, {
124 owner: this.git.options.owner,
125 repo: this.git.options.repo,
126 baseUrl: project.html_url,
127 labels: this.config.labels,
128 baseBranch: this.config.baseBranch,
129 prereleaseBranches: this.config.prereleaseBranches,
130 });
131 this.hooks.onCreateChangelog.call(changelog, version);
132 changelog.loadDefaultHooks();
133 return changelog;
134 }
135 /**
136 * Generate a changelog from a range of commits.
137 *
138 * @param from - sha or tag to start changelog from
139 * @param to - sha or tag to end changelog at (defaults to HEAD)
140 */
141 async generateReleaseNotes(from, to = "HEAD", version) {
142 const commits = await this.getCommitsInRelease(from, to);
143 const changelog = await this.makeChangelog(version);
144 return changelog.generateReleaseNotes(commits);
145 }
146 /** Get all the commits that will be included in a release */
147 async getCommitsInRelease(from, to = "HEAD") {
148 const allCommits = await this.getCommits(from, to);
149 const allPrCommits = await Promise.all(allCommits
150 .filter((commit) => commit.pullRequest)
151 .map(async (commit) => {
152 const [err, commits = []] = await await_to_js_1.default(this.git.getCommitsForPR(Number(commit.pullRequest.number)));
153 return err ? [] : commits;
154 }));
155 const allPrCommitHashes = allPrCommits
156 .filter(Boolean)
157 .reduce((all, pr) => [...all, ...pr.map((subCommit) => subCommit.sha)], []);
158 const uniqueCommits = allCommits.filter((commit) => (commit.pullRequest || !allPrCommitHashes.includes(commit.hash)) &&
159 !commit.subject.includes("[skip ci]"));
160 const commitsWithoutPR = uniqueCommits.filter((commit) => !commit.pullRequest);
161 const batches = lodash_chunk_1.default(commitsWithoutPR, 10);
162 const queries = await Promise.all(batches
163 .map((batch) => match_sha_to_pr_1.buildSearchQuery(this.git.options.owner, this.git.options.repo, batch.map((c) => c.hash)))
164 .filter((q) => Boolean(q))
165 .map((q) => this.git.graphql(q)));
166 const data = queries.filter((q) => Boolean(q));
167 if (!data.length) {
168 return uniqueCommits;
169 }
170 const commitsInRelease = [
171 ...uniqueCommits,
172 ];
173 const logParse = await this.createLogParse();
174 const entries = data.reduce((acc, result) => [...acc, ...Object.entries(result)], []);
175 await Promise.all(entries
176 .filter((result) => Boolean(result[1]))
177 .map(([key, result]) => match_sha_to_pr_1.processQueryResult({
178 sha: key,
179 result,
180 commitsWithoutPR,
181 owner: this.git.options.owner,
182 prereleaseBranches: this.config.prereleaseBranches,
183 }))
184 .filter((commit) => Boolean(commit))
185 .map(async (commit) => {
186 const index = commitsInRelease.findIndex((c) => c && c.hash === commit.hash);
187 commitsInRelease[index] = await logParse.normalizeCommit(commit);
188 }));
189 return commitsInRelease.filter((commit) => Boolean(commit));
190 }
191 /** Update a changelog with a new set of release notes */
192 async updateChangelogFile(title, releaseNotes, changelogPath) {
193 const date = new Date().toDateString();
194 let newChangelog = "#";
195 if (title) {
196 newChangelog += ` ${title}`;
197 }
198 newChangelog += ` (${date})\n\n${releaseNotes}`;
199 if (fs.existsSync(changelogPath)) {
200 this.logger.verbose.info("Old changelog exists, prepending changes.");
201 const oldChangelog = await readFile(changelogPath, "utf8");
202 newChangelog = `${newChangelog}\n\n---\n\n${oldChangelog}`;
203 }
204 await writeFile(changelogPath, newChangelog);
205 this.logger.verbose.info("Wrote new changelog to filesystem.");
206 await exec_promise_1.default("git", ["add", changelogPath]);
207 }
208 /**
209 * Prepend a set of release notes to the changelog.md
210 *
211 * @param releaseNotes - Release notes to prepend to the changelog
212 * @param lastRelease - Last release version of the code. Could be the first commit SHA
213 * @param currentVersion - Current version of the code
214 */
215 async addToChangelog(releaseNotes, lastRelease, currentVersion) {
216 this.hooks.createChangelogTitle.tapPromise("Default", async () => {
217 let version;
218 if (lastRelease.match(/\d+\.\d+\.\d+/)) {
219 version = await this.calcNextVersion(lastRelease);
220 }
221 else {
222 // lastRelease is a git sha. no releases have been made
223 const bump = await this.getSemverBump(lastRelease);
224 version = semver_1.inc(currentVersion, bump);
225 }
226 this.logger.verbose.info("Calculated next version to be:", version);
227 if (!version) {
228 return "";
229 }
230 return this.config.noVersionPrefix || version.startsWith("v")
231 ? version
232 : `v${version}`;
233 });
234 this.logger.verbose.info("Adding new changes to changelog.");
235 const title = await this.hooks.createChangelogTitle.promise();
236 await this.updateChangelogFile(title || "", releaseNotes, "CHANGELOG.md");
237 }
238 /**
239 * Get a range of commits. The commits will have PR numbers and labels attached
240 *
241 * @param from - Tag or SHA to start at
242 * @param to - Tag or SHA to end at (defaults to HEAD)
243 */
244 async getCommits(from, to = "HEAD") {
245 this.logger.verbose.info(`Getting commits from ${from} to ${to}`);
246 const gitlog = await this.git.getGitLog(from, to);
247 this.logger.veryVerbose.info("Got gitlog:\n", gitlog);
248 const logParse = await this.createLogParse();
249 const commits = (await logParse.normalizeCommits(gitlog)).filter((commit) => {
250 let released;
251 try {
252 // This determines: Is this commit an ancestor of this commit?
253 // ↓ ↓
254 child_process_1.execSync(`git merge-base --is-ancestor ${from} ${commit.hash}`, {
255 encoding: "utf8",
256 });
257 released = false;
258 }
259 catch (error) {
260 try {
261 // --is-ancestor returned false so the commit might be **before** "from"
262 // so test if it is and do not release this commit again
263 // This determines: Is this commit an ancestor of this commit?
264 // ↓ ↓
265 child_process_1.execSync(`git merge-base --is-ancestor ${commit.hash} ${from}`, {
266 encoding: "utf8",
267 });
268 released = true;
269 }
270 catch (error) {
271 // neither commit is a parent of the other so include it
272 released = false;
273 }
274 }
275 if (released) {
276 const shortHash = commit.hash.slice(0, 8);
277 this.logger.verbose.warn(`Commit already released, omitting: ${shortHash}: "${commit.subject}"`);
278 }
279 return !released;
280 });
281 this.logger.veryVerbose.info("Added labels to commits:\n", commits);
282 return commits;
283 }
284 /** Go through the configured labels and either add them to the project or update them */
285 async addLabelsToProject(labels, options = {}) {
286 const oldLabels = ((await this.git.getProjectLabels()) || []).map((l) => l.toLowerCase());
287 const labelsToCreate = labels.filter((label) => {
288 if (label.releaseType === "release" &&
289 !this.config.onlyPublishWithReleaseLabel) {
290 return false;
291 }
292 if (label.releaseType === "skip" &&
293 this.config.onlyPublishWithReleaseLabel) {
294 return false;
295 }
296 return true;
297 });
298 if (!options.dryRun) {
299 await Promise.all(labelsToCreate.map(async (label) => {
300 if (oldLabels.some((o) => label.name.toLowerCase() === o)) {
301 return this.git.updateLabel(label);
302 }
303 return this.git.createLabel(label);
304 }));
305 }
306 const repoMetadata = await this.git.getProject();
307 const justLabelNames = labelsToCreate.reduce((acc, label) => [...acc, label.name], []);
308 if (justLabelNames.length > 0) {
309 const state = options.dryRun ? "Would have created" : "Created";
310 this.logger.log.log(`${state} labels: ${justLabelNames.join(", ")}`);
311 }
312 else {
313 const state = options.dryRun ? "would have been" : "were";
314 this.logger.log.log(`No labels ${state} created, they must have already been present on your project.`);
315 }
316 if (options.dryRun) {
317 return;
318 }
319 this.logger.log.log(`\nYou can see these, and more at ${repoMetadata.html_url}/labels`);
320 }
321 /**
322 * Calculate the SEMVER bump over a range of commits using the PR labels
323 *
324 * @param from - Tag or SHA to start at
325 * @param to - Tag or SHA to end at (defaults to HEAD)
326 */
327 async getSemverBump(from, to = "HEAD") {
328 const commits = await this.getCommits(from, to);
329 const labels = commits.map((commit) => commit.labels);
330 const { onlyPublishWithReleaseLabel } = this.config;
331 const options = { onlyPublishWithReleaseLabel };
332 this.logger.verbose.info("Calculating SEMVER bump using:\n", {
333 labels,
334 versionLabels: this.versionLabels,
335 options,
336 });
337 const result = semver_2.calculateSemVerBump(labels, this.versionLabels, options);
338 this.logger.verbose.success("Calculated SEMVER bump:", result);
339 return result;
340 }
341 /** Given a tag get the next incremented version */
342 async calcNextVersion(lastTag) {
343 const bump = await this.getSemverBump(lastTag);
344 return semver_1.inc(lastTag, bump);
345 }
346 /** Create the class that will parse the log for PR info */
347 async createLogParse() {
348 const logParse = new log_parse_1.default();
349 logParse.hooks.parseCommit.tapPromise("Author Info", async (commit) => this.attachAuthor(commit));
350 logParse.hooks.parseCommit.tapPromise("PR Information", async (commit) => this.addPrInfoToCommit(commit));
351 logParse.hooks.parseCommit.tapPromise("PR Commits", async (commit) => {
352 const prsSinceLastRelease = await this.getPRsSinceLastRelease();
353 return this.getPRForRebasedCommits(commit, prsSinceLastRelease);
354 });
355 this.hooks.onCreateLogParse.call(logParse);
356 return logParse;
357 }
358 /** Get a the PRs that have been merged since the last GitHub release. */
359 async getPRsSinceLastRelease() {
360 let lastRelease;
361 try {
362 lastRelease = await this.git.getLatestReleaseInfo();
363 }
364 catch (error) {
365 const firstCommit = await this.git.getFirstCommit();
366 lastRelease = {
367 published_at: await this.git.getCommitDate(firstCommit),
368 };
369 }
370 if (!lastRelease) {
371 return [];
372 }
373 const prsSinceLastRelease = await this.git.searchRepo({
374 q: `is:pr is:merged merged:>=${lastRelease.published_at}`,
375 });
376 if (!prsSinceLastRelease || !prsSinceLastRelease.items) {
377 return [];
378 }
379 const data = await Promise.all(prsSinceLastRelease.items.map(async (pr) => this.git.getPullRequest(Number(pr.number))));
380 return data.map((item) => item.data);
381 }
382 /**
383 * Add the PR info (labels and body) to the commit
384 *
385 * @param commit - Commit to modify
386 */
387 async addPrInfoToCommit(commit) {
388 const modifiedCommit = Object.assign({}, commit);
389 if (!modifiedCommit.labels) {
390 modifiedCommit.labels = [];
391 }
392 if (modifiedCommit.pullRequest) {
393 const [err, info] = await await_to_js_1.default(this.git.getPr(modifiedCommit.pullRequest.number));
394 if (err || !info || !info.data) {
395 return modifiedCommit;
396 }
397 const labels = info ? info.data.labels.map((l) => l.name) : [];
398 modifiedCommit.labels = [
399 ...new Set([...labels, ...modifiedCommit.labels]),
400 ];
401 modifiedCommit.pullRequest.body = info.data.body;
402 modifiedCommit.subject = info.data.title || modifiedCommit.subject;
403 const hasPrOpener = modifiedCommit.authors.some((author) => author.username === info.data.user.login);
404 // If we can't find the use who opened the PR in authors attempt
405 // to add that user.
406 if (!hasPrOpener) {
407 const user = await this.git.getUserByUsername(info.data.user.login);
408 if (user) {
409 modifiedCommit.authors.push(Object.assign(Object.assign({}, user), { username: user.login }));
410 }
411 }
412 }
413 return modifiedCommit;
414 }
415 /**
416 * Commits from rebased PRs do not have messages that tie them to a PR
417 * Instead we have to find all PRs since the last release and try to match
418 * their merge commit SHAs.
419 */
420 getPRForRebasedCommits(commit, pullRequests) {
421 const matchPr = pullRequests.find((pr) => pr.merge_commit_sha === commit.hash);
422 if (!commit.pullRequest && matchPr) {
423 const labels = matchPr.labels.map((label) => label.name) || [];
424 commit.labels = [...new Set([...labels, ...commit.labels])];
425 commit.pullRequest = {
426 number: matchPr.number,
427 };
428 }
429 return commit;
430 }
431 /** Parse the commit for information about the author and any other author that might have helped. */
432 async attachAuthor(commit) {
433 var _a, _b;
434 const modifiedCommit = Object.assign({}, commit);
435 let resolvedAuthors = [];
436 // If there is a pull request we will attempt to get the authors
437 // from any commit in the PR
438 if (modifiedCommit.pullRequest) {
439 const [prCommitsErr, prCommits] = await await_to_js_1.default(this.git.getCommitsForPR(Number(modifiedCommit.pullRequest.number)));
440 if (prCommitsErr || !prCommits) {
441 return commit;
442 }
443 resolvedAuthors = await Promise.all(prCommits.map(async (prCommit) => {
444 if (!prCommit.author) {
445 return prCommit.commit.author;
446 }
447 return Object.assign(Object.assign(Object.assign({}, prCommit.author), (await this.git.getUserByUsername(prCommit.author.login))), { hash: prCommit.sha });
448 }));
449 }
450 else {
451 const [, response] = await await_to_js_1.default(this.git.getCommit(commit.hash));
452 if ((_b = (_a = response === null || response === void 0 ? void 0 : response.data) === null || _a === void 0 ? void 0 : _a.author) === null || _b === void 0 ? void 0 : _b.login) {
453 const username = response.data.author.login;
454 const author = await this.git.getUserByUsername(username);
455 resolvedAuthors.push(Object.assign(Object.assign({ name: commit.authorName, email: commit.authorEmail }, author), { hash: commit.hash }));
456 }
457 else if (commit.authorEmail) {
458 const author = await this.git.getUserByEmail(commit.authorEmail);
459 resolvedAuthors.push(Object.assign(Object.assign({ email: commit.authorEmail, name: commit.authorName }, author), { hash: commit.hash }));
460 }
461 }
462 modifiedCommit.authors = resolvedAuthors.map((author) => (Object.assign(Object.assign({}, author), (author && "login" in author ? { username: author.login } : {}))));
463 modifiedCommit.authors.forEach((author) => {
464 this.logger.veryVerbose.info(`Found author: ${author.username} ${author.email} ${author.name}`);
465 });
466 return modifiedCommit;
467 }
468}
469tslib_1.__decorate([
470 typescript_memoize_1.Memoize()
471], Release.prototype, "makeChangelog", null);
472tslib_1.__decorate([
473 typescript_memoize_1.Memoize()
474], Release.prototype, "getPRsSinceLastRelease", null);
475exports.default = Release;
476//# sourceMappingURL=release.js.map
\No newline at end of file