# 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,
        limit: 30,
        milestoneMatch: 'Release {{tag_name}}'
    };

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

            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 => 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
         *
         * @return {Promise}
         */
        _getLastTags(releases) {
            const loaded = utils.task(this, 'Getting tags');

            return this._listTags({
                per_page: this.options.limit
            })
                .then(({ data: tags }) => {
                    loaded();

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

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

                    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(response => ({
                    id: tag.releaseId,
                    name: tag.tag.name,
                    date: response.data.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 all releases
         *
         * @since 0.5.0
         * @private
         *
         * @return {Promise} The promise which resolves an array of releases
         */
        _getListReleases() {
            const loaded = utils.task(this, 'Getting the list of releases');

            return this._listReleases({
                per_page: this.options.limit
            })
                .then(({ data: releases }) => {
                    loaded();

                    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(filteredIssues.length + ' issues found\n');

                        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 => this._getLastTags(releases.length ? releases : false))
                .then(tags => {
                    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;