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.json';
import ObjectAssign from 'object-assign-deep';
import fs from 'fs';

const defaults = {
    tags: [],
    prefix: '',
    template: templateConfig,
    prerelease: false,
    generate: false,
    override: false,
    ignoreLabels: false,
    ignoreIssuesWith: 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, ignoreLabels, ignoreIssuesWith } = this.options;

        this.options.tags = utils.convertStringToArray(tags);
        this.options.ignoreLabels = utils.convertStringToArray(ignoreLabels);
        this.options.ignoreIssuesWith = utils.convertStringToArray(ignoreIssuesWith);
        this.options.limit = this.options.tags.indexOf('all') >= 0 ? MAX_TAGS_LIMIT : TAGS_LIMIT;

        if (!token) {
            throw chalk.red('You must provide the TOKEN');
        }

        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}
     */
    release() {
        utils.printTask('Generate release notes');

        return this._hasNetwork()
            .then(this._getReleaseBlocks.bind(this))
            .then(blocks => {
                console.log('');

                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}
     */
    changelog() {
        utils.printTask('Generate changelog file');

        return this._hasNetwork()
            .then(this._checkChangelogFile.bind(this))
            .then(() => {
                if (this.options.generate) {
                    return this._getReleaseBlocks();
                }

                return this._getListReleases();
            })
            .then(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(releases => {
                this._createChangelog(this._templateReleases(releases));
            });
    }

    /**
     * Check if the changelog file exists
     *
     * @since 0.8.0
     * @private
     *
     * @return {Promise}
     */
    _checkChangelogFile() {
        const filePath = process.cwd() + '/' + this.options.changelogFilename;

        if (fs.existsSync(filePath) && !this.options.override) {
            return Promise.reject(chalk.black(chalk.bgYellow('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  {string} body The body of the file
     */
    _createChangelog(body) {
        const filePath = process.cwd() + '/' + this.options.changelogFilename;

        fs.writeFileSync(filePath, this.options.template.changelogTitle + body);

        console.log(chalk.green(`\nChangelog 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}
     */
    _editRelease(releaseId, releaseOptions) {
        const loaded = utils.task(this, 'Updating latest release');

        return this.repo.updateRelease(releaseId, releaseOptions)
            .then(({ data: release }) => {
                loaded();

                console.log(chalk.green(`\n${release.name} has been successfully updated!`));
                console.log(chalk.blue(`See 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}
     */
    _createRelease(releaseOptions) {
        const loaded = utils.task(this, 'Preparing the release');

        return this.repo.createRelease(releaseOptions)
            .then(({ data: release }) => {
                loaded();

                console.log(chalk.green(`\n${release.name} has been successfully created!`));
                console.log(chalk.blue(`See 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}
     */
    _getLastTags(releases, page = 1, limit = this.options.limit) {
        return this._listTags({
            per_page: limit,
            page
        })
            .then(({ headers: { link }, data: tags }) => {
                if (!tags.length) {
                    throw chalk.red('Looks 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)
                    .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 && 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(tag => this.repo.getCommit(tag.tag.commit.sha)
            .then(({ data: { committer } }) => ({
                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 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
     */
    _getListReleases(page = 1, limit = this.options.limit) {
        const loaded = utils.task(this, 'Getting the list of releases');

        return this._listReleases({
            per_page: limit,
            page
        })
            .then(({ headers: { link }, data: releases }) => {
                loaded();

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

                process.stdout.write(releases.length + ' releases found\n');

                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,
            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}
     */
    _templateCommits({ sha, commit: { author: { name }, message, url } }) {
        return generate({
            sha,
            message: message.split('\n')[0],
            url,
            author: 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
        }, this.options.template.issue);
    }

    /**
     * Generate the Changelog issues body template
     *
     * @since 0.5.0
     * @private
     *
     * @param  {Object[]} blocks
     *
     * @return {string}
     */
    _templateIssueBody(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: function(message) {
                return message.match(/^merge/i);
            },
            commits: function(message) {
                return !message.match(/^merge/i);
            },
            all: function() {
                return true;
            }
        };

        if (filterMap[messageType]) {
            return filterMap[messageType](message);
        }

        return filterMap.commits(message);
    }

    /**
     * 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
     */
    _getCommitsBetweenTwo(since, until) {
        process.stdout.write(chalk.green('Get commits between ' + utils.formatDate(new Date(since)) + ' and ' + utils.formatDate(new Date(until)) + '\n'));

        const options = {
            since: since,
            until: until,
            per_page: 100
        };

        return this.repo.listCommits(options)
            .then(({ data }) => 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[]}
     */
    _getCommitBlocks(releaseRanges) {
        console.log(chalk.blue('\nCreating the body blocks from commits:'));

        return Promise.all(
            releaseRanges
                .map(range => this._getCommitsBetweenTwo(range[1].date, range[0].date)
                    .then(commits => ({
                        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'
                    })))
        );
    }

    /**
     * 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
     */
    _getClosedIssues(releaseRanges) {
        const loaded = utils.task(this, 'Getting all closed issues');

        return this.issues.listIssues({
            state: 'closed',
            since: releaseRanges[releaseRanges.length - 1][1].date
        })
            .then(({ data }) => {
                loaded();

                return data;
            });
    }

    /**
     * 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) {
            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) => {
            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;
                });
            }).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) {
        return !issue.pull_request && !this._lablesAreIgnored(issue.labels) &&
            !((this.options.onlyMilestones || this.options.dataSource === 'milestones') && !issue.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)
        );
    }

    /**
     * Get the blocks of issues based on release dates
     *
     * @since 0.5.0
     * @private
     *
     * @param  {Array} releaseRanges The array of date ranges
     *
     * @return {Promise[]}
     */
    _getIssueBlocks(releaseRanges) {
        console.log('Creating the body blocks from releases:');

        return this._getClosedIssues(releaseRanges)
            .then(issues => 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);

                    process.stdout.write(`${this.options.prefix + range[0].name} has ${filteredIssues.length} issues found\t`);

                    return {
                        id: range[0].id,
                        release: range[0].name,
                        name: this.options.prefix + range[0].name,
                        published_at: range[0].date,
                        body: this._templateIssueBody(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 === 'all') {
            sortedReleaseDates.push({
                id: 0,
                date: new Date(0)
            });
        }

        for (let 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.
     *
     * @return {Promise} Resolving the release blocks
     */
    _getReleaseBlocks() {
        let loaded;
        const dataSource = {
            issues: this._getIssueBlocks.bind(this),
            commits: this._getCommitBlocks.bind(this),
            milestones: this._getIssueBlocks.bind(this)
        };

        return this._getListReleases()
            .then(releases => {
                loaded = utils.task(this, 'Getting tags');

                return this._getLastTags(releases.length ? releases : false);
            })
            .then(tags => {
                loaded();

                console.log('Tags found: ' + tags.map(({ tag: { name } }) => name).join(', '));

                loaded = utils.task(this, 'Getting the tag dates ranges');

                return Promise.all(this._getTagDates(tags));
            })
            .then(releaseDates => {
                loaded();

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

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

export default Gren;