UNPKG

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