gren.js

'use strict';

var utils = require('./utils');
var githubInfo = require('./github-info');
var template = require('./template');
var Github = require('github-api');
var fs = require('fs');
var chalk = require('chalk');
var Promise = Promise || require('es6-promise').Promise;
var connectivity = require('connectivity');
var templateConfig = require('./templates.json');
var ObjectAssign = require('object-assign-deep');
var configFile = utils.getConfigFromFile(process.cwd());

var defaults = {
    tags: false,
    timeWrap: 'latest', // || history
    changelogFilename: 'CHANGELOG.md',
    dataSource: 'issues', // || commits
    draft: false,
    force: false,
    prefix: '',
    includeMessages: 'commits', // || merges || all
    prerelease: false,
    dateZero: new Date(0),
    generate: false,
    override: false,
    ignoreLabels: false, // || array of labels
    ignoreIssuesWith: false, // || array of labels
    template: templateConfig,
    groupBy: false
};

/**
 * Edit a release from a given tag (in the options)
 *
 * @since 0.5.0
 * @private
 *
 * @param  {GithubReleaseNotes} gren The gren object
 * @param  {number} releaseId The id of the release to edit
 * @param  {Object} releaseOptions The options to build the release:
 * @example
 * {
 *   "tag_name": "v1.0.0",
 *   "target_commitish": "master",
 *   "name": "v1.0.0",
 *   "body": "Description of the release",
 *   "draft": false,
 *   "prerelease": false
 * }
 *
 * @return {Promise}
 */
function editRelease(gren, releaseId, releaseOptions) {
    var loaded = utils.task(gren, 'Updating latest release');

    return gren.repo.updateRelease(releaseId, releaseOptions)
        .then(function(response) {
            loaded();

            var release = response.data;

            console.log(chalk.green(release.name + ' has been successfully updated!'));

            return release;
        });
}

/**
 * Create a release from a given tag (in the options)
 *
 * @since 0.1.0
 * @private
 *
 * @param  {GithubReleaseNotes} gren The gren object
 * @param  {Object} releaseOptions The options to build the release:
 * @example {
 *   "tag_name": "1.0.0",
 *   "target_commitish": "master",
 *   "name": "v1.0.0",
 *   "body": "Description of the release",
 *   "draft": false,
 *   "prerelease": false
 * }
 *
 * @return {Promise}
 */
function createRelease(gren, releaseOptions) {
    var loaded = utils.task(gren, 'Preparing the release');

    return gren.repo.createRelease(releaseOptions)
        .then(function(response) {
            loaded();
            var release = response.data;

            console.log(chalk.green(release.name + ' has been successfully created!'));

            return release;
        });
}

/**
 * Creates the options to make the release
 *
 * @since 0.2.0
 * @private
 *
 * @param  {GithubReleaseNotes} gren The gren object
 * @param  {Object[]} tags The collection of tags
 *
 * @return {Promise}
 */
function prepareRelease(gren, block) {
    var releaseOptions = {
        tag_name: block.release,
        name: block.name,
        body: block.body,
        draft: gren.options.draft,
        prerelease: gren.options.prerelease
    };

    if (block.id) {
        if (!gren.options.override) {
            console.warn(chalk.black(chalk.bgYellow('Skipping ' + block.release + ' (use --override to replace it)')));

            return Promise.resolve();
        }

        return editRelease(gren, block.id, releaseOptions);
    }

    return createRelease(gren, releaseOptions);
}

/**
 * Get the tags information from the given ones, and adds
 * the next one in case only one is given
 *
 * @since 0.5.0
 * @private
 *
 * @param  {Array|string} selectedTags
 * @param  {Object[]} tags
 *
 * @return {Boolean|Array}
 */
function getSelectedTags(optionTags, tags) {
    if (optionTags.indexOf('all') >= 0) {
        return tags;
    }

    if (!optionTags.length) {
        return false;
    }

    var selectedTags = [].concat(optionTags);

    return tags.filter(function(tag, index) {
        var isSelectedTag = selectedTags.indexOf(tag.name) !== -1;

        if (isSelectedTag && selectedTags.length === 1 && tags[index + 1]) {
            selectedTags.push(tags[index + 1].name);
        }
        return isSelectedTag;
    }).slice(0, 2);
}

