1 | "use strict";
|
2 | Object.defineProperty(exports, "__esModule", { value: true });
|
3 | const tslib_1 = require("tslib");
|
4 | const await_to_js_1 = tslib_1.__importDefault(require("await-to-js"));
|
5 | const endent_1 = tslib_1.__importDefault(require("endent"));
|
6 | const fs = tslib_1.__importStar(require("fs"));
|
7 | const lodash_chunk_1 = tslib_1.__importDefault(require("lodash.chunk"));
|
8 | const semver_1 = require("semver");
|
9 | const util_1 = require("util");
|
10 | const typescript_memoize_1 = require("typescript-memoize");
|
11 | const changelog_1 = tslib_1.__importDefault(require("./changelog"));
|
12 | const log_parse_1 = tslib_1.__importDefault(require("./log-parse"));
|
13 | const semver_2 = tslib_1.__importStar(require("./semver"));
|
14 | const exec_promise_1 = tslib_1.__importDefault(require("./utils/exec-promise"));
|
15 | const logger_1 = require("./utils/logger");
|
16 | const make_hooks_1 = require("./utils/make-hooks");
|
17 | const child_process_1 = require("child_process");
|
18 | exports.releaseLabels = [
|
19 | semver_2.default.major,
|
20 | semver_2.default.minor,
|
21 | semver_2.default.patch,
|
22 | 'skip',
|
23 | 'release'
|
24 | ];
|
25 |
|
26 | exports.isVersionLabel = (label) => exports.releaseLabels.includes(label);
|
27 | exports.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 |
|
70 | exports.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());
|
77 | const readFile = util_1.promisify(fs.readFile);
|
78 | const writeFile = util_1.promisify(fs.writeFile);
|
79 |
|
80 |
|
81 |
|
82 |
|
83 | function 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 | }
|
123 | exports.buildSearchQuery = buildSearchQuery;
|
124 |
|
125 | function 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 |
|
152 |
|
153 | class Release {
|
154 |
|
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 |
|
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 |
|
183 |
|
184 |
|
185 |
|
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 |
|
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 |
|
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 |
|
249 |
|
250 |
|
251 |
|
252 |
|
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 |
|
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 |
|
279 |
|
280 |
|
281 |
|
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 |
|
290 |
|
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 |
|
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 |
|
341 |
|
342 |
|
343 |
|
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 |
|
360 | async calcNextVersion(lastTag) {
|
361 | const bump = await this.getSemverBump(lastTag);
|
362 | return semver_1.inc(lastTag, bump);
|
363 | }
|
364 |
|
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 |
|
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 |
|
402 |
|
403 |
|
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 |
|
431 |
|
432 |
|
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 |
|
446 | async attachAuthor(commit) {
|
447 | const modifiedCommit = Object.assign({}, commit);
|
448 | let resolvedAuthors = [];
|
449 |
|
450 |
|
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 | }
|
482 | tslib_1.__decorate([
|
483 | typescript_memoize_1.Memoize()
|
484 | ], Release.prototype, "makeChangelog", null);
|
485 | tslib_1.__decorate([
|
486 | typescript_memoize_1.Memoize()
|
487 | ], Release.prototype, "createLogParse", null);
|
488 | tslib_1.__decorate([
|
489 | typescript_memoize_1.Memoize()
|
490 | ], Release.prototype, "getPRsSinceLastRelease", null);
|
491 | exports.default = Release;
|
492 |
|
\ | No newline at end of file |