UNPKG

@silvapav/github-release-notes

Version:

Node module to publish release notes based on commits between the last two tags.

999 lines (880 loc) 26.3 kB
'use strict'; var utils = require('./utils'); var githubInfo = require('./github-info'); var template = require('./template'); var Github = require('github-api'); var fs = require('fs'); var chalk = require('chalk'); var Promise = Promise || require('es6-promise').Promise; var connectivity = require('connectivity'); var templateConfig = require('./templates.json'); var ObjectAssign = require('object-assign-deep'); var configFile = utils.getConfigFromFile(process.cwd()); var XMLHttpRequest = require('xmlhttprequest').XMLHttpRequest; var defaults = { tags: false, changelogFilename: 'CHANGELOG.md', dataSource: 'commits', // || issues onlyMilestones: false, milestoneMatch: 'Release {{tag_name}}', draft: false, force: false, prefix: '', includeMessages: 'commits', // || merges || all prerelease: false, dateZero: new Date(0), generate: false, override: false, ignoreLabels: false, // || array of labels ignoreIssuesWith: false, // || array of labels ignoreCommitsWith: false, template: templateConfig, groupBy: false }; /** * Edit a release from a given tag (in the options) * * @since 0.5.0 * @private * * @param {GithubReleaseNotes} gren The gren object * @param {number} releaseId The id of the release to edit * @param {Object} releaseOptions The options to build the release: * @example * { * "tag_name": "v1.0.0", * "target_commitish": "master", * "name": "v1.0.0", * "body": "Description of the release", * "draft": false, * "prerelease": false * } * * @return {Promise} */ function editRelease(gren, releaseId, releaseOptions) { var loaded = utils.task(gren, 'Updating latest release'); return gren.repo.updateRelease(releaseId, releaseOptions) .then(function(response) { loaded(); var release = response.data; console.log(chalk.green(release.name + ' has been successfully updated!')); return release; }); } /** * Create a release from a given tag (in the options) * * @since 0.1.0 * @private * * @param {GithubReleaseNotes} gren The gren object * @param {Object} releaseOptions The options to build the release: * @example { * "tag_name": "1.0.0", * "target_commitish": "master", * "name": "v1.0.0", * "body": "Description of the release", * "draft": false, * "prerelease": false * } * * @return {Promise} */ function createRelease(gren, releaseOptions) { var loaded = utils.task(gren, 'Preparing the release'); return gren.repo.createRelease(releaseOptions) .then(function(response) { loaded(); var release = response.data; console.log(chalk.green(release.name + ' has been successfully created!')); return release; }); } /** * Creates the options to make the release * * @since 0.2.0 * @private * * @param {GithubReleaseNotes} gren The gren object * @param {Object[]} tags The collection of tags * * @return {Promise} */ function prepareRelease(gren, block) { var releaseOptions = { tag_name: block.release, name: block.name, body: block.body, draft: gren.options.draft, prerelease: gren.options.prerelease }; if (block.id) { if (!gren.options.override) { console.warn(chalk.black(chalk.bgYellow('Skipping ' + block.release + ' (use --override to replace it)'))); return Promise.resolve(); } return editRelease(gren, block.id, releaseOptions); } return createRelease(gren, releaseOptions); } /** * Get the tags information from the given ones, and adds * the next one in case only one is given * * @since 0.5.0 * @private * * @param {Array|string} selectedTags * @param {Object[]} tags * * @return {Boolean|Array} */ function getSelectedTags(optionTags, tags) { if (optionTags.indexOf('all') >= 0) { return tags; } if (!optionTags.length) { return false; } var selectedTags = [].concat(optionTags); return tags.filter(function(tag, index) { var isSelectedTag = selectedTags.indexOf(tag.name) !== -1; if (isSelectedTag && selectedTags.length === 1 && tags[index + 1]) { selectedTags.push(tags[index + 1].name); } return isSelectedTag; }).slice(0, 2); } /** * Get all the tags of the repo * * @since 0.1.0 * @private * * @param {GithubReleaseNotes} gren The gren object * * @return {Promise} */ function getLastTags(gren, releases) { var loaded = utils.task(gren, 'Getting tags'); return gren.repo.listTags() .then(function(response) { loaded(); var tags = response.data; var filteredTags = (getSelectedTags(gren.options.tags, tags) || [tags[0], tags[1]]) .filter(Boolean) .map(function(tag) { var tagRelease = releases && releases.filter(function(release) { return release.tag_name === tag.name; })[0] || false; var releaseId = tagRelease ? tagRelease.id : null; return { tag: tag, releaseId: releaseId }; }); console.log('Tags found: ' + filteredTags.map(function(tag) { return tag.tag.name; }).join(', ')); return filteredTags; }); } /** * Get the dates of the last two tags * * @since 0.1.0 * @private * * @param {GithubReleaseNotes} gren The gren object * @param {Object[]} tags List of all the tags in the repo * * @return {Promise[]} The promises which returns the dates */ function getTagDates(gren, tags) { return tags.map(function(tag) { return gren.repo.getCommit(tag.tag.commit.sha) .then(function(response) { return { id: tag.releaseId, name: tag.tag.name, date: response.data.committer.date }; }); }); } /** * Get all releases * * @since 0.5.0 * @private * * @param {GithubReleaseNotes} gren The gren object * * @return {Promise} The promise which resolves an array of releases */ function getListReleases(gren) { var loaded = utils.task(gren, 'Getting the list of releases'); return gren.repo.listReleases() .then(function(response) { loaded(); var releases = response.data; process.stdout.write(releases.length + ' releases found\n'); return releases; }); } /** * Return the templated commit message * * @since 0.1.0 * @private * * @param {string} message * * @return {string} */ function templateCommits(gren, commits) { return template.generate({ message: commits.commit.message.split('\n', 1)[0], url: commits.html_url }, gren.options.template.commit); } /** * Generate the MD template from all the labels of a specific issue * * @since 0.5.0 * @private * * @param {Object} issue * * @return {string} */ function templateLabels(gren, issue) { if (!issue.labels.length && gren.options.template.noLabel) { issue.labels.push({name: gren.options.template.noLabel}); } return issue.labels .filter(function(label) { return gren.options.ignoreLabels.indexOf(label.name) === -1; }) .map(function(label) { return template.generate({ label: label.name }, gren.options.template.label); }).join(''); } /** * Generate the releases bodies from a release Objects Array * * @since 0.8.0 * @private * * @param {GithubReleaseNotes} gren * @param {Array} releases The release Objects Array coming from GitHub * * @return {string} */ function templateReleases(gren, releases) { return releases.map(function(release) { return template.generate({ release: release.name, date: utils.formatDate(new Date(release.published_at)), body: release.body }, gren.options.template.release); }).join(gren.options.template.releaseSeparator); } /** * Generate the MD template for each issue * * @since 0.5.0 * @private * * @param {Object} issue * * @return {string} */ function templateIssue(gren, issue) { return template.generate({ labels: templateLabels(gren, issue), name: issue.title, text: '#' + issue.number, url: issue.html_url }, gren.options.template.issue); } /** * Generate the Changelog issues body template * * @since 0.5.0 * @private * * @param {Object[]} blocks * * @return {string} */ function templateIssueBody(body, rangeBody) { return (body.length ? body.join('\n') : rangeBody || '*No changelog for this release.*') + '\n'; } /** * Generates the template for the groups * * @since 0.8.0 * @private * * @param {GithubReleaseNotes} gren * @param {Object} groups The groups to template e.g. * { * 'bugs': [{...}, {...}, {...}] * } * * @return {string} */ function templateGroups(gren, groups) { return Object.keys(groups).map(function(group) { var heading = template.generate({ heading: group }, gren.options.template.group); var body = groups[group].join('\n'); return heading + '\n' + body; }); } /** * Return a commit messages generated body * * @since 0.1.0 * @private * * @param {string} message * * @return {string} */ function generateCommitsBody(gren, messages) { messages.length === 1 && messages.push(null); return messages .slice(0, -1) .filter(function(message) { var messageType = gren.options.includeMessages; var filterMap = { merges: function(message) { return message.commit.message.match(/^merge/i); }, commits: function(message) { return !message.commit.message.match(/^merge/i); }, all: function() { return true; } }; const shouldIgnoreMessage = gren.options.ignoreCommitsWith.every(commitMessage => { const regex = new RegExp(commitMessage, 'i'); return !message.commit.message.match(regex); }); if (filterMap[messageType]) { return filterMap[messageType](message) && shouldIgnoreMessage && message.commit.message.startsWith("["); } return filterMap.commits(message) && shouldIgnoreMessage && message.commit.message.startsWith("["); }) .map(templateCommits.bind(null, gren)) .join('\n'); } /** * Transforms the commits to commit messages * * @since 0.1.0 * @private * * @param {Object[]} commits The array of object containing the commits * * @return {String[]} */ function commitMessages(commits) { return commits.map(function(commitObject) { return commitObject.commit.message.split('\n', 1)[0]; }); } /** * Gets all the commits between two dates * * @since 0.1.0 * @private * * @param {GithubReleaseNotes} gren The gren object * @param {string} since The since tag object * @param {string} until The until tag object * * @return {Promise} The promise which resolves the [Array] commit messages */ function getCommitsBetweenTwo(gren, since, until) { process.stdout.write(chalk.green('Get commits between ' + utils.formatDate(new Date(since)) + ' and ' + utils.formatDate(new Date(until)) + '\n')); var targetBranch; if (until.name.substring(7) === "1") { targetBranch = "release-private-" + until.name.substring(1); } else { targetBranch = "release-public-" + until.name.substring(1); } var options = { since: since, until: until, per_page: 100, sha: targetBranch }; return gren.repo.listCommits(options) .then(function(response) { var complete_data = response.data; if(response_link) { var last_page = response_link.substring( response_link.lastIndexOf("<") + 1, response_link.lastIndexOf(">") ); var num_pages = parseInt(last_page[last_page.length - 1]); var url = last_page.slice(0, -1) for( var i = 2; i <= num_pages; i++) { const xmlHttp = new XMLHttpRequest(); xmlHttp.open("GET", url + i, false); xmlHttp.setRequestHeader('Authorization', 'Bearer ' + gren.options.token ); xmlHttp.send(); var parsed_body = JSON.parse(xmlHttp.responseText); complete_data = complete_data.concat(parsed_body); } } return complete_data; }); } /** * Get the blocks of commits based on release dates * * @since 0.5.0 * @private * * @param {GithubReleaseNotes} gren * @param {Array} releaseRanges The array of date ranges * * @return {Promise[]} */ function getCommitBlocks(gren, releaseRanges) { console.log(chalk.blue('\nCreating the body blocks from commits:')); return Promise.all( releaseRanges .map(function(range) { return getCommitsBetweenTwo(gren, range[1], range[0]) .then(function(commits) { return { id: range[0].id, name: gren.options.prefix + range[0].name, release: range[0].name, published_at: range[0].date, body: generateCommitsBody(gren, commits) + '\n' }; }); }) ); } /** * Compare the ignored labels with the passed ones * * @since 0.6.0 * @private * * @param {Array} ignoreLabels The labels to ignore * @param {Array} labels The labels to check * * @return {boolean} If the labels array contain any of the ignore ones */ function compareIssueLabels(ignoreLabels, labels) { return ignoreLabels .reduce(function(carry, ignoredLabel) { return carry && labels.map(function(label) { return label.name; }).indexOf(ignoredLabel) === -1; }, true); } /** * Filter the issue based on gren options and labels * * @since 0.9.0 * @private * * @param {GithubReleaseNotes} gren * @param {Object} issue * * @return {Boolean} */ function filterIssue(gren, issue) { return !issue.pull_request && compareIssueLabels(gren.options.ignoreIssuesWith, issue.labels) && !((gren.options.onlyMilestones || gren.options.dataSource === 'milestones') && !issue.milestone); } /** * Get all the closed issues from the current repo * * @since 0.5.0 * @private * * @param {GithubReleaseNotes} gren The gren object * @param {Array} releaseRanges The array of date ranges * * @return {Promise} The promise which resolves the list of the issues */ function getClosedIssues(gren, releaseRanges) { var loaded = utils.task(gren, 'Getting all closed issues'); return gren.issues.listIssues({ state: 'closed', since: releaseRanges[releaseRanges.length - 1][1].date }) .then(function(response) { loaded(); var filteredIssues = response.data.filter(filterIssue.bind(null, gren)); process.stdout.write(filteredIssues.length + ' issues found\n'); return filteredIssues; }); } /** * Group the issues based on their first label * * @since 0.8.0 * @private * * @param {GithubReleaseNotes} gren * @param {Array} issues * * @return {string} */ function groupByLabel(gren, issues) { var groups = []; issues.forEach(function(issue) { if (!issue.labels.length && gren.options.template.noLabel) { issue.labels.push({name: gren.options.template.noLabel}); } var labelName = issue.labels[0].name; if (!groups[labelName]) { groups[labelName] = []; } groups[labelName].push(templateIssue(gren, issue)); }); return templateGroups(gren, utils.sortObject(groups)); } /** * Create groups of issues based on labels * * @since 0.8.0 * @private * * @param {GithubReleaseNotes} gren * @param {Array} issues The array of all the issues. * * @return {Array} */ function groupBy(gren, issues) { var groupBy = gren.options.groupBy; if (!groupBy) { return issues.map(templateIssue.bind(null, gren)); } if (groupBy === 'label') { return groupByLabel(gren, issues); } if (typeof groupBy !== 'object') { throw chalk.red('The option for groupBy is invalid, please check the documentation'); } var allLabels = Object.keys(groupBy).reduce(function(carry, group) { return carry.concat(groupBy[group]); }, []); var groups = Object.keys(groupBy).reduce(function(carry, group) { var groupIssues = issues.filter(function(issue) { if (!issue.labels.length && gren.options.template.noLabel) { issue.labels.push({name: gren.options.template.noLabel}); } return issue.labels.some(function(label) { var isOtherLabel = groupBy[group].indexOf('...') !== -1 && allLabels.indexOf(label.name) === -1; return groupBy[group].indexOf(label.name) !== -1 || isOtherLabel; }); }).map(templateIssue.bind(null, gren)); if (groupIssues.length) { carry[group] = groupIssues; } return carry; }, {}); return templateGroups(gren, groups); } /** * Filter the issue based on the date range, or if is in the release * milestone. * * @since 0.9.0 * @private * * @param {GithubReleaseNotes} gren * @param {Array} range The release ranges * @param {Object} issue GitHub issue * * @return {Boolean} */ function filterBlockIssue(gren, range, issue) { if (gren.options.dataSource === 'milestones') { return gren.options.milestoneMatch.replace('{{tag_name}}', range[0].name) === issue.milestone.title; } return utils.isInRange( Date.parse(issue.closed_at), Date.parse(range[1].date), Date.parse(range[0].date) ); } /** * Get the blocks of issues based on release dates * * @since 0.5.0 * @private * * @param {GithubReleaseNotes} gren * @param {Array} releaseRanges The array of date ranges * * @return {Promise[]} */ function getIssueBlocks(gren, releaseRanges) { console.log('Creating the body blocks from releases:'); return getClosedIssues(gren, releaseRanges) .then(function(issues) { return releaseRanges .map(function(range) { var filteredIssues = issues.filter(filterBlockIssue.bind(null, gren, range)); var body = (!range[0].body || gren.options.override) && groupBy(gren, filteredIssues); return { id: range[0].id, release: range[0].name, name: gren.options.prefix + range[0].name, published_at: range[0].date, body: templateIssueBody(body, range[0].body) }; }); }); } /** * Sort releases by dates * * @since 0.5.0 * @private * * @param {Array} releaseDates * * @return {Array} */ function sortReleasesByDate(releaseDates) { return releaseDates.sort(function(release1, release2) { return new Date(release2.date) - new Date(release1.date); }); } /** * Create the ranges of release dates * * @since 0.5.0 * @private * * @param {GithubReleaseNotes} gren * @param {Array} releaseDates The release dates * * @return {Array} */ function createReleaseRanges(gren, releaseDates) { var ranges = []; var range = 2; var sortedReleaseDates = sortReleasesByDate(releaseDates); if (sortedReleaseDates.length === 1) { sortedReleaseDates.push({ id: 0, date: new Date(0) }); } for (var i = 0; i < sortedReleaseDates.length - 1; i++) { ranges.push(sortedReleaseDates.slice(i, i + range)); } return ranges; } /** * Generate release blocks based on issues or commit messages * depending on the option. * * @param {GithubReleaseNotes} gren * * @return {Promise} Resolving the release blocks */ function getReleaseBlocks(gren) { var loaded; var dataSource = { issues: getIssueBlocks, commits: getCommitBlocks, milestones: getIssueBlocks }; return getListReleases(gren) .then(function(releases) { return getLastTags(gren, releases.length ? releases : false); }) .then(function(tags) { loaded = utils.task(gren, 'Getting the tag dates ranges'); return Promise.all(getTagDates(gren, tags)); }) .then(function(releaseDates) { loaded(); return dataSource[gren.options.dataSource]( gren, createReleaseRanges(gren, releaseDates) ); }); } /** * Check if the changelog file exists * * @since 0.8.0 * @private * * @param {GithubReleaseNotes} gren * * @return {Promise} */ function checkChangelogFile(gren) { var filePath = process.cwd() + '/' + gren.options.changelogFilename; if (fs.existsSync(filePath) && !gren.options.override) { Promise.reject(chalk.red('Looks like there is already a changelog, to override it use --override')); } return Promise.resolve(); } /** * Create the changelog file * * @since 0.8.0 * @private * * @param {GithubReleaseNotes} gren * @param {string} body The body of the file */ function createChangelog(gren, body) { var filePath = process.cwd() + '/' + gren.options.changelogFilename; fs.writeFileSync(filePath, gren.options.template.changelogTitle + body); console.log(chalk.green('\nChangelog created!')); } /** * Generate the GithubReleaseNotes getting the options from the git config * * @since 0.5.0 * @private * * @return {Promise[]} */ function generateOptions(options) { return Promise.all([ options.username && options.repo ? Promise.resolve({ username: options.username, repo: options.repo }) : githubInfo.repo(), options.token ? Promise.resolve({ token: options.token }) : githubInfo.token() ]); } /** * Check if there is connectivity * * @since 0.5.0 * @private * * @return {Promise} */ function hasNetwork() { return new Promise(function(resolve) { connectivity(function(isOnline) { if (!isOnline) { console.warn(chalk.yellow('WARNING: Looks like you don\'t have network connectivity!')); } resolve(); }); }); } /** * @param {Object} [options] The options of the module * * @since 0.1.0 * @public * * @constructor */ function GithubReleaseNotes(options) { this.options = ObjectAssign({}, defaults, configFile, options || utils.getBashOptions(process.argv)); this.options.tags = utils.convertStringToArray(this.options.tags); this.options.ignoreLabels = utils.convertStringToArray(this.options.ignoreLabels); this.options.ignoreIssuesWith = utils.convertStringToArray(this.options.ignoreIssuesWith); this.options.ignoreCommitsWith = utils.convertStringToArray(this.options.ignoreCommitsWith); this.repo = null; this.issues = null; } /** * Initialise the GithubReleaseNotes module, create the options and run * a given module method * * @since 0.5.0 * @public * * @param {function} action * * @return {Promise} The generated options */ GithubReleaseNotes.prototype.init = function() { var gren = this; gren.tasks = []; return hasNetwork() .then(function() { return generateOptions(gren.options); }) .then(function(optionData) { gren.options = ObjectAssign(...optionData, gren.options); if (!gren.options.token) { throw chalk.red('You need to provide the token'); } var githubApi = new Github({ token: gren.options.token }, gren.options.apiUrl); gren.repo = githubApi.getRepo(gren.options.username, gren.options.repo); gren.issues = githubApi.getIssues(gren.options.username, gren.options.repo); }); }; /** * Get All the tags, get the dates, get the commits between those dates and prepeare the release * * @since 0.1.0 * @public * * @return {Promise} */ GithubReleaseNotes.prototype.release = function() { utils.printTask('\nRelease'); var gren = this; return getReleaseBlocks(this) .then(function(blocks) { return blocks.reduce(function(carry, block) { return carry.then(prepareRelease.bind(null, gren, block)); }, Promise.resolve()); }); }; /** * Generate the Changelog based on the github releases, or * from fresh generated releases. * * @since 0.5.0 * * @public * * @param {string} type The type of changelog */ GithubReleaseNotes.prototype.changelog = function() { utils.printTask('\nChangelog'); var gren = this; return checkChangelogFile(this) .then(function() { if (gren.options.generate) { return getReleaseBlocks(gren); } return getListReleases(gren); }) .then(function(releases) { if (releases.length === 0) { throw chalk.red('There are no releases, use --generate to create release notes, or run the release command.'); } return Promise.resolve(releases); }) .then(function(releases) { createChangelog(gren, templateReleases(gren, releases)); }); }; module.exports = GithubReleaseNotes;