/**
 * Get all the tags of the repo
 *
 * @since 0.1.0
 * @private
 *
 * @param  {GithubReleaseNotes} gren The gren object
 *
 * @return {Promise}
 */
function getLastTags(gren, releases) {
    var loaded = utils.task(gren, 'Getting tags');

    return gren.repo.listTags()
        .then(function(response) {
            loaded();

            var tags = response.data;
            var filteredTags = (getSelectedTags(gren.options.tags, tags) || [tags[0], tags[1]])
                .filter(Boolean)
                .map(function(tag) {
                    var tagRelease = releases && releases.filter(function(release) {
                        return release.tag_name === tag.name;
                    })[0] || false;
                    var releaseId = tagRelease ? tagRelease.id : null;

                    return {
                        tag: tag,
                        releaseId: releaseId
                    };
                });

            console.log('Tags found: ' + filteredTags.map(function(tag) {
                return tag.tag.name;
            }).join(', '));

            return filteredTags;
        });
}

/**
 * Get the dates of the last two tags
 *
 * @since 0.1.0
 * @private
 *
 * @param  {GithubReleaseNotes} gren The gren object
 * @param  {Object[]} tags List of all the tags in the repo
 *
 * @return {Promise[]}     The promises which returns the dates
 */
function getTagDates(gren, tags) {
    return tags.map(function(tag) {
        return gren.repo.getCommit(tag.tag.commit.sha)
            .then(function(response) {
                return {
                    id: tag.releaseId,
                    name: tag.tag.name,
                    date: response.data.committer.date
                };
            });
    });
}

/**
 * Get all releases
 *
 * @since 0.5.0
 * @private
 *
 * @param  {GithubReleaseNotes} gren The gren object
 *
 * @return {Promise} The promise which resolves an array of releases
 */
function getListReleases(gren) {
    var loaded = utils.task(gren, 'Getting the list of releases');

    return gren.repo.listReleases()
        .then(function(response) {
            loaded();

            var releases = response.data;

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

            return releases;
        });
}

/**
 * Return the templated commit message
 *
 * @since 0.1.0
 * @private
 *
 * @param  {string} message
 *
 * @return {string}
 */
function templateCommits(gren, message) {
    return template.generate({
        message: message
    }, gren.options.template.commit);
}

/**
 * Generate the MD template from all the labels of a specific issue
 *
 * @since 0.5.0
 * @private
 *
 * @param  {Object} issue
 *
 * @return {string}
 */
function templateLabels(gren, issue) {
    if (!issue.labels.length && gren.options.template.noLabel) {
        issue.labels.push({name: gren.options.template.noLabel});
    }

    return issue.labels
        .filter(function(label) {
            return gren.options.ignoreLabels.indexOf(label.name) === -1;
        })
        .map(function(label) {
            return template.generate({
                label: label.name
            }, gren.options.template.label);
        }).join('');
}

/**
 * Generate the releases bodies from a release Objects Array
 *
 * @since 0.8.0
 * @private
 *
 * @param  {GithubReleaseNotes} gren
 * @param  {Array} releases The release Objects Array coming from GitHub
 *
 * @return {string}
 */
function templateReleases(gren, releases) {
    return releases.map(function(release) {
        return template.generate({
            release: release.name,
            date: utils.formatDate(new Date(release.published_at)),
            body: release.body
        }, gren.options.template.release);
    }).join(gren.options.template.releaseSeparator);
}

/**
 * Generate the MD template for each issue
 *
 * @since 0.5.0
 * @private
 *
 * @param  {Object} issue
 *
 * @return {string}
 */
function templateIssue(gren, issue) {
    return template.generate({
        labels: templateLabels(gren, issue),
        name: issue.title,
        text: '#' + issue.number,
        url: issue.html_url
    }, gren.options.template.issue);
}

/**
 * Generate the Changelog issues body template
 *
 * @since 0.5.0
 * @private
 *
 * @param  {Object[]} blocks
 *
 * @return {string}
 */
function templateIssueBody(body, rangeBody) {
    return (body.length ? body.join('\n') : rangeBody || '*No changelog for this release.*') + '\n';
}

/**
 * Generates the template for the groups
 *
 * @since  0.8.0
 * @private
 *
 * @param  {GithubReleaseNotes} gren
 * @param  {Object} groups The groups to template e.g.
 * {
 *     'bugs': [{...}, {...}, {...}]
 * }
 *
 * @return {string}
 */
