Gren.js

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);
        }

        this.githubApi = new Github({
            token
        }, apiUrl);

        this.repo = this.githubApi.getRepo(username, repo);
        this.issues = this.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
     * @deprecated
     *
     * @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 all the tags of the repo
     *
     * @since 0.1.0
     * @private
     * @deprecated
     *
     * @param {Array} releases
     * @param {number} page
     *
     * @return {Promise}
     */
    async _getAllTags(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 = tags
            .filter((tag) => tag && this.options.ignoreTagsWith.every(ignoreTag => !tag.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 (totalPages && page < totalPages) {
            return this._getAllTags(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,
            user_login: issue.user.login,
            user_url: issue.user.html_url
        }, 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 = [], addEmptyCommit) {
        const bodyMessages = Array.from(commits);

        if (bodyMessages.length === 1 || addEmptyCommit) {
            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, range[1].id === 0) + '\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;
    }

    async _getSingleIssue({ user, repo, number }) {
        const loaded = utils.task(this, `Getting single ${number} issue data`);
        const { data: issue } = await this.githubApi.getIssues(user, repo).getIssue(number);
        loaded(`Issue details found: ${issue.number} ${issue.title}`);
        return issue;
    }

    /**
     * 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.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  {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;
    }

    async _getPullRequestWithIssueBlocks(releaseRanges) {
        const loaded = utils.task(this, `Getting all merged pull requests`);
        const since = releaseRanges[releaseRanges.length - 1][1].date;
        let relevantIssues = [];

        const re = new RegExp(`([\\w-]+)/([\\w-]+)/issues/([0-9]+)`, 'gi');

        const prs = (await this._getMergedPullRequests(since))
            .filter(this._filterPullRequest.bind(this))
            .map(pr => {
                const matches = pr.body.match(re);
                const relatedIssues = matches && matches.map(issue => {
                    const [user, repo, , number] = issue.split('/');
                    return { user, repo, number };
                });
                return Object.assign({}, pr, { relatedIssues });
            });

        const release = releaseRanges
            .map(range => {
                const list = prs.filter(this._filterBlockPullRequest.bind(this, range));
                list.forEach(({ relatedIssues }) => { relevantIssues = relevantIssues.concat(relatedIssues || []); });
                loaded(`Pull Requests found: ${list.length}`);
                return Object.assign({}, range, { list });
            });

        const issuesDetails = (await Promise.all(relevantIssues.map(this._getSingleIssue.bind(this))))
            .reduce((acc, el) => { acc[el.number] = el; return acc; }, {});

        return release
            .map(range => {
                const list = range.list.map(el => el.relatedIssues && el.relatedIssues.length ? el.relatedIssues.map(({ number }) => issuesDetails[number]) : [el]);
                return Object.assign({}, range, { list: [].concat(...list) });
            })
            .map(this._render.bind(this));
    }

    _render(range) {
        const body = (!range[0].body || this.options.override) && this._groupBy(range.list);

        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)
        };
    }

    /**
     * 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++) {
            const until = sortedReleaseDates[i + 1].date;
            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),
            'prs-with-issues': this._getPullRequestWithIssueBlocks.bind(this)
        };
        const releases = await this._getListReleases();
        this.tasks['Getting releases'].text = 'Getting tags';

        const tags = await this._getAllTags(releases.length ? releases : false);

        this._validateRequiredTagsExists(tags, this.options.tags);

        const releaseDates = this._sortReleasesByDate(await Promise.all(this._getTagDates(tags)));
        const selectedTags = (this._getSelectedTags(releaseDates) || [releaseDates[0], releaseDates[1]]);

        loaded(`Tags found: ${selectedTags.map(({ name }) => name).join(', ')}`);

        return dataSource[this.options.dataSource](
            this._createReleaseRanges(selectedTags)
        );
    }

    /**
     * Check that the require tags exist 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;