diff --git a/README.md b/README.md index df63063..9b0e182 100644 --- a/README.md +++ b/README.md @@ -35,19 +35,19 @@ As [recommended by GitHub](https://github.com/blog/180-local-github-config), Gad ### Commands -In your projet repository, just enter `gad`. - -| Command | Description | -|---|---| -| __sprint__ | Show the state of the current sprint | -| __sprints__ | Show the state of all sprints | -| __backlog__ | Show the state of the backlog | -| __review__ | Display PullRequest that are awaiting your review | -| __changelog__ | Generate a markdown changelog of the current sprint | -| __estimate__ | Show stories that are missing estimation | -| __status__ | Show the status of the repository | -| __help__ | Show list of commands | -| __exit__ | Quit the dashboard | +In your projet repository, just enter `gad [command] (options)`. + +| Command | Description | Options | +|---|---|---| +| __sprint__ | Show the state of the current sprint | __sprint__ `-s=-1` Show the previous sprint | +| __sprints__ | Show the state of all sprints | __limit__ `-l=2` limit the number of sprint to display | +| __backlog__ | Show the state of the backlog | | +| __review__ | Display PullRequest that are awaiting your review | | +| __changelog__ | Generate a markdown changelog of the current sprint | __all__ `--all` include open issues in the changelog. __sprint__ `-s=-2` Show the changelog from two sprints ago | +| __estimate__ | Show stories that are missing estimation | | +| __status__ | Show the status of the repository | | +| __help__ | Show list of commands | | +| __exit__ | Quit the dashboard | | ### Options diff --git a/gad.js b/gad.js index d909f0b..fe0a411 100644 --- a/gad.js +++ b/gad.js @@ -2,12 +2,18 @@ const { execSync } = require('child_process'); const { homedir } = require('os'); +const minimist = require('minimist'); +const GithubAgileDashboard = require('./src/GithubAgileDashboard'); +const { red } = require('./src/Util/colors'); function lookup(command) { try { return execSync(command).toString().trim(); } catch (error) { return ''; } } function remote(url) { return (new RegExp('git@github.com:(.+)\\/(.+)\\.git', 'ig').exec(url) || new Array(3).fill(null)).slice(1); } +process.on('uncaughtException', function onError(error) { console.error(red(error.message)); process.exit(1); }); + const [defaultOwner, defaultRepo] = remote(lookup('git -C . config --get remote.origin.url')); -const { owner, repo, user, password, cacheDir, _: commands} = require('minimist')(process.argv.slice(2), { +const { owner, repo, user, password, cacheDir, _: command} = minimist(process.argv.slice(2), { + stopEarly: true, default: { owner: defaultOwner, repo: defaultRepo, @@ -26,4 +32,4 @@ const { owner, repo, user, password, cacheDir, _: commands} = require('minimist' } }); -module.exports = new (require('./src/GithubAgileDashboard'))(owner, repo, user, password, cacheDir, commands); +module.exports = new GithubAgileDashboard(owner, repo, user, password, cacheDir, command.join(' ')); diff --git a/src/CLI.js b/src/CLI.js index d81967e..b69d4be 100644 --- a/src/CLI.js +++ b/src/CLI.js @@ -1,5 +1,6 @@ const EventEmitter = require('events'); const Readline = require('readline'); +const minimist = require('minimist'); class CLI extends EventEmitter { /** @@ -9,14 +10,14 @@ class CLI extends EventEmitter { /** * @param {String} prompt - * @param {Array} commandStack command stack + * @param {Array} commandStack Command stack */ constructor(prompt = '', commandStack = []) { super(); const { stdin, stdout } = process; - this.commands = new Set(); + this.commands = new Map(); this.readline = Readline.createInterface({ input: stdin, output: stdout, prompt }); this.commandStack = commandStack; this.ready = false; @@ -37,15 +38,31 @@ class CLI extends EventEmitter { * * @param {String} name * @param {Function} callback + * @param {Object} options */ - on(name, callback) { + on(name, callback, options = {}) { super.on(name, callback); if (CLI.RESERVED.indexOf(name) < 0) { - this.commands.add(name); + this.commands.set(name, { default: options, alias: this.getAlias(options) }); } } + /** + * Create alias automatically + * + * @param {Object} options + * + * @return {Object} + */ + getAlias(options) { + const alias = {}; + + Object.keys(options).forEach(option => alias[option[0]] = option); + + return alias; + } + /** * Mark as ready */ @@ -59,7 +76,8 @@ class CLI extends EventEmitter { let line; while (line = this.commandStack.shift()) { - this.readline.write(`${line}\r\n`); + this.readline.prompt(); + this.readline.write(`${line.trim()}\r\n`); } } } @@ -77,8 +95,8 @@ class CLI extends EventEmitter { * Display command result */ result(message) { - this.write(typeof message === 'string' ? message : message.join('\r\n')); - this.readline.prompt(); + this.write('\r\n' + (typeof message === 'string' ? message : message.join('\r\n')) + '\r\n'); + setImmediate(this.close); } /** @@ -96,11 +114,13 @@ class CLI extends EventEmitter { * @return {String} */ getCommand(input) { - if (this.commands.has(input.trim())) { - return input; + const [ command, ...options] = input.split(' '); + + if (!this.commands.has(command)) { + return { command: 'unknown' }; } - return 'unknown'; + return { command, options: minimist(options, this.commands.get(command)) }; } /** @@ -118,7 +138,9 @@ class CLI extends EventEmitter { * @param {String} line */ onLine(line) { - this.emit(this.getCommand(line) || 'unknown'); + const { command, options } = this.getCommand(line); + + this.emit(command || 'unknown', options); } /** diff --git a/src/Display/BurnDownChart.js b/src/Display/BurnDownChart.js index 6e21522..bac61fc 100644 --- a/src/Display/BurnDownChart.js +++ b/src/Display/BurnDownChart.js @@ -40,7 +40,7 @@ class BurnDownChart { display() { const { dayWidth, gutter } = this.constructor; const burnDown = this.getBurnDown(this.milestone); - const labelLength = Array.from(burnDown.keys()).reduce((max, label) => Math.max(label.length, max), 0); + const labelLength = Math.max(Array.from(burnDown.keys()).reduce((max, label) => Math.max(label.length, max), 0), 1); const maxPoints = Math.ceil(this.milestone.points); const pointsPerDay = maxPoints / burnDown.size; const lines = [ diff --git a/src/GitHub/Milestone.js b/src/GitHub/Milestone.js index a8116ab..baaa502 100644 --- a/src/GitHub/Milestone.js +++ b/src/GitHub/Milestone.js @@ -1,6 +1,7 @@ const Issue = require('./Issue'); -const DateUtil = require('../Util/DateUtil'); const BurnDownChart = require('../Display/BurnDownChart'); +const DateUtil = require('../Util/DateUtil'); +const { green, yellow } = require('../Util/colors'); class Milestone { /** @@ -144,7 +145,16 @@ class Milestone { displaySprint() { const { title, length, done, todo, inProgress, readyToReview, progress, points } = this; - return `${title} ・ 📉 ${(progress * 100).toFixed(2)}% ・ 📫 ${todo}pts ・ 🚧 ${inProgress}pts ・ 🔍 ${readyToReview}pts ・ ✅ ${done}pts ・ (${length} stories ・ ${points}pts)`; + return [ + title, + `${green(length)} stories`, + `${yellow(points)} points`, + `📉 ${(progress * 100).toFixed(2)}%`, + `📫 ${todo}pts`, + `🚧 ${inProgress}pts`, + `🔍 ${readyToReview}pts`, + `✅ ${done}pts`, + ].join(' ・ '); } /** @@ -155,7 +165,7 @@ class Milestone { displayBacklog() { const { title, length, points } = this; - return `${title} ・ 📇 ${length} stories ・ ${points} points`; + return `${title} ・ 📇 ${green(length)} stories ・ ${yellow(points)} points`; } /** @@ -170,14 +180,18 @@ class Milestone { /** * Returns a changelog of the sprint * + * @param {Boolean} all Display all issue (and not just those that are done) + * * @return {Array} */ - displayChangelog() { - return ['## Changelog:'].concat( - this.getIssueByStatus('done') - .sort(Issue.sortByPoint) - .map(issue => `- ${issue.title}`) - ); + displayChangelog(all = false) { + const issues = all ? this.issues : this.getIssueByStatus('done'); + + return [`# ${this.title}`, '## Changelog '].concat( + issues + .sort(Issue.sortByPoint) + .map(issue => `- ${issue.title}`) + ).join('\r\n'); } } diff --git a/src/GitHub/Project.js b/src/GitHub/Project.js index 71d01b8..273b787 100644 --- a/src/GitHub/Project.js +++ b/src/GitHub/Project.js @@ -24,14 +24,36 @@ class Project { } /** - * Get current milestone + * Get sprint by index * + * @param {Number} index If null: current sprint. If negative: previous sprint from current. If position: number of the sprint. * @param {Date} date * * @return {Milestone} */ - getCurrentMilestone(date = DateUtil.day()) { - return this.getSprints().find(milestone => milestone.isCurrent(date)); + getSprint(number = null, date = DateUtil.day()) { + const sprints = this.getSprints(); + + if (!number) { + return sprints.find(milestone => milestone.isCurrent(date)); + } + + if (number > 0) { + if (typeof sprints[number - 1] === 'undefined') { + throw new Error(`No sprint ${number}: only ${sprints.length} sprints found.`); + } + + return sprints[number - 1]; + } else { + const date = DateUtil.day(); + const current = sprints.findIndex(milestone => milestone.isCurrent(date)); + + if (typeof sprints[current + number] === 'undefined') { + throw new Error(`No sprint ${number}.`); + } + + return sprints[current + number]; + } } /** @@ -40,7 +62,7 @@ class Project { * @return {Milestone[]} */ getSprints() { - return this.milestones.sort(Milestone.sort).filter(milestone => !milestone.isBacklog()); + return this.milestones.filter(milestone => !milestone.isBacklog()); } /** @@ -82,6 +104,7 @@ class Project { load(issues) { issues.filter(data => typeof data.pull_request === 'undefined').forEach(this.loadIssue); issues.filter(data => typeof data.pull_request !== 'undefined').forEach(this.loadPullRequest); + this.milestones.sort(Milestone.sort); this.getSprints().forEach((milestone, index, sprints) => milestone.previous = sprints[index - 1] || null); } diff --git a/src/GithubAgileDashboard.js b/src/GithubAgileDashboard.js index f9de3c4..0233e73 100644 --- a/src/GithubAgileDashboard.js +++ b/src/GithubAgileDashboard.js @@ -10,10 +10,10 @@ class GithubAgileDashboard { * @param {String} username * @param {String} password * @param {String} cacheDir - * @param {Array} commands + * @param {String} command */ - constructor(owner, repo, username, password, cacheDir, commands = ['status']) { - this.cli = new CLI('gad> ', commands); + constructor(owner, repo, username, password, cacheDir, command = 'status') { + this.cli = new CLI('gad> ', [command]); this.loader = new HttpLoader(this.setProject.bind(this), owner, repo, username.trim(), password, cacheDir); this.user = username.trim(); this.project = null; @@ -35,13 +35,12 @@ class GithubAgileDashboard { */ onInit() { if (!this.cli.ready) { - this.cli.on('help', this.helpCommand); this.cli.on('status', this.statusCommand); - this.cli.on('sprint', this.sprintCommand); - this.cli.on('sprints', this.sprintsCommand); + this.cli.on('sprint', this.sprintCommand, { sprint: 0 }); + this.cli.on('sprints', this.sprintsCommand, { limit: null }); this.cli.on('backlog', this.backlogCommand); this.cli.on('review', this.reviewCommand); - this.cli.on('changelog', this.changelogCommand); + this.cli.on('changelog', this.changelogCommand, { sprint: 0, all: false }); this.cli.on('estimate', this.estimateCommand); this.cli.on('unknown', this.helpCommand); this.cli.on('refresh', this.loader.load); @@ -76,22 +75,31 @@ class GithubAgileDashboard { backlogCommand() { const milestones = this.project.getBacklogs(); - this.cli.result(milestones.map(milestone => milestone.display())); + this.cli.result(milestones.map(milestone => ' ' + milestone.display())); } /** * Show the state of the current sprint */ - sprintCommand() { - this.cli.result(this.project.getCurrentMilestone().display()); + sprintCommand(options) { + const { sprint } = options; + this.cli.result(this.project.getSprint(sprint).display()); } /** - * Show the state of all sprints + * Generate a markdown changelog of the current sprint */ - sprintsCommand() { - const milestones = this.project.getSprints(); + changelogCommand(options) { + const { all, sprint } = options; + this.cli.result(this.project.getSprint(sprint).displayChangelog(all)); + } + /** + * Show the state of all sprints + */ + sprintsCommand(options) { + const { limit } = options; + const milestones = this.project.getSprints().reverse().slice(0, limit || undefined); this.cli.result(milestones.map(milestone => milestone.display())); } @@ -106,30 +114,36 @@ class GithubAgileDashboard { return this.cli.result('Nothing to review. Good job! 👍'); } - this.cli.result([`🔍 ${green(length)} pull requests awaiting your review:`] - .concat(pullRequests.map(pullRequest => pullRequest.display())) - .join('\r\n')); - } - - /** - * Generate a markdown changelog of the current sprint - */ - changelogCommand() { - this.cli.result(this.project.getCurrentMilestone().displayChangelog()); + this.cli.result( + [`🔍 ${green(length)} pull request(s) awaiting your review:`] + .concat(pullRequests.map(pullRequest => ' ' + pullRequest.display())) + .join('\r\n') + ); } /** * Show stories that are missing estimation */ estimateCommand() { - this.cli.result(this.project.getIssuesMissingEstimation().map(issue => issue.display())); + const issues = this.project.getIssuesMissingEstimation(); + const { length } = issues; + + if (length === 0) { + return this.cli.result('Nothing to estimate. Good job! 👍'); + } + + this.cli.result( + [`🔍 ${green(length)} issue(s) awaiting estimation:`] + .concat(issues.map(issue => ' ' + issue.display())) + .join('\r\n') + ); } /** * Display help */ helpCommand() { - this.cli.result(`Available commands: ${Array.from(this.cli.commands).join(', ')}`); + this.cli.result(`Available commands: ${Array.from(this.cli.commands.keys()).join(', ')}`); } }