+ import chalk from 'chalk';
+import Github from 'github-api';
+import utils from './_utils.js';
+import { generate } from './_template.js';
+import connectivity from 'connectivity';
+import templateConfig from './templates.js';
+import ObjectAssign from 'object-assign-deep';
+import fs from 'fs';
+
+const defaults = {
+ tags: [],
+ prefix: '',
+ template: templateConfig,
+ prerelease: false,
+ generate: false,
+ quiet: false,
+ override: false,
+ debug: false,
+ ignoreLabels: false,
+ ignoreIssuesWith: false,
+ ignoreCommitsWith: false,
+ groupBy: false,
+ milestoneMatch: 'Release {{tag_name}}'
+};
+
+const MAX_TAGS_LIMIT = 99;
+const TAGS_LIMIT = 30;
+
+/** Class creating release notes and changelog notes */
+class Gren {
+ constructor(props = {}) {
+ this.options = ObjectAssign({}, defaults, props);
+ this.tasks = [];
+
+ const {
+ username,
+ repo,
+ token,
+ apiUrl,
+ tags,
+ limit,
+ ignoreLabels,
+ ignoreIssuesWith,
+ ignoreCommitsWith,
+ ignoreTagsWith
+ } = this.options;
+
+ this.options.tags = utils.convertStringToArray(tags);
+ this.options.ignoreLabels = utils.convertStringToArray(ignoreLabels);
+ this.options.ignoreIssuesWith = utils.convertStringToArray(ignoreIssuesWith);
+ this.options.ignoreCommitsWith = utils.convertStringToArray(ignoreCommitsWith);
+ this.options.ignoreTagsWith = utils.convertStringToArray(ignoreTagsWith);
+
+ if (limit && limit > 0 && limit <= MAX_TAGS_LIMIT) {
+ this.options.limit = limit;
+ } else if (this.options.tags.indexOf('all') >= 0) {
+ this.options.limit = MAX_TAGS_LIMIT;
+ } else {
+ this.options.limit = TAGS_LIMIT;
+ }
+
+ if (!token) {
+ throw chalk.red('You must provide the TOKEN');
+ }
+
+ if (this.options.debug) {
+ this._outputOptions(this.options);
+ }
+
+ const githubApi = new Github({
+ token
+ }, apiUrl);
+
+ this.repo = githubApi.getRepo(username, repo);
+ this.issues = githubApi.getIssues(username, repo);
+ }
+
+ /**
+ * Generate release notes and draft a new release
+ *
+ * @since 0.10.0
+ * @public
+ *
+ * @return {Promise}
+ */
+ async release() {
+ utils.printTask(this.options.quiet, 'Generate release notes');
+
+ await this._hasNetwork();
+ const blocks = await this._getReleaseBlocks();
+
+ return blocks.reduce((carry, block) => carry.then(this._prepareRelease.bind(this, block)), Promise.resolve());
+ }
+
+ /**
+ * Generate changelog file based on the release notes or generate new one
+ *
+ * @since 0.10.0
+ * @public
+ *
+ * @return {Promise}
+ */
+ async changelog() {
+ utils.printTask(this.options.quiet, 'Generate changelog file');
+
+ await this._hasNetwork();
+ this._checkChangelogFile();
+
+ const releases = this.options.generate ? await this._getReleaseBlocks() : await this._getListReleases();
+
+ if (releases.length === 0) {
+ throw chalk.red('There are no releases, use --generate to create release notes, or run the release command.');
+ }
+
+ return this._createChangelog(this._templateReleases(releases));
+ }
+
+ /**
+ * Check if the changelog file exists
+ *
+ * @since 0.8.0
+ * @private
+ *
+ * @return {string}
+ */
+ _checkChangelogFile() {
+ const filePath = process.cwd() + '/' + this.options.changelogFilename;
+
+ if (fs.existsSync(filePath) && !this.options.override) {
+ throw chalk.black(chalk.bgYellow('Looks like there is already a changelog, to override it use --override'));
+ }
+
+ return filePath;
+ }
+
+ /**
+ * Create the changelog file
+ *
+ * @since 0.8.0
+ * @private
+ *
+ * @param {string} body The body of the file
+ */
+ _createChangelog(body) {
+ const loaded = utils.task(this, `Creating ${this.options.changelogFilename}`);
+ const filePath = process.cwd() + '/' + this.options.changelogFilename;
+
+ fs.writeFileSync(filePath, this.options.template.changelogTitle + body);
+
+ loaded(chalk.green(`Changelog created in ${filePath}`));
+ }
+
+ /**
+ * Edit a release from a given tag (in the options)
+ *
+ * @since 0.5.0
+ * @private
+ *
+ * @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}
+ */
+ async _editRelease(releaseId, releaseOptions) {
+ const loaded = utils.task(this, 'Updating latest release');
+ const { data: release } = await this.repo.updateRelease(releaseId, releaseOptions);
+
+ loaded(chalk.green(`${release.name} has been successfully updated!`) + chalk.blue(`\nSee the results here: ${release.html_url}`));
+
+ return release;
+ }
+
+ /**
+ * Create a release from a given tag (in the options)
+ *
+ * @since 0.1.0
+ * @private
+ *
+ * @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}
+ */
+ async _createRelease(releaseOptions) {
+ const loaded = utils.task(this, 'Preparing the release');
+ const { data: release } = await this.repo.createRelease(releaseOptions);
+
+ loaded(chalk.green(`${release.name} has been successfully created!`) + chalk.blue(`\nSee the results here: ${release.html_url}`));
+
+ return release;
+ }
+
+ /**
+ * Creates the options to make the release
+ *
+ * @since 0.2.0
+ * @private
+ *
+ * @param {Object[]} tags The collection of tags
+ *
+ * @return {Promise}
+ */
+ _prepareRelease(block) {
+ const releaseOptions = {
+ tag_name: block.release,
+ name: block.name,
+ body: block.body,
+ draft: this.options.draft,
+ prerelease: this.options.prerelease
+ };
+
+ if (block.id) {
+ if (!this.options.override) {
+ console.warn(chalk.black(chalk.bgYellow(`Skipping ${block.release} (use --override to replace it)`)));
+
+ return Promise.resolve();
+ }
+
+ return this._editRelease(block.id, releaseOptions);
+ }
+
+ return this._createRelease(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} allTags
+ * @param {Object[]} tags
+ *
+ * @return {Boolean|Array}
+ */
+ _getSelectedTags(allTags) {
+ const { tags } = this.options;
+
+ if (tags.indexOf('all') >= 0) {
+ return allTags;
+ }
+
+ if (!allTags || !allTags.length || !tags.length) {
+ return false;
+ }
+
+ const selectedTags = [].concat(tags);
+
+ return allTags.filter(({ name }, index) => {
+ const isSelectedTag = selectedTags.includes(name);
+
+ if (isSelectedTag && selectedTags.length === 1 && allTags[index + 1]) {
+ selectedTags.push(allTags[index + 1].name);
+ }
+ return isSelectedTag;
+ }).slice(0, 2);
+ }
+
+ /**
+ * Temporary function for this.repo.listReleases to accept options
+ *
+ * @see https://github.com/github-tools/github/pull/485
+ * @param {Object} options
+ *
+ * @return {Promise}
+ */
+ _listTags(options) {
+ return this.repo._request('GET', `/repos/${this.repo.__fullname}/tags`, options);
+ }
+
+ /**
+ * Get all the tags of the repo
+ *
+ * @since 0.1.0
+ * @private
+ *
+ * @param {Array} releases
+ * @param {number} page
+ *
+ * @return {Promise}
+ */
+ async _getLastTags(releases, page = 1, limit = this.options.limit) {
+ const { headers: { link }, data: tags } = await this._listTags({
+ per_page: limit,
+ page
+ });
+
+ if (!tags.length) {
+ throw chalk.red('\nLooks like you have no tags! Tag a commit first and then run gren again');
+ }
+
+ const filteredTags = (this._getSelectedTags(tags) || [tags[0], tags[1]])
+ .filter(Boolean)
+ .filter(({ name }) => this.options.ignoreTagsWith.every(ignoreTag => !name.match(ignoreTag)))
+ .map(tag => {
+ const tagRelease = releases ? releases.filter(release => release.tag_name === tag.name)[0] : false;
+ const releaseId = tagRelease ? tagRelease.id : null;
+
+ return {
+ tag: tag,
+ releaseId: releaseId
+ };
+ });
+ const totalPages = this._getLastPage(link);
+
+ if ((this.options.tags.indexOf('all') >= 0 || filteredTags.length < 2) && totalPages && +page < totalPages) {
+ return this._getLastTags(releases, page + 1).then(moreTags => moreTags.concat(filteredTags));
+ }
+
+ return filteredTags;
+ }
+
+ /**
+ * Get the dates of the last two tags
+ *
+ * @since 0.1.0
+ * @private
+ *
+ * @param {Object[]} tags List of all the tags in the repo
+ *
+ * @return {Promise[]} The promises which returns the dates
+ */
+ _getTagDates(tags) {
+ return tags.map(async tag => {
+ const { data: { committer } } = await this.repo.getCommit(tag.tag.commit.sha);
+
+ return {
+ id: tag.releaseId,
+ name: tag.tag.name,
+ date: committer.date
+ };
+ });
+ }
+
+ /**
+ * Temporary function for this.repo.listReleases to accept options
+ *
+ * @see https://github.com/github-tools/github/pull/485
+ * @param {Object} options
+ *
+ * @return {Promise}
+ */
+ _listReleases(options) {
+ return this.repo._request('GET', `/repos/${this.repo.__fullname}/releases`, options);
+ }
+
+ /**
+ * Get the merged pull requests from the repo
+ *
+ * @private
+ *
+ * @param {number} page
+ * @param {number} limit
+ *
+ * @return {Promise[]} The promises which returns pull requests
+ */
+ async _getMergedPullRequests(since, page = 1, limit = 1000) {
+ const results = await this.repo.listPullRequests({
+ state: 'closed',
+ sort: 'updated',
+ direction: 'desc',
+ per_page: limit,
+ page
+ });
+
+ const { headers: { link }, data: prs } = results;
+ const totalPages = this._getLastPage(link);
+ const filterPrs = prs.filter(pr => pr.merged_at);
+ if (prs.length > 0 && since < prs[prs.length - 1].updated_at &&
+ totalPages && +page < totalPages) {
+ return this._getMergedPullRequests(since, page + 1).then(prsResults => prsResults.concat(filterPrs));
+ }
+
+ return filterPrs;
+ }
+
+ /**
+ * Get the last page from a Hypermedia link
+ *
+ * @since 0.11.1
+ * @private
+ *
+ * @param {string} link
+ *
+ * @return {boolean|number}
+ */
+ _getLastPage(link) {
+ const linkMatch = Boolean(link) && link.match(/page=(\d+)>; rel="last"/);
+
+ return linkMatch && +linkMatch[1];
+ }
+
+ /**
+ * Get all releases
+ *
+ * @since 0.5.0
+ * @private
+ *
+ * @return {Promise} The promise which resolves an array of releases
+ */
+ async _getListReleases(page = 1, limit = this.options.limit) {
+ const loaded = utils.task(this, 'Getting the list of releases');
+ const { headers: { link }, data: releases } = await this._listReleases({
+ per_page: limit,
+ page
+ });
+
+ const totalPages = this._getLastPage(link);
+
+ if (this.options.tags.indexOf('all') >= 0 && totalPages && +page < totalPages) {
+ return this._getListReleases(page + 1).then(moreReleases => moreReleases.concat(releases));
+ }
+
+ loaded(`Releases found: ${releases.length}`);
+
+ return releases;
+ }
+
+ /**
+ * Generate the releases bodies from a release Objects Array
+ *
+ * @since 0.8.0
+ * @private
+ * @ignore
+ *
+ * @param {Array} releases The release Objects Array coming from GitHub
+ *
+ * @return {string}
+ */
+ _templateReleases(releases) {
+ const { template } = this.options;
+
+ return releases.map(release => generate({
+ release: release.name || release.tag_name,
+ date: utils.formatDate(new Date(release.published_at)),
+ body: release.body
+ }, template.release)).join(template.releaseSeparator);
+ }
+
+ /**
+ * Return the templated commit message
+ *
+ * @since 0.1.0
+ * @private
+ *
+ * @param {Object} commit
+ *
+ * @return {string}
+ */
+ // eslint-disable-next-line camelcase
+ _templateCommits({ sha, html_url, author, commit: { author: { name }, message } }) {
+ return generate({
+ sha,
+ message: message.split('\n')[0],
+ url: html_url,
+ author: author && author.login,
+ authorName: name
+ }, this.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}
+ */
+ _templateLabels(issue) {
+ const labels = Array.from(issue.labels);
+
+ if (!labels.length && this.options.template.noLabel) {
+ labels.push({name: this.options.template.noLabel});
+ }
+
+ return labels
+ .filter(label => this.options.ignoreLabels.indexOf(label.name) === -1)
+ .map(label => generate({
+ label: label.name
+ }, this.options.template.label)).join('');
+ }
+
+ /**
+ * Generate the MD template for each issue
+ *
+ * @since 0.5.0
+ * @private
+ *
+ * @param {Object} issue
+ *
+ * @return {string}
+ */
+ _templateIssue(issue) {
+ return generate({
+ labels: this._templateLabels(issue),
+ name: issue.title,
+ text: '#' + issue.number,
+ url: issue.html_url,
+ body: issue.body,
+ pr_base: issue.base && issue.base.ref,
+ pr_head: issue.head && issue.head.ref
+ }, this.options.template.issue);
+ }
+
+ /**
+ * Generate the Changelog issues body template
+ *
+ * @since 0.5.0
+ * @private
+ *
+ * @param {Object[]} blocks
+ *
+ * @return {string}
+ */
+ _templateBody(body, rangeBody) {
+ if (Array.isArray(body) && body.length) {
+ return body.join('\n') + '\n';
+ }
+
+ if (rangeBody) {
+ return `${rangeBody}\n`;
+ }
+
+ return '*No changelog for this release.*\n';
+ }
+
+ /**
+ * Generates the template for the groups
+ *
+ * @since 0.8.0
+ * @private
+ *
+ * @param {Object} groups The groups to template e.g.
+ * {
+ * 'bugs': [{...}, {...}, {...}]
+ * }
+ *
+ * @return {string}
+ */
+ _templateGroups(groups) {
+ return Object.entries(groups).map(([key, value]) => {
+ const heading = generate({
+ heading: key
+ }, this.options.template.group);
+ const body = value.join('\n');
+
+ return heading + '\n' + body;
+ });
+ }
+
+ /**
+ * Filter a commit based on the includeMessages option and commit message
+ *
+ * @since 0.10.0
+ * @private
+ *
+ * @param {Object} commit
+ *
+ * @return {Boolean}
+ */
+ _filterCommit({ commit: { message } }) {
+ const messageType = this.options.includeMessages;
+ const filterMap = {
+ merges: message => message.match(/^merge/i),
+ commits: message => !message.match(/^merge/i),
+ all: () => true
+ };
+ const shouldIgnoreMessage = this.options.ignoreCommitsWith.every(commitMessage => {
+ const regex = new RegExp(commitMessage, 'i');
+ return !message.split('\n')[0].match(regex);
+ });
+
+ if (filterMap[messageType]) {
+ return filterMap[messageType](message) && shouldIgnoreMessage;
+ }
+
+ return filterMap.commits(message) && shouldIgnoreMessage;
+ }
+
+ /**
+ * Return a commit messages generated body
+ *
+ * @since 0.1.0
+ * @private
+ *
+ * @param {Array} commits
+ *
+ * @return {string}
+ */
+ _generateCommitsBody(commits = []) {
+ const bodyMessages = Array.from(commits);
+
+ if (bodyMessages.length === 1) {
+ bodyMessages.push(null);
+ }
+
+ return bodyMessages
+ .slice(0, -1)
+ .filter(this._filterCommit.bind(this))
+ .map(this._templateCommits.bind(this))
+ .join('\n');
+ }
+
+ /**
+ * Gets all the commits between two dates
+ *
+ * @since 0.1.0
+ * @private
+ *
+ * @param {string} since The since date in ISO
+ * @param {string} until The until date in ISO
+ *
+ * @return {Promise} The promise which resolves the [Array] commit messages
+ */
+ async _getCommitsBetweenTwo(since, until) {
+ const options = {
+ since: since,
+ until: until,
+ per_page: 100
+ };
+
+ const { data } = await this.repo.listCommits(options);
+
+ return data;
+ }
+
+ /**
+ * Get the blocks of commits based on release dates
+ *
+ * @since 0.5.0
+ * @private
+ *
+ * @param {Array} releaseRanges The array of date ranges
+ *
+ * @return {Promise[]}
+ */
+ async _getCommitBlocks(releaseRanges) {
+ const taskName = 'Creating the body blocks from commits';
+ const loaded = utils.task(this, taskName);
+
+ const ranges = await Promise.all(
+ releaseRanges
+ .map(async range => {
+ const [{ date: until }, { date: since }] = range;
+
+ this.tasks[taskName].text = `Get commits between ${utils.formatDate(new Date(since))} and ${utils.formatDate(new Date(until))}`;
+ const commits = await this._getCommitsBetweenTwo(since, until);
+
+ return {
+ id: range[0].id,
+ name: this.options.prefix + range[0].name,
+ release: range[0].name,
+ published_at: range[0].date,
+ body: this._generateCommitsBody(commits) + '\n'
+ };
+ })
+ );
+
+ loaded(`Commit ranges loaded: ${ranges.length}`);
+
+ return ranges;
+ }
+
+ /**
+ * Compare the ignored labels with the passed ones
+ *
+ * @since 0.10.0
+ * @private
+ *
+ * @param {Array} labels The labels to check
+ * @example [{
+ * name: 'bug'
+ * }]
+ *
+ * @return {boolean} If the labels array contains any of the ignore ones
+ */
+ _lablesAreIgnored(labels) {
+ if (!labels || !Array.isArray(labels)) {
+ return false;
+ }
+
+ const { ignoreIssuesWith } = this.options;
+
+ return ignoreIssuesWith.some(label => labels.map(({ name }) => name).includes(label));
+ }
+
+ /**
+ * Get all the closed issues from the current repo
+ *
+ * @since 0.5.0
+ * @private
+ *
+ * @param {Array} releaseRanges The array of date ranges
+ *
+ * @return {Promise} The promise which resolves the list of the issues
+ */
+ async _getClosedIssues(releaseRanges) {
+ const type = {
+ issues: 'Issues',
+ milestones: 'Issues'
+ }[this.options.dataSource];
+ const loaded = utils.task(this, `Getting all closed ${type}`);
+ const { data: issues } = await this.issues.listIssues({
+ state: 'closed',
+ since: releaseRanges[releaseRanges.length - 1][1].date
+ });
+
+ loaded(`${type} found: ${issues.length}`);
+
+ return issues;
+ }
+
+ /**
+ * Group the issues based on their first label
+ *
+ * @since 0.8.0
+ * @private
+ *
+ * @param {Array} issues
+ *
+ * @return {string}
+ */
+ _groupByLabel(issues) {
+ const groups = [];
+
+ Object.values(ObjectAssign({}, issues)).forEach(issue => {
+ if (!issue.labels.length) {
+ if (!this.options.template.noLabel) {
+ return;
+ }
+
+ issue.labels.push({name: this.options.template.noLabel});
+ }
+
+ const labelName = issue.labels[0].name;
+
+ if (!groups[labelName]) {
+ groups[labelName] = [];
+ }
+
+ groups[labelName].push(this._templateIssue(issue));
+ });
+
+ return this._templateGroups(utils.sortObject(groups));
+ }
+
+ /**
+ * Create groups of issues based on labels
+ *
+ * @since 0.8.0
+ * @private
+ *
+ * @param {Array} issues The array of all the issues.
+ *
+ * @return {Array}
+ */
+ _groupBy(passedIssues) {
+ const { groupBy } = this.options;
+ const issues = Object.values(ObjectAssign({}, passedIssues));
+
+ if (!groupBy || groupBy === 'false') {
+ return issues.map(this._templateIssue.bind(this));
+ }
+
+ if (groupBy === 'label') {
+ return this._groupByLabel(issues);
+ }
+
+ if (typeof groupBy !== 'object' || Array.isArray(groupBy)) {
+ throw chalk.red('The option for groupBy is invalid, please check the documentation');
+ }
+
+ const allLabels = Object.values(groupBy).reduce((carry, group) => carry.concat(group), []);
+ const groups = Object.keys(groupBy).reduce((carry, group, i, arr) => {
+ const groupIssues = issues.filter(issue => {
+ if (!issue.labels.length && this.options.template.noLabel) {
+ issue.labels.push({name: this.options.template.noLabel});
+ }
+
+ return issue.labels.some(label => {
+ const isOtherLabel = groupBy[group].indexOf('...') !== -1 && allLabels.indexOf(label.name) === -1;
+
+ return groupBy[group].indexOf(label.name) !== -1 || isOtherLabel;
+ }) && !arr.filter(title => carry[title]).some(title => carry[title].indexOf(this._templateIssue(issue)) !== -1);
+ }).map(this._templateIssue.bind(this));
+
+ if (groupIssues.length) {
+ carry[group] = groupIssues;
+ }
+
+ return carry;
+ }, {});
+
+ return this._templateGroups(groups);
+ }
+
+ /**
+ * Filter the issue based on gren options and labels
+ *
+ * @since 0.9.0
+ * @private
+ *
+ * @param {Object} issue
+ *
+ * @return {Boolean}
+ */
+ _filterIssue(issue) {
+ const { dataSource } = this.options;
+
+ return (issue.pull_request ? dataSource === 'prs' : dataSource === 'issues' | dataSource === 'milestones') && !this._lablesAreIgnored(issue.labels) &&
+ !((this.options.onlyMilestones || dataSource === 'milestones') && !issue.milestone);
+ }
+
+ /**
+ * Filter the pull request based on gren options and labels
+ * @private
+ *
+ * @param {Object} pullRequest
+ *
+ * @return {Boolean}
+ */
+ _filterPullRequest(pullRequest) {
+ return !this._lablesAreIgnored(pullRequest.labels) && !(this.options.onlyMilestones && !pullRequest.milestone);
+ }
+
+ /**
+ * Filter the issue based on the date range, or if is in the release
+ * milestone.
+ *
+ * @since 0.9.0
+ * @private
+ *
+ * @param {Array} range The release ranges
+ * @param {Object} issue GitHub issue
+ *
+ * @return {Boolean}
+ */
+ _filterBlockIssue(range, issue) {
+ if (this.options.dataSource === 'milestones') {
+ return this.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)
+ );
+ }
+
+ /**
+ * Filter the pull requests in case the release is milestone,
+ * or otherwise by dates range.
+ *
+ * @private
+ *
+ * @param {Array} range The release ranges
+ * @param {Object} pullRequest GitHub pull request
+ *
+ * @return {Boolean}
+ */
+ _filterBlockPullRequest(range, pullRequest) {
+ if (this.options.dataSource === 'milestones') {
+ return this.options.milestoneMatch.replace('{{tag_name}}', range[0].name) === pullRequest.milestone.title;
+ }
+
+ return utils.isInRange(
+ Date.parse(pullRequest.merged_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 {Array} releaseRanges The array of date ranges
+ *
+ * @return {Promise[]}
+ */
+ async _getIssueBlocks(releaseRanges) {
+ const issues = await this._getClosedIssues(releaseRanges);
+ const release = releaseRanges
+ .map(range => {
+ const filteredIssues = Array.from(issues)
+ .filter(this._filterIssue.bind(this))
+ .filter(this._filterBlockIssue.bind(this, range));
+ const body = (!range[0].body || this.options.override) && this._groupBy(filteredIssues);
+ return {
+ id: range[0].id,
+ release: range[0].name,
+ name: this.options.prefix + range[0].name,
+ published_at: range[0].date,
+ body: this._templateBody(body, range[0].body)
+ };
+ });
+
+ return release;
+ }
+ /**
+ * Get the blocks of pull requests based on the release dates
+ *
+ * @private
+ *
+ * @param {Array} releaseRanges The array of date ranges
+ *
+ * @return {Promise[]}
+ */
+ async _getPullRequestsBlocks(releaseRanges) {
+ const loaded = utils.task(this, `Getting all merged pull requests`);
+ const since = releaseRanges[releaseRanges.length - 1][1].date;
+ const prs = await this._getMergedPullRequests(since);
+
+ let totalPrs = 0;
+ const release = releaseRanges
+ .map(range => {
+ const filteredPullRequests = Array.from(prs)
+ .filter(this._filterPullRequest.bind(this))
+ .filter(this._filterBlockPullRequest.bind(this, range));
+ totalPrs += filteredPullRequests.length;
+ const body = (!range[0].body || this.options.override) && this._groupBy(filteredPullRequests);
+ return {
+ id: range[0].id,
+ release: range[0].name,
+ name: this.options.prefix + range[0].name,
+ published_at: range[0].date,
+ body: this._templateBody(body, range[0].body)
+ };
+ });
+ loaded(`Pull Requests found: ${totalPrs}`);
+ return release;
+ }
+
+ /**
+ * Sort releases by dates
+ *
+ * @since 0.5.0
+ * @private
+ *
+ * @param {Array} releaseDates
+ *
+ * @return {Array}
+ */
+ _sortReleasesByDate(releaseDates) {
+ return Array.from(releaseDates).sort((release1, release2) => new Date(release2.date) - new Date(release1.date));
+ }
+
+ /**
+ * Create the ranges of release dates
+ *
+ * @since 0.5.0
+ * @private
+ *
+ * @param {Array} releaseDates The release dates
+ *
+ * @return {Array}
+ */
+ _createReleaseRanges(releaseDates) {
+ const ranges = [];
+ const RANGE = 2;
+ const sortedReleaseDates = this._sortReleasesByDate(releaseDates);
+
+ if (sortedReleaseDates.length === 1 || this.options.tags.indexOf('all') >= 0) {
+ sortedReleaseDates.push({
+ id: 0,
+ date: new Date(0)
+ });
+ }
+
+ for (let i = 0; i < sortedReleaseDates.length - 1; i++) {
+ // NOTE: Get one second later
+ const until = (new Date(new Date(sortedReleaseDates[i + 1].date).getTime() + 1000).toISOString()).replace(/\.000Z$/, 'Z');
+ ranges.push([
+ sortedReleaseDates[i],
+ {
+ ...sortedReleaseDates[i + RANGE - 1],
+ date: until
+ }
+ ]);
+ }
+
+ return ranges;
+ }
+
+ /**
+ * Generate release blocks based on issues or commit messages
+ * depending on the option.
+ *
+ * @return {Promise} Resolving the release blocks
+ */
+ async _getReleaseBlocks() {
+ const loaded = utils.task(this, 'Getting releases');
+ const dataSource = {
+ issues: this._getIssueBlocks.bind(this),
+ commits: this._getCommitBlocks.bind(this),
+ milestones: this._getIssueBlocks.bind(this),
+ prs: this._getPullRequestsBlocks.bind(this)
+ };
+ const releases = await this._getListReleases();
+ this.tasks['Getting releases'].text = 'Getting tags';
+
+ const tags = await this._getLastTags(releases.length ? releases : false);
+ this._validateRequiredTagsExists(tags, this.options.tags);
+ const releaseDates = await Promise.all(this._getTagDates(tags));
+
+ loaded(`Tags found: ${tags.map(({ tag: { name } }) => name).join(', ')}`);
+
+ return dataSource[this.options.dataSource](
+ this._createReleaseRanges(releaseDates)
+ );
+ }
+
+ /**
+ * Check that the require tags are exists in tags
+ *
+ * @param {Array} tags
+ * @param {Array} requireTags
+ *
+ * @throws{Exception} Will throw exception in case that
+ * @requireTags were set to 2 specific tags and these tags aren't exists in @tags
+ */
+ _validateRequiredTagsExists(tags, requireTags) {
+ if (requireTags.indexOf('all') >= 0 || !(requireTags instanceof Array)) return;
+
+ const tagsNames = tags.map(tagData => tagData.tag.name);
+
+ const missingTags = requireTags.filter(requireTag => tagsNames.indexOf(requireTag) < 0);
+ if (missingTags.length > 0) {
+ const inflection = (missingTags.length === 1) ? 'tag is' : 'tags are';
+ throw chalk.red(`\nThe following ${inflection} not found in the repository: ${missingTags}. ` +
+ 'please provide existing tags.');
+ }
+ }
+
+ /**
+ * Check if there is connectivity
+ *
+ * @since 0.5.0
+ * @private
+ *
+ * @return {Promise}
+ */
+ _hasNetwork() {
+ return new Promise(resolve => {
+ connectivity(isOnline => {
+ if (!isOnline) {
+ console.warn(chalk.yellow('WARNING: Looks like you don\'t have network connectivity!'));
+ }
+
+ resolve(isOnline);
+ });
+ });
+ }
+
+ /**
+ * Output the options in the terminal in a formatted way
+ *
+ * @param {Object} options
+ */
+ _outputOptions(options) {
+ const camelcaseToSpaces = value => value.replace(/([A-Z])/g, ' $1').toLowerCase().replace(/\w/, a => a.toUpperCase());
+ const outputs = Object.entries(options)
+ .filter(option => option !== 'debug')
+ .map(([key, value]) => `${chalk.yellow(camelcaseToSpaces(key))}: ${value.toString() || 'empty'}`);
+
+ process.stdout.write('\n' + chalk.blue('Options: \n') + outputs.join('\n') + '\n');
+ }
+}
+
+export default Gren;
+
+
+