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('deep-assign');
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),
    override: false,
    ignoreLabels: false, // || array of labels
    ignoreIssuesWith: false, // || array of labels
    template: templateConfig
};

/**
 * 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]])
                .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  {Object[]} releases A list of release Objects
 *
 * @return {Array} The list of the dates
 */
function getReleaseDates(gren, releases) {
    return [].concat(releases).map(function(release) {
        return {
            id: release.id,
            name: release.name,
            tag_name: release.tag_name,
            date: release.created_at,
            body: release.body || null
        };
    });
}

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

/**
 * Get the latest releases
 *
 * @since 0.5.0
 * @private
 *
 * @param  {GithubReleaseNotes} gren The gren object
 *
 * @return {Promise} The promise which resolves the tag name of the release
 */
function getLastTwoReleases(gren) {
    return getListReleases(gren)
        .then(function(releases) {
            return releases.slice(0, 2);
        });
}

/**
 * Return a string with a - to be a bulvar list (used for a mapping)
 *
 * @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) {
        issue.labels.push({name: 'closed'});
    }

    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.issueInfo.label);
        }).join('');
}

/**
 * Generate the MD template a block
 *
 * @since 0.5.0
 * @private
 *
 * @param  {Object} block ({name: 'v1.2.3', body: []})
 *
 * @return {string}
 */
function templateBlock(gren, block) {
    var date = new Date(block.date);
    var releaseTemplate = template.generate({
        release: block.name,
        date: utils.formatDate(date)
    }, template.generate(gren.options.template.releaseInfo, gren.options.template.release));

    return releaseTemplate + '\n\n' + block.body;
}

/**
 * Generate the MD template for each issue
 *
 * @since 0.5.0
 * @private
 *
 * @param  {Object} issue
 *
 * @return {string}
 */
function templateIssue(gren, issue) {
    var issueTemplate = template.generate(gren.options.template.issueInfo, gren.options.template.issue);
    var nameTemplate = template.generate({
        name: issue.title
    }, gren.options.template.issueInfo.name);

    return template.generate({
        labels: templateLabels(gren, issue),
        name: nameTemplate,
        text: '#' + issue.number,
        url: issue.html_url
    }, issueTemplate);
}

/**
 * Generate the Changelog MD template
 *
 * @since 0.5.0
 * @private
 *
 * @param  {Object[]} blocks
 *
 * @return {string}
 */
function templateChangelog(gren, blocks) {
    return '# Changelog\n\n' +
        blocks
            .map(templateBlock.bind(null, gren))
            .join('\n\n --- \n\n');
}

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

/**
 * Return a commit messages generated body
 *
 * @since 0.1.0
 * @private
 *
 * @param  {string} message
 *
 * @return {string}
 */
function generateCommitsBody(gren, messages) {
    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(commits) {
            return commitMessages(commits);
        });
}

/**
 * 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,
                        release: range[0].name,
                        date: 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;
    });
}

/**
 * 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 body = (!range[0].body || gren.options.override) &&
                        issues.filter(function(issue) {
                            return utils.isInRange(
                                Date.parse(issue.closed_at),
                                Date.parse(range[1].date),
                                Date.parse(range[0].date)
                            );
                        })
                        .map(templateIssue.bind(null, gren));

                    return {
                        id: range[0].id,
                        release: range[0].name,
                        name: gren.options.prefix + range[0].name,
                        date: 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 a CHANGELOG.md file based on Time and issues
 *
 * @since 0.5.0
 * @private
 *
 * @return {Promise[]}
 */
function generateReleaseDatesChangelogBody(gren) {
    var releaseActions = {
        history: getListReleases,
        latest: getLastTwoReleases
    };
    var dataSource = {
        issues: getIssueBlocks,
        commits: getCommitBlocks
    };

    return releaseActions[gren.options.timeWrap](gren)
        .then(function(releases) {
            if (releases.length === 0) {
                throw chalk.red('There are no releases! Run gren to generate release notes');
            }

            var releaseRanges = createReleaseRanges(gren, getReleaseDates(gren, releases));

            return dataSource[gren.options.dataSource](gren, releaseRanges);
        })
        .then(function(blocks) {
            return templateChangelog(gren, blocks);
        });
}

/**
 * Create the CHANGELOG.md file
 *
 * @since 0.5.0
 * @private
 *
 * @param  {string} body
 *
 * @return {boolean}
 */
function createChangelog(gren, body) {
    function createFile(fileBody) {
        fs.writeFile(gren.options.changelogFilename, fileBody, function(err) {
            if (err) {
                throw err;
            }

            process.stdout.write('\n' + chalk.green('The changelog file has been saved!\n'));

            return true;
        });
    }

    try {
        fs.readFile(gren.options.changelogFilename, 'utf-8', function(err, data) {
            if (err) {
                console.error(chalk.red(err));
                return;
            }

            var newReleaseName = body.match(/(##\s[\w\s.]+)/)[0];

            if (data.match(newReleaseName)) {
                if (gren.options.force) {
                    createFile(body + '\n\n --- \n\n' + data.replace(/^(#\s?\w*\n\n)/g, ''));

                    return true;
                } else if (gren.options.override) {
                    createFile(body);

                    return true;
                }

                console.error(chalk.red('\nThis release is already in the changelog\n'));

                return false;
            }

            createFile(body + '\n --- \n\n' + data.replace(/^(#\s?\w*\n\n)/g, ''));
        });
    } catch (e) {
        createFile(body);
    }
}

/**
 * Generate the GithubReleaseNotes getting the options from the git config
 *
 * @since 0.5.0
 * @private
 *
 * @return {Promise[]}
 */
function generateOptions(options) {
    return Promise.all([
        options.user ? Promise.resolve(options.user) : githubInfo.user(),
        options.repo ? Promise.resolve(options.repo) : githubInfo.repo(),
        options.token ? Promise.resolve(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 loaded;
    var gren = this;
    var dataSource = {
        issues: getIssueBlocks,
        commits: getCommitBlocks
    };

    return getListReleases(this)
        .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)
            );
        })
        .then(function(blocks) {
            return blocks.reduce(function(carry, block) {
                return carry.then(prepareRelease.bind(null, gren, block));
            }, Promise.resolve());
        });
};

/**
 * Generate the Changelog
 *
 * @since 0.5.0
 * @public
 *
 * @param {string} type The type of changelog
 */
GithubReleaseNotes.prototype.changelog = function() {
    utils.printTask('\nChangelog');

    var gren = this;

    return generateReleaseDatesChangelogBody(this)
        .then(function(changelogBody) {
            return createChangelog(gren, changelogBody);
        })
        .then(function(success) {
            return success;
        });
};

module.exports = GithubReleaseNotes;