UNPKG

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