diff --git a/lib/base.js b/lib/base.js new file mode 100644 index 00000000..5fd75a29 --- /dev/null +++ b/lib/base.js @@ -0,0 +1,111 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const fs = require("fs"); +const Github = require("github"); +const path = require("path"); +const updateNotifier = require("update-notifier"); +const configs = require("./configs"); +// -- Config ------------------------------------------------------------------- +const config = configs.getConfig(); +function clone(o) { + return JSON.parse(JSON.stringify(o)); +} +exports.clone = clone; +// -- Utils -------------------------------------------------------------------- +function load() { } +exports.load = load; +exports.github = setupGithubClient(config); +function asyncReadPackages(callback) { + function read(err, data) { + if (err) { + throw err; + } + callback(JSON.parse(data)); + } + fs.readFile(path.join(__dirname, '..', 'package.json'), read); + configs.getPlugins().forEach(plugin => { + fs.readFile(path.join(configs.getNodeModulesGlobalPath(), plugin, 'package.json'), read); + }); +} +exports.asyncReadPackages = asyncReadPackages; +function notifyVersion(pkg) { + var notifier = updateNotifier({ pkg }); + if (notifier.update) { + notifier.notify(); + } +} +exports.notifyVersion = notifyVersion; +function checkVersion() { + asyncReadPackages(notifyVersion); +} +exports.checkVersion = checkVersion; +function expandAliases(options) { + if (config.alias) { + options.fwd = config.alias[options.fwd] || options.fwd; + options.submit = config.alias[options.submit] || options.submit; + options.user = config.alias[options.user] || options.user; + } +} +exports.expandAliases = expandAliases; +function find(filepath, opt_pattern) { + return fs.readdirSync(filepath).filter(file => { + return (opt_pattern || /.*/).test(file); + }); +} +exports.find = find; +function getUser() { + return config.github_user; +} +exports.getUser = getUser; +// Export some config methods to allow plugins to access them +exports.getConfig = configs.getConfig; +exports.writeGlobalConfig = configs.writeGlobalConfig; +function setupGithubClient(config) { + const github = new Github({ + debug: false, + host: config.api.host, + protocol: config.api.protocol, + version: config.api.version, + pathPrefix: config.api.pathPrefix, + }); + function paginate(method) { + return function paginatedMethod(payload, cb) { + let results = []; + const getSubsequentPages = (link, pagesCb) => { + if (github.hasNextPage(link)) { + github.getNextPage(link, (err, res) => { + if (err) { + return pagesCb(err); + } + results = res; + return getSubsequentPages(res.meta.link, pagesCb); + }); + } + pagesCb(); + }; + method(payload, (err, res) => { + if (err) { + return cb(err, null); + } + if (!Array.isArray(res)) { + return cb(err, res); + } + results = res; + getSubsequentPages(res.meta.link, err => { + cb(err, results); + }); + }); + }; + } + for (let key in github.repos) { + if (typeof github.repos[key] === 'function') { + github.repos[key] = paginate(github.repos[key]); + } + } + return github; +} diff --git a/lib/cmd-anonymizer.js b/lib/cmd-anonymizer.js new file mode 100644 index 00000000..29bd2e61 --- /dev/null +++ b/lib/cmd-anonymizer.js @@ -0,0 +1,74 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +function CmdAnonymizer(commandDetails, redaction) { + this.last = null; + this.invoked = []; + this.redaction = redaction; + this.options = commandDetails.options; + this.shorthands = commandDetails.shorthands; +} +CmdAnonymizer.prototype.extractArgument = function (word) { + return word.replace(/-{0,2}/, ''); +}; +CmdAnonymizer.prototype.isOptionValue = function (option, value) { + const choice = this.options[option]; + const booleans = ['true', 'false']; + return ((choice instanceof Array && choice.indexOf(value) !== -1) || + (choice === Boolean && booleans.indexOf(value.toLowerCase()) !== -1) || + (typeof choice === 'string' && choice === value)); +}; +CmdAnonymizer.prototype.isValueInOptions = function (options, value) { + if (!(options instanceof Array)) { + return this.isOptionValue(options, value); + } + return options.some(function (each) { + return this.isOptionValue(this.extractArgument(each), value); + }, this); +}; +CmdAnonymizer.prototype.classify = function (word) { + const arg = this.extractArgument(word); + const whitelist = ['verbose', 'no-hooks']; + if (whitelist.indexOf(arg) === 0) { + this.invoked.push(word); + this.last = arg; + return; + } + if (this.shorthands && this.shorthands[arg]) { + this.invoked.push(word); + this.last = this.shorthands[arg]; + return; + } + if (this.options && this.options[arg]) { + this.invoked.push(word); + this.last = arg; + return; + } + if (this.options && this.isValueInOptions(this.last, word)) { + this.invoked.push(word); + this.last = undefined; + return; + } + if (this.options && + this.options[this.last] instanceof Array && + this.options[this.last].indexOf(word) !== -1) { + this.invoked.push(word); + this.last = undefined; + return; + } + this.invoked.push(this.redaction); + this.last = undefined; +}; +CmdAnonymizer.prototype.resolve = function (cmd) { + // quasi-strict white list approach (best-effort) + this.invoked.push(cmd.shift()); + cmd.forEach(this.classify, this); + return this.invoked; +}; +CmdAnonymizer.prototype.resolveToString = function (cmd) { + return this.resolve(cmd).join(' '); +}; +module.exports = CmdAnonymizer; diff --git a/lib/cmd.js b/lib/cmd.js new file mode 100644 index 00000000..788f690f --- /dev/null +++ b/lib/cmd.js @@ -0,0 +1,191 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +// -- Requires ------------------------------------------------------------------------------------- +const async = require("async"); +const fs = require("fs"); +const nopt = require("nopt"); +const path = require("path"); +const base_1 = require("./base"); +const user_1 = require("./cmds/user"); +const configs = require("./configs"); +const git = require("./git"); +const config = configs.getConfig(); +// -- Utils ---------------------------------------------------------------------------------------- +function hasCommandInOptions(commands, options) { + if (commands) { + return commands.some(c => { + return options[c] !== undefined; + }); + } + return false; +} +function invokePayload(options, command, cooked, remain) { + var payload; + if (command.DETAILS.payload && !hasCommandInOptions(command.DETAILS.commands, options)) { + payload = remain.concat(); + payload.shift(); + command.DETAILS.payload(payload, options); + } +} +function resolveCmd(name, commandDir) { + return __awaiter(this, void 0, void 0, function* () { + const commandFiles = base_1.find(commandDir, /\.js$/i); + const commandName = commandFiles.filter(file => { + switch (file) { + case 'milestone.js': + if (name === 'ms') + return true; + break; + case 'notification.js': + if (name === 'nt') + return true; + break; + case 'pull-request.js': + if (name === 'pr') + return true; + break; + } + if (file.startsWith(name)) { + return true; + } + return false; + })[0]; + if (commandName) { + return yield Promise.resolve().then(() => require(path.join(commandDir, commandName))); + } + return resolvePlugin(name); + }); +} +function resolvePlugin(name) { + // If plugin command exists, register the executed plugin name on + // process.env. This may simplify core plugin infrastructure. + process.env.NODEGH_PLUGIN = name; + return { default: configs.getPlugin(name).Impl }; +} +function loadCommand(name) { + return __awaiter(this, void 0, void 0, function* () { + let Command; + const commandDir = path.join(__dirname, 'cmds'); + const commandPath = path.join(commandDir, `${name}.js`); + if (fs.existsSync(commandPath)) { + Command = yield Promise.resolve().then(() => require(commandPath)); + } + else { + Command = yield resolveCmd(name, commandDir); + } + return Command.default; + }); +} +function setUp() { + let Command; + let iterative; + let options; + const operations = []; + const parsed = nopt(process.argv); + let remain = parsed.argv.remain; + let cooked = parsed.argv.cooked; + operations.push(callback => { + base_1.checkVersion(); + callback(); + }); + operations.push((callback) => __awaiter(this, void 0, void 0, function* () { + var module = remain[0]; + if (cooked[0] === '--version' || cooked[0] === '-v') { + module = 'version'; + } + else if (!remain.length || cooked.indexOf('-h') >= 0 || cooked.indexOf('--help') >= 0) { + module = 'help'; + } + try { + Command = yield loadCommand(module); + } + catch (err) { + throw new Error(`Cannot find module ${module}\n${err}`); + } + options = nopt(Command.DETAILS.options, Command.DETAILS.shorthands, process.argv, 2); + iterative = Command.DETAILS.iterative; + cooked = options.argv.cooked; + remain = options.argv.remain; + options.number = options.number || [remain[1]]; + options.remote = options.remote || config.default_remote; + if (module === 'help') { + callback(); + } + else { + user_1.default.login(callback); + } + })); + async.series(operations, () => __awaiter(this, void 0, void 0, function* () { + let iterativeValues; + const remoteUrl = git.getRemoteUrl(options.remote); + options.isTTY = {}; + options.isTTY.in = Boolean(process.stdin.isTTY); + options.isTTY.out = Boolean(process.stdout.isTTY); + options.loggedUser = base_1.getUser(); + options.remoteUser = git.getUserFromRemoteUrl(remoteUrl); + if (!options.user) { + if (options.repo || options.all) { + options.user = options.loggedUser; + } + else { + options.user = process.env.GH_USER || options.remoteUser || options.loggedUser; + } + } + options.repo = options.repo || git.getRepoFromRemoteURL(remoteUrl); + options.currentBranch = options.currentBranch || git.getCurrentBranch(); + base_1.expandAliases(options); + options.github_host = config.github_host; + options.github_gist_host = config.github_gist_host; + // Try to retrieve iterative values from iterative option key, + // e.g. option['number'] === [1,2,3]. If iterative option key is not + // present, assume [undefined] in order to initialize the loop. + iterativeValues = options[iterative] || [undefined]; + iterativeValues.forEach((value) => __awaiter(this, void 0, void 0, function* () { + options = base_1.clone(options); + // Value can be undefined when the command doesn't have a iterative + // option. + options[iterative] = value; + invokePayload(options, Command, cooked, remain); + if (process.env.NODE_ENV === 'testing') { + const { prepareTestFixtures } = yield Promise.resolve().then(() => require('./utils')); + yield new Command(options).run(prepareTestFixtures(Command.name, cooked)); + } + else { + yield new Command(options).run(); + } + })); + })); +} +exports.setUp = setUp; +function run() { + if (!fs.existsSync(configs.getUserHomePath())) { + configs.createGlobalConfig(); + } + base_1.load(); + configs.getConfig(); + // If configs.PLUGINS_PATH_KEY is undefined, try to cache it before proceeding. + if (configs.getConfig()[configs.PLUGINS_PATH_KEY] === undefined) { + configs.getNodeModulesGlobalPath(); + } + try { + process.env.GH_PATH = path.join(__dirname, '../'); + this.setUp(); + } + catch (e) { + console.error(e.stack || e); + } +} +exports.run = run; diff --git a/lib/cmds/alias.js b/lib/cmds/alias.js new file mode 100644 index 00000000..fe112c81 --- /dev/null +++ b/lib/cmds/alias.js @@ -0,0 +1,84 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +Object.defineProperty(exports, "__esModule", { value: true }); +// -- Requires ------------------------------------------------------------------------------------- +const base = require("../base"); +const configs = require("../configs"); +const logger = require("../logger"); +const config = base.getConfig(); +// -- Constructor ---------------------------------------------------------------------------------- +function Alias(options) { + this.options = options; +} +exports.default = Alias; +// -- Constants ------------------------------------------------------------------------------------ +Alias.DETAILS = { + alias: 'al', + description: 'Create alias for a username.', + commands: ['add', 'list', 'remove'], + options: { + add: String, + list: Boolean, + remove: String, + user: String, + }, + shorthands: { + a: ['--add'], + l: ['--list'], + r: ['--remove'], + u: ['--user'], + }, + payload(payload, options) { + if (payload[0]) { + options.add = payload[0]; + options.user = payload[1]; + } + else { + options.list = true; + } + }, +}; +// -- Commands ------------------------------------------------------------------------------------- +Alias.prototype.run = function () { + const instance = this; + const options = instance.options; + if (options.add) { + if (!options.user) { + logger.error('You must specify an user, try --user username.'); + } + logger.debug(`Creating alias ${options.add}`); + instance.add(); + } + if (options.list) { + instance.list((err, data) => { + let item; + for (item in data) { + if (data.hasOwnProperty(item)) { + logger.log(`${logger.colors.cyan(item)}: ${logger.colors.magenta(data[item])}`); + } + } + }); + } + if (options.remove) { + logger.debug(`Removing alias ${options.remove}`); + instance.remove(); + } +}; +Alias.prototype.add = function () { + const instance = this; + const options = instance.options; + configs.writeGlobalConfig(`alias.${options.add}`, options.user); +}; +Alias.prototype.list = function (opt_callback) { + opt_callback && opt_callback(null, config.alias); +}; +Alias.prototype.remove = function () { + const instance = this; + const options = instance.options; + delete config.alias[options.remove]; + configs.writeGlobalConfig('alias', config.alias); +}; diff --git a/lib/cmds/gists.js b/lib/cmds/gists.js new file mode 100644 index 00000000..f6a42dad --- /dev/null +++ b/lib/cmds/gists.js @@ -0,0 +1,199 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +Object.defineProperty(exports, "__esModule", { value: true }); +// -- Requires ------------------------------------------------------------------------------------- +const inquirer = require("inquirer"); +const openUrl = require("opn"); +const base = require("../base"); +const hooks = require("../hooks"); +const logger = require("../logger"); +const config = base.getConfig(); +const testing = process.env.NODE_ENV === 'testing'; +// -- Constructor ---------------------------------------------------------------------------------- +function Gists(options) { + this.options = options; +} +exports.default = Gists; +// -- Constants ------------------------------------------------------------------------------------ +Gists.DETAILS = { + alias: 'gi', + description: 'Provides a set of util commands to work with Gists.', + iterative: 'delete', + commands: ['browser', 'delete', 'fork', 'list', 'new'], + options: { + browser: Boolean, + content: String, + delete: [String, Array], + description: String, + fork: String, + id: String, + list: Boolean, + new: String, + private: Boolean, + user: String, + }, + shorthands: { + B: ['--browser'], + c: ['--content'], + D: ['--delete'], + d: ['--description'], + f: ['--fork'], + i: ['--id'], + l: ['--list'], + N: ['--new'], + p: ['--private'], + u: ['--user'], + }, + payload(payload, options) { + options.list = true; + }, +}; +// -- Commands ------------------------------------------------------------------------------------- +Gists.prototype.run = function (done) { + const instance = this; + const options = instance.options; + instance.config = config; + if (options.paste) { + logger.error('Sorry, this functionality was removed.'); + return; + } + if (options.browser) { + !testing && instance.browser(options.id || options.loggedUser); + } + if (options.delete) { + hooks.invoke('gists.delete', instance, afterHooksCallback => { + logger.log(`Deleting gist ${logger.colors.green(`${options.loggedUser}/${options.delete}`)}`); + if (testing) { + return _deleteHandler(true, afterHooksCallback); + } + inquirer + .prompt([ + { + type: 'input', + message: 'Are you sure? This action CANNOT be undone. [y/N]', + name: 'confirmation', + }, + ]) + .then(answers => _deleteHandler(answers.confirmation.toLowerCase() === 'y', afterHooksCallback)); + function _deleteHandler(proceed, cb) { + if (proceed) { + instance.delete(options.delete, err => { + if (err) { + logger.error("Can't delete gist."); + return; + } + cb && cb(); + done && done(); + }); + } + else { + logger.log('Not deleted.'); + } + } + }); + } + if (options.fork) { + hooks.invoke('gists.fork', instance, afterHooksCallback => { + logger.log(`Forking gist on ${logger.colors.green(options.loggedUser)}`); + instance.fork(options.fork, (err, gist) => { + if (err) { + logger.error(JSON.parse(err.message).message); + return; + } + logger.log(gist.html_url); + afterHooksCallback(); + done && done(); + }); + }); + } + if (options.list) { + logger.log(`Listing gists for ${logger.colors.green(options.user)}`); + instance.list(options.user, err => { + if (err) { + logger.error(`Can't list gists for ${options.user}.`); + return; + } + done && done(); + }); + } + if (options.new) { + hooks.invoke('gists.new', instance, afterHooksCallback => { + const privacy = options.private ? 'private' : 'public'; + options.new = options.new; + logger.log(`Creating ${logger.colors.magenta(privacy)} gist on ${logger.colors.green(options.loggedUser)}`); + instance.new(options.new, options.content, (err, gist) => { + if (gist) { + options.id = gist.id; + } + if (err) { + logger.error(`Can't create gist. ${JSON.parse(err.message).message}`); + return; + } + logger.log(gist.html_url); + afterHooksCallback(); + done && done(); + }); + }); + } +}; +Gists.prototype.browser = function (gist) { + openUrl(config.github_gist_host + gist, { wait: false }); +}; +Gists.prototype.delete = function (id, opt_callback) { + var payload = { + id, + }; + base.github.gists.delete(payload, opt_callback); +}; +Gists.prototype.fork = function (id, opt_callback) { + var payload = { + id, + }; + base.github.gists.fork(payload, opt_callback); +}; +Gists.prototype.list = function (user, opt_callback) { + const instance = this; + const payload = { + user, + }; + base.github.gists.getFromUser(payload, (err, gists) => { + instance.listCallback_(err, gists, opt_callback); + }); +}; +Gists.prototype.listCallback_ = function (err, gists, opt_callback) { + const instance = this; + const options = instance.options; + if (err && !options.all) { + logger.error(logger.getErrorMessage(err)); + } + if (gists && gists.length > 0) { + gists.forEach(gist => { + logger.log(`${logger.colors.yellow(`${gist.owner.login}/${gist.id}`)} ${logger.getDuration(gist.updated_at)}`); + if (gist.description) { + logger.log(gist.description); + } + logger.log(`${logger.colors.blue(gist.html_url)}\n`); + }); + opt_callback && opt_callback(err); + } +}; +Gists.prototype.new = function (name, content, opt_callback) { + const instance = this; + const file = {}; + const options = instance.options; + let payload; + options.description = options.description || ''; + file[name] = { + content, + }; + payload = { + description: options.description, + files: file, + public: !options.private, + }; + base.github.gists.create(payload, opt_callback); +}; diff --git a/lib/cmds/hello.js b/lib/cmds/hello.js new file mode 100644 index 00000000..9df73296 --- /dev/null +++ b/lib/cmds/hello.js @@ -0,0 +1,40 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +Object.defineProperty(exports, "__esModule", { value: true }); +// -- Requires ------------------------------------------------------------------------------------- +const logger = require("../logger"); +// -- Constructor ---------------------------------------------------------------------------------- +function Hello(options) { + this.options = options; +} +exports.default = Hello; +// -- Constants ------------------------------------------------------------------------------------ +Hello.DETAILS = { + alias: 'he', + description: 'Hello world example. Copy to start a new command.', + commands: ['world'], + options: { + world: Boolean, + }, + shorthands: { + w: ['--world'], + }, + payload(payload, options) { + options.world = true; + }, +}; +// -- Commands ------------------------------------------------------------------------------------- +Hello.prototype.run = function () { + const instance = this; + const options = instance.options; + if (options.world) { + instance.world(); + } +}; +Hello.prototype.world = function () { + logger.log('hello world :)'); +}; diff --git a/lib/cmds/help.js b/lib/cmds/help.js new file mode 100644 index 00000000..dfd16af9 --- /dev/null +++ b/lib/cmds/help.js @@ -0,0 +1,217 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +// -- Requires ------------------------------------------------------------------------------------- +const nopt = require("nopt"); +const path = require("path"); +const stream = require("stream"); +const url = require("url"); +const base = require("../base"); +const configs = require("../configs"); +const logger = require("../logger"); +// -- Constructor ---------------------------------------------------------------------------------- +function Help() { + this.options = nopt(Help.DETAILS.options, Help.DETAILS.shorthands, process.argv, 2); +} +exports.default = Help; +// -- Constants ------------------------------------------------------------------------------------ +Help.DETAILS = { + description: 'List all commands and options available.', + options: { + all: Boolean, + help: Boolean, + }, + shorthands: { + a: ['--all'], + h: ['--help'], + }, +}; +// -- Commands ------------------------------------------------------------------------------------- +Help.prototype.run = function () { + return __awaiter(this, void 0, void 0, function* () { + const instance = this; + const cmdDir = path.join(__dirname, '../cmds/'); + const files = base.find(cmdDir, /\.js$/); + let filter; + const options = this.options; + let plugins; + // Remove help from command list + files.splice(files.indexOf('help.js'), 1); + files.splice(files.indexOf('version.js'), 1); + // Get external plugins + plugins = configs.getPlugins(); + plugins.forEach(plugin => { + try { + files.push(configs.getPluginPath(plugin)); + } + catch (e) { + logger.warn(`Can't get ${plugin} plugin path.`); + } + }); + filter = options.argv.remain[0]; + if (filter === 'help') { + filter = options.argv.remain[1]; + } + const commands = yield Promise.all(files.map((dir) => __awaiter(this, void 0, void 0, function* () { + let cmd = yield Promise.resolve().then(() => require(path.resolve(cmdDir, dir))); + let flags = []; + if (cmd.default) { + cmd = cmd.default; + } + else { + cmd = cmd.Impl; + } + const alias = cmd.DETAILS.alias || ''; + const name = path.basename(dir, '.js').replace(/^gh-/, ''); + let offset = 20 - alias.length - name.length; + if (offset < 1) { + offset = 1; + } + if (offset !== 1 && alias.length === 0) { + offset += 2; + } + if (filter && filter !== alias && filter !== name) { + return; + } + if (filter || options.all) { + flags = instance.groupOptions_(cmd.DETAILS); + offset = 1; + } + return { + alias, + flags, + name, + description: cmd.DETAILS.description, + offset: ' '.repeat(offset + 1), + }; + }))); + if (filter && commands.length === 0) { + throw new Error(`No manual entry for ${filter}`); + } + logger.log(this.listCommands_(commands)); + }); +}; +Help.prototype.listFlags_ = function (command) { + const flags = command.flags; + let content = ''; + flags.forEach(flag => { + content += ' '; + if (flag.shorthand) { + content += `-${flag.shorthand}, `; + } + content += `--${flag.option}`; + if (flag.cmd) { + content += '*'; + } + if (flag.type) { + content += logger.colors.cyan(` (${flag.type})`); + } + content += '\n'; + }); + if (flags.length !== 0) { + content += '\n'; + } + return content; +}; +Help.prototype.listCommands_ = function (commands) { + let content = 'usage: gh [payload] [--flags] [--verbose] [--no-color] [--no-hooks]\n\n'; + content += 'List of available commands:\n'; + commands.forEach(command => { + if (command && command.hasOwnProperty('alias')) { + content += ' '; + content += `${logger.colors.magenta(command.alias)}, `; + content += `${logger.colors.magenta(command.name)}${command.offset}${command.description}\n`; + content += this.listFlags_(command); + } + }); + content += `\n(*) Flags that can execute an action.\n'gh help' lists available commands.\n'gh help -a' lists all available subcommands.`; + return content; +}; +Help.prototype.groupOptions_ = function (details) { + const instance = this; + let cmd; + let options; + let shorthands; + let grouped = []; + options = Object.keys(details.options); + shorthands = Object.keys(details.shorthands); + options.forEach(option => { + let foundShorthand; + let type; + shorthands.forEach(shorthand => { + var shorthandValue = details.shorthands[shorthand][0]; + if (shorthandValue === `--${option}`) { + foundShorthand = shorthand; + } + }); + cmd = instance.isCommand_(details, option); + type = instance.getType_(details.options[option]); + grouped.push({ + cmd, + option, + type, + shorthand: foundShorthand, + }); + }); + return grouped; +}; +Help.prototype.getType_ = function (type) { + let types; + const separator = ', '; + if (Array.isArray(type)) { + types = type; + // Iterative options have an Array reference as the last type + // e.g. [String, Array], [Boolean, Number, Array], [.., Array] + if (type[type.length - 1] === Array) { + type.pop(); + } + type = ''; + types.forEach(function (eachType) { + type += this.getType_(eachType) + separator; + }, this); + type = type.substr(0, type.length - separator.length); + return type; + } + switch (type) { + case String: + type = 'String'; + break; + case url: + type = 'Url'; + break; + case Number: + type = 'Number'; + break; + case path: + type = 'Path'; + break; + case stream.Stream: + type = 'Stream'; + break; + case Date: + type = 'Date'; + break; + case Boolean: + type = 'Boolean'; + break; + } + return type; +}; +Help.prototype.isCommand_ = function (details, option) { + if (details.commands && details.commands.indexOf(option) > -1) { + return true; + } + return false; +}; diff --git a/lib/cmds/issue.js b/lib/cmds/issue.js new file mode 100644 index 00000000..f5bf681e --- /dev/null +++ b/lib/cmds/issue.js @@ -0,0 +1,493 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +Object.defineProperty(exports, "__esModule", { value: true }); +// -- Requires ------------------------------------------------------------------------------------- +const async = require("async"); +const lodash_1 = require("lodash"); +const openUrl = require("opn"); +const base = require("../base"); +const hooks = require("../hooks"); +const logger = require("../logger"); +const config = base.getConfig(); +// -- Constructor ---------------------------------------------------------------------------------- +function Issue(options) { + this.options = options; + if (!options.repo && !options.all) { + logger.error('You must specify a Git repository with a GitHub remote to run this command'); + } +} +exports.default = Issue; +// -- Constants ------------------------------------------------------------------------------------ +Issue.DETAILS = { + alias: 'is', + description: 'Provides a set of util commands to work with Issues.', + iterative: 'number', + commands: ['assign', 'browser', 'close', 'comment', 'list', 'new', 'open', 'search'], + options: { + all: Boolean, + assign: Boolean, + assignee: String, + browser: Boolean, + close: Boolean, + comment: String, + detailed: Boolean, + label: String, + list: Boolean, + link: Boolean, + message: String, + milestone: [Number, String], + 'no-milestone': Boolean, + new: Boolean, + number: [String, Array], + open: Boolean, + remote: String, + repo: String, + search: String, + state: ['open', 'closed'], + title: String, + user: String, + }, + shorthands: { + a: ['--all'], + A: ['--assignee'], + B: ['--browser'], + C: ['--close'], + c: ['--comment'], + d: ['--detailed'], + L: ['--label'], + k: ['--link'], + l: ['--list'], + m: ['--message'], + M: ['--milestone'], + N: ['--new'], + n: ['--number'], + o: ['--open'], + r: ['--repo'], + s: ['--search'], + S: ['--state'], + t: ['--title'], + u: ['--user'], + }, + payload(payload, options) { + if (payload[0]) { + if (/^\d+$/.test(payload[0])) { + options.browser = true; + options.number = payload[0]; + return; + } + options.new = true; + options.title = options.title || payload[0]; + options.message = options.message || payload[1]; + } + else { + options.list = true; + } + }, +}; +Issue.STATE_CLOSED = 'closed'; +Issue.STATE_OPEN = 'open'; +// -- Commands ------------------------------------------------------------------------------------- +Issue.prototype.run = function (done) { + const instance = this; + const options = instance.options; + instance.config = config; + options.state = options.state || Issue.STATE_OPEN; + if (options.assign) { + hooks.invoke('issue.assign', instance, afterHooksCallback => { + const user = options.user || options.loggedUser; + logger.log(`Assigning issue ${logger.colors.green(`#${options.number}`)} on ${logger.colors.green(`${user}/${options.repo}`)} to ${logger.colors.green(options.assignee)}`); + instance.assign((err, issue) => { + if (err) { + logger.error("Can't assign issue."); + return; + } + logger.log(issue.html_url); + afterHooksCallback(); + done && done(); + }); + }); + } + if (options.browser) { + process.env.NODE_ENV !== 'testing' && + instance.browser(options.user, options.repo, options.number); + } + if (options.close) { + hooks.invoke('issue.close', instance, afterHooksCallback => { + options.state = Issue.STATE_CLOSED; + logger.log(`Closing issue ${logger.colors.green(`#${options.number}`)} on ${logger.colors.green(`${options.user}/${options.repo}`)}`); + instance.close((err, issue) => { + if (err) { + logger.error("Can't close issue."); + return; + } + logger.log(issue.html_url); + afterHooksCallback(); + done && done(); + }); + }); + } + if (options.comment) { + logger.log(`Adding comment on issue ${logger.colors.green(`#${options.number}`)}`); + instance.comment((err, issue) => { + if (err) { + throw new Error(`Can't add comment.\n${err}`); + } + logger.log(issue.html_url); + done && done(); + }); + } + if (options.list) { + if (options.all) { + logger.log(`Listing ${logger.colors.green(options.state)} issues for ${logger.colors.green(options.user)}`); + instance.listFromAllRepositories(err => { + if (err) { + logger.error(`Can't list issues for ${options.user}.`); + return; + } + }); + } + else { + logger.log(`Listing ${logger.colors.green(options.state)} issues on ${logger.colors.green(`${options.user}/${options.repo}`)}`); + instance.list(options.user, options.repo, err => { + if (err) { + logger.error(`Can't list issues on ${options.user}/${options.repo}`); + return; + } + done && done(); + }); + } + } + if (options.new) { + hooks.invoke('issue.new', instance, afterHooksCallback => { + logger.log(`Creating a new issue on ${logger.colors.green(`${options.user}/${options.repo}`)}`); + instance.new((err, issue) => { + if (err) { + throw new Error(`Can't create new issue.\n${err}`); + } + if (issue) { + options.number = issue.number; + } + logger.log(issue.html_url); + afterHooksCallback(); + done && done(); + }); + }); + } + if (options.open) { + hooks.invoke('issue.open', instance, afterHooksCallback => { + logger.log(`Opening issue ${logger.colors.green(`#${options.number}`)} on ${logger.colors.green(`${options.user}/${options.repo}`)}`); + instance.open((err, issue) => { + if (err) { + logger.error("Can't open issue."); + return; + } + logger.log(issue.html_url); + afterHooksCallback(); + done && done(); + }); + }); + } + if (options.search) { + let { repo, user } = options; + const query = logger.colors.green(options.search); + if (options.all) { + repo = undefined; + logger.log(`Searching for ${query} in issues for ${logger.colors.green(user)}\n`); + } + else { + logger.log(`Searching for ${query} in issues for ${logger.colors.green(`${user}/${repo}`)}\n`); + } + instance.search(user, repo, err => { + if (err) { + if (options.all) { + logger.error(`Can't search issues for ${user}`); + } + else { + logger.error(`Can't search issues on ${user}/${repo}`); + } + return; + } + done && done(); + }); + } +}; +Issue.prototype.assign = function (opt_callback) { + var instance = this; + instance.getIssue_((err, issue) => { + if (err) { + opt_callback && opt_callback(err); + } + else { + instance.editIssue_(issue.title, Issue.STATE_OPEN, opt_callback); + } + }); +}; +Issue.prototype.browser = function (user, repo, number) { + if (!number) { + number = ''; + } + openUrl(`${config.github_host}${user}/${repo}/issues/${number}`, { wait: false }); +}; +Issue.prototype.close = function (opt_callback) { + var instance = this; + instance.getIssue_((err, issue) => { + if (err) { + opt_callback && opt_callback(err); + } + else { + instance.editIssue_(issue.title, Issue.STATE_CLOSED, opt_callback); + } + }); +}; +Issue.prototype.comment = function (opt_callback) { + const instance = this; + let options = instance.options; + let body; + let payload; + body = logger.applyReplacements(options.comment, config.replace) + config.signature; + payload = { + body, + number: options.number, + repo: options.repo, + user: options.user, + }; + base.github.issues.createComment(payload, opt_callback); +}; +Issue.prototype.editIssue_ = function (title, state, opt_callback) { + const instance = this; + const options = instance.options; + let payload; + options.label = options.label || []; + payload = { + state, + title, + labels: options.label, + number: options.number, + assignee: options.assignee, + milestone: options.milestone, + repo: options.repo, + user: options.user, + }; + base.github.issues.edit(payload, opt_callback); +}; +Issue.prototype.getIssue_ = function (opt_callback) { + const instance = this; + const options = instance.options; + let payload; + payload = { + number: options.number, + repo: options.repo, + user: options.user, + }; + base.github.issues.getRepoIssue(payload, opt_callback); +}; +Issue.prototype.list = function (user, repo, opt_callback) { + const instance = this; + const options = instance.options; + const operations = []; + let payload; + options.label = options.label || ''; + payload = { + repo, + user, + labels: options.label, + state: options.state, + }; + if (options['no-milestone']) { + payload.milestone = 'none'; + } + else if (options.milestone) { + payload.milestone = options.milestone; + } + if (options.milestone) { + operations.push(callback => { + base.github.issues.getAllMilestones({ + repo, + user, + }, (err, results) => { + if (err) { + logger.warn(err.message); + } + results.some(milestone => { + if (options.milestone === milestone.title) { + logger.debug(`Milestone ${milestone.title} number: ${milestone.number}`); + payload.milestone = milestone.number; + return true; + } + }); + callback(); + }); + }); + } + if (options.assignee) { + payload.assignee = options.assignee; + } + operations.push(callback => { + base.github.issues.repoIssues(payload, callback); + }); + async.series(operations, (err, results) => { + if (err && !options.all) { + logger.error(logger.getErrorMessage(err)); + } + const issues = results[0].map(result => { + if (result) { + return result; + } + }); + if (issues && issues.length > 0) { + const formattedIssues = formatIssues(issues, options.detailed); + logger.log(formattedIssues); + } + else { + logger.log('No issues.'); + } + opt_callback && opt_callback(err); + }); +}; +Issue.prototype.listFromAllRepositories = function (opt_callback) { + const instance = this; + const options = instance.options; + let payload; + payload = { + type: 'all', + user: options.user, + }; + base.github.repos.getAll(payload, (err, repositories) => { + if (err) { + opt_callback && opt_callback(err); + } + else { + repositories.forEach(repository => { + instance.list(repository.owner.login, repository.name, opt_callback); + }); + } + }); +}; +Issue.prototype.new = function (opt_callback) { + const instance = this; + const options = instance.options; + let body; + let payload; + if (options.message) { + body = logger.applyReplacements(options.message, config.replace); + } + if (options.label) { + options.label = options.label.split(','); + } + else { + options.label = []; + } + payload = { + body, + assignee: options.assignee, + repo: options.repo, + title: options.title, + user: options.user, + labels: options.label, + }; + base.github.issues.create(payload, opt_callback); +}; +Issue.prototype.open = function (opt_callback) { + var instance = this; + instance.getIssue_((err, issue) => { + if (err) { + opt_callback && opt_callback(err); + } + else { + instance.editIssue_(issue.title, Issue.STATE_OPEN, opt_callback); + } + }); +}; +Issue.prototype.search = function (user, repo, opt_callback, options) { + const instance = this; + const operations = []; + let query = ['type:issue']; + let payload; + options = instance.options || options; + options.label = options.label || ''; + if (!options.all && repo) { + query.push(`repo:${repo}`); + } + if (user) { + query.push(`user:${user}`); + } + query.push(options.search); + payload = { + q: query.join(' '), + type: 'Issues', + }; + operations.push(callback => { + base.github.search.issues(payload, callback); + }); + async.series(operations, (err, results) => { + if (err && !options.all) { + logger.error(logger.getErrorMessage(err)); + } + const issues = results[0].items.map(result => { + if (result) { + return result; + } + }); + if (issues && issues.length > 0) { + var formattedIssues = formatIssues(issues, options.detailed); + logger.log(formattedIssues); + } + else { + logger.log('Could not find any issues matching your query.'); + } + opt_callback && opt_callback(err, formattedIssues); + }); +}; +function formatIssues(issues, showDetailed) { + issues.sort((a, b) => { + return a.number > b.number ? -1 : 1; + }); + if (issues && issues.length > 0) { + const formattedIssuesArr = issues.map(issue => { + const issueNumber = logger.colors.green(`#${issue.number}`); + const issueUser = logger.colors.magenta(`@${issue.user.login}`); + const issueDate = `(${logger.getDuration(issue.created_at)})`; + let formattedIssue = `${issueNumber} ${issue.title} ${issueUser} ${issueDate}`; + if (showDetailed) { + if (issue.body) { + formattedIssue = ` + ${formattedIssue} + ${issue.body} + `; + } + if (lodash_1.isArray(issue.labels) && issue.labels.length > 0) { + const labels = issue.labels.map(label => label.name); + const labelHeading = labels.length > 1 ? 'labels: ' : 'label: '; + formattedIssue = ` + ${formattedIssue} + ${logger.colors.yellow(labelHeading) + labels.join(', ')} + `; + } + if (issue.milestone) { + const { number, title } = issue.milestone; + formattedIssue = ` + ${formattedIssue} + ${`${logger.colors.green('milestone: ')} ${title} - ${number}`} + `; + } + formattedIssue = ` + ${formattedIssue} + ${logger.colors.blue(issue.html_url)} + `; + } + return trim(formattedIssue); + }); + return formattedIssuesArr.join('\n\n'); + } + return null; +} +function trim(str) { + return str + .replace(/^[ ]+/gm, '') + .replace(/[\r\n]+/g, '\n') + .trim(); +} diff --git a/lib/cmds/milestone.js b/lib/cmds/milestone.js new file mode 100644 index 00000000..a8be1690 --- /dev/null +++ b/lib/cmds/milestone.js @@ -0,0 +1,123 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +Object.defineProperty(exports, "__esModule", { value: true }); +// -- Requires ------------------------------------------------------------------------------------- +const async = require("async"); +const base = require("../base"); +const logger = require("../logger"); +// -- Constructor ---------------------------------------------------------------------------------- +function Milestone(options) { + this.options = options; + if (options.organization) { + options.all = true; + } + if (!options.repo && !options.all) { + logger.error('You must specify a Git repository with a GitHub remote to run this command'); + } +} +exports.default = Milestone; +// -- Constants ------------------------------------------------------------------------------------ +Milestone.DETAILS = { + alias: 'ms', + description: 'Provides a set of util commands to work with Milestones.', + iterative: 'number', + commands: ['list'], + options: { + all: Boolean, + organization: String, + list: Boolean, + }, + shorthands: { + a: ['--all'], + o: ['--organization'], + l: ['--list'], + }, + payload(payload, options) { + options.list = true; + }, +}; +// -- Commands ------------------------------------------------------------------------------------- +Milestone.prototype.run = function (done) { + const instance = this; + const options = instance.options; + if (options.list) { + if (options.all) { + logger.log(`Listing milestones for ${logger.colors.green(options.organization || options.user)}`); + instance.listFromAllRepositories(err => { + if (err) { + throw new Error(`Can't list milestones for ${options.user}.\n${err}`); + } + }); + } + else { + const userRepo = `${options.user}/${options.repo}`; + logger.log(`Listing milestones on ${logger.colors.green(userRepo)}`); + instance.list(options.user, options.repo, err => { + if (err) { + throw new Error(`Can't list milestones on ${userRepo}\n${err}`); + } + done && done(); + }); + } + } +}; +Milestone.prototype.list = function (user, repo, opt_callback) { + const instance = this; + const options = instance.options; + let payload; + payload = { + repo, + user, + }; + base.github.issues.getAllMilestones(payload, (err, milestones) => { + if (err && !options.all) { + throw new Error(logger.getErrorMessage(err)); + } + milestones.sort((a, b) => { + return a.due_on > b.due_on ? -1 : 1; + }); + if (milestones && milestones.length > 0) { + milestones.forEach(milestone => { + const due = milestone.due_on ? logger.getDuration(milestone.due_on) : 'n/a'; + const description = milestone.description || ''; + const title = logger.colors.green(milestone.title); + const state = logger.colors.magenta(`@${milestone.state} (due ${due})`); + const prefix = options.all ? logger.colors.blue(`${user}/${repo} `) : ''; + logger.log(`${prefix} ${title} ${description} ${state}`); + }); + } + opt_callback && opt_callback(err); + }); +}; +Milestone.prototype.listFromAllRepositories = function (opt_callback) { + const instance = this; + const options = instance.options; + const operations = []; + let op = 'getAll'; + let payload; + payload = { + type: 'all', + user: options.user, + }; + if (options.organization) { + op = 'getFromOrg'; + payload.org = options.organization; + } + base.github.repos[op](payload, (err, repositories) => { + if (err) { + opt_callback && opt_callback(err); + } + else { + repositories.forEach(repository => { + operations.push(callback => { + instance.list(repository.owner.login, repository.name, callback); + }); + }); + } + async.series(operations, opt_callback); + }); +}; diff --git a/lib/cmds/notification.js b/lib/cmds/notification.js new file mode 100644 index 00000000..e4aaa8a3 --- /dev/null +++ b/lib/cmds/notification.js @@ -0,0 +1,173 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +Object.defineProperty(exports, "__esModule", { value: true }); +// -- Requires ------------------------------------------------------------------------------------- +const async = require("async"); +const base = require("../base"); +const logger = require("../logger"); +const printed = {}; +// -- Constructor ---------------------------------------------------------------------------------- +function Notifications(options) { + this.options = options; + if (!options.repo) { + logger.error('You must specify a Git repository with a GitHub remote to run this command'); + } +} +exports.default = Notifications; +// -- Constants ------------------------------------------------------------------------------------ +Notifications.DETAILS = { + alias: 'nt', + description: 'Provides a set of util commands to work with Notifications.', + commands: ['latest', 'watch'], + options: { + latest: Boolean, + remote: String, + repo: String, + user: String, + watch: Boolean, + }, + shorthands: { + l: ['--latest'], + r: ['--repo'], + u: ['--user'], + w: ['--watch'], + }, + payload(payload, options) { + options.latest = true; + }, +}; +// -- Commands ------------------------------------------------------------------------------------- +Notifications.prototype.run = function (done) { + const instance = this; + const options = instance.options; + if (options.latest) { + logger.log(`Listing activities on ${logger.colors.green(`${options.user}/${options.repo}`)}`); + instance.latest(false, done); + } + if (options.watch) { + logger.log(`Watching any activity on ${logger.colors.green(`${options.user}/${options.repo}`)}`); + instance.watch(); + } +}; +Notifications.prototype.latest = function (opt_watch, done) { + const instance = this; + const options = instance.options; + let operations; + let payload; + let listEvents; + let filteredListEvents = []; + operations = [ + function (callback) { + payload = { + user: options.user, + repo: options.repo, + }; + base.github.events.getFromRepo(payload, (err, data) => { + if (!err) { + listEvents = data; + } + callback(err); + }); + }, + function (callback) { + listEvents.forEach(event => { + event.txt = instance.getMessage_(event); + if (opt_watch) { + if (!printed[event.created_at]) { + filteredListEvents.push(event); + } + } + else { + filteredListEvents.push(event); + } + printed[event.created_at] = true; + }); + callback(); + }, + ]; + async.series(operations, err => { + if (err) { + throw new Error(`Can't get latest notifications.\n${err}`); + } + if (filteredListEvents.length) { + if (!options.watch) { + logger.log(logger.colors.yellow(`${options.user}/${options.repo}`)); + } + filteredListEvents.forEach(event => { + logger.log(`${logger.colors.magenta(`@${event.actor.login}`)} ${event.txt} ${logger.colors.cyan(options.repo)} ${logger.getDuration(event.created_at)}`); + }); + } + done && done(); + }); +}; +Notifications.prototype.watch = function () { + const instance = this; + const intervalTime = 3000; + instance.latest(); + setInterval(() => { + instance.latest(true); + }, intervalTime); +}; +Notifications.prototype.getMessage_ = function (event) { + let txt = ''; + const type = event.type; + const payload = event.payload; + switch (type) { + case 'CommitCommentEvent': + txt = 'commented on a commit at'; + break; + case 'CreateEvent': + txt = `created ${payload.ref_type} ${logger.colors.green(payload.ref)} at`; + break; + case 'DeleteEvent': + txt = `removed ${payload.ref_type} ${logger.colors.green(payload.ref)} at`; + break; + case 'ForkEvent': + txt = 'forked'; + break; + case 'GollumEvent': + txt = `${payload.pages[0].action} the ${logger.colors.green(payload.pages[0].page_name)} wiki page at`; + break; + case 'IssueCommentEvent': + txt = `commented on issue ${logger.colors.green(`#${payload.issue.number}`)} at`; + break; + case 'IssuesEvent': + txt = `${payload.action} issue ${logger.colors.green(`#${payload.issue.number}`)} at`; + break; + case 'MemberEvent': + txt = `added ${logger.colors.green(`@${payload.member.login}`)} as a collaborator to`; + break; + case 'PageBuildEvent': + txt = 'builded a GitHub Page at'; + break; + case 'PublicEvent': + txt = 'open sourced'; + break; + case 'PullRequestEvent': + txt = `${payload.action} pull request ${logger.colors.green(`#${payload.number}`)} at`; + break; + case 'PullRequestReviewCommentEvent': + txt = `commented on pull request ${logger.colors.green(`#${payload.pull_request.number}`)} at`; + break; + case 'PushEvent': + txt = `pushed ${logger.colors.green(payload.size)} commit(s) to`; + break; + case 'ReleaseEvent': + txt = `released ${logger.colors.green(payload.release.tag_name)} at`; + break; + case 'StatusEvent': + txt = 'changed the status of a commit at'; + break; + case 'WatchEvent': + txt = 'starred'; + break; + default: + logger.error(`event type not found: ${logger.colors.red(type)}`); + break; + } + return txt; +}; diff --git a/lib/cmds/pull-request.js b/lib/cmds/pull-request.js new file mode 100644 index 00000000..09fd4354 --- /dev/null +++ b/lib/cmds/pull-request.js @@ -0,0 +1,866 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +Object.defineProperty(exports, "__esModule", { value: true }); +// -- Requires ------------------------------------------------------------------------------------- +const async = require("async"); +const lodash_1 = require("lodash"); +const openUrl = require("opn"); +const base = require("../base"); +const git = require("../git"); +const hooks = require("../hooks"); +const logger = require("../logger"); +const issue_1 = require("./issue"); +const config = base.getConfig(); +// -- Constructor ---------------------------------------------------------------------------------- +function PullRequest(options) { + this.options = options; + if (!options.repo && !options.all) { + logger.error('You must specify a Git repository with a GitHub remote to run this command'); + } + this.issue = new issue_1.default(options); +} +exports.default = PullRequest; +// -- Constants ------------------------------------------------------------------------------------ +PullRequest.DETAILS = { + alias: 'pr', + description: 'Provides a set of util commands to work with Pull Requests.', + iterative: 'number', + commands: [ + 'browser', + 'close', + 'comment', + 'fetch', + 'fwd', + 'info', + 'list', + 'merge', + 'open', + 'rebase', + 'submit', + ], + options: { + all: Boolean, + branch: String, + browser: Boolean, + close: Boolean, + comment: String, + description: String, + detailed: Boolean, + direction: String, + fetch: Boolean, + fwd: String, + issue: Number, + info: Boolean, + link: Boolean, + list: Boolean, + me: Boolean, + merge: Boolean, + number: [String, Array], + open: Boolean, + org: String, + rebase: Boolean, + remote: String, + repo: String, + sort: String, + state: ['open', 'closed'], + submit: String, + title: String, + user: String, + }, + shorthands: { + a: ['--all'], + b: ['--branch'], + B: ['--browser'], + C: ['--close'], + c: ['--comment'], + D: ['--description'], + d: ['--detailed'], + f: ['--fetch'], + i: ['--issue'], + I: ['--info'], + k: ['--link'], + l: ['--list'], + M: ['--merge'], + m: ['--me'], + n: ['--number'], + o: ['--open'], + O: ['--org'], + R: ['--rebase'], + r: ['--repo'], + S: ['--state'], + s: ['--submit'], + t: ['--title'], + u: ['--user'], + }, + payload(payload, options) { + if (payload[0]) { + options.fetch = true; + } + else { + options.list = true; + } + }, +}; +PullRequest.DIRECTION_DESC = 'desc'; +PullRequest.DIRECTION_ASC = 'asc'; +PullRequest.FETCH_TYPE_CHECKOUT = 'checkout'; +PullRequest.FETCH_TYPE_MERGE = 'merge'; +PullRequest.FETCH_TYPE_REBASE = 'rebase'; +PullRequest.FETCH_TYPE_SILENT = 'silent'; +PullRequest.SORT_CREATED = 'created'; +PullRequest.SORT_COMPLEXITY = 'complexity'; +PullRequest.STATE_CLOSED = 'closed'; +PullRequest.STATE_OPEN = 'open'; +// -- Commands ------------------------------------------------------------------------------------- +PullRequest.prototype.options = null; +PullRequest.prototype.issues = null; +PullRequest.prototype.run = function (done) { + const instance = this; + const options = instance.options; + instance.config = config; + options.number = + options.number || + instance.getPullRequestNumberFromBranch_(options.currentBranch, config.pull_branch_name_prefix); + options.pullBranch = instance.getBranchNameFromPullNumber_(options.number); + options.state = options.state || PullRequest.STATE_OPEN; + if (!options.pullBranch && (options.close || options.fetch || options.merge)) { + logger.error("You've invoked a method that requires an issue number."); + } + if (options.browser && process.env.NODE_ENV !== 'testing') { + instance.browser(options.user, options.repo, options.number); + } + if (!options.list) { + options.branch = options.branch || config.default_branch; + } + if (options.close) { + instance._closeHandler(done); + } + if (options.comment) { + instance._commentHandler(done); + } + if (options.fetch) { + instance._fetchHandler(); + } + else if (options.merge || options.rebase) { + instance._mergeHandler(); + } + if (options.fwd === '') { + options.fwd = config.default_pr_forwarder; + } + if (options.fwd) { + this._fwdHandler(); + } + if (options.info) { + this._infoHandler(done); + } + if (options.list) { + this._listHandler(done); + } + if (options.open) { + this._openHandler(done); + } + if (options.submit === '') { + options.submit = config.default_pr_reviewer; + } + if (options.submit) { + this._submitHandler(done); + } +}; +PullRequest.prototype.addComplexityParamToPulls_ = function (pulls, opt_callback) { + const instance = this; + let metrics; + let operations; + const options = instance.options; + operations = pulls.map(pull => { + return function (callback) { + options.number = pull.number; + instance.getPullRequest_((err, pull2) => { + if (!err) { + metrics = { + additions: pull2.additions, + changedFiles: pull2.changed_files, + comments: pull2.comments, + deletions: pull2.deletions, + reviewComments: pull2.review_comments, + }; + pull.complexity = instance.calculateComplexity_(metrics); + } + callback(err, pull); + }); + }; + }); + async.series(operations, (err, results) => { + opt_callback(err, results); + }); +}; +PullRequest.prototype.browser = function (user, repo, number) { + if (number) { + openUrl(`${config.github_host}${user}/${repo}/pull/${number}`, { wait: false }); + } + else { + openUrl(`${config.github_host}${user}/${repo}/pulls`, { wait: false }); + } +}; +PullRequest.prototype.calculateComplexity_ = function (metrics) { + let complexity; + const weightAddition = 2; + const weightChangedFile = 2; + const weightComment = 2; + const weightDeletion = 2; + const weightReviewComment = 1; + complexity = + metrics.additions * weightAddition + + metrics.changedFiles * weightChangedFile + + metrics.comments * weightComment + + metrics.deletions * weightDeletion + + metrics.reviewComments * weightReviewComment; + return complexity; +}; +PullRequest.prototype.close = function (opt_callback) { + const instance = this; + const options = instance.options; + let operations; + let pull; + operations = [ + function (callback) { + instance.getPullRequest_((err, data) => { + if (!err) { + pull = data; + } + callback(err); + }); + }, + function (callback) { + instance.updatePullRequest_(pull.title, pull.body, PullRequest.STATE_CLOSED, callback); + }, + function (callback) { + if (options.pullBranch === options.currentBranch) { + git.checkout(pull.base.ref); + } + if (options.pullBranch) { + git.deleteBranch(options.pullBranch); + } + callback(); + }, + ]; + async.series(operations, err => { + opt_callback && opt_callback(err, pull); + }); +}; +PullRequest.prototype.checkPullRequestIntegrity_ = function (originalError, user, opt_callback) { + const instance = this; + const options = instance.options; + let payload; + let pull; + payload = { + user, + repo: options.repo, + state: PullRequest.STATE_OPEN, + }; + base.github.pullRequests.getAll(payload, (err, pulls) => { + if (!err) { + pulls.forEach(data => { + if (data.base.ref === options.branch && + data.head.ref === options.currentBranch && + data.base.sha === data.head.sha && + data.base.user.login === user && + data.head.user.login === options.user) { + pull = data; + originalError = null; + return; + } + }); + } + opt_callback && opt_callback(originalError, pull); + }); +}; +PullRequest.prototype.fetch = function (opt_type, opt_callback) { + const instance = this; + const options = instance.options; + let headBranch; + let repoUrl; + instance.getPullRequest_((err, pull) => { + if (err) { + opt_callback && opt_callback(err); + return; + } + headBranch = pull.head.ref; + repoUrl = config.ssh === false ? pull.head.repo.clone_url : pull.head.repo.ssh_url; + git.fetch(repoUrl, headBranch, options.pullBranch); + if (opt_type !== PullRequest.FETCH_TYPE_SILENT) { + git[opt_type](options.pullBranch); + } + opt_callback(err, pull); + }); +}; +PullRequest.prototype.filterPullsSentByMe_ = function (pulls) { + const instance = this; + const options = instance.options; + pulls = pulls.filter(pull => { + if (options.loggedUser === pull.user.login) { + return pull; + } + }); + return pulls; +}; +PullRequest.prototype.forward = function (opt_callback) { + const instance = this; + const options = instance.options; + let operations; + let submittedPull; + let pull; + operations = [ + function (callback) { + instance.fetch(PullRequest.FETCH_TYPE_SILENT, (err, data) => { + pull = data; + callback(err); + }); + }, + function (callback) { + options.title = pull.title; + options.description = pull.body; + options.submittedUser = pull.user.login; + instance.submit(options.fwd, (err, data) => { + if (err) { + callback(err); + return; + } + options.submittedPullNumber = data.number; + submittedPull = data; + callback(); + }); + }, + ]; + async.series(operations, err => { + opt_callback && opt_callback(err, submittedPull); + }); +}; +PullRequest.prototype.getPullRequest_ = function (opt_callback) { + const instance = this; + const options = instance.options; + let payload; + payload = { + number: options.number, + repo: options.repo, + user: options.user, + }; + base.github.pullRequests.get(payload, opt_callback); +}; +PullRequest.prototype.getBranchNameFromPullNumber_ = function (number) { + if (number) { + return config.pull_branch_name_prefix + number; + } +}; +PullRequest.prototype.getPullRequestNumberFromBranch_ = function (currentBranch, prefix) { + if (currentBranch && lodash_1.startsWith(currentBranch, prefix)) { + return currentBranch.replace(prefix, ''); + } +}; +PullRequest.prototype.getPullsTemplateJson_ = function (pulls, opt_callback) { + const instance = this; + const options = instance.options; + let branch; + let branches; + let json; + branches = {}; + json = { + branches: [], + }; + pulls.forEach(pull => { + branch = pull.base.ref; + if (!options.branch || options.branch === branch) { + branches[branch] = branches[branch] || []; + branches[branch].push(pull); + } + }); + Object.keys(branches).forEach(branch => { + json.branches.push({ + name: branch, + pulls: branches[branch], + total: branches[branch].length, + }); + }); + opt_callback && opt_callback(null, json); +}; +PullRequest.prototype.printPullInfo_ = function (pull) { + const options = this.options; + let status = ''; + switch (pull.combinedStatus) { + case 'success': + status = logger.colors.green(' ✓'); + break; + case 'failure': + status = logger.colors.red(' ✗'); + break; + } + var headline = `${logger.colors.green(`#${pull.number}`)} ${pull.title} ${logger.colors.magenta(`@${pull.user.login}`)} (${logger.getDuration(pull.created_at)})${status}`; + if (options.link) { + headline += ` ${logger.colors.blue(pull.html_url)}`; + } + logger.log(headline); + if (options.detailed && !options.link) { + logger.log(logger.colors.blue(pull.html_url)); + } + if (pull.mergeable_state === 'clean') { + logger.log(`Mergeable (${pull.mergeable_state})`); + } + else if (pull.mergeable_state !== undefined) { + logger.warn(`Not mergeable (${pull.mergeable_state})`); + } + if ((options.info || options.detailed) && pull.body) { + logger.log(`${pull.body}\n`); + } +}; +PullRequest.prototype.get = function (user, repo, number, opt_callback) { + const pr = this; + let payload; + payload = { + repo, + user, + number, + }; + base.github.pullRequests.get(payload, (err, pull) => { + if (err) { + logger.warn(`Can't get pull request ${user}/${repo}/${number}`); + return; + } + pr.printPullInfo_(pull); + opt_callback && opt_callback(); + }); +}; +PullRequest.prototype.list = function (user, repo, opt_callback) { + const instance = this; + let options = instance.options; + let json; + let operations; + let payload; + let pulls; + let sort; + sort = options.sort; + if (options.sort === PullRequest.SORT_COMPLEXITY) { + sort = PullRequest.SORT_CREATED; + } + payload = { + repo, + sort, + user, + direction: options.direction, + state: options.state, + }; + operations = [ + function (callback) { + base.github.pullRequests.getAll(payload, (err, data) => { + pulls = []; + if (!err) { + if (options.me) { + pulls = instance.filterPullsSentByMe_(data); + } + else { + pulls = data; + } + } + if (err && err.code === 404) { + // some times a repo is found, but you can't listen its prs + // due to the repo being disabled (e.g., private repo with debt) + logger.warn(`Can't list pull requests for ${user}/${payload.repo}`); + callback(); + } + else { + callback(err); + } + }); + }, + function (callback) { + if (options.sort && options.sort === PullRequest.SORT_COMPLEXITY) { + instance.addComplexityParamToPulls_(pulls, (err, data) => { + if (!err) { + pulls = instance.sortPullsByComplexity_(data); + } + callback(err); + }); + } + else { + callback(); + } + }, + function (callback) { + var statusOperations = []; + var statusPayload; + pulls.forEach(pull => { + statusOperations.push(callback => { + statusPayload = { + repo, + user, + sha: pull.head.sha, + }; + base.github.statuses.getCombined(statusPayload, (err, data) => { + pull.combinedStatus = data.state; + callback(err); + }); + }); + }); + async.series(statusOperations, err => { + callback(err); + }); + }, + function (callback) { + instance.getPullsTemplateJson_(pulls, (err, data) => { + if (!err) { + json = data; + } + callback(err); + }); + }, + ]; + async.series(operations, err => { + if (!err && pulls.length) { + logger.log(logger.colors.yellow(`${user}/${repo}`)); + json.branches.forEach(branch => { + logger.log(`${branch.name} (${branch.total})`); + branch.pulls.forEach(instance.printPullInfo_, instance); + }); + } + opt_callback && opt_callback(err); + }); +}; +PullRequest.prototype.listFromAllRepositories = function (opt_callback) { + const instance = this; + const options = instance.options; + let payload; + let apiMethod; + payload = { + type: 'all', + user: options.user, + per_page: 100, + }; + if (options.org) { + apiMethod = 'getFromOrg'; + payload.org = options.org; + } + else { + apiMethod = 'getAll'; + } + base.github.repos[apiMethod](payload, (err, repositories) => { + if (err) { + opt_callback && opt_callback(err); + } + else { + repositories.forEach(repository => { + instance.list(repository.owner.login, repository.name, opt_callback); + }); + } + }); +}; +PullRequest.prototype.listFromAllOrgRepositories = function (opt_callback) { + const instance = this; + const options = instance.options; + let payload; + payload = { + type: 'all', + user: options.user, + org: options.org, + per_page: 100, + }; + base.github.repos.getFromOrg(payload, (err, repositories) => { + if (err) { + opt_callback && opt_callback(err); + } + else { + repositories.forEach(repository => { + instance.list(repository.owner.login, repository.name, opt_callback); + }); + } + }); +}; +PullRequest.prototype.merge = function (opt_callback) { + const instance = this; + const options = instance.options; + let method = 'merge'; + if (options.rebase) { + method = 'rebase'; + } + git.checkout(options.branch); + git[method](options.pullBranch); + git.push(config.default_remote, options.branch); + git.deleteBranch(options.pullBranch); + opt_callback && opt_callback(); +}; +PullRequest.prototype.open = function (opt_callback) { + var instance = this; + instance.getPullRequest_((err, pull) => { + if (err) { + opt_callback && opt_callback(err); + } + else { + instance.updatePullRequest_(pull.title, pull.body, PullRequest.STATE_OPEN, opt_callback); + } + }); +}; +PullRequest.prototype.setMergeCommentRequiredOptions_ = function (opt_callback) { + const options = this.options; + const lastCommitSHA = git.getLastCommitSHA(); + const changes = git.countUserAdjacentCommits(); + options.currentSHA = lastCommitSHA; + if (changes > 0) { + options.changes = changes; + } + options.pullHeadSHA = `${lastCommitSHA}~${changes}`; + opt_callback && opt_callback(); +}; +PullRequest.prototype.sortPullsByComplexity_ = data => { + const instance = this; + const options = instance.options; + data.sort((a, b) => { + if (a.complexity > b.complexity) { + return -1; + } + if (a.complexity < b.complexity) { + return +1; + } + return 0; + }); + if (options.direction === PullRequest.DIRECTION_ASC) { + data.reverse(); + } + return data; +}; +PullRequest.prototype.submit = function (user, opt_callback) { + const instance = this; + const options = instance.options; + let operations; + let pullBranch; + pullBranch = options.pullBranch || options.currentBranch; + if (process.env.NODE_ENV === 'testing') { + pullBranch = 'test'; + } + operations = [ + function (callback) { + git.push(config.default_remote, pullBranch); + callback(); + }, + function (callback) { + if (!options.title) { + options.title = git.getLastCommitMessage(pullBranch); + } + callback(); + }, + function (callback) { + var payload = { + user, + base: options.branch, + head: `${options.user}:${pullBranch}`, + repo: options.repo, + }; + if (options.issue) { + payload.issue = options.issue; + base.github.pullRequests.createFromIssue(payload, callback); + } + else { + payload.body = options.description; + payload.title = options.title; + base.github.pullRequests.create(payload, callback); + } + }, + ]; + async.series(operations, (err, results) => { + if (err) { + instance.checkPullRequestIntegrity_(err, user, opt_callback); + } + else { + opt_callback && opt_callback(err, results[2]); + } + }); +}; +PullRequest.prototype.updatePullRequest_ = function (title, opt_body, state, opt_callback) { + const instance = this; + const options = instance.options; + let payload; + if (opt_body) { + opt_body = logger.applyReplacements(opt_body, config.replace); + } + payload = { + state, + title, + body: opt_body, + number: options.number, + repo: options.repo, + user: options.user, + }; + base.github.pullRequests.update(payload, opt_callback); +}; +PullRequest.prototype._fetchHandler = function () { + const instance = this; + const options = this.options; + let fetchType = PullRequest.FETCH_TYPE_CHECKOUT; + if (options.merge) { + fetchType = PullRequest.FETCH_TYPE_MERGE; + } + else if (options.rebase) { + fetchType = PullRequest.FETCH_TYPE_REBASE; + } + hooks.invoke('pull-request.fetch', instance, afterHooksCallback => { + let operation = ''; + let branch = options.pullBranch; + if (options.merge) { + operation = ' and merging'; + branch = options.currentBranch; + } + if (options.rebase) { + operation = ' and rebasing'; + branch = options.currentBranch; + } + logger.log(`Fetching pull request ${logger.colors.green(`#${options.number}`)}${operation} into branch ${logger.colors.green(branch)}`); + instance.fetch(fetchType, err => { + if (err) { + throw new Error(`Can't fetch pull request ${options.number}.`); + } + afterHooksCallback(); + }); + }); +}; +PullRequest.prototype._mergeHandler = function () { + const instance = this; + const options = this.options; + let operation = 'Merging'; + hooks.invoke('pull-request.merge', instance, afterHooksCallback => { + if (options.rebase) { + operation = 'Rebasing'; + } + logger.log(`${operation} pull request ${logger.colors.green(`#${options.number}`)} into branch ${logger.colors.green(options.branch)}`); + instance.merge(); + instance.setMergeCommentRequiredOptions_(afterHooksCallback); + }); +}; +PullRequest.prototype._fwdHandler = function () { + const instance = this; + const options = this.options; + hooks.invoke('pull-request.fwd', instance, afterHooksCallback => { + logger.log(`Forwarding pull request ${logger.colors.green(`#${options.number}`)} to ${logger.colors.magenta(`@${options.fwd}`)}`); + instance.forward((err, pull) => { + if (err) { + logger.error(`Can't forward pull request ${options.number} to ${options.fwd}.`); + return; + } + if (pull) { + options.forwardedPull = pull.number; + } + logger.log(pull.html_url); + instance.setMergeCommentRequiredOptions_(afterHooksCallback); + }); + }); +}; +PullRequest.prototype._closeHandler = function (done) { + const instance = this; + const options = this.options; + hooks.invoke('pull-request.close', instance, afterHooksCallback => { + logger.log(`Closing pull request ${logger.colors.green(`#${options.number}`)}`); + instance.close((err, pull) => { + if (err) { + logger.warn(`Can't close pull request ${options.number}.`); + return; + } + logger.log(pull.html_url); + instance.setMergeCommentRequiredOptions_(afterHooksCallback); + done && done(); + }); + }); +}; +PullRequest.prototype._commentHandler = function (done) { + var options = this.options; + logger.log(`Adding comment on pull request ${logger.colors.green(`#${options.number}`)}`); + this.issue.comment((err, pull) => { + if (err) { + logger.error(`Can't comment on pull request ${options.number}.`); + return; + } + logger.log(pull.html_url); + done && done(); + }); +}; +PullRequest.prototype._infoHandler = function (done) { + const instance = this; + const options = this.options; + instance.get(options.user, options.repo, options.number, err => { + if (err) { + throw new Error(`Can't get pull requests.\n${err}`); + } + done && done(); + }); +}; +PullRequest.prototype._listHandler = function (done) { + const instance = this; + const options = this.options; + let who; + options.sort = options.sort || PullRequest.SORT_CREATED; + options.direction = options.direction || PullRequest.DIRECTION_DESC; + if (options.all) { + who = options.user; + if (options.org) { + who = options.org; + } + logger.log(`Listing all ${options.state} pull requests for ${logger.colors.green(who)}`); + instance.listFromAllRepositories(err => { + if (err) { + throw new Error(`Can't list all pull requests from repos.\n${err}`); + } + done && done(); + }); + } + else { + if (options.me) { + logger.log(`Listing ${options.state} pull requests sent by ${logger.colors.green(options.loggedUser)} on ${logger.colors.green(`${options.user}/${options.repo}`)}`); + } + else { + logger.log(`Listing ${options.state} pull requests on ${logger.colors.green(`${options.user}/${options.repo}`)}`); + } + instance.list(options.user, options.repo, err => { + if (err) { + throw new Error(`Can't list pull requests.\n${err}`); + } + done && done(); + }); + } +}; +PullRequest.prototype._openHandler = function (done) { + const instance = this; + const options = this.options; + hooks.invoke('pull-request.open', instance, afterHooksCallback => { + logger.log(`Opening pull request ${logger.colors.green(`#${options.number}`)}`); + instance.open((err, pull) => { + if (err) { + logger.error(`Can't open pull request ${options.number}.`); + return; + } + logger.log(pull.html_url); + afterHooksCallback(); + done && done(); + }); + }); +}; +PullRequest.prototype._submitHandler = function (done) { + const instance = this; + const options = this.options; + hooks.invoke('pull-request.submit', instance, afterHooksCallback => { + logger.log(`Submitting pull request to ${logger.colors.magenta(`@${options.submit}`)}`); + instance.submit(options.submit, (err, pull) => { + if (err) { + var cause = 'User Not Found'; + if (err.code !== 404) { + err = JSON.parse(err.message).errors[0]; + cause = err.message ? err.message : JSON.stringify(err); + } + logger.error(`Can't submit pull request. ${cause}`); + } + if (pull) { + options.submittedPull = pull.number; + } + logger.log(pull.html_url); + instance.setMergeCommentRequiredOptions_(afterHooksCallback); + done && done(); + }); + }); +}; diff --git a/lib/cmds/repo.js b/lib/cmds/repo.js new file mode 100644 index 00000000..d3da5537 --- /dev/null +++ b/lib/cmds/repo.js @@ -0,0 +1,474 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +Object.defineProperty(exports, "__esModule", { value: true }); +// -- Requires ------------------------------------------------------------------------------------- +const fs = require("fs"); +const inquirer = require("inquirer"); +const openUrl = require("opn"); +const url = require("url"); +const base = require("../base"); +const git = require("../git"); +const hooks = require("../hooks"); +const logger = require("../logger"); +const config = base.getConfig(); +const testing = process.env.NODE_ENV === 'testing'; +// -- Constructor ---------------------------------------------------------------------------------- +function Repo(options) { + this.options = options; +} +exports.default = Repo; +// -- Constants ------------------------------------------------------------------------------------ +Repo.DETAILS = { + alias: 're', + description: 'Provides a set of util commands to work with Repositories.', + commands: ['browser', 'clone', 'delete', 'fork', 'list', 'new', 'update'], + options: { + browser: Boolean, + clone: Boolean, + color: String, + delete: String, + description: String, + detailed: Boolean, + gitignore: String, + fork: String, + homepage: String, + init: Boolean, + label: Boolean, + list: Boolean, + new: String, + organization: String, + page: String, + per_page: String, + private: Boolean, + protocol: String, + repo: String, + type: ['all', 'forks', 'member', 'owner', 'public', 'private', 'sources'], + update: String, + user: String, + }, + shorthands: { + B: ['--browser'], + c: ['--clone'], + C: ['--color'], + D: ['--delete'], + d: ['--detailed'], + f: ['--fork'], + L: ['--label'], + l: ['--list'], + N: ['--new'], + O: ['--organization'], + p: ['--private'], + P: ['--protocol'], + r: ['--repo'], + t: ['--type'], + U: ['--update'], + u: ['--user'], + }, + payload(payload, options) { + if (options.browser !== false) { + options.browser = true; + } + }, +}; +Repo.TYPE_ALL = 'all'; +Repo.TYPE_FORKS = 'forks'; +Repo.TYPE_MEMBER = 'member'; +Repo.TYPE_OWNER = 'owner'; +Repo.TYPE_PRIVATE = 'private'; +Repo.TYPE_PUBLIC = 'public'; +Repo.TYPE_SOURCES = 'sources'; +// -- Commands ------------------------------------------------------------------------------------- +Repo.prototype.run = function (done) { + const instance = this; + const options = instance.options; + let user = options.loggedUser; + instance.config = config; + if (options.browser) { + instance.browser(options.user, options.repo); + } + if (options.clone) { + hooks.invoke('repo.get', instance, afterHooksCallback => { + if (options.organization) { + user = options.organization; + } + else if (options.user) { + user = options.user; + } + if (fs.existsSync(`${process.cwd()}/${options.repo}`)) { + logger.error(`Can't clone ${logger.colors.green(`${user}/${options.repo}`)}. ${logger.colors.green(options.repo)} already exists in this directory.`); + return; + } + instance.get(user, options.repo, (err1, repo) => { + if (err1) { + logger.error(`Can't clone ${logger.colors.green(`${user}/${options.repo}`)}. ${JSON.parse(err1).message}`); + return; + } + logger.log(repo.html_url); + var repoUrl; + if (options.protocol) { + if (options.protocol === 'https') { + repoUrl = `https://github.com/${user}/${options.repo}.git`; + } + } + else { + repoUrl = `git@github.com:${user}/${options.repo}.git`; + } + if (repo) { + instance.clone_(user, options.repo, repoUrl); + } + afterHooksCallback(); + }); + }); + } + if (options.delete && !options.label) { + hooks.invoke('repo.delete', instance, afterHooksCallback => { + logger.log(`Deleting repo ${logger.colors.green(`${options.user}/${options.delete}`)}`); + if (testing) { + return _deleteHandler(true); + } + inquirer + .prompt([ + { + type: 'input', + message: 'Are you sure? This action CANNOT be undone. [y/N]', + name: 'confirmation', + }, + ]) + .then(answers => _deleteHandler(answers.confirmation.toLowerCase() === 'y')); + function _deleteHandler(proceed) { + if (proceed) { + instance.delete(options.user, options.delete, err => { + if (err) { + logger.error("Can't delete repo."); + return; + } + afterHooksCallback(); + done && done(); + }); + } + else { + logger.log('Not deleted.'); + } + } + }); + } + if (options.fork) { + hooks.invoke('repo.fork', instance, afterHooksCallback => { + if (options.organization) { + user = options.organization; + } + options.repo = options.fork; + logger.log(`Forking repo ${logger.colors.green(`${options.user}/${options.repo}`)} on ${logger.colors.green(`${user}/${options.repo}`)}`); + instance.fork((err1, repo) => { + if (err1) { + logger.error(`Can't fork. ${JSON.parse(err1).message}`); + return; + } + logger.log(repo.html_url); + if (repo && options.clone) { + instance.clone_(options.loggedUser, options.repo, repo.ssh_url); + } + afterHooksCallback(); + done && done(); + }); + }); + } + if (options.label) { + if (options.organization) { + user = options.organization; + } + else if (options.user) { + user = options.user; + } + if (options.delete) { + hooks.invoke('repo.deleteLabel', instance, afterHooksCallback => { + options.label = options.delete; + logger.log(`Deleting label ${logger.colors.green(options.label)} on ${logger.colors.green(`${user}/${options.repo}`)}`); + instance.deleteLabel(user, err1 => { + if (err1) { + logger.error("Can't delete label."); + return; + } + afterHooksCallback(); + done && done(); + }); + }); + } + else if (options.list) { + hooks.invoke('repo.listLabels', instance, afterHooksCallback => { + if (options.page) { + logger.log(`Listing labels from page ${logger.colors.green(options.page)} on ${logger.colors.green(`${user}/${options.repo}`)}`); + } + else { + logger.log(`Listing labels on ${logger.colors.green(`${user}/${options.repo}`)}`); + } + instance.listLabels(user, err1 => { + if (err1) { + logger.error("Can't list labels."); + return; + } + afterHooksCallback(); + done && done(); + }); + }); + } + else if (options.new) { + hooks.invoke('repo.createLabel', instance, afterHooksCallback => { + options.label = options.new; + logger.log(`Creating label ${logger.colors.green(options.label)} on ${logger.colors.green(`${user}/${options.repo}`)}`); + instance.createLabel(user, err1 => { + if (err1) { + throw new Error(`Can't create label.\n${err1}`); + } + afterHooksCallback(); + done && done(); + }); + }); + } + else if (options.update) { + hooks.invoke('repo.updateLabel', instance, afterHooksCallback => { + options.label = options.update; + logger.log(`Updating label ${logger.colors.green(options.label)} on ${logger.colors.green(`${user}/${options.repo}`)}`); + instance.updateLabel(user, err1 => { + if (err1) { + logger.error("Can't update label."); + return; + } + afterHooksCallback(); + done && done(); + }); + }); + } + } + if (options.list && !options.label) { + if (options.organization) { + user = options.organization; + options.type = options.type || Repo.TYPE_ALL; + } + else { + user = options.user; + options.type = options.type || Repo.TYPE_OWNER; + } + if (options.isTTY.out) { + logger.log(`Listing ${logger.colors.green(options.type)} repos for ${logger.colors.green(user)}`); + } + instance.list(user, err => { + if (err) { + logger.error("Can't list repos."); + } + done && done(); + }); + } + if (options.new && !options.label) { + hooks.invoke('repo.new', instance, afterHooksCallback => { + options.repo = options.new; + if (options.organization) { + options.user = options.organization; + } + logger.log(`Creating a new repo on ${logger.colors.green(`${options.user}/${options.new}`)}`); + instance.new((err1, repo) => { + if (err1) { + logger.error(`Can't create new repo. ${JSON.parse(err1.message).message}`); + return; + } + logger.log(repo.html_url); + if (repo && options.clone) { + instance.clone_(options.user, options.repo, repo.ssh_url); + } + afterHooksCallback(); + done && done(); + }); + }); + } +}; +Repo.prototype.browser = function (user, repo) { + !testing && openUrl(`${config.github_host}${user}/${repo}`, { wait: false }); +}; +Repo.prototype.clone_ = function (user, repo, repo_url) { + logger.log(`Cloning ${logger.colors.green(`${user}/${repo}`)}`); + git.clone(url.parse(repo_url).href, repo); +}; +Repo.prototype.createLabel = function (user, opt_callback) { + const instance = this; + const options = instance.options; + let payload; + payload = { + user, + color: options.color, + name: options.new, + repo: options.repo, + }; + console.log('payload', payload); + base.github.issues.createLabel(payload, opt_callback); +}; +Repo.prototype.delete = function (user, repo, opt_callback) { + var payload; + payload = { + user, + repo, + }; + base.github.repos.delete(payload, opt_callback); +}; +Repo.prototype.deleteLabel = function (user, opt_callback) { + const instance = this; + const options = instance.options; + let payload; + payload = { + user, + name: options.delete, + repo: options.repo, + }; + base.github.issues.deleteLabel(payload, opt_callback); +}; +Repo.prototype.get = function (user, repo, opt_callback) { + var payload; + payload = { + user, + repo, + }; + base.github.repos.get(payload, opt_callback); +}; +Repo.prototype.list = function (user, opt_callback) { + const instance = this; + let method = 'getFromUser'; + const options = instance.options; + let payload; + payload = { + user, + type: options.type, + per_page: 100, + }; + if (options.organization) { + method = 'getFromOrg'; + payload.org = options.organization; + } + if (options.type === 'public' || options.type === 'private') { + if (user === options.user) { + method = 'getAll'; + } + else { + logger.error('You can only list your own public and private repos.'); + return; + } + } + base.github.repos[method](payload, (err, repos) => { + instance.listCallback_(err, repos, opt_callback); + }); +}; +Repo.prototype.listCallback_ = function (err, repos, opt_callback) { + const instance = this; + const options = instance.options; + let pos; + let repo; + if (err && !options.all) { + logger.error(logger.getErrorMessage(err)); + } + if (repos && repos.length > 0) { + for (pos in repos) { + if (repos.hasOwnProperty(pos) && repos[pos].full_name) { + repo = repos[pos]; + logger.log(repo.full_name); + if (options.detailed) { + logger.log(logger.colors.blue(repo.html_url)); + if (repo.description) { + logger.log(logger.colors.blue(repo.description)); + } + if (repo.homepage) { + logger.log(logger.colors.blue(repo.homepage)); + } + logger.log(`last update ${logger.getDuration(repo.updated_at)}`); + } + if (options.isTTY.out) { + logger.log(`${logger.colors.green(`forks: ${repo.forks}, stars: ${repo.watchers}, issues: ${repo.open_issues}`)}\n`); + } + } + } + opt_callback && opt_callback(err); + } +}; +Repo.prototype.listLabels = function (user, opt_callback) { + const instance = this; + const options = instance.options; + let payload; + payload = { + user, + page: options.page, + per_page: options.per_page, + repo: options.repo, + }; + base.github.issues.getLabels(payload, (err, labels) => { + instance.listLabelsCallback_(err, labels, opt_callback); + }); +}; +Repo.prototype.listLabelsCallback_ = function (err, labels, opt_callback) { + const instance = this; + const options = instance.options; + if (err && !options.all) { + logger.error(logger.getErrorMessage(err)); + } + if (labels && labels.length > 0) { + labels.forEach(label => { + logger.log(logger.colors.yellow(label.name)); + }); + opt_callback && opt_callback(err); + } +}; +Repo.prototype.fork = function (opt_callback) { + const instance = this; + const options = instance.options; + let payload; + payload = { + user: options.user, + repo: options.repo, + }; + if (options.organization) { + payload.organization = options.organization; + } + base.github.repos.fork(payload, opt_callback); +}; +Repo.prototype.new = function (opt_callback) { + const instance = this; + const options = instance.options; + let payload; + let method = 'create'; + options.description = options.description || ''; + options.gitignore = options.gitignore || ''; + options.homepage = options.homepage || ''; + options.init = options.init || false; + if (options.type === Repo.TYPE_PRIVATE) { + options.private = true; + } + options.private = options.private || false; + if (options.gitignore) { + options.init = true; + } + payload = { + auto_init: options.init, + description: options.description, + gitignore_template: options.gitignore, + homepage: options.homepage, + name: options.new, + private: options.private, + }; + if (options.organization) { + method = 'createFromOrg'; + payload.org = options.organization; + } + base.github.repos[method](payload, opt_callback); +}; +Repo.prototype.updateLabel = function (user, opt_callback) { + const instance = this; + const options = instance.options; + let payload; + payload = { + user, + color: options.color, + name: options.update, + repo: options.repo, + }; + base.github.issues.updateLabel(payload, opt_callback); +}; diff --git a/lib/cmds/user.js b/lib/cmds/user.js new file mode 100644 index 00000000..591fbeb7 --- /dev/null +++ b/lib/cmds/user.js @@ -0,0 +1,164 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +Object.defineProperty(exports, "__esModule", { value: true }); +// -- Requires ------------------------------------------------------------------------------------- +const fs = require("fs"); +const inquirer = require("inquirer"); +const moment = require("moment"); +const userhome = require("userhome"); +const base = require("../base"); +const configs = require("../configs"); +const logger = require("../logger"); +const config = configs.getConfig(); +const testing = process.env.NODE_ENV === 'testing'; +// -- Constructor ---------------------------------------------------------------------------------- +function User(options) { + this.options = options; +} +exports.default = User; +// -- Constants ------------------------------------------------------------------------------------ +User.DETAILS = { + alias: 'us', + description: 'Provides the ability to login and logout if needed.', + commands: ['login', 'logout', 'whoami'], + options: { + login: Boolean, + logout: Boolean, + whoami: Boolean, + }, + shorthands: { + l: ['--login'], + L: ['--logout'], + w: ['--whoami'], + }, + payload(payload, options) { + options.login = true; + }, +}; +// -- Commands ------------------------------------------------------------------------------------- +User.prototype.run = function () { + const instance = this; + const options = instance.options; + if (options.login) { + if (User.hasCredentials()) { + logger.log(`You're logged in as ${logger.colors.green(options.user)}`); + } + } + if (options.logout) { + logger.log(`Logging out of user ${logger.colors.green(options.user)}`); + !testing && User.logout(); + } + if (options.whoami) { + logger.log(options.user); + } +}; +// -- Static --------------------------------------------------------------------------------------- +User.authorize = function () { + base.github.authenticate({ + type: 'oauth', + token: getCorrectToken(configs.getConfig()), + }); +}; +User.authorizationCallback_ = function (user, err, res) { + if (err) { + logger.error(err); + return; + } + if (res.token) { + configs.writeGlobalConfigCredentials(user, res.token); + User.authorize(); + } + logger.log('Authentication succeed.'); +}; +User.createAuthorization = function (opt_callback) { + logger.log("First we need authorization to use GitHub's API. Login with your GitHub account."); + inquirer + .prompt([ + { + type: 'input', + message: 'Enter your GitHub user', + name: 'user', + }, + { + type: 'password', + message: 'Enter your GitHub password', + name: 'password', + }, + ]) + .then(answers => { + var payload = { + note: `Node GH (${moment().format('MMMM Do YYYY, h:mm:ss a')})`, + note_url: 'https://github.com/eduardolundgren/node-gh', + scopes: ['user', 'public_repo', 'repo', 'repo:status', 'delete_repo', 'gist'], + }; + base.github.authenticate({ + type: 'basic', + username: answers.user, + password: answers.password, + }); + base.github.authorization.create(payload, (err, res) => { + const isTwoFactorAuthentication = err && err.message && err.message.indexOf('OTP') > 0; + if (isTwoFactorAuthentication) { + User.twoFactorAuthenticator_(payload, answers.user, opt_callback); + } + else { + User.authorizationCallback_(answers.user, err, res); + opt_callback && opt_callback(err); + } + }); + }); +}; +User.hasCredentials = function () { + if ((config.github_token && config.github_user) || + (process.env.GH_TOKEN && process.env.GH_USER)) { + return true; + } + return false; +}; +User.login = function (opt_callback) { + if (User.hasCredentials()) { + User.authorize(); + opt_callback && opt_callback(); + } + else { + User.createAuthorization(opt_callback); + } +}; +User.logout = function () { + configs.removeGlobalConfig('github_user'); + configs.removeGlobalConfig('github_token'); +}; +User.twoFactorAuthenticator_ = function (payload, user, opt_callback) { + inquirer + .prompt([ + { + type: 'input', + message: 'Enter your two-factor code', + name: 'otp', + }, + ]) + .then(factor => { + if (!payload.headers) { + payload.headers = []; + } + payload.headers['X-GitHub-OTP'] = factor.otp; + base.github.authorization.create(payload, (err, res) => { + User.authorizationCallback_(user, err, res); + opt_callback && opt_callback(err); + }); + }); +}; +function getCorrectToken(config) { + if (process.env.CONTINUOUS_INTEGRATION) { + return process.env.GH_TOKEN; + } + if (process.env.NODE_ENV === 'testing') { + // Load your local token when generating test fixtures + return JSON.parse(fs.readFileSync(userhome('.gh.json')).toString()).github_token; + } + return config.github_token; +} diff --git a/lib/cmds/version.js b/lib/cmds/version.js new file mode 100644 index 00000000..e9ddc035 --- /dev/null +++ b/lib/cmds/version.js @@ -0,0 +1,21 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const base = require("../base"); +const logger = require("../logger"); +function Version() { } +exports.default = Version; +Version.DETAILS = { + alias: 'v', + description: 'Print gh version.', +}; +Version.prototype.run = function () { + base.asyncReadPackages(this.printVersion); +}; +Version.prototype.printVersion = function (pkg) { + logger.log(`${pkg.name} ${pkg.version}`); +}; diff --git a/lib/configs.js b/lib/configs.js new file mode 100644 index 00000000..449c2f78 --- /dev/null +++ b/lib/configs.js @@ -0,0 +1,215 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const fs = require("fs"); +const path = require("path"); +const userhome = require("userhome"); +const which = require("which"); +const exec = require("./exec"); +const logger = require("./logger"); +let cache = {}; +let plugins; +exports.PLUGINS_PATH_KEY = 'plugins_path'; +// -- Config ------------------------------------------------------------------- +function getNodeModulesGlobalPath() { + let result; + let path = getConfig()[exports.PLUGINS_PATH_KEY]; + if (path === undefined) { + result = exec.spawnSync('npm', ['root', '-g']); + if (result.stdout) { + path = result.stdout; + process.env.NODE_ENV !== 'testing' && writeGlobalConfig(exports.PLUGINS_PATH_KEY, path); + } + else { + logger.warn("Can't resolve plugins directory path."); + } + } + return path; +} +exports.getNodeModulesGlobalPath = getNodeModulesGlobalPath; +function getProjectConfigPath() { + return path.join(process.cwd(), '.gh.json'); +} +exports.getProjectConfigPath = getProjectConfigPath; +function getUserHomePath() { + return process.env.NODE_ENV === 'testing' ? '../default.gh.json' : userhome('.gh.json'); +} +exports.getUserHomePath = getUserHomePath; +function resolveGhConfigs(opt_plugin) { + const globalConfig = getGlobalConfig(opt_plugin); + let projectConfig; + const result = {}; + try { + projectConfig = JSON.parse(fs.readFileSync(getProjectConfigPath()).toString()); + Object.keys(globalConfig).forEach(key => { + result[key] = globalConfig[key]; + }); + Object.keys(projectConfig).forEach(key => { + result[key] = projectConfig[key]; + }); + return result; + } + catch (e) { + logger.debug(e.message); + if (e.code !== 'MODULE_NOT_FOUND' && e.code !== 'ENOENT') { + throw e; + } + return globalConfig; + } +} +function getConfig(opt_plugin) { + let config = cache[opt_plugin]; + if (!config) { + config = resolveGhConfigs(opt_plugin); + cache[opt_plugin] = config; + } + const protocol = `${config.api.protocol}://`; + const is_enterprise = config.api.host !== 'api.github.com'; + if (config.github_host === undefined) { + config.github_host = `${protocol}${is_enterprise ? config.api.host : 'github.com'}/`; + } + if (config.github_gist_host === undefined) { + config.github_gist_host = `${protocol}${is_enterprise ? `${config.api.host}/gist` : 'gist.github.com'}/`; + } + return config; +} +exports.getConfig = getConfig; +function getGlobalConfig(opt_plugin) { + let defaultConfig; + let configPath; + let userConfig; + configPath = getUserHomePath(); + if (!fs.existsSync(configPath)) { + createGlobalConfig(); + } + defaultConfig = JSON.parse(fs.readFileSync(getGlobalConfigPath()).toString()); + userConfig = JSON.parse(fs.readFileSync(configPath).toString()); + Object.keys(userConfig).forEach(key => { + defaultConfig[key] = userConfig[key]; + }); + if (opt_plugin) { + getPlugins().forEach(plugin => { + addPluginConfig(defaultConfig, plugin); + }); + } + return defaultConfig; +} +exports.getGlobalConfig = getGlobalConfig; +function getGlobalConfigPath() { + return path.join(__dirname, '../default.gh.json'); +} +exports.getGlobalConfigPath = getGlobalConfigPath; +function removeGlobalConfig(key) { + var config = getGlobalConfig(); + delete config[key]; + saveJsonConfig(getUserHomePath(), config); + cache = {}; +} +exports.removeGlobalConfig = removeGlobalConfig; +function createGlobalConfig() { + saveJsonConfig(getUserHomePath(), JSON.parse(fs.readFileSync(getGlobalConfigPath()).toString())); + cache = {}; +} +exports.createGlobalConfig = createGlobalConfig; +function writeGlobalConfig(jsonPath, value) { + const config = getGlobalConfig(); + let i; + let output; + let path; + let pathLen; + path = jsonPath.split('.'); + output = config; + for (i = 0, pathLen = path.length; i < pathLen; i++) { + output[path[i]] = config[path[i]] || (i + 1 === pathLen ? value : {}); + output = output[path[i]]; + } + saveJsonConfig(getUserHomePath(), config); + cache = {}; +} +exports.writeGlobalConfig = writeGlobalConfig; +function saveJsonConfig(path, object) { + const options = { + mode: parseInt('0600', 8), + }; + fs.writeFileSync(path, JSON.stringify(object, null, 4), options); +} +exports.saveJsonConfig = saveJsonConfig; +function writeGlobalConfigCredentials(user, token) { + const configPath = getUserHomePath(); + writeGlobalConfig('github_user', user); + writeGlobalConfig('github_token', token); + logger.log(`Writing GH config data: ${configPath}`); +} +exports.writeGlobalConfigCredentials = writeGlobalConfigCredentials; +// -- Plugins ------------------------------------------------------------------ +function addPluginConfig(config, plugin) { + let pluginConfig; + let userConfig; + try { + // Always use the plugin name without prefix. To be safe removing "gh-" + // prefix from passed plugin. + plugin = getPluginBasename(plugin || process.env.NODEGH_PLUGIN); + pluginConfig = require(path.join(getNodeModulesGlobalPath(), `gh-${plugin}`, 'gh-plugin.json')); + // Merge default plugin configuration with the user's. + userConfig = config.plugins[plugin] || {}; + Object.keys(userConfig).forEach(key => { + pluginConfig[key] = userConfig[key]; + }); + config.plugins[plugin] = pluginConfig; + } + catch (e) { + if (e.code !== 'MODULE_NOT_FOUND') { + throw e; + } + } +} +exports.addPluginConfig = addPluginConfig; +function resolvePlugins() { + var pluginsPath = getNodeModulesGlobalPath(); + if (pluginsPath === '') { + return []; + } + try { + plugins = fs.readdirSync(pluginsPath).filter(plugin => { + return plugin.substring(0, 3) === 'gh-'; + }); + } + catch (e) { + plugins = []; + logger.warn("Can't read plugins directory."); + } + finally { + return plugins; + } +} +function getPlugins() { + if (!plugins) { + plugins = resolvePlugins(); + } + return plugins; +} +exports.getPlugins = getPlugins; +function getPlugin(plugin) { + plugin = getPluginBasename(plugin); + return require(getPluginPath(`gh-${plugin}`)); +} +exports.getPlugin = getPlugin; +function getPluginPath(plugin) { + return fs.realpathSync(which.sync(plugin)); +} +exports.getPluginPath = getPluginPath; +function getPluginBasename(plugin) { + return plugin && plugin.replace('gh-', ''); +} +exports.getPluginBasename = getPluginBasename; +function isPluginIgnored(plugin) { + if (getConfig().ignored_plugins.indexOf(getPluginBasename(plugin)) > -1) { + return true; + } + return false; +} +exports.isPluginIgnored = isPluginIgnored; diff --git a/lib/exec.js b/lib/exec.js new file mode 100644 index 00000000..839e7731 --- /dev/null +++ b/lib/exec.js @@ -0,0 +1,60 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const child_process = require("child_process"); +const logger = require("./logger"); +function spawnSync(cmd, args, options) { + var exec; + logger.debug(`spawnSync: ${cmd} ${args.join(' ')}`); + exec = child_process.spawnSync(cmd, args, options); + if (exec.error && exec.error.code === 'ENOENT' && process.platform === 'win32') { + logger.debug("Invoking patched sapwnSync due to Windows' libuv bug"); + exec = child_process.spawnSync(`${cmd}.cmd`, args, options); + } + return { + stdout: exec.stdout.toString().trim(), + stderr: exec.stderr.toString().trim(), + status: exec.status, + }; +} +exports.spawnSync = spawnSync; +function spawnSyncStream(cmd, args, options) { + let proc; + let err; + if (!options) { + options = {}; + } + options.stdio = ['pipe', process.stdout, process.stderr]; + logger.debug(`spawnSyncStream: ${cmd} ${args.join(' ')}`); + proc = child_process.spawnSync(cmd, args, options); + if (proc.status !== 0) { + err = new Error(); + err.code = proc.status; + err.message = `Child process terminated with error code ${err.code}`; + throw err; + } + return proc; +} +exports.spawnSyncStream = spawnSyncStream; +function execSync(cmd, options) { + if (!options) { + options = {}; + } + logger.debug(`execSync: ${cmd}`); + options.stdio = ['pipe', process.stdout, process.stderr]; + return child_process.execSync(cmd, options); +} +exports.execSync = execSync; +function execSyncInteractiveStream(cmd, options) { + if (!options) { + options = {}; + } + logger.debug(`execSyncInteractiveStream: ${cmd}`); + options.stdio = 'inherit'; + return child_process.execSync(cmd, options); +} +exports.execSyncInteractiveStream = execSyncInteractiveStream; diff --git a/lib/git.js b/lib/git.js new file mode 100644 index 00000000..c7547150 --- /dev/null +++ b/lib/git.js @@ -0,0 +1,179 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const exec = require("./exec"); +const logger = require("./logger"); +const git_command = process.env.GH_GIT_COMMAND || 'git'; +const testing = process.env.NODE_ENV === 'testing'; +function checkout(branch, newBranch) { + var args = ['checkout', branch]; + if (newBranch) { + args.push('-B', newBranch); + } + return !testing && exec.spawnSyncStream(git_command, args); +} +exports.checkout = checkout; +function clone(url, folder) { + var args = ['clone', url]; + if (folder) { + args.push(folder); + } + return !testing && exec.spawnSyncStream(git_command, args); +} +exports.clone = clone; +function _merge(branch, type) { + try { + const args = [type, branch]; + !testing && exec.spawnSyncStream(git_command, [...args]); + } + catch (err) { + if (err.code && err.code !== 0) { + !testing && exec.spawnSyncStream(git_command, [type, '--abort']); + throw err; + } + } +} +exports._merge = _merge; +function merge(branch) { + return !testing && this._merge(branch, 'merge'); +} +exports.merge = merge; +function rebase(branch) { + return !testing && this._merge(branch, 'rebase'); +} +exports.rebase = rebase; +function push(remote, branch) { + var args = ['push', remote]; + if (branch) { + args.push(branch); + } + return !testing && exec.spawnSyncStream(git_command, args); +} +exports.push = push; +function fetch(repoUrl, headBranch, pullBranch) { + var args = ['fetch', repoUrl, `${headBranch}:${pullBranch}`, '--no-tags']; + return !testing && exec.spawnSyncStream(git_command, args); +} +exports.fetch = fetch; +function countUserAdjacentCommits() { + let git; + let params; + let commits = 0; + const user = getConfig('user.name'); + let author; + do { + params = ['log', '-1', `--skip=${commits}`, '--pretty=%an']; + git = exec.spawnSync(git_command, params); + if (git.status !== 0) { + logger.error(git.stderr); + } + author = git.stdout; + commits += 1; + } while (author === user); + commits -= 1; + return commits; +} +exports.countUserAdjacentCommits = countUserAdjacentCommits; +function deleteBranch(branch) { + if (testing) { + return; + } + var git = exec.spawnSync(git_command, ['branch', '-d', branch]); + if (git.status !== 0) { + logger.debug(git.stderr); + } + return git.stdout; +} +exports.deleteBranch = deleteBranch; +function findRoot() { + return exec.spawnSync(git_command, ['rev-parse', '--show-toplevel']).stdout; +} +exports.findRoot = findRoot; +function getCommitMessage(branch, number) { + let git; + const params = ['log']; + if (!number) { + number = 1; + } + params.push(`-${number}`, '--first-parent', '--no-merges', '--pretty=%s'); + if (branch) { + params.push(branch); + } + params.push('--'); + git = exec.spawnSync(git_command, params); + if (git.status !== 0) { + logger.debug("Can't get commit message."); + return; + } + return git.stdout; +} +exports.getCommitMessage = getCommitMessage; +function getConfig(key) { + var git = exec.spawnSync(git_command, ['config', '--get', key]); + if (git.status !== 0) { + throw new Error(`No git config found for ${key}\n`); + } + return git.stdout; +} +exports.getConfig = getConfig; +function getCurrentBranch() { + var git = exec.spawnSync(git_command, ['symbolic-ref', '--short', 'HEAD']); + if (git.status !== 0) { + logger.debug("Can't get current branch."); + return; + } + return git.stdout; +} +exports.getCurrentBranch = getCurrentBranch; +function getLastCommitMessage(branch) { + return getCommitMessage(branch, 1); +} +exports.getLastCommitMessage = getLastCommitMessage; +function getLastCommitSHA() { + var git = exec.spawnSync(git_command, ['rev-parse', '--short', 'HEAD']); + if (git.status !== 0) { + throw new Error("Can't retrieve last commit."); + } + return git.stdout; +} +exports.getLastCommitSHA = getLastCommitSHA; +function getRemoteUrl(remote) { + try { + return getConfig(`remote.${remote}.url`); + } + catch (e) { + logger.debug("Can't get remote URL."); + return; + } +} +exports.getRemoteUrl = getRemoteUrl; +function getRepoFromRemoteURL(url) { + var parsed = parseRemoteUrl(url); + return parsed && parsed[1]; +} +exports.getRepoFromRemoteURL = getRepoFromRemoteURL; +function getUserFromRemoteUrl(url) { + var parsed = parseRemoteUrl(url); + return parsed && parsed[0]; +} +exports.getUserFromRemoteUrl = getUserFromRemoteUrl; +function getRepo(remote) { + return getRepoFromRemoteURL(getRemoteUrl(remote)); +} +exports.getRepo = getRepo; +function getUser(remote) { + return getUserFromRemoteUrl(getRemoteUrl(remote)); +} +exports.getUser = getUser; +function parseRemoteUrl(url) { + var parsed = /[\/:]([\w-]+)\/(.*?)(?:\.git)?$/.exec(url); + if (parsed) { + parsed.shift(); + } + return parsed; +} +exports.parseRemoteUrl = parseRemoteUrl; diff --git a/lib/hooks.js b/lib/hooks.js new file mode 100644 index 00000000..091bbee6 --- /dev/null +++ b/lib/hooks.js @@ -0,0 +1,138 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const async = require("async"); +const lodash_1 = require("lodash"); +const truncate = require("truncate"); +const configs = require("./configs"); +const exec = require("./exec"); +const logger = require("./logger"); +const config = configs.getConfig(); +function createContext(scope) { + return { + options: scope.options, + signature: config.signature, + }; +} +exports.createContext = createContext; +function getHooksArrayFromPath_(path, opt_config) { + const keys = path.split('.'); + let key = keys.shift(); + let hooks; + opt_config = opt_config || config; + hooks = opt_config.hooks || {}; + while (hooks[key]) { + hooks = hooks[key]; + key = keys.shift(); + } + return Array.isArray(hooks) ? hooks : []; +} +exports.getHooksArrayFromPath_ = getHooksArrayFromPath_; +function getHooksFromPath(path) { + const plugins = configs.getPlugins(); + let pluginHooks = []; + // First, load all core hooks for the specified path. + const hooks = getHooksArrayFromPath_(path); + // Second, search all installed plugins and load the hooks for each into + // core hooks array. + process.env.NODE_ENV !== 'testing' && + plugins.forEach(plugin => { + var pluginConfig; + plugin = configs.getPluginBasename(plugin); + if (config.plugins && !configs.isPluginIgnored(plugin)) { + pluginConfig = config.plugins[plugin]; + if (pluginConfig) { + pluginHooks = pluginHooks.concat(getHooksArrayFromPath_(path, pluginConfig)); + } + } + }); + return hooks.concat(pluginHooks); +} +exports.getHooksFromPath = getHooksFromPath; +function invoke(path, scope, opt_callback) { + const after = getHooksFromPath(`${path}.after`); + const before = getHooksFromPath(`${path}.before`); + let beforeOperations; + let afterOperations; + const options = scope.options; + let context; + if (options.hooks === false || process.env.NODEGH_HOOK_IS_LOCKED) { + opt_callback && opt_callback(lodash_1.noop); + return; + } + context = createContext(scope); + beforeOperations = [ + function (callback) { + setupPlugins_(context, 'setupBeforeHooks', callback); + }, + ]; + before.forEach(cmd => { + beforeOperations.push(wrapCommand_(cmd, context, 'before')); + }); + afterOperations = [ + function (callback) { + setupPlugins_(context, 'setupAfterHooks', callback); + }, + ]; + after.forEach(cmd => { + afterOperations.push(wrapCommand_(cmd, context, 'after')); + }); + afterOperations.push(callback => { + process.env.NODEGH_HOOK_IS_LOCKED = 'false'; + callback(); + }); + process.env.NODEGH_HOOK_IS_LOCKED = 'true'; + async.series(beforeOperations, () => { + opt_callback && + opt_callback(() => { + async.series(afterOperations); + }); + }); +} +exports.invoke = invoke; +function setupPlugins_(context, setupFn, opt_callback) { + const plugins = configs.getPlugins(); + const operations = []; + plugins.forEach(plugin => { + try { + plugin = configs.getPlugin(plugin); + } + catch (e) { + logger.warn(`Can't get ${plugin} plugin.`); + } + if (plugin && plugin[setupFn]) { + operations.push(callback => { + plugin[setupFn](context, callback); + }); + } + }); + async.series(operations, () => { + opt_callback && opt_callback(); + }); +} +exports.setupPlugins_ = setupPlugins_; +function wrapCommand_(cmd, context, when) { + return function (callback) { + var raw = logger.compileTemplate(cmd, context); + if (!raw) { + callback && callback(); + return; + } + logger.log(logger.colors.cyan('[hook]'), truncate(raw.trim(), 120)); + try { + exec.execSyncInteractiveStream(raw, { cwd: process.cwd() }); + } + catch (e) { + logger.debug(`[${when} hook failure]`); + } + finally { + logger.debug(logger.colors.cyan(`[end of ${when} hook]`)); + } + callback && callback(); + }; +} +exports.wrapCommand_ = wrapCommand_; diff --git a/lib/logger.js b/lib/logger.js new file mode 100644 index 00000000..a66c6798 --- /dev/null +++ b/lib/logger.js @@ -0,0 +1,173 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const color = require("colors/safe"); +const fs = require("fs"); +const handlebars = require("handlebars"); +const moment = require("moment"); +const path = require("path"); +const wordwrap = require("wordwrap"); +if (process.env.NODE_ENV === 'testing' || process.env.COLOR === 'false') { + color.disable(); +} +exports.colors = color; +const wrap = wordwrap.hard(0, 80); +function stripHandlebarsNewLine(str) { + return str.replace(/[\s\t\r\n](\{\{[#\/])/g, '$1'); +} +function debug(...args) { + if (!process.env.GH_VERBOSE) { + return; + } + if (typeof args[0] === 'string') { + args[0] = `DEBUG: ${args[0]}`; + console.log(...args); + return; + } + console.log('DEBUG:', ...args); +} +exports.debug = debug; +function insane(...args) { + if (!process.env.GH_VERBOSE_INSANE) { + return; + } + console.log(...args); +} +exports.insane = insane; +function error(...args) { + if (typeof args[0] === 'string') { + args[0] = `fatal: ${args[0]}`; + } + console.error(...args); + process.exit(1); +} +exports.error = error; +function warn(...args) { + args[0] = `warning: ${args[0]}`; + console.error(...args); +} +exports.warn = warn; +function log(...args) { + console.log(...args); +} +exports.log = log; +function getDuration(start, opt_end = Date.now()) { + return moment.duration(moment(start).diff(opt_end)).humanize(true); +} +exports.getDuration = getDuration; +function applyReplacements(output, replaceMap) { + var regexPattern; + for (regexPattern in replaceMap) { + if (replaceMap.hasOwnProperty(regexPattern)) { + output = output.replace(new RegExp(regexPattern, 'g'), replaceMap[regexPattern]); + } + } + return output; +} +exports.applyReplacements = applyReplacements; +function getErrorMessage(err) { + var msg; + // General normalizer + if (!err) { + return 'No error message.'; + } + if (err.errors) { + return err.errors; + } + // Normalize github api error + if (!err.message) { + return err; + } + try { + msg = JSON.parse(err.message); + } + catch (e) { + return err.message; + } + if (typeof msg === 'string') { + return msg; + } + if (msg.errors && msg.errors[0] && msg.errors[0].message) { + return msg.errors[0].message; + } + if (msg.message) { + return msg.message; + } + // Normalize git error + return err.message.replace('Command failed: fatal: ', '').trim(); +} +exports.getErrorMessage = getErrorMessage; +function compileTemplate(source, map) { + var template = handlebars.compile(source); + return applyReplacements(template(map)); +} +exports.compileTemplate = compileTemplate; +function logTemplate(source, map) { + console.log(compileTemplate(source, map || {})); +} +exports.logTemplate = logTemplate; +function logTemplateFile(file, map) { + let templatePath; + let source; + templatePath = path.join(file); + if (!fs.existsSync(templatePath)) { + templatePath = path.join(__dirname, 'cmds/templates', file); + } + source = fs.readFileSync(templatePath).toString(); + logTemplate(stripHandlebarsNewLine(source), map); +} +exports.logTemplateFile = logTemplateFile; +function registerHelper(name, callback) { + handlebars.registerHelper(name, callback); +} +exports.registerHelper = registerHelper; +function registerHelpers_() { + handlebars.registerHelper('date', date => { + return getDuration(date); + }); + handlebars.registerHelper('compareLink', function () { + const { github_host, user, repo, pullHeadSHA, currentSHA } = this.options; + return `${github_host}${user}/${repo}/compare/${pullHeadSHA}...${currentSHA}`; + }); + handlebars.registerHelper('forwardedLink', function () { + const { github_host, fwd, repo, forwardedPull } = this.options; + return `${github_host}${fwd}/${repo}/pull/${forwardedPull}`; + }); + handlebars.registerHelper('link', function () { + const { github_host, user, repo, number } = this.options; + return `${github_host}${user}/${repo}/pull/${number}`; + }); + handlebars.registerHelper('submittedLink', function () { + const { github_host, submit, repo, submittedPull } = this.options; + return `${github_host}${submit}/${repo}/pull/${submittedPull}`; + }); + handlebars.registerHelper('issueLink', function () { + const { github_host, user, repo, number } = this.options; + return `${github_host}${user}/${repo}/issues/${number}`; + }); + handlebars.registerHelper('gistLink', function () { + const { github_gist_host, loggedUser, id } = this.options; + return `${github_gist_host}${loggedUser}/${id}`; + }); + handlebars.registerHelper('repoLink', function () { + const { github_gist_host, user, repo } = this.options; + return `${github_gist_host}${user}/${repo}`; + }); + handlebars.registerHelper('wordwrap', (text, padding, stripNewLines) => { + let gutter = ''; + if (stripNewLines !== false) { + text = text.replace(/[\r\n\s\t]+/g, ' '); + } + text = wrap(text).split('\n'); + if (padding > 0) { + gutter = ' '.repeat(padding); + } + return text.join(`\n${gutter}`); + }); +} +exports.registerHelpers_ = registerHelpers_; +registerHelpers_(); diff --git a/lib/rest-api-client.js b/lib/rest-api-client.js new file mode 100644 index 00000000..2a10d890 --- /dev/null +++ b/lib/rest-api-client.js @@ -0,0 +1,117 @@ +"use strict"; +/** + * © 2013 Liferay, Inc. and Node GH contributors + * (see file: CONTRIBUTORS) + * SPDX-License-Identifier: BSD-3-Clause + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const http = require("http"); +const lodash_1 = require("lodash"); +const request = require("request"); +const url = require("url"); +const logger = require("./logger"); +class RestApiClient { + constructor(options) { + this.DEFAULT_CONFIG = { + protocol: 'https', + host: 'localhost', + port: '443', + user: 'user', + password: 'password', + base: '', + strictSSL: true, + }; + options = lodash_1.merge(this.DEFAULT_CONFIG, options); + this.options = options; + } + encode() { + return encodeURIComponent.apply(this, arguments); + } + url(pathname, query) { + const options = this.options; + const uri = url.format({ + query, + hostname: options.host, + pathname: options.base + pathname, + port: options.port, + protocol: options.protocol, + }); + return decodeURIComponent(uri); + } + authorize(p) { + const options = this.options; + if (p.oauth) { + p.oauth = options.oauth; + return; + } + if (typeof options.user === 'string') { + p.auth = { + user: options.user, + pass: options.password, + }; + } + } + request(method, path, params) { + if (typeof path === 'object') { + let args = Array.from(path); + args.unshift(method); + return this.request.apply(this, args); + } + let options = this.options; + let p = { + method, + strictSSL: options.strictSSL, + uri: this.url(path), + json: true, + followAllRedirects: true, + }; + if (params) { + p = lodash_1.merge(p, params); + } + this.authorize(p); + let id = Math.floor(Math.random() * 10000000); + let begin = new Date().getTime(); + return new Promise((resolve, reject) => { + logger.debug(`New request #${id} started at ${begin}:\n${method} ${p.uri}`); + logger.insane(p); + request(p, (error, response) => { + let end = new Date().getTime(); + logger.debug(`End of request #${id} at ${end} (${end - + begin}ms) with status code: ${response && response.statusCode}`); + if (response) { + logger.insane('Response headers:'); + logger.insane(response.headers); + logger.debug('Response body'); + logger.debug(response.body); + } + if (error) { + reject(error); + return; + } + if (response.statusCode < 200 || response.statusCode > 399) { + reject({ + response, + error: `${response.statusCode} ${http.STATUS_CODES[response.statusCode]}`, + code: response.statusCode, + msg: http.STATUS_CODES[response.statusCode], + }); + return; + } + resolve(response); + }); + }); + } + get() { + return this.request('GET', arguments); + } + post() { + return this.request('POST', arguments); + } + put() { + return this.request('PUT', arguments); + } + delete() { + return this.request('DELETE', arguments); + } +} +module.exports = RestApiClient; diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 00000000..9a390636 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,121 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const lodash_1 = require("lodash"); +const nock = require("nock"); +const nockBack = nock.back; +function prepareTestFixtures(cmdName, argv) { + let id = 0; + // These should only include the flags that you need for e2e tests + const cmds = [ + { + name: 'Issue', + flags: ['--comment', '--new', '--open', '--close', '--search', '--assign'], + }, + { + name: 'PullRequest', + flags: ['--info', '--fetch', '--comment', '--open', '--close', '--submit'], + }, + { + name: 'Gists', + flags: ['--new', '--fork', '--delete'], + }, + { + name: 'Milestone', + flags: ['--list'], + }, + { + name: 'Notifications', + }, + { + name: 'Repo', + flags: ['--list', '--new', '--fork', '--delete'], + }, + { + name: 'User', + flags: ['--logout', '--whoami'], + }, + { + name: 'Version', + flags: ['--version'], + }, + ].filter(cmd => filterByCmdName(cmd, cmdName)); + const newCmdName = formatCmdName(cmds[0], argv); + nockBack.fixtures = `${process.cwd()}/__tests__/nockFixtures`; + nockBack.setMode('record'); + const nockPromise = nockBack(`${newCmdName}.json`, { + before, + afterRecord, + }); + return () => nockPromise.then(({ nockDone }) => nockDone()).catch(err => { + throw new Error(`Nock ==> ${err}`); + }); + /* --- Normalization Functions --- */ + function normalize(value, key) { + if (!value) + return value; + if (lodash_1.isPlainObject(value)) { + return lodash_1.mapValues(value, normalize); + } + if (lodash_1.isArray(value) && lodash_1.isPlainObject(value[0])) { + return lodash_1.map(value, normalize); + } + if (key.includes('_at')) { + return '2017-10-10T16:00:00Z'; + } + if (key.includes('_count')) { + return 42; + } + if (key.includes('id')) { + return 1000 + id++; + } + if (key.includes('node_id')) { + return 'MDA6RW50aXR5MQ=='; + } + if (key.includes('url')) { + return value.replace(/[1-9][0-9]{2,10}/, '000000001'); + } + return value; + } + function afterRecord(fixtures) { + const normalizedFixtures = fixtures.map(fixture => { + delete fixture.rawHeaders; + fixture.path = stripAccessToken(fixture.path); + if (lodash_1.isArray(fixture.response)) { + fixture.response = fixture.response.slice(0, 3).map(res => { + return lodash_1.mapValues(res, normalize); + }); + } + else { + fixture.response = lodash_1.mapValues(fixture.response, normalize); + } + return fixture; + }); + return normalizedFixtures; + } + function stripAccessToken(path) { + return path.replace(/access_token(.*?)(&|$)/, ''); + } + function before(scope) { + scope.filteringPath = () => stripAccessToken(scope.path); + } +} +exports.prepareTestFixtures = prepareTestFixtures; +function filterByCmdName(cmd, cmdName) { + return cmd.name === cmdName; +} +function formatCmdName(cmd, argv) { + if (argv.length === 1) { + return cmd.name; + } + return cmd.flags.reduce((flagName, current) => { + if (flagName) { + return flagName; + } + if (argv.includes(current)) { + return concatUpper(cmd.name, current.slice(2)); + } + }, null); +} +function concatUpper(one, two) { + return `${one}${lodash_1.upperFirst(two)}`; +} diff --git a/package.json b/package.json index 1633cd13..000a5e8c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gh", "description": "GitHub command line tools.", - "version": "1.13.8", + "version": "1.13.9", "homepage": "http://nodegh.io", "author": { "name": "Eduardo Lundgren",