function templateGroups(gren, groups) {
    return Object.keys(groups).map(function(group) {
        var heading = template.generate({
            heading: group
        }, gren.options.template.group);
        var body = groups[group].join('\n');

        return heading + '\n' + body;
    });
}

/**
 * Return a commit messages generated body
 *
 * @since 0.1.0
 * @private
 *
 * @param  {string} message
 *
 * @return {string}
 */
function generateCommitsBody(gren, messages) {
    messages.length === 1 && messages.push(null);
    return messages
        .slice(0, -1)
        .filter(function(message) {
            var messageType = gren.options.includeMessages;
            var filterMap = {
                merges: function(message) {
                    return message.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);
        })
        .map(templateCommits.bind(null, gren))
        .join('\n');
}

/**
 * Transforms the commits to commit messages
 *
 * @since 0.1.0
 * @private
 *
 * @param  {Object[]} commits The array of object containing the commits
 *
 * @return {String[]}
 */
function commitMessages(commits) {
    return commits.map(function(commitObject) {
        return commitObject.commit.message;
    });
}

/**
 * Gets all the commits between two dates
 *
 * @since 0.1.0
 * @private
 *
 * @param  {GithubReleaseNotes} gren The gren object
 * @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
 */
function getCommitsBetweenTwo(gren, since, until) {
    process.stdout.write(chalk.green('Get commits between ' + utils.formatDate(new Date(since)) + ' and ' + utils.formatDate(new Date(until)) + '\n'));

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

    return gren.repo.listCommits(options)
        .then(function(response) {
            return commitMessages(response.data);
        });
}

/**
 * Get the blocks of commits based on release dates
 *
 * @since 0.5.0
 * @private
 *
 * @param  {GithubReleaseNotes} gren
 * @param  {Array} releaseRanges The array of date ranges
 *
 * @return {Promise[]}
 */
function getCommitBlocks(gren, releaseRanges) {
    console.log(chalk.blue('\nCreating the body blocks from commits:'));

    return Promise.all(
        releaseRanges
            .map(function(range) {
                return getCommitsBetweenTwo(gren, range[1].date, range[0].date)
                    .then(function(commits) {
                        return {
                            id: range[0].id,
                            name: gren.options.prefix + range[0].name,
                            release: range[0].name,
                            published_at: range[0].date,
                            body: generateCommitsBody(gren, commits) + '\n'
                        };
                    });
            })
    );
}

/**
 * Compare the ignored labels with the passed ones
 *
 * @since 0.6.0
 * @private
 *
 * @param  {Array} ignoreLabels    The labels to ignore
 * @param  {Array} labels   The labels to check
 *
 * @return {boolean}    If the labels array contain any of the ignore ones
 */
function compareIssueLabels(ignoreLabels, labels) {
    return ignoreLabels
        .reduce(function(carry, ignoredLabel) {
            return carry && labels.map(function(label) {
                return label.name;
            }).indexOf(ignoredLabel) === -1;
        }, true);
}

/**
 * Get all the closed issues from the current repo
 *
 * @since 0.5.0
 * @private
 *
 * @param  {GithubReleaseNotes} gren The gren object
 * @param  {Array} releaseRanges The array of date ranges
 *
 * @return {Promise} The promise which resolves the list of the issues
 */
function getClosedIssues(gren, releaseRanges) {
    var loaded = utils.task(gren, 'Getting all closed issues');

    return gren.issues.listIssues({
        state: 'closed',
        since: releaseRanges[releaseRanges.length - 1][1].date
    })
    .then(function(response) {
        loaded();

        var filteredIssues = response.data.filter(function(issue) {
            return !issue.pull_request && compareIssueLabels(gren.options.ignoreIssuesWith, issue.labels);
        });

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

        return filteredIssues;
    });
}

/**
 * Group the issues based on their first label
 *
 * @since 0.8.0
 * @private
 *
 * @param  {GithubReleaseNotes} gren
 * @param  {Array} issues
 *
 * @return {string}
 */
function groupByLabel(gren, issues) {
    var groups = [];

    issues.forEach(function(issue) {
        if (!issue.labels.length && gren.options.template.noLabel) {
            issue.labels.push({name: gren.options.template.noLabel});
        }

        var labelName = issue.labels[0].name;

        if (!groups[labelName]) {
            groups[labelName] = [];
        }

        groups[labelName].push(templateIssue(gren, issue));
    });

    return templateGroups(gren, utils.sortObject(groups));
}

/**
 * Create groups of issues based on labels
 *
 * @since  0.8.0
 * @private
 *
 * @param {GithubReleaseNotes} gren
 * @param  {Array} issues The array of all the issues.
 *
 * @return {Array}
 */
function groupBy(gren, issues) {
    var groupBy = gren.options.groupBy;

    if (!groupBy) {
        return issues.map(templateIssue.bind(null, gren));
    }

    if (groupBy === 'label') {
        return groupByLabel(gren, issues);
    }

    if (typeof groupBy !== 'object') {
        throw chalk.red('The option for groupBy is invalid, please check the documentation');
    }

    var allLabels = Object.keys(groupBy).reduce(function(carry, group) {
        return carry.concat(groupBy[group]);
    }, []);

    var groups = Object.keys(groupBy).reduce(function(carry, group) {
        var groupIssues = issues.filter(function(issue) {
            if (!issue.labels.length && gren.options.template.noLabel) {
                issue.labels.push({name: gren.options.template.noLabel});
            }

            return issue.labels.some(function(label) {
                var isOtherLabel = groupBy[group].indexOf('...') !== -1 && allLabels.indexOf(label.name) === -1;

                return groupBy[group].indexOf(label.name) !== -1 || isOtherLabel;
            });
        }).map(templateIssue.bind(null, gren));

        if (groupIssues.length) {
            carry[group] = groupIssues;
        }

        return carry;
    }, {});

    return templateGroups(gren, groups);
}

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

    return getClosedIssues(gren, releaseRanges)
        .then(function(issues) {
            return releaseRanges
                .map(function(range) {
                    var filteredIssues = issues.filter(function(issue) {
                        return utils.isInRange(
                            Date.parse(issue.closed_at),
                            Date.parse(range[1].date),
                            Date.parse(range[0].date)
                        );
                    });

                    var body = (!range[0].body || gren.options.override) && groupBy(gren, filteredIssues);

                    return {
                        id: range[0].id,
                        release: range[0].name,
                        name: gren.options.prefix + range[0].name,
                        published_at: range[0].date,
                        body: templateIssueBody(body, range[0].body)
                    };
                });
        });
}

