diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..201241fe --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# Editor code style configuration +# http://editorconfig.org/ + +root = true + +[*] +indent_style = space +indent_size = 4 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 00000000..6df7020c --- /dev/null +++ b/.eslintrc @@ -0,0 +1,26 @@ +{ + "env": { + "node": true + }, + "parserOptions": { + "ecmaVersion": 5, + "sourceType": "module", + "ecmaFeatures": { + "impliedStrict": true + }, + "allowImportExportEverywhere": false + }, + + "extends": [ + "standard" + ], + + "rules": { + "semi": [2, "always"], + "no-empty": 2, + "array-callback-return": 2, + "indent": [2, 4, { "SwitchCase": 1 }], + "space-before-function-paren": [2, "never"], + "no-debugger": 0 + } +} diff --git a/.gitignore b/.gitignore index 41aa0714..dc4da13c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store +docs/ node_modules/ npm-debug.log \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..f87719ca --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: + - node_js + +node_js: + - "5.1" + +cache: + directories: + - node_modules + +before_install: + - npm install -g grunt-cli + +script: + - grunt test \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..78341a75 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## 0.4.0 (03/03/2016) + +- [**enhancement**] Include Various Types Of Commit Messages [#5](https://github.com/github-tools/github-release-notes/issues/5) + + --- + +## v0.3.2 (07/12/2015) + +- [**enhancement**] Cleanse option [#3](https://github.com/github-tools/github-release-notes/issues/3) + + --- + +## v0.1.0 (12/11/2015) + +*No changelog for this release.* \ No newline at end of file diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 00000000..c6c8247f --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,37 @@ +'use strict'; + +module.exports = function(grunt) { + grunt.loadNpmTasks('grunt-eslint'); + grunt.loadNpmTasks('grunt-jsdoc'); + grunt.loadNpmTasks('grunt-contrib-nodeunit'); + + grunt.initConfig({ + nodeunit: { + all: ['test/**/*.js'] + }, + eslint: { + options: { + fix: true + }, + target: [ + 'Gruntfile.js', + 'src/**/*.js' + ] + }, + jsdoc: { + dist: { + src: ['README.md', 'src/*.js'], + readme: 'README.md', + version: true, + options: { + destination: 'docs', + template: 'node_modules/ink-docstrap/template', + configure: 'node_modules/ink-docstrap/template/jsdoc.conf.json' + } + } + } + }); + + grunt.registerTask('ship', ['eslint', 'jsdoc']); + grunt.registerTask('test', ['eslint', 'nodeunit']); +}; diff --git a/README.md b/README.md index 27ecfac4..7e1b0f6f 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,138 @@ -# github-release-notes +# Github Release Notes [![npm version](https://badge.fury.io/js/github-release-notes.svg)](https://badge.fury.io/js/github-release-notes) +[![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg)](http://standardjs.com) +[![Build Status](https://travis-ci.org/github-tools/github-release-notes.svg)](https://travis-ci.org/github-tools/github-release-notes) -> Node module which generates a release from the latest tag and compiles release notes based on commit messages between the last tag and the latest release. +> Node module that generates release notes based on commit messages or closed issues between tags. It also can create a full changelog or add the latest release notes to the existing changelog file. ## Installation -The plugin requires Node `^0.12.`, since is using Promises. - Install `github-release-notes` via npm: ```shell -npm install github-release-notes --save-dev +npm install github-release-notes -g ``` ## Usage -You can run the command via the terminal (the three arguments are all required): +**gren** can be ran through the terminal, but before you can use it, you need to set up a couple of things. + +### Github Informations + +**gren** by default looks for your local git configuration to get the repo informations. This means you can run the command directly from the git repo folder. + +Otherwise, you can run it from wherever and specify a different repo as target, with: ```shell -node github-release-notes --token=[token] --username=[username] --repo=[repo name] +gren --username=[username] --repo=[repo name] +``` + +#### Token + +To work, **gren** needs a `github token` (that can be easily generated following [this link](https://help.github.com/articles/creating-an-access-token-for-command-line-use/)). _You only need "repo" scope._ + +Once generated, you can run the gren command with the token as variable: + +```shell +gren --token=your_token_here +``` + +Or you can add it to your `~/.bash_profile` or `~/.zshrc`) as follows: + +```shell +export GREN_GITHUB_TOKEN=your_token_here +``` + +And you're ready to use it! Just run this command in your terminal: + +```shell +gren +``` + +The module will look for the last tag, get all the issues closed in the time between that tag and the one before, and it will build release notes and draft the new release! + +## Options + +Following the options for the module: + +- `--action=release|changelog` The **gren** action to run. Default: `release` _(see details below for changelog generator)_ +- `--tags=0.1.0|0.2.0,0.1.0` A specific tag or the range of tags to build the release notes from. +- `--time-wrap=latest|history` The release notes you want to include in the changelog. Default: `latest` _Only applicable to the `changelog` action_ +- `--changelog-filename=CHANGELOG.md` The name of the changelog file. Default: `CHANGELOG.md` +- `--data-source=issues|commits` The informations you want to use to build release notes. Default: `issues` +- `--draft=true|false` To set the release as a draft. Default: `false` +- `--prerelease=true|false` To set the release as a prerelease. Default: `false` +- `--prefix=v` Add a prefix to the tag version `e.g. v1.0.1`. Default: `null` +- `--include-messages=merges|commits|all` used to filter the messages added to the release notes. Default: `commits` +- `--override=true|false` Override the release notes if existing. Default: `false` + +## Examples + +The ways to use **gren** are various. + +### Simple + +The simple way, just looks for the last tag, gets all the issues closed between that tag and the one before and creates the new release with the generated body. + +``` +gren ``` -To generate a github token, follow [this link](https://help.github.com/articles/creating-an-access-token-for-command-line-use/); +### Commit messages -### Optionals +Adding the flag `--data-source=commits` will change the source of the release notes to be the commit messages. -There are optional arguments such as: +``` +gren --data-source=commits +``` + +### Release specific tags + +The flag `--tags` accepts one or two tags. +If you only give one tag, it will get the issues (or commit messages) between that tag and the one before. +If you give two tags it will generate the release notes with the issues (or commit messages) between those two tag dates. + +``` +gren --tags=2.0.0,1.0.0 +``` + +### Override an existing release + +If you trying to create an existing release, **gren** will throw an error *"0.3.0 is a release, use --override flag to override an existing release!* +If you want then to override, simple use: + +``` +gren --override --tags=0.3.0 +``` + + +## Changelog Generator + +**gren** can also update generate the changelog. + +The following command, will generate the release notes for the latest release, and add it to an existing file or create it in the same directory where you run the command. + +```shell +gren --action=changelog +``` + +The generated release notes will be added at the top of the file, and will look like this: + +> # Changelog +## v0.4.3 (02/03/2016) +[**bug**] This is a issue name [#123](https://github.com/github-tools/github-tools) + +### Generate a full changelog + +If tou want to generate the whole changelog, you need to use the `--time-wrap=history`. This will generate a changelog based on issues (or on commit messages if the `--data-source=commits` is present). + +If you want to override the existing changelog, use `--override`. + +The usage would then be: + +``` +gren --time-wrap=history --override +``` -- `--draft=true` To set the release as a draft. Default: `false` -- `--prerelease=true` To set the release as a prerelease. Default: `false` -- `--prefix=v` Add a prefix to the tag version `e.g. v1.0.1` -- `--include-messages=merges/commits/all` used to filter the messages added to the release notes. Default: `commits` \ No newline at end of file +To see a full example of the changelog here [CHANGELOG.md](#) \ No newline at end of file diff --git a/bin/gren.js b/bin/gren.js new file mode 100644 index 00000000..1c59adef --- /dev/null +++ b/bin/gren.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node + +'use strict'; + +var GithubReleaseNotes = require('../src/gren'); +var gren = new GithubReleaseNotes(); +var utils = require('../src/utils'); + +var action = utils.getBashOptions(process.argv)['action']; + +gren.init() +.then(function (success) { + if(success) { + return gren[action || 'release'](); + } +}); diff --git a/github-release-notes.js b/github-release-notes.js index 563bc44a..af5efc7f 100644 --- a/github-release-notes.js +++ b/github-release-notes.js @@ -1,6 +1,14 @@ 'use strict'; -var GithubReleaseNotes = require('./src/index'); +var GithubReleaseNotes = require('./src/gren'); var gren = new GithubReleaseNotes(); +var utils = require('./src/utils'); -gren.release(); \ No newline at end of file +var action = utils.getBashOptions(process.argv)['action']; + +gren.init() +.then(function (success) { + if(success) { + return gren[action || 'release'](); + } +}); diff --git a/package.json b/package.json index 616838be..719b8561 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,29 @@ { "name": "github-release-notes", - "version": "0.4.0", + "version": "0.5.0", "description": "Node module to publish release notes based on commits between the last two tags.", "main": "./github-release-notes.js", "scripts": { "start": "node github-release-notes.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "grunt test" }, "repository": { "type": "git", "url": "git+https://github.com/alexcanessa/github-release-notes.git" }, + "preferGlobal": "true", + "bin": { + "gren": "bin/gren.js" + }, "keywords": [ "Github", "Release", "notes", - "Release", - "Tag" + "Tag", + "Changelog", + "Changelog Generator", + "Issues", + "Commits" ], "author": "alexcanessa", "license": "ISC", @@ -25,6 +32,21 @@ }, "homepage": "https://github.com/alexcanessa/github-release-notes#readme", "dependencies": { - "github-api": "git+https://github.com/alexcanessa/github.git" + "chalk": "^1.1.3", + "es6-promise": "^3.2.1", + "github-api": "^2.1.0", + "is-online": "^5.1.1", + "object-assign": "^4.1.0" + }, + "devDependencies": { + "eslint": "^3.6.0", + "eslint-config-standard": "^6.0.1", + "eslint-plugin-promise": "^2.0.1", + "eslint-plugin-standard": "^2.0.0", + "grunt": "^1.0.1", + "grunt-contrib-nodeunit": "^1.0.0", + "grunt-eslint": "^19.0.0", + "grunt-jsdoc": "^2.1.0", + "ink-docstrap": "^1.2.1" } } diff --git a/src/github-info.js b/src/github-info.js new file mode 100644 index 00000000..de3721ca --- /dev/null +++ b/src/github-info.js @@ -0,0 +1,102 @@ +'use strict'; + +let exec = require('child_process').exec; +let chalk = require('chalk'); +let Promise = Promise || require('es6-promise').Promise; + +/** +* Execute a command in the bash and run a callback +* +* @since 0.5.0 +* @private +* +* @param {string} command The command to execute +* @param {Function} callback The callback which returns the stdout +* +* @return {Promise} +*/ +function executeCommand(command, callback) { + return new Promise(function(resolve, reject) { + exec(command, function(err, stdout, stderr) { + if (err || stderr) { + reject(err || stderr); + } else { + resolve(stdout.replace('\n', '')); + } + }); + }) + .then(callback) + .catch(function(error) { + throw new Error(chalk.red(error) + chalk.yellow('Make sure you\'re running the command from the repo folder, or you using the --username and --repo flags.')); + }); +} + +/** +* Get user informations +* +* @since 0.5.0 +* @public +* +* @param {Function} callback +* +* @return {Promise} The promise that resolves user informations ({ user: username}) +*/ +function user(callback) { + return executeCommand('git config user.name', function(user) { + return { + user: user + }; + }) + .then(callback); +} + +/** +* Get repo informations +* +* @since 0.5.0 +* @public +* +* @param {Function} callback +* +* @return {Promise} The promise that resolves repo informations ({user: user, name: name}) +*/ +function repo(callback) { + return executeCommand('git config remote.origin.url', function(repo) { + let repoPath = repo + .replace(/([^:]*:)|\.[^.]+$/g, '') + .split('/'); + let user = repoPath[0]; + let name = repoPath[1]; + + return { + username: user, + repo: name + }; + }) + .then(callback); +} + +/** +* Get token informations +* +* @since 0.5.0 +* @public +* +* @param {Function} callback +* +* @return {Promise} The promise that resolves token informations ({token: token}) +*/ +function token(callback) { + return executeCommand('echo $GREN_GITHUB_TOKEN', function(token) { + return { + token: token + }; + }) + .then(callback); +} + +module.exports = { + user: user, + repo: repo, + token: token +}; diff --git a/src/gren.js b/src/gren.js new file mode 100644 index 00000000..e2fd8cf8 --- /dev/null +++ b/src/gren.js @@ -0,0 +1,890 @@ +'use strict'; + +var utils = require('./utils'); +var githubInfo = require('./github-info'); +var Github = require('github-api'); +var fs = require('fs'); +var chalk = require('chalk'); +var Promise = Promise || require('es6-promise').Promise; +var isOnline = require('is-online'); +var ObjectAssign = require('object-assign'); + +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 +}; + +/** + * Edit arelease 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('Updating latest release'); + + return new Promise(function(resolve, reject) { + gren.repo.updateRelease(releaseId, releaseOptions, function(err, release) { + loaded(); + + if (err) { + reject(chalk.red(err)); + } else { + console.log(chalk.green('\n\n' + release.name + ' has been successfully updated!')); + + resolve(true); + } + }); + }); +} + +/** + * 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": "v1.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('Preparing the release'); + + return new Promise(function(resolve, reject) { + gren.repo.createRelease(releaseOptions, function(err, release) { + loaded(); + + if (err) { + var responseText = JSON.parse(err.request.responseText); + console.log(chalk.red( + responseText.message + '\n' + + responseText.errors[0].code + )); + reject(false); + } else { + console.log(chalk.green('\n\n' + release.name + ' has been successfully created!')); + + resolve(true); + } + }); + }); +} + +/** + * 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: gren.options.prefix + block.release, + body: block.body, + draft: gren.options.draft, + prerelease: gren.options.prerelease + }; + + if (block.id) { + return editRelease(gren, block.id, releaseOptions); + } else { + 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 {Boolean|Array} selectedTags + * @param {Object[]} tags + * + * @return {Boolean|Array} + */ +function getSelectedTags(optionTags, tags) { + if (!optionTags) { + 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('Getting tags'); + + return new Promise(function(resolve, reject) { + gren.repo.listTags(function(err, tags) { + loaded(); + + if (err) { + reject(err); + } else { + 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 + }; + }); + + if (filteredTags[0].releaseId && !gren.options.override) { + reject(chalk.red(filteredTags[0].tag.name + ' is a release, use --override flag to override an existing release!')); + } + + resolve(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 new Promise(function(resolve, reject) { + gren.repo.getCommit(tag.tag.commit.sha, function(err, commit) { + if (err) { + reject(err); + } else { + resolve({ + id: tag.releaseId, + name: tag.tag.name, + date: commit.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('Getting the list of releases'); + + return new Promise(function(resolve, reject) { + gren.repo.listReleases(function(err, releases) { + loaded(); + + if (err && err.request.status !== 404) { + reject(err); + } else { + if (err && err.request.status === 404) { + resolve(false); + } else { + process.stdout.write(releases.length + ' releases found\n'); + resolve(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(message) { + return '- ' + message; +} + +/** + * Generate the MD template from all the labels of a specific issue + * + * @since 0.5.0 + * @private + * + * @param {Object} issue + * + * @return {string} + */ +function templateLabels(issue) { + return issue.labels ? issue.labels.map(function(label) { + return '[**' + label.name + '**] '; + }) + .join('') : '[closed]'; +} + +/** + * Generate the MD template a block + * + * @since 0.5.0 + * @private + * + * @param {Object} block ({name: 'v1.2.3', body: []}) + * + * @return {string} + */ +function templateBlock(block) { + var date = new Date(block.date); + + return '## ' + block.release + ' (' + utils.formatDate(date) + ')' + '\n\n' + + block.body; +} + +/** + * Generate the MD template for each issue + * + * @since 0.5.0 + * @private + * + * @param {Object} issue + * + * @return {string} + */ +function templateIssue(issue) { + return '- ' + templateLabels(issue) + issue.title + ' [#' + issue.number + '](' + issue.html_url + ')'; +} + +/** + * Generate the Changelog MD template + * + * @since 0.5.0 + * @private + * + * @param {Object[]} blocks + * + * @return {string} + */ +function templateChangelog(blocks) { + return '# Changelog\n\n' + + blocks + .map(templateBlock) + .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 + '\n' : '*No changelog for this release.*'; +} + +/** + * 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) + .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 new Promise(function(resolve, reject) { + gren.repo.listCommits(options, function(err, commits) { + if (err) { + reject(err); + } else { + resolve(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' + }; + }); + }) + ); +} + +/** + * Get all the closed issues from the current repo + * + * @since 0.5.0 + * @private + * + * @param {GithubReleaseNotes} gren The gren object + * + * @return {Promise} The promise which resolves the list of the issues + */ +function getClosedIssues(gren) { + var loaded = utils.task('Getting all closed issues'); + + return new Promise(function(resolve, reject) { + gren.issues.listIssues({ + state: 'closed' + }, function(err, issues) { + loaded(); + + if (err) { + reject(err); + } else { + var filteredIssues = issues.filter(function(issue) { + return !issue.pull_request; + }); + + process.stdout.write(filteredIssues.length + ' issues found\n'); + + resolve(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('\nCreating the body blocks from issues:'); + + return getClosedIssues(gren) + .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); + + return { + id: range[0].id, + release: 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(release1.date) < new Date(release2.date) ? 1 : -1; + }); +} + +/** + * 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(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\.]+)/)[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, reject) { + isOnline(function(err, online) { + if (err) { + reject(chalk.red(err)); + } + + resolve(online); + }); + }); +} + +/** + * @param {Object} [options] The options of the module + * + * @since 0.1.0 + * @public + * + * @constructor + */ +function GithubReleaseNotes(options) { + this.options = ObjectAssign({}, defaults, options || utils.getBashOptions(process.argv)); + this.options.tags = this.options.tags && this.options.tags.split(','); + 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; + + return hasNetwork() + .then(function(success) { + if (success) { + return generateOptions(gren.options); + } else { + throw chalk.red('You need to have network connectivity'); + } + }) + .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); + + return true; + }) + .catch(function(error) { + console.log(error); + }); +}; + +/** + * 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('Release'); + + 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('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 prepareRelease(gren, blocks[0]); + }) + .then(function(success) { + return success; + }) + .catch(function(error) { + console.error(error); + + return gren.options.force; + }); +}; + +/** + * 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; + }) + .catch(function(error) { + console.error(error); + + return gren.options.force; + }); +}; + +module.exports = GithubReleaseNotes; diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 5b910075..00000000 --- a/src/index.js +++ /dev/null @@ -1,286 +0,0 @@ -'use strict'; - -var Github = require('github-api'); - -/** - * Create a release from a given tag (in the options) - * - * @param {GithubReleaseNotes} gren The gren object - * @param {Object} releaseOptions The options to build the release: - * { - * "tag_name": "v1.0.0", - * "target_commitish": "master", - * "name": "v1.0.0", - * "body": "Description of the release", - * "draft": false, - * "prerelease": false - * } - */ -function makeRelease(gren, releaseOptions) { - return new Promise(function (resolve, reject) { - gren.repo.makeRelease(releaseOptions, function (err, release) { - if(err) { - var responseText = JSON.parse(err.request.responseText); - console.error( - responseText.message + '\n' - + responseText.errors[0].code - ); - reject(false); - } else { - console.log(release.tag_name + ' successfully created!'); - resolve(true); - } - }); - }); -} - -/** - * Return a string with a - to be a bullet list (used for a mapping) - * - * @param {string} message - * - * @return {string} - */ -function createBody(message) { - return '- ' + message; -} - -/** - * Transforms the commits to commit messages - * - * @param {Object[]} commits The array of object containing the commits - * - * @return {String[]} - */ -function commitMessages(commits) { - return commits.map(function (commitObject) { - return commitObject.commit.message; - }); -} -{ - - key: function(message) { return message.method }, -} - -/** - * Creates the options to make the release - * - * @param {GithubReleaseNotes} gren The gren object - * @param {Object[]} tags The collection of tags - * @param {string[]} commitMessages The commit messages to create the release body - */ -function prepareRelease(gren, tagName, commitMessages) { - var body = commitMessages - .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(message) { return true; } - }; - - if(filterMap[messageType]) { - return filterMap[messageType](message); - } - - return filterMap.commits(message); - }) - .map(createBody) - .join('\n'); - - var releaseOptions = { - tag_name: tagName, - name: (gren.options.prefix || '') + tagName, - body: body, - draft: gren.options.draft || false, - prerelease: gren.options.prerelease || false - }; - - return makeRelease(gren, releaseOptions); -} - -/** - * Gets all the commits between two dates - * - * @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) { - var options = { - since: since, - until: until - }; - - return new Promise(function (resolve, reject) { - gren.repo.getCommits(options, function (err, commits) { - if(err) { - reject(err); - } else { - resolve(commitMessages(commits)); - } - }); - }); -} - -/** - * Get the dates of the last two tags - * - * @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, lastTag, lastRelease) { - return [lastTag, lastRelease].map(function (tag) { - return new Promise(function (resolve, reject) { - gren.repo.getCommit('master', tag.commit.sha, function (err, commit) { - if(err) { - reject(err); - } else { - resolve(commit.committer.date); - } - }); - }); - }) -} - -/** - * Get all the tags of the repo - * - * @param {GithubReleaseNotes} gren The gren object - * - * @return {Promise} - */ -function getLastTags(gren, releaseTagName) { - return new Promise(function (resolve, reject) { - gren.repo.listTags(function (err, tags) { - if(err) { - reject(err); - } else { - var filteredTags = tags.filter(function(tag, index) { - return index === 0 || (releaseTagName ? tag.name === releaseTagName : index === tags.length-1 ); - }); - - resolve(filteredTags); - } - }); - }); -} - -/** - * Get the latest release - * - * @param {GithubReleaseNotes} gren The gren object - * - * @return {Promise} The promise which resolves the tag name of the release - */ -function getLatestRelease(gren) { - return new Promise(function (resolve, reject) { - gren.repo.getLatestRelease(function (err, release) { - if(err && err.request.status !== 404) { - reject(err); - } else { - if(err && err.request.status === 404) { - resolve(false); - } else { - resolve(release.tag_name); - } - } - }); - }); -} - -/** - * Transforms a dasherize string into a camel case one. - * - * @param {[string]} value The dasherize string - * - * @return {[string]} The camel case string - */ -function dashToCamelCase(value) { - return value.replace(/-([a-z])/g, function (match) { - return match[1].toUpperCase(); - }); -} - -/** - * Create a literal object of the node module options - * - * @param {Array} args The array of arguments (the module arguments start from index 2) - * - * @return {Object} The object containg the key/value options - */ -function getOptions(args) { - var settings = {}; - - for(var i=2;i