/**
 * Sort releases by dates
 *
 * @since 0.5.0
 * @private
 *
 * @param {Array} releaseDates
 *
 * @return {Array}
 */
function sortReleasesByDate(releaseDates) {
    return releaseDates.sort(function(release1, release2) {
        return new Date(release2.date) - new Date(release1.date);
    });
}

/**
 * Create the ranges of release dates
 *
 * @since 0.5.0
 * @private
 *
 * @param  {GithubReleaseNotes} gren
 * @param  {Array} releaseDates The release dates
 *
 * @return {Array}
 */
function createReleaseRanges(gren, releaseDates) {
    var ranges = [];
    var range = 2;
    var sortedReleaseDates = sortReleasesByDate(releaseDates);

    if (sortedReleaseDates.length === 1 || gren.options.timeWrap === 'history') {
        sortedReleaseDates.push({
            id: 0,
            date: new Date(0)
        });
    }

    for (var i = 0; i < sortedReleaseDates.length - 1; i++) {
        ranges.push(sortedReleaseDates.slice(i, i + range));
    }

    return ranges;
}

/**
 * Generate release blocks based on issues or commit messages
 * depending on the option.
 *
 * @param  {GithubReleaseNotes} gren
 *
 * @return {Promise} Resolving the release blocks
 */
function getReleaseBlocks(gren) {
    var loaded;
    var dataSource = {
        issues: getIssueBlocks,
        commits: getCommitBlocks
    };

    return getListReleases(gren)
        .then(function(releases) {
            return getLastTags(gren, releases.length ? releases : false);
        })
        .then(function(tags) {
            loaded = utils.task(gren, 'Getting the tag dates ranges');

            return Promise.all(getTagDates(gren, tags));
        })
        .then(function(releaseDates) {
            loaded();

            return dataSource[gren.options.dataSource](
                gren,
                createReleaseRanges(gren, releaseDates)
            );
        });
}

/**
 * Check if the changelog file exists
 *
 * @since 0.8.0
 * @private
 *
 * @param  {GithubReleaseNotes} gren
 *
 * @return {Promise}
 */
function checkChangelogFile(gren) {
    var filePath = process.cwd() + '/' + gren.options.changelogFilename;

    if (fs.existsSync(filePath) && !gren.options.override) {
        Promise.reject(chalk.red('Looks like there is already a changelog, to override it use --override'));
    }

    return Promise.resolve();
}

/**
 * Create the changelog file
 *
 * @since 0.8.0
 * @private
 *
 * @param  {GithubReleaseNotes} gren
 * @param  {string} body The body of the file
 */
function createChangelog(gren, body) {
    var filePath = process.cwd() + '/' + gren.options.changelogFilename;

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

    console.log(chalk.green('\nChangelog created!'));
}

/**
 * Generate the GithubReleaseNotes getting the options from the git config
 *
 * @since 0.5.0
 * @private
 *
 * @return {Promise[]}
 */
function generateOptions(options) {
    return Promise.all([
        options.username && options.repo ? Promise.resolve({ username: options.username, repo: options.repo }) : githubInfo.repo(),
        options.token ? Promise.resolve({ token: options.token }) : githubInfo.token()
    ]);
}

/**
 * Check if there is connectivity
 *
 * @since 0.5.0
 * @private
 *
 * @return {Promise}
 */
function hasNetwork() {
    return new Promise(function(resolve) {
        connectivity(function(isOnline) {
            if (!isOnline) {
                console.warn(chalk.yellow('WARNING: Looks like you don\'t have network connectivity!'));
            }

            resolve();
        });
    });
}

/**
 * @param  {Object} [options] The options of the module
 *
 * @since  0.1.0
 * @public
 *
 * @constructor
 */
function GithubReleaseNotes(options) {
    this.options = ObjectAssign({}, defaults, configFile, options || utils.getBashOptions(process.argv));
    this.options.tags = utils.convertStringToArray(this.options.tags);
    this.options.ignoreLabels = utils.convertStringToArray(this.options.ignoreLabels);
    this.options.ignoreIssuesWith = utils.convertStringToArray(this.options.ignoreIssuesWith);
    this.repo = null;
    this.issues = null;
    this.isEditingLatestRelease = false;
}

/**
 * Initialise the GithubReleaseNotes module, create the options and run
 * a given module method
 *
 * @since 0.5.0
 * @public
 *
 * @param  {function} action
 *
 * @return {Promise} The generated options
 */
GithubReleaseNotes.prototype.init = function() {
    var gren = this;

    gren.tasks = [];

    return hasNetwork()
        .then(function() {
            return generateOptions(gren.options);
        })
        .then(function(optionData) {
            gren.options = ObjectAssign(...optionData, gren.options);

            if (!gren.options.token) {
                throw chalk.red('You need to provide the token');
            }

            var githubApi = new Github({
                token: gren.options.token
            });

            gren.repo = githubApi.getRepo(gren.options.username, gren.options.repo);
            gren.issues = githubApi.getIssues(gren.options.username, gren.options.repo);
        });
};

/**
 * Get All the tags, get the dates, get the commits between those dates and prepeare the release
 *
 * @since  0.1.0
 * @public
 *
 * @return {Promise}
 */
GithubReleaseNotes.prototype.release = function() {
    utils.printTask('\nRelease');

    var gren = this;

    return getReleaseBlocks(this)
        .then(function(blocks) {
            return blocks.reduce(function(carry, block) {
                return carry.then(prepareRelease.bind(null, gren, block));
            }, Promise.resolve());
        });
};

/**
 * Generate the Changelog based on the github releases, or
 * from fresh generated releases.
 *
 * @since 0.5.0
 *
 * @public
 *
 * @param {string} type The type of changelog
 */
GithubReleaseNotes.prototype.changelog = function() {
    utils.printTask('\nChangelog');

    var gren = this;

    return checkChangelogFile(this)
        .then(function() {
            if (gren.options.generate) {
                return getReleaseBlocks(gren);
            }

            return getListReleases(gren);
        })
        .then(function(releases) {
            if (releases.length === 0) {
                throw chalk.red('There are no releases, use --generate to create release notes, or run the release command.');
            }

            return Promise.resolve(releases);
        })
        .then(function(releases) {
            createChangelog(gren, templateReleases(gren, releases));
        });
};

module.exports = GithubReleaseNotes;