├── .eslintignore ├── doc ├── hooks │ ├── pre-push.txt │ └── command.txt ├── cli │ ├── help.auth.txt │ ├── help.init.txt │ ├── help.help.txt │ ├── help._hook.txt │ ├── help.txt │ └── help.config.txt └── issue-service │ └── sample.js ├── bin └── github-todos ├── .eslintrc ├── lib ├── check-env.js ├── help.js ├── ask.js ├── cli │ ├── help.js │ ├── list-services.js │ ├── auth.js │ ├── config.js │ ├── init.js │ └── _hook.js ├── fs.js ├── commands.js ├── parse-diff.js ├── git.js ├── issue-service │ ├── todotxt.js │ ├── index.js │ ├── bitbucket.js │ └── github.js ├── config.js └── todos.js ├── .github-todos-ignore ├── .gitignore ├── LICENSE ├── package.json ├── index.js └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | -------------------------------------------------------------------------------- /doc/hooks/pre-push.txt: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | {command} 4 | -------------------------------------------------------------------------------- /doc/hooks/command.txt: -------------------------------------------------------------------------------- 1 | github-todos _hook --remote="$1" || exit 1 2 | -------------------------------------------------------------------------------- /bin/github-todos: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require("../")(process.argv.slice(2)); 4 | -------------------------------------------------------------------------------- /doc/cli/help.auth.txt: -------------------------------------------------------------------------------- 1 | Generates a new oauth token for Github API 2 | 3 | Usage: ght auth [options] 4 | -------------------------------------------------------------------------------- /doc/cli/help.init.txt: -------------------------------------------------------------------------------- 1 | Install git hooks in current repository. 2 | 3 | Usage: ght init [options] 4 | -------------------------------------------------------------------------------- /doc/cli/help.help.txt: -------------------------------------------------------------------------------- 1 | Show command's usage. 2 | This help can also be triggered with "--help" option. 3 | 4 | Usage: ght help [command] 5 | -------------------------------------------------------------------------------- /doc/cli/help._hook.txt: -------------------------------------------------------------------------------- 1 | THIS COMMAND IS "PRIVATE" AND SHOULD NOT BE CALLED DIRECTLY 2 | It should be called only by git as post-commit hook 3 | 4 | Usage: ght _hook [options] 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "rules": { 6 | "no-use-before-define": 0, 7 | // Please let me align my JSON 8 | "key-spacing": 0, 9 | "no-multi-spaces": 0 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /doc/cli/help.txt: -------------------------------------------------------------------------------- 1 | Usage: ght [options] 2 | 3 | Help: ght help 4 | 5 | Commands: 6 | config manage configuration 7 | help show command's help 8 | init initialize git hook in current repository 9 | auth authenticate to Github API 10 | list-services list available issue services 11 | -------------------------------------------------------------------------------- /lib/check-env.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var which = require("which"); 4 | var Promise = require("bluebird"); 5 | 6 | 7 | module.exports = checkEnv; 8 | 9 | 10 | var whichP = Promise.promisify(which); 11 | 12 | function checkEnv () { 13 | return whichP("git").then(null, function (err) { 14 | if (process.env.DEBUG) { 15 | console.error(err.stack); 16 | } 17 | throw new Error("git command not found in PATH"); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /.github-todos-ignore: -------------------------------------------------------------------------------- 1 | write something useful 2 | write something useful please 3 | we should definitely improve this`. I don't get disturbed and let the little things aside, focusing on the main goal. 4 | …" causes an issue to be created or commented 5 | # …" then it will comment the corresponding issue 6 | …" into "TODO # …" after creating or commenting issue, all modifications being isolated in a 7 | comments (default: false) 8 | = TODO, label.FIXME = TODO, empty = disable trigger) -------------------------------------------------------------------------------- /lib/help.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var fs = require("fs"); 4 | var path = require("path"); 5 | var _ = require("lodash"); 6 | 7 | var helpDir = path.join(__dirname, "..", "doc", "cli"); 8 | 9 | function content (key) { 10 | var file = "help" + (key ? ("." + key) : "") + ".txt"; 11 | var buffer = fs.readFileSync(path.join(helpDir, file), {encoding: "utf8"}).trim(); 12 | return function () { 13 | return buffer; 14 | }; 15 | } 16 | 17 | var help = module.exports = content(); 18 | 19 | var keys = _.map(_.filter(_.invoke(fs.readdirSync(helpDir), "match", /^help\.(.*)\.txt$/)), _.property(1)); 20 | _.merge(help, _.zipObject(keys, _.map(keys, content))); 21 | -------------------------------------------------------------------------------- /lib/ask.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var inquirer = require("inquirer"); 4 | var ttys = require("ttys"); 5 | var Promise = require("bluebird"); 6 | 7 | module.exports = function (questions) { 8 | var promise = new Promise(function (resolve) { 9 | inquirer.prompt(questions, resolve, { 10 | "input": ttys.stdin, 11 | "output": ttys.stdout 12 | }); 13 | }); 14 | 15 | promise.choices = function (answer, choices) { 16 | return promise.then(function (answers) { 17 | var choice = choices[answers[answer] || "default"]; 18 | if (typeof choice === "function") { 19 | return choice(answers); 20 | } else { 21 | return choice; 22 | } 23 | }); 24 | }; 25 | 26 | return promise; 27 | }; 28 | -------------------------------------------------------------------------------- /lib/cli/help.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var help = require("../help"); 4 | 5 | exports.run = function (argv, opts) { 6 | var commandName = argv._[1]; 7 | 8 | if (commandName) { 9 | var command; 10 | try { 11 | command = require("./" + commandName); 12 | } catch (e) { 13 | command = null; 14 | } 15 | 16 | if (!command) { 17 | throw new Error("Unknown command: " + commandName); 18 | } 19 | 20 | if (!help[commandName]) { 21 | throw new Error("No help available for command: " + commandName); 22 | } 23 | 24 | if (command.config) { 25 | opts = command.config(opts); 26 | } 27 | 28 | opts.usage(help[commandName]()).showHelp(); 29 | } else { 30 | opts.showHelp(); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | 30 | # Local configuration 31 | .github-todos 32 | -------------------------------------------------------------------------------- /lib/fs.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var fs = require("fs"); 4 | var _ = require("lodash"); 5 | var Promise = require("bluebird"); 6 | 7 | var readFileStrict = _.partialRight(Promise.promisify(fs.readFile), {encoding: "utf8"}); 8 | 9 | function readFile (filename) { 10 | return readFileStrict(filename).then(null, function (err) { 11 | // convert ENOENT to null content 12 | var code = err.code || (err.cause && err.cause.code); 13 | if (code === "ENOENT") { 14 | return null; 15 | } 16 | 17 | throw err; 18 | }); 19 | } 20 | 21 | var writeFile = _.partialRight(Promise.promisify(fs.writeFile), {encoding: "utf8"}); 22 | 23 | var readDir = Promise.promisify(fs.readdir); 24 | 25 | 26 | module.exports = { 27 | "readFile": readFile, 28 | "readFileStrict": readFileStrict, 29 | "writeFile": writeFile, 30 | "readDir": readDir 31 | }; 32 | -------------------------------------------------------------------------------- /lib/commands.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var debug = require("debug")("github-todos"); 4 | var Promise = require("bluebird"); 5 | 6 | 7 | module.exports = { 8 | "load": load, 9 | "run": run 10 | }; 11 | 12 | 13 | // Safe-require command module 14 | function load (commandName) { 15 | var command; 16 | 17 | try { 18 | command = require("./cli/" + commandName); 19 | } catch (e) { 20 | debug("Error loading command", commandName, e); 21 | command = null; 22 | } 23 | 24 | return command; 25 | } 26 | 27 | // Safe-fun command 28 | function run (command, opts, conf) { 29 | // Use "new Promise" to isolate process and catch any error 30 | return new Promise(function (resolve, reject) { 31 | if (command.config) { 32 | opts = command.config(opts, conf); 33 | } 34 | 35 | Promise.cast(command.run(opts.argv, opts, conf)).then(resolve, reject); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /lib/cli/list-services.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _ = require("lodash"); 4 | 5 | var service = require("../issue-service"); 6 | 7 | 8 | exports.config = function (opts) { 9 | return opts 10 | .boolean("json") 11 | .describe("json", "Output in JSON format"); 12 | }; 13 | 14 | exports.run = function (argv) { 15 | return service.list().then(function (services) { 16 | if (argv.json) { 17 | var names = _.pluck(services, "name"); 18 | var metas = _.map(services, _.partialRight(_.omit, "name")); 19 | console.log(JSON.stringify(_.zipObject(names, metas), null, " ")); 20 | } else { 21 | _.each(services, function (meta) { 22 | console.log(meta.desc ? "%s - %s" : "%s", meta.name, meta.desc); 23 | console.log(" Repo format: %s", meta.repo || "Undocumented"); 24 | if (meta.conf) { 25 | console.log(" Configuration: %s", meta.conf.join(", ")); 26 | } 27 | }); 28 | } 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /lib/cli/auth.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var service = require("../issue-service"); 4 | var config = require("../config"); 5 | 6 | /* eslint no-process-exit:0 */ 7 | 8 | exports.config = function (opts) { 9 | return opts 10 | .boolean("force") 11 | .alias("force", "f") 12 | .describe("force", "Force re-authentication"); 13 | }; 14 | 15 | exports.run = function (argv) { 16 | return config.list().then(function (conf) { 17 | var Service = service(conf.service); 18 | 19 | if (argv.force && Service.meta.conf) { 20 | Service.meta.conf.forEach(function (option) { 21 | delete conf[option]; 22 | }); 23 | } 24 | 25 | return Service.connect(conf) 26 | .then(null, function (err) { 27 | console.error("Connection to '" + Service.meta.name + "' failed"); 28 | throw err; 29 | }) 30 | .then(function () { 31 | console.log("Connection to '" + Service.meta.name + "' succeeded"); 32 | }); 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /lib/parse-diff.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _ = require("lodash"); 4 | var diffParse = require("diff-parse"); 5 | 6 | 7 | module.exports = parseDiff; 8 | 9 | 10 | function parseDiff (string) { 11 | var parsed = diffParse(string); 12 | 13 | // FIX a bug in line numbers, seems to somehow not detect chunk and calculate lines like a drunk monkey then 14 | // I will suppose it still outputs lines in valid order, and rely on that 15 | return _.map(parsed, fixParsedDiff); 16 | } 17 | 18 | function fixParsedDiff (file) { 19 | if (file.lines[0].content[0] === "@" && !file.lines[0].chunk) { 20 | // Buggy 21 | file.lines = _.map(file.lines, fixDiffLine); 22 | } 23 | 24 | return file; 25 | } 26 | 27 | function fixDiffLine (line, index) { 28 | if (index === 0) { 29 | if (line.content[0] === "@" && !line.chunk) { 30 | line.type = "chunk"; 31 | line.chunk = true; 32 | } 33 | } else { 34 | line.ln = index; 35 | } 36 | 37 | return line; 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Nicolas Chambrier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-todos", 3 | "version": "3.1.0", 4 | "description": "Git hook to convert your TODOs into Github issues", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/naholyr/github-todos.git" 12 | }, 13 | "bin": { 14 | "github-todos": "bin/github-todos", 15 | "ght": "bin/github-todos" 16 | }, 17 | "preferGlobal": true, 18 | "author": "Nicolas Chambrier ", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/naholyr/github-todos/issues" 22 | }, 23 | "homepage": "https://github.com/naholyr/github-todos", 24 | "keywords": [ 25 | "git", 26 | "github", 27 | "issues", 28 | "todo", 29 | "hook" 30 | ], 31 | "dependencies": { 32 | "bluebird": "^2.3.11", 33 | "debug": "^2.1.0", 34 | "diff-parse": "0.0.13", 35 | "github": "^0.2.2", 36 | "ini": "^1.3.0", 37 | "inquirer": "git://github.com/naholyr/Inquirer.js", 38 | "lodash": "^2.4.1", 39 | "minimatch": "^1.0.0", 40 | "mustache": "^0.8.2", 41 | "open": "0.0.5", 42 | "optimist": "^0.6.1", 43 | "request": "^2.48.0", 44 | "todotxt": "^1.1.0", 45 | "ttys": "0.0.3", 46 | "update-notifier": "^0.2.2", 47 | "which": "^1.0.5" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/git.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var path = require("path"); 4 | var debug = require("debug")("github-todos"); 5 | var exec = require("child_process").exec; 6 | var Promise = require("bluebird"); 7 | 8 | 9 | module.exports = { 10 | "run": run, 11 | "dir": dir, 12 | "dirty": dirty, 13 | "stash": { 14 | "save": stashSave, 15 | "pop": stashPop 16 | }, 17 | "currentBranch": currentBranch 18 | }; 19 | 20 | 21 | function run (args) { 22 | if (Array.isArray(args)) { 23 | args = args.join(" "); 24 | } 25 | 26 | debug("Shell: git " + args); 27 | 28 | return new Promise(function (resolve, reject) { 29 | exec("git " + args, function (err, stdout /*, stderr */) { 30 | if (!err) { 31 | resolve((stdout || "").trim()); 32 | } else { 33 | if (stdout) { 34 | err.message += "\n" + stdout; 35 | } 36 | reject(err); 37 | } 38 | }); 39 | }); 40 | } 41 | 42 | function dir (subdir) { 43 | return run("rev-parse --git-dir").then(function (result) { 44 | return result ? path.join(result, subdir) : null; 45 | }); 46 | } 47 | 48 | function dirty () { 49 | return run("status --porcelain").then(function (stdout) { 50 | return stdout !== ""; 51 | }); 52 | } 53 | 54 | function stashSave () { 55 | return run("stash save --include-untracked"); 56 | } 57 | 58 | function stashPop () { 59 | return run("stash pop --index"); 60 | } 61 | 62 | function currentBranch () { 63 | return run("rev-parse --abbrev-ref HEAD"); 64 | } 65 | -------------------------------------------------------------------------------- /doc/cli/help.config.txt: -------------------------------------------------------------------------------- 1 | Show or edit configuration options for github-todos hook in current git repository. 2 | 3 | Usage: ght config [name] [value] [options] 4 | 5 | Local options (repository-wide, higher priority, use --global to use global scope): 6 | service Issue service (default: "github", use command "list-services" for more information) 7 | repo Repository identifier (format depends on issue service, default: extracted from origin) 8 | inject-issue Inject issue number in TODO comments (default: false) 9 | context Number of lines to include in code extract (default: 3, 0 to disable) 10 | signature Signature automatically added to issues and comments (default: link to project) 11 | case-sensitive Respect case in marker detection (default: true) 12 | label-whitespace Ignore markers not followed by a whitespace (default: true) 13 | label. Label to be added to issues triggered by (default: label.TODO = TODO, label.FIXME = TODO, empty = disable trigger) 14 | branches Comma-separated list of branches for which hook is enabled (default: "master,develop", supports wildcards) 15 | remotes Comma-separated list of remotes for which hook is enabled (default: "origin", supports wildcards) 16 | files Comma-separated list of filenames for which hook is enabled (default: "**", supports wildcards, prefix with dash to exclude, order: from less to most important) 17 | 18 | Global options (use --local to override locally to repository): 19 | confirm-create Always ask for confirmation before opening new issue (default: true) 20 | open-url Open created issues and comments in your main browser (default: false) 21 | 22 | Github related options: 23 | github.token (global) OAuth token 24 | github.host (global) API host (default: api.github.com) 25 | github.secure (global) Use HTTPS? (default: true) 26 | github.version (global) Github API version (default: 3.0.0) 27 | Note: Check https://github.com/settings/applications for "Github-Todos" to revoke access 28 | 29 | Bitbucket related options: 30 | bitbucket.key (global) OAuth consumer key 31 | bitbucket.secret (global) OAuth consumer secret 32 | Note: Check https://bitbucket.org/account/user/naholyr/api for "github-todos" to revoke access 33 | 34 | Todo.txt related options: 35 | todotxt.context Add this context to tasks 36 | todotxt.project Add this project to tasks 37 | todotxt.priority Set task priority 38 | -------------------------------------------------------------------------------- /lib/issue-service/todotxt.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _ = require("lodash"); 4 | var Path = require("path"); 5 | var fs = require("../fs"); 6 | var todotxt = require("todotxt"); 7 | 8 | 9 | // Exposed API 10 | module.exports = { 11 | "meta": { 12 | "desc": "TODO.txt issue service", 13 | "repo": "/path/to/todo.txt (default = ./todo.txt)", 14 | "conf": ["todotxt.context", "todotxt.project", "todotxt.priority"] 15 | }, 16 | 17 | "connect": connect, 18 | "findIssueByTitle": findIssueByTitle, 19 | "allIssues": allIssues, 20 | "getFileUrl": getFileUrl, 21 | "createIssue": createIssue, 22 | "commentIssue": commentIssue, 23 | "tagIssue": tagIssue, 24 | "guessRepoFromUrl": guessRepoFromUrl 25 | }; 26 | 27 | 28 | // Converters 29 | 30 | function convertIssue (item) { 31 | if (!item) { 32 | return null; 33 | } 34 | 35 | return { 36 | "type": "issue", 37 | "number": item.number, 38 | "title": item.text, 39 | "labels": item.contexts 40 | }; 41 | } 42 | 43 | function convertIssues (items) { 44 | return items.map(convertIssue); 45 | } 46 | 47 | function read (repo) { 48 | return fs.readFile(repo).then(todotxt.parse).catch(_.constant([])); 49 | } 50 | 51 | function write (items, repo) { 52 | return fs.writeFile(repo, todotxt.stringify(items)); 53 | } 54 | 55 | function getItemByNumber (items, number) { 56 | var item = _.find(items, {number: number}); 57 | if (item) { 58 | return Promise.resolve(item); 59 | } else { 60 | return Promise.reject("Task #" + number + " not found"); 61 | } 62 | } 63 | 64 | 65 | function connect (conf) { 66 | return read(conf.repo).then(function (items) { 67 | // Add conf options to object 68 | items.options = { 69 | "context": conf["todotxt.context"], 70 | "project": conf["todotxt.project"], 71 | "priority": conf["todotxt.priority"] 72 | }; 73 | 74 | return items; 75 | }); 76 | } 77 | 78 | function findIssueByTitle (items, repo, title) { 79 | title = title.toLowerCase(); 80 | return convertIssue(_.find(items, function (item) { 81 | return item && item.text.toLowerCase().indexOf(title) !== -1; 82 | })); 83 | } 84 | 85 | function allIssues (client, repo) { 86 | return fs.readFile(repo).then(todotxt.parse).then(convertIssues); 87 | } 88 | 89 | // Synchronously generate direct link to todo.txt 90 | function getFileUrl (repo, path) { 91 | return "file://" + Path.resolve(path); 92 | } 93 | 94 | // Note: body is ignored 95 | function createIssue (items, repo, title, body, labels) { 96 | var item = todotxt.item({text: title, date: new Date(), number: items.length + 1}); 97 | labels.forEach(item.addContext); 98 | 99 | if (items.options) { 100 | if (items.options.priority) { 101 | item.priority = items.options.priority; 102 | } 103 | if (items.options.context) { 104 | item.addContext(items.options.context); 105 | } 106 | if (items.options.project) { 107 | item.addProject(items.options.project); 108 | } 109 | } 110 | 111 | items.push(item); 112 | 113 | return write(items, repo).then(_.constant(convertIssue(item))); 114 | } 115 | 116 | // Unsupported: reopen issue instead 117 | function commentIssue (items, repo, number) { 118 | return getItemByNumber(items, number).then(function (item) { 119 | item.complete = false; 120 | return write(items, repo).then(_.constant(convertIssue(item))); 121 | }); 122 | } 123 | 124 | function tagIssue (items, repo, number, label) { 125 | return getItemByNumber(items, number).then(function (item) { 126 | item.addContext(label); 127 | return write(items, repo).then(_.constant(convertIssue(item))); 128 | }); 129 | } 130 | 131 | function guessRepoFromUrl () { 132 | return Path.resolve("./todo.txt"); 133 | } 134 | -------------------------------------------------------------------------------- /lib/cli/config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _ = require("lodash"); 4 | var ini = require("ini"); 5 | 6 | var config = require("../config"); 7 | 8 | 9 | exports.config = function (opts) { 10 | return opts 11 | .boolean("unset") 12 | .describe("unset", "Remove option") 13 | .boolean("json") 14 | .describe("json", "Output in JSON format") 15 | .boolean("extra") 16 | .describe("extra", "Include extra options (not used by Github-Todos)") 17 | .boolean("global") 18 | .describe("global", "Force fetching/storing option in global scope") 19 | .boolean("local") 20 | .describe("local", "Force fetching/storing option in local scope") 21 | .boolean("defaults") 22 | .describe("defaults", "Use default values instead of hiding unset options"); 23 | }; 24 | 25 | exports.run = function (argv) { 26 | if (argv.global && argv.local) { 27 | throw new Error("You cannot use '--local' and '--global' simultaneously"); 28 | } 29 | 30 | var scope = argv.global ? "global" : (argv.local ? "local" : null); 31 | var option = argv._[1]; 32 | var value = argv._[2] || ""; 33 | 34 | return config.defaults().then(function (defaults) { 35 | var keys = Object.keys(defaults); 36 | 37 | if (option && !argv.extra && !_.contains(keys, option)) { 38 | throw new Error("Unsupported option '" + option + "': use --extra to force"); 39 | } 40 | 41 | if (!option) { 42 | // list options 43 | return config.list(scope) 44 | .then(null, function (err) { 45 | if (process.env.DEBUG) { 46 | console.error(err.stack); 47 | } 48 | throw new Error("Cannot get config list, are you in a git repository?"); 49 | }) 50 | .then(function (options) { 51 | if (!argv.extra) { 52 | options = _.omit(options, function (value, key) { 53 | return !_.contains(keys, key); 54 | }); 55 | } 56 | 57 | if (argv.defaults) { 58 | options = _.merge({}, defaults, options); 59 | } 60 | 61 | if (argv.json) { 62 | console.log(JSON.stringify(options, null, " ")); 63 | } else { 64 | console.log(ini.stringify(options, {"whitespace": true})); 65 | } 66 | }); 67 | } 68 | 69 | if (argv.unset) { 70 | return config.unset(option, scope).then(null, function (err) { 71 | if (process.env.DEBUG) { 72 | console.error(err.stack); 73 | } 74 | throw new Error("Failed to unset option, are you in a git repository?"); 75 | }); 76 | } 77 | 78 | if (!value) { 79 | return config.get(option, scope) 80 | .then(null, function (err) { 81 | if (process.env.DEBUG) { 82 | console.error(err.stack); 83 | } 84 | throw new Error("Failed to get option, are you in a git repository?"); 85 | }) 86 | .then(function (value) { 87 | if (value === null) { 88 | if (!argv.defaults || defaults[option] === null) { 89 | throw new Error("Option '" + option + "' not set"); 90 | } else { 91 | value = defaults[option]; 92 | } 93 | } 94 | 95 | if (argv.json) { 96 | var result = {}; 97 | result[option] = value; 98 | console.log(JSON.stringify(result, null, " ")); 99 | } else { 100 | console.log(value); 101 | } 102 | }); 103 | } 104 | 105 | return config.set(option, value, scope) 106 | .then(null, function (err) { 107 | if (process.env.DEBUG) { 108 | console.error(err.stack); 109 | } 110 | throw new Error("Failed to set option, are you in a git repository?"); 111 | }); 112 | }); 113 | }; 114 | -------------------------------------------------------------------------------- /lib/issue-service/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var fs = require("../fs"); 4 | var _ = require("lodash"); 5 | var Promise = require("bluebird"); 6 | var debug = require("debug")("github-todos"); 7 | 8 | var config = require("../config"); 9 | 10 | 11 | module.exports = getService; 12 | 13 | function getService (service) { 14 | if (!service) { 15 | service = "github"; // hardcoded since config.defaults() became async 16 | } 17 | 18 | return wrapServiceAPI(service); 19 | } 20 | 21 | getService.list = listServices; 22 | 23 | function listServices () { 24 | return fs.readDir(__dirname) 25 | .then(_.partialRight(_.map, function (file) { 26 | if (file === "index.js") { 27 | return null; 28 | } 29 | 30 | try { 31 | var service = require("./" + file); 32 | var name = file.replace(/\.[^\.]+$/, ""); 33 | 34 | return _.merge( 35 | { "desc": name }, 36 | service.meta || {}, 37 | { "name": name } 38 | ); 39 | } catch (e) { 40 | debug("failed loading issue service", file, e); 41 | return null; 42 | } 43 | })) 44 | .then(_.filter); 45 | } 46 | 47 | 48 | var FAKE_ISSUE = { 49 | "type": "issue", 50 | "number": -1, 51 | "url": "http://nope", 52 | "title": "FAKE", 53 | "labels": [] 54 | }; 55 | 56 | var FAKE_COMMENT = { 57 | "type": "comment", 58 | "issue": -1, 59 | "url": "http://nope" 60 | }; 61 | 62 | function wrapServiceAPI (name) { 63 | var api = require("./" + name); 64 | 65 | var state = { 66 | client: null // while unconnected 67 | }; 68 | 69 | var service = { 70 | // Meta data 71 | "meta": _.merge({name: name}, api.meta), 72 | 73 | // Connection 74 | "connect": wrapDryRun(connect, dryRunConnect(state)), 75 | 76 | // Methods requiring authentication 77 | "findIssueByTitle": wrapRequireClient(wrapDryRun(api.findIssueByTitle, null), state, connect), 78 | "allIssues": wrapRequireClient(wrapDryRun(api.allIssues, []), state, connect), 79 | "createIssue": wrapRequireClient(wrapDryRun(api.createIssue, FAKE_ISSUE), state, connect), 80 | "commentIssue": wrapRequireClient(wrapDryRun(api.commentIssue, FAKE_COMMENT), state, connect), 81 | "tagIssue": wrapRequireClient(wrapDryRun(api.tagIssue, null), state, connect), 82 | 83 | // Optional validation method 84 | "validateConfig": api.validateConfig || _.constant(Promise.resolve()), 85 | 86 | // Sync methods 87 | "getFileUrl": api.getFileUrl, 88 | "guessRepoFromUrl": api.guessRepoFromUrl 89 | }; 90 | 91 | function connect (conf) { 92 | debug("service.connect"); 93 | return service.validateConfig(conf).then(function (modifiedConf) { 94 | if (typeof modifiedConf !== "object") { 95 | modifiedConf = conf; 96 | } 97 | 98 | return api.connect(conf).then(function (client) { 99 | state.client = client; 100 | return service; 101 | }); 102 | }); 103 | } 104 | 105 | return service; 106 | } 107 | 108 | function dryRunConnect (state) { 109 | return function () { 110 | state.client = {}; // make it truthy for "requireClient" 111 | return Promise.resolve(); 112 | }; 113 | } 114 | 115 | // "work" is called only if github client is connected, otherwise try to authenticate and call work 116 | // Function(…, cb) → Function(…, cb) 117 | function wrapRequireClient (work, state, connect) { 118 | return function () { 119 | var self = this; 120 | var args = Array.prototype.slice.call(arguments); 121 | 122 | if (!state.client) { 123 | debug("no client: connect before work"); 124 | return config.list().then(connect).then(function () { 125 | return work.apply(self, [state.client].concat(args)); 126 | }); 127 | } else { 128 | // Already connected: work! 129 | return work.apply(self, [state.client].concat(args)); 130 | } 131 | }; 132 | } 133 | 134 | function wrapDryRun (work, result) { 135 | return function () { 136 | if (process.env.DRY_RUN) { 137 | debug("dry-run fallback", work.name, arguments); 138 | if (typeof result === "function") { 139 | return result(); 140 | } else { 141 | return Promise.resolve(result); 142 | } 143 | } 144 | 145 | return work.apply(this, arguments); 146 | }; 147 | } 148 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* eslint no-process-exit:0 */ 4 | 5 | var optimist = require("optimist"); 6 | var ttys = require("ttys"); 7 | var _ = require("lodash"); 8 | 9 | var help = require("./lib/help"); 10 | var commands = require("./lib/commands"); 11 | var checkEnv = require("./lib/check-env"); 12 | var config = require("./lib/config"); 13 | 14 | 15 | module.exports = safeMain; 16 | 17 | 18 | // We need to regenerate optimist options if --help or -h is encountered 19 | function getOpts (processArgv) { 20 | return optimist(processArgv) 21 | .usage(help()) 22 | // Show version 23 | .boolean("version") 24 | .describe("version", "Show version and exit") 25 | // Help options (converted to help command) 26 | .boolean("h") 27 | .describe("h", "Show help") 28 | .alias("h", "help") 29 | // Update notification control 30 | .boolean("no-notifier") 31 | .describe("no-notifier", "Disable update notifier"); 32 | } 33 | 34 | // Check for package update 35 | function checkUpdate () { 36 | var pkg = require("./package.json"); 37 | require("update-notifier")({ 38 | packageName: pkg.name, 39 | packageVersion: pkg.version 40 | }).notify(); 41 | } 42 | 43 | // Show version and exit 44 | function showVersion () { 45 | var pkg = require("./package.json"); 46 | console.log(pkg.version); 47 | process.exit(0); 48 | } 49 | 50 | // Transform CLI args to convert --help && -h into help command 51 | function transformHelpArgs (processArgv) { 52 | var args = (processArgv || []).slice(); 53 | 54 | // Remove "--help" and "-h" from args 55 | var longIndex = args.indexOf("--help"); 56 | if (longIndex !== -1) { 57 | args.splice(longIndex, 1); 58 | } 59 | var shortIndex = args.indexOf("-h"); 60 | if (shortIndex !== -1) { 61 | args.splice(shortIndex, 1); 62 | } 63 | 64 | // Replace `$0 …` by `$0 help …` 65 | args.unshift("help"); 66 | 67 | return args; 68 | } 69 | 70 | // Main execution, after env has been checked 71 | function main (processArgv, conf) { 72 | 73 | // CLI input 74 | var opts = getOpts(processArgv); 75 | var argv = opts.argv; 76 | 77 | // Update notifier 78 | if (!argv["no-notifier"]) { 79 | checkUpdate(); 80 | } 81 | 82 | // Convert options "--help" and "-h" into command "help" 83 | if (argv.help) { 84 | processArgv = transformHelpArgs(processArgv); 85 | // Regenerate opts from newly forged process.argv 86 | opts = getOpts(processArgv); 87 | argv = opts.argv; 88 | } 89 | 90 | // "--version" 91 | if (argv.version) { 92 | showVersion(); 93 | } 94 | 95 | var commandName = argv._[0]; 96 | 97 | // Demand command name 98 | if (!commandName) { 99 | console.error("No command specified"); 100 | opts.showHelp(); 101 | process.exit(127); 102 | } 103 | 104 | // Load command module 105 | var command = commands.load(commandName); 106 | 107 | // Demand valid command 108 | if (!command) { 109 | console.error("Unknown command: " + commandName); 110 | opts.showHelp(); 111 | process.exit(127); 112 | } 113 | 114 | process.on("exit", function () { 115 | ttys.stdin.end(); 116 | }); 117 | 118 | // Configure opts and run command (inside a domain to catch any error) 119 | commands.run(command, opts, conf).then(ok, fail); 120 | 121 | function ok () { 122 | process.exit(0); 123 | } 124 | 125 | function fail (e) { 126 | if (e.code === "EINTERRUPT") { 127 | process.exit(127); 128 | } else { 129 | console.error("%s", e); 130 | if (process.env.DEBUG) { 131 | throw e; 132 | } 133 | process.exit(1); 134 | } 135 | } 136 | } 137 | 138 | // Main execution 139 | // processArgv = CLI args === process.argv.slice(2) 140 | function safeMain (processArgv) { 141 | // Benchmark 142 | if (process.env.TIME) { 143 | var start = Date.now(); 144 | console.log("[Github-Todos] Start…"); 145 | process.on("exit", function () { 146 | var diff = Date.now() - start; 147 | console.log("[Github-Todos] Execution time: %d ms", diff); 148 | }); 149 | } 150 | 151 | // Disabled? 152 | if (process.env.NO_GITHUB_TODOS) { 153 | console.log("[Github-Todos] Disabled from environment"); 154 | process.exit(0); 155 | return; 156 | } 157 | 158 | // Check env then execute 159 | checkEnv() 160 | .then(null, function (err) { 161 | console.error("Error found in your current environment: %s", err); 162 | if (process.env.DEBUG) { 163 | console.error(err.stack); 164 | } 165 | process.exit(2); 166 | }) 167 | .then(function () { 168 | return config.list(); 169 | }) 170 | .then(_.partial(main, processArgv)); 171 | } 172 | -------------------------------------------------------------------------------- /doc/issue-service/sample.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | Sample issue service 5 | */ 6 | 7 | // Assuming a module already exists to connect to your service API 8 | var Client = require("your-service-api-client"); 9 | 10 | // Lodash may be useful (partial, partialRight, map, filter, reduce, constant, 11 | // property… are often useful methods with promises) 12 | var _ = require("lodash"); 13 | 14 | // Promise-friendly wrapper around Inquirer 15 | var ask = require("../ask"); 16 | 17 | // Configuration layer API, should be useful to automatically save your credentials 18 | var config = require("../config"); 19 | 20 | 21 | // Exposed API 22 | module.exports = { 23 | "meta": { 24 | "desc": "Sample issue service", 25 | "repo": "user/repository", 26 | "conf": ["my-host.token"] 27 | }, 28 | 29 | "connect": connect, 30 | "findIssueByTitle": findIssueByTitle, 31 | "allIssues": allIssues, 32 | "getFileUrl": getFileUrl, 33 | "createIssue": createIssue, 34 | "commentIssue": commentIssue, 35 | "tagIssue": tagIssue, 36 | "guessRepoFromUrl": guessRepoFromUrl 37 | }; 38 | 39 | 40 | // Convert issue to Github-Todos format 41 | function convertIssue (issue) { 42 | if (!issue) { 43 | return null; 44 | } 45 | 46 | return { 47 | "type": "issue", 48 | "number": issue.number, 49 | "url": issue.url, 50 | "title": issue.title, 51 | "labels": issue.labels 52 | }; 53 | } 54 | 55 | // Convert comment to Github-Todos format 56 | function convertComment (comment) { 57 | if (!comment) { 58 | return null; 59 | } 60 | 61 | return { 62 | "type": "comment", 63 | "issue": comment.issue, 64 | "url": comment.url 65 | }; 66 | } 67 | 68 | // This implementation relies on allIssues() 69 | function findIssueByTitle (client, repo, title) { 70 | title = title.toLowerCase(); 71 | return allIssues(client, repo).then(_.partialRight(_.find, function (issue) { 72 | return issue.title.toLowerCase() === title; 73 | })); 74 | } 75 | 76 | // Assuming client.getIssues returns promise of issues 77 | function allIssues (client, repo) { 78 | return client.getIssues(repo).then(function (issues) { 79 | return issues.map(convertIssue); 80 | }); 81 | } 82 | 83 | // Assuming client.createIssue returns a promise of issue 84 | function createIssue (client, repo, title, body, labels) { 85 | return client.createIssue(repo, title, body, labels).then(convertIssue); 86 | } 87 | 88 | // Assuming client.createComment returns a promise of comment 89 | function commentIssue (client, repo, number, comment) { 90 | return client.createComment(repo, number, comment).then(convertComment); 91 | } 92 | 93 | // Assuming client.addLabel returns a promise of issue 94 | function tagIssue (client, repo, number, label) { 95 | return client.addLabel(repo, number, label).then(convertIssue); 96 | } 97 | 98 | // Synchronously generate direct link to file 99 | function getFileUrl (repo, path, sha, line) { 100 | return "https://my-host.com/" + repo + "/file/" + sha + "/" + path + "#" + line; 101 | } 102 | 103 | // Synchronously generate "repo" value from remote url 104 | function guessRepoFromUrl (url) { 105 | var match = url.match(/my-host\.com[:\/]([^\/]+\/[^\/]+?)(?:\.git)?$/); 106 | 107 | return match && match[1]; 108 | } 109 | 110 | // Assuming client.setToken sets authorization token for next calls 111 | function connect (conf) { 112 | // Instantiating API client, this is the one that will be passed to other methods 113 | var client = new Client(); 114 | 115 | if (conf["my-host.token"]) { 116 | // Authorize client 117 | client.setToken(conf["my-host.token"]); 118 | // Check token… 119 | return checkToken(client) 120 | .then(null, function () { 121 | // …and create a new one if it failed 122 | console.error("Existing token is invalid"); 123 | return createToken(client); 124 | }); 125 | } else { 126 | // No token found: create new one 127 | return createToken(client); 128 | } 129 | } 130 | 131 | // Assuming client.checkAuthorization returns a promise of anything, rejected if 132 | // authentication fails 133 | function checkToken (client) { 134 | return client.checkAuthorization().then(function () { 135 | // Authentication successful: return client back to connect() 136 | return client; 137 | }); 138 | } 139 | 140 | // Assuming client.createToken returns a promise of string 141 | function createToken (client) { 142 | return ask([ 143 | {"type": "input", "message": "Username", "name": "user"}, 144 | {"type": "password", "message": "Password", "name": "password"} 145 | ]).then(function (answers) { 146 | return client.createToken(answers.username, answers.password) 147 | .then(saveToken) 148 | .then(function (token) { 149 | // Authorize client… 150 | client.setToken(token); 151 | // …and send it back to connect() 152 | return client; 153 | }); 154 | }); 155 | } 156 | 157 | // Persist credentials to service-specific option 158 | function saveToken (token) { 159 | return config.set("my-host.token", token).then(_.constant(token)); 160 | } 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://badge.fury.io/js/github-todos.svg)](http://badge.fury.io/js/github-todos) 2 | [![Dependency Status](https://david-dm.org/naholyr/github-todos.png)](https://david-dm.org/naholyr/github-todos) 3 | [![Flattr this git repo](http://api.flattr.com/button/flattr-badge-large.png)](https://flattr.com/submit/auto?user_id=naholyr&url=https%3A%2F%2Fgithub.com%2Fnaholyr%2Fgithub-todos&title=Github-Todos&language=javascript&tags=github&category=software) 4 | [![Gittip donate](https://img.shields.io/gratipay/naholyr.svg)](https://gratipay.com/naholyr) 5 | 6 | # github-todos 7 | 8 | Github-Todos is a git hook to convert your TODOs into Github issues. 9 | 10 | You can read [the full presentation from wiki](https://github.com/naholyr/github-todos/wiki/Full-presentation) for detailed information. 11 | 12 | ## Basic usage 13 | 14 | * Install hook on your repository 15 | 16 | ```sh 17 | # If you're not using Github, set service FIRST 18 | github-todos config service bitbucket # check out github-todos list-services 19 | 20 | github-todos init 21 | ``` 22 | 23 | * Check and maybe tweak configuration 24 | 25 | ```sh 26 | github-todos config --defaults 27 | 28 | # want to enable issue injection? 29 | github-todos config inject-issue true 30 | 31 | # check configuration help 32 | github-todos help config 33 | ``` 34 | 35 | * Work, commit, push 36 | 37 | ``` 38 | [Github-Todos] Checking Github Authentication… OK 39 | [Github-Todos] Created issue #11 (do something better) - https://github.com/user/repo/issues/11 40 | [Github-Todos] Created issue #12 (add security filter) - https://github.com/user/repo/issues/12 41 | [Github-Todos] Added comment to issue #12 (add security filter) - https://github.com/user/repo/issues/11/#… 42 | [Github-Todos] Injecting issue numbers to files… 43 | [Github-Todos] Added a commit containing issue injections 44 | ``` 45 | 46 | ## Install 47 | 48 | ```sh 49 | npm install -g github-todos 50 | ``` 51 | 52 | ### Authenticate to Github 53 | 54 | ```sh 55 | github-todos auth 56 | ``` 57 | 58 | ## Configuration 59 | 60 | There seems to be a lot of options, but as this tool can have critical impact on your project (creating dumb issues, causing conflicts on workspace…) it's important for it to have conservative defaults, and for you to understand these options. 61 | 62 | Use `github-todos help config` for more details (including formats). Here is a short-list of most probably useful options: 63 | 64 | * Repository configuration: 65 | * `repo` is the repository to create issues on (format: "user/repository", default: guessed from remote origin) 66 | * `service` is the issue service (default: "github", available: "github") 67 | * `branches` are the branches on which the hook will be enabled (default: `master,develop`) 68 | * `remotes` are the remotes on which the hook will be enabled (advice: setting more than one will cause duplicate issues when you will push the same commits to different enabled remotes, default: `origin`) 69 | * `files` are the files on which the hook will be enabled (default: `**`, prefix with a dash `-` to exclude, for example `**,-vendors/**`). 70 | * Detection: 71 | * `label.` enables a marker and associates a Github label to it (default: `label.TODO=TODO` and `label.FIXME=TODO`) 72 | * `label-whitespace` forces a whitespace to be found next to marker to trigger hook (default: `true`) 73 | * `case-sensitive` forces case sensitivity (default: `false`) 74 | * Others: 75 | * `inject-issue` hook will modify your files (and commit changes, after push) to add issue number next to TODOs (default: `false`) 76 | * `confirm-create` hook will ask for user confirmation before opening any new issue (default: `true`) 77 | * `open-url` will open issues and comments in your main browser (default: `false`) 78 | * `context` is the number of line you want to include in your issue or comment body (default: `3`) 79 | 80 | ### .github-todos-ignore 81 | 82 | This file will contain all TODOs you wish to automatically ignore (false positives, issues that should not be created on purpose…). 83 | 84 | For example, if your `.github-todos-ignore` file is as follows: 85 | 86 | ``` 87 | write something useful 88 | ``` 89 | 90 | and you're about to commit the following TODOs 91 | 92 | ```diff 93 | + TODO write something useful 94 | + TODO write something useful please 95 | ``` 96 | 97 | then the first one will be simply ignored. 98 | 99 | ## Advanced usage 100 | 101 | ### Environment variables 102 | 103 | Some behavior can be altered using environment variables. Why not use CLI arguments? Because you may want to enable those options during a `git push`. For example `DRY_RUN=1 git push` to simulate safely, or `NO_GITHUB_TODOS=1 git push` for faster push. 104 | 105 | * set `DRY_RUN=1` to simulate instead of really execute: in this mode no call to Github API will occur, and issues will not be injected even if `inject-issue` option is enabled. 106 | * Note that in this mode the git hook will fail, which should abort the git command 107 | * set `NO_GITHUB_TODOS=1` to entirely disable Github-Todos. 108 | * set `SHOW_TIME=1` to display the time spent in Github-Todos (if you suspect it dramatically slows down your git push, that can be a good start). 109 | * set `DEBUG=github-todos` to show verbose internal debugging information. 110 | 111 | ### Cleanup 112 | 113 | If you want to uninstall hook for current repository: 114 | 115 | ```sh 116 | github-todos init --no-connect --uninstall 117 | ``` 118 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _ = require("lodash"); 4 | var ini = require("ini"); 5 | var path = require("path"); 6 | var Promise = require("bluebird"); 7 | 8 | var fs = require("./fs"); 9 | var pkg = require("../package.json"); 10 | var git = require("./git"); 11 | 12 | // Circular dependencies 13 | var service; 14 | setImmediate(function () { 15 | service = require("./issue-service"); 16 | }); 17 | 18 | 19 | module.exports = { 20 | "list": list, 21 | "unset": unset, 22 | "set": set, 23 | "get": get, 24 | "defaults": getDefaults, 25 | "globals": getGlobals 26 | }; 27 | 28 | var defaults = { 29 | "inject-issue": false, 30 | "confirm-create": true, 31 | "open-url": false, 32 | "service": "github", 33 | "repo": null, 34 | "context": 3, 35 | "case-sensitive": false, 36 | "signature": "(automatically generated by [Github-Todos](https://github.com/naholyr/github-todos))", 37 | "label-whitespace": true, 38 | "label.TODO": "TODO", 39 | "label.FIXME": "TODO", 40 | "github.host": "api.github.com", 41 | "github.secure": true, 42 | "github.version": "3.0.0", 43 | "branches": "develop,master", 44 | "remotes": "origin", 45 | "files": "**" 46 | }; 47 | 48 | var globals = [ 49 | "confirm-create", 50 | "open-url", 51 | "github.host", 52 | "github.secure", 53 | "github.version" 54 | ]; 55 | 56 | var booleans = [ 57 | "inject-issue", 58 | "confirm-create", 59 | "open-url", 60 | "case-sensitive", 61 | "label-whitespace", 62 | "github.secure" 63 | ]; 64 | 65 | var numbers = [ 66 | "context" 67 | ]; 68 | 69 | 70 | function getDefaults () { 71 | return service.list() 72 | .then(_.partialRight(_.pluck, "conf")) 73 | .then(_.filter) 74 | .then(_.flatten) 75 | .then(_.object) 76 | .then(_.partialRight(_.merge, defaults)); 77 | } 78 | 79 | function getGlobals () { 80 | return service.list() 81 | .then(_.partialRight(_.pluck, "conf")) 82 | .then(_.filter) 83 | .then(_.flatten) 84 | .then(_.object) 85 | .then(_.partial(_.union, globals)); 86 | } 87 | 88 | function isGlobal (option) { 89 | return getGlobals().then(_.partialRight(_.contains, option)); 90 | } 91 | 92 | function filename (scope) { 93 | if (scope === "global") { 94 | // Global configuration file 95 | var home = process.platform.match(/^win/) ? process.env.USERPROFILE : process.env.HOME; 96 | return Promise.resolve(path.join(home, ".github-todos")); 97 | } else if (scope === "local" || !scope) { 98 | // Local configuration file 99 | return git.dir(path.join("..", ".github-todos")); 100 | } else { 101 | // Specific configuration file 102 | return Promise.resolve(scope); 103 | } 104 | } 105 | 106 | // Check if an option should have a boolean value 107 | function isBoolean (key) { 108 | return _.contains(booleans, key); 109 | } 110 | 111 | // Check if an option should have a numeric value 112 | function isNumber (key) { 113 | return _.contains(numbers, key); 114 | } 115 | 116 | function asBoolean (value) { 117 | if (_.isBoolean(value)) { 118 | return value; 119 | } 120 | var iValue = parseInt(value); 121 | if (!isNaN(iValue)) { 122 | return value !== 0; 123 | } 124 | try { 125 | return !!JSON.parse(value); 126 | } catch (e) { 127 | return false; 128 | } 129 | } 130 | 131 | function read (scope) { 132 | return filename(scope) 133 | .then(fs.readFile) 134 | .then(function (content) { 135 | return ini.parse(content || ""); 136 | }) 137 | .then(_.partial(_.merge, { 138 | "_version": pkg.version 139 | })) 140 | // Here we could place some conversion logic: 141 | // if (conf._version !== pkg.version) 142 | .then(function (conf) { 143 | return _.mapValues(conf, function (v, k) { 144 | return isBoolean(k) ? asBoolean(v) : (isNumber(k) ? Number(v) : v); 145 | }); 146 | }); 147 | } 148 | 149 | function write (scope, conf) { 150 | return filename(scope).then(function (file) { 151 | return fs.writeFile(file, ini.stringify(conf)).then(_.constant(conf)); 152 | }); 153 | } 154 | 155 | function list (scope) { 156 | return scope 157 | ? read(scope) 158 | : Promise.all([read("global"), read("local")]).spread(_.merge); 159 | } 160 | 161 | /* eslint no-underscore-dangle:0 */ 162 | function _unset (option, scope) { 163 | return read(scope) 164 | .then(_.partialRight(_.omit, option)) 165 | .then(_.partial(write, scope)); 166 | } 167 | 168 | function unset (option, scope) { 169 | if (scope === "local") { 170 | // Force local 171 | return _unset(option, "local"); 172 | } else if (scope === "global") { 173 | // Force global 174 | return _unset(option, "global"); 175 | } else { 176 | // Decide depending on option preferences 177 | return isGlobal(option).then(function (glob) { 178 | if (!glob) { 179 | // Local option 180 | return _unset(option, "local"); 181 | } else { 182 | // Global option: unset globally AND locally 183 | return Promise.all([_unset(option, "global"), _unset(option, "local")]).spread(_.merge); 184 | } 185 | }); 186 | } 187 | } 188 | 189 | function get (option, scope) { 190 | // re-use list() to support scope merging 191 | return list(scope).then(_.property(option)); 192 | } 193 | 194 | function set (option, value, scope) { 195 | scope = scope 196 | ? Promise.resolve(scope) 197 | : isGlobal(option).then(function (glob) { return glob ? "global" : "local"; }); 198 | 199 | function update (file) { 200 | return read(file).then(function (conf) { 201 | conf[option] = value; 202 | return write(file, conf); 203 | }); 204 | } 205 | 206 | return scope.then(filename).then(update); 207 | } 208 | -------------------------------------------------------------------------------- /lib/issue-service/bitbucket.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var request = require("request"); 4 | var _ = require("lodash"); 5 | var Promise = require("bluebird"); 6 | var debug = require("debug")("github-todos:bitbucket"); 7 | 8 | var ask = require("../ask"); 9 | var config = require("../config"); 10 | 11 | 12 | // Exposed API 13 | module.exports = { 14 | "meta": { 15 | "desc": "Bitbucket issue service", 16 | "repo": "user/repository", 17 | "conf": ["bitbucket.key", "bitbucket.secret"] 18 | }, 19 | 20 | "connect": connect, 21 | "findIssueByTitle": findIssueByTitle, 22 | "allIssues": allIssues, 23 | "getFileUrl": getFileUrl, 24 | "createIssue": createIssue, 25 | "commentIssue": commentIssue, 26 | "tagIssue": tagIssue, 27 | "guessRepoFromUrl": guessRepoFromUrl 28 | }; 29 | 30 | 31 | // Base API tools 32 | 33 | var HOST = "https://bitbucket.org"; 34 | 35 | var getP = Promise.promisify(request.get); 36 | var postP = Promise.promisify(request.post); 37 | 38 | function responseBody (res, body) { 39 | if (res.statusCode === 204) { 40 | return null; 41 | } 42 | 43 | if (String(res.statusCode)[0] !== "2") { 44 | throw new Error(body); 45 | } 46 | 47 | if (_.isString(body) && body.length > 0) { 48 | return JSON.parse(body); 49 | } 50 | 51 | return body; 52 | } 53 | 54 | function get (path, options) { 55 | debug("GET", HOST + "/api/" + path, options); 56 | return getP(_.merge({ url: HOST + "/api/" + path }, options || {})).spread(responseBody); 57 | } 58 | 59 | function post (path, data, options) { 60 | debug("POST", HOST + "/api/" + path, data, options); 61 | return postP(_.merge({url: HOST + "/api/" + path, form: data}, options || {})).spread(responseBody); 62 | } 63 | 64 | 65 | // Converters 66 | 67 | function convertIssue (issue) { 68 | if (!issue) { 69 | return null; 70 | } 71 | 72 | return { 73 | "type": "issue", 74 | "number": issue.local_id, 75 | "url": HOST + issue.resource_uri.replace(/^\/[\d\.]+\/repositories/, ""), 76 | "title": issue.title, 77 | "labels": [] // unsupported 78 | }; 79 | } 80 | 81 | function findIssueByTitle (oauth, repo, title) { 82 | title = title.toLowerCase(); 83 | return allIssues(oauth, repo).then(_.partialRight(_.find, function (issue) { 84 | return issue.title.toLowerCase() === title; 85 | })); 86 | } 87 | 88 | function allIssues (oauth, repo) { 89 | return get("1.0/repositories/" + repo + "/issues", { "oauth": oauth }) 90 | .then(null, function (err) { 91 | if (err.message.match(/Not Found/i)) { 92 | console.error("[Github-Todos] Issues not found: have you enabled issue tracking on your repository?"); 93 | console.error("[Github-Todos] Please check https://bitbucket.org/" + repo + "/admin/issues"); 94 | throw err; 95 | } 96 | }) 97 | .then(_.property("issues")) 98 | .map(convertIssue); 99 | } 100 | 101 | function createIssue (oauth, repo, title, body /*, labels */) { 102 | var data = { 103 | "title": title, 104 | "content": body 105 | }; 106 | 107 | return post("1.0/repositories/" + repo + "/issues", data, { "oauth": oauth }).then(convertIssue); 108 | } 109 | 110 | function commentIssue (oauth, repo, number, comment) { 111 | var data = { 112 | "content": comment 113 | }; 114 | 115 | return post("1.0/repositories/" + repo + "/issues/" + number + "/comments", data, { "oauth": oauth }).then(function (comment) { 116 | return { 117 | "type": "comment", 118 | "issue": number, 119 | "url": HOST + "/" + repo + "/issue/" + number + "#comment-" + comment.comment_id 120 | }; 121 | }); 122 | } 123 | 124 | function tagIssue (/* oauth, repo, number, label */) { 125 | // Unsupported 126 | return Promise.resolve(); 127 | } 128 | 129 | // Synchronously generate direct link to file 130 | function getFileUrl (repo, path, sha, line) { 131 | return HOST + "/" + repo + "/src/" + sha + "/" + path + "#cl-" + line; 132 | } 133 | 134 | // Synchronously generate "repo" value from remote url 135 | function guessRepoFromUrl (url) { 136 | var match = url.match(/bitbucket\.org[:\/]([^\/]+\/[^\/]+?)(?:\.git)?$/); 137 | 138 | return match && match[1]; 139 | } 140 | 141 | function connect (conf) { 142 | if (conf["bitbucket.secret"] && conf["bitbucket.key"]) { 143 | var oauth = { 144 | "consumer_key": conf["bitbucket.key"], 145 | "consumer_secret": conf["bitbucket.secret"] 146 | }; 147 | 148 | return checkOAuth(oauth).then(null, createOAuth); 149 | } else { 150 | // No token found: create new one 151 | return createOAuth(); 152 | } 153 | } 154 | 155 | function checkOAuth (oauth) { 156 | return get("1.0/user", {"oauth": oauth}).then(function (user) { 157 | if (!user) { 158 | throw new Error("Authentication failed"); 159 | } 160 | 161 | return oauth; 162 | }); 163 | } 164 | 165 | function createOAuth () { 166 | return ask([ 167 | {"type": "input", "message": "Bitbucket username", "name": "user"}, 168 | {"type": "password", "message": "Bitbucket password", "name": "pass"} 169 | ]).then(function (auth) { 170 | return get("1.0/users/" + auth.user + "/consumers", { "auth": auth }) 171 | .then(_.partialRight(_.findLast, {"name": "github-todos"})) 172 | .then(function (found) { 173 | if (found) { 174 | return found; 175 | } 176 | 177 | var data = { 178 | "name": "github-todos", 179 | "description": "Github-Todos CLI" 180 | }; 181 | 182 | return post("1.0/users/" + auth.user + "/consumers", data, { "auth": auth }); 183 | }) 184 | .then(saveOAuth); 185 | }); 186 | } 187 | 188 | function saveOAuth (consumer) { 189 | return config.set("bitbucket.key", consumer.key) 190 | .then(function () { 191 | return config.set("bitbucket.secret", consumer.secret); 192 | }) 193 | .then(function () { 194 | return _.pick(consumer, ["key", "secret"]); 195 | }); 196 | } 197 | -------------------------------------------------------------------------------- /lib/cli/init.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* eslint no-process-exit:0 */ 4 | 5 | var fs = require("fs"); 6 | var path = require("path"); 7 | var Promise = require("bluebird"); 8 | var _ = require("lodash"); 9 | 10 | var git = require("../git"); 11 | var service = require("../issue-service"); 12 | var config = require("../config"); 13 | 14 | 15 | var templates = path.join(__dirname, "..", "..", "doc", "hooks"); 16 | var prePushCommand = fs.readFileSync(path.join(templates, "command.txt"), {encoding: "utf8"}).trim(); 17 | var prePushTxt = fs.readFileSync(path.join(templates, "pre-push.txt"), {encoding: "utf8"}); 18 | var prePushScript = prePushTxt.replace(/\{\s*command\s*\}/g, prePushCommand); 19 | 20 | exports.config = function (opts) { 21 | return opts 22 | .boolean("u") 23 | .alias("u", "uninstall") 24 | .describe("u", "Uninstall Github-Todos hook") 25 | .boolean("f") 26 | .alias("f", "force") 27 | .describe("f", "Force-delete hook on uninstall, or force-add command to existing hook on install") 28 | .boolean("connect") 29 | .default("connect", true) 30 | .describe("connect", "Check or create Github authentication credentials before generating hook"); 31 | }; 32 | 33 | exports.run = function (argv) { 34 | var fileP = git.dir("hooks/pre-push"); 35 | 36 | var connectP = Promise.resolve(); 37 | if (argv.connect) { 38 | console.log("[Github-Todos] To disable checking credentials on 'init', add option '--no-connect'"); 39 | connectP = config.list().then(function (conf) { 40 | return service(conf.service).connect(conf); 41 | }); 42 | } 43 | 44 | return Promise.all([fileP, connectP]).spread(function (file /*, connected */) { 45 | var found = fs.existsSync(file); 46 | var content = found && fs.readFileSync(file, {encoding: "utf8"}); 47 | var isUnmodifiedScript = content && (content.trim() === prePushScript.trim()); 48 | var commandFound = content && content.indexOf(prePushCommand) !== -1; 49 | 50 | if (argv.uninstall) { 51 | if (isUnmodifiedScript || argv.force) { 52 | removeHook(file); 53 | } else if (content && commandFound) { 54 | removeFromHook(file, content); 55 | } else { 56 | cannotUninstall(file, content); 57 | } 58 | } else { 59 | if (commandFound && !argv.force) { 60 | commandAlreadyInHook(file); 61 | } else if (found) { 62 | addToHook(file, content, commandFound); 63 | } else { 64 | createHook(file); 65 | } 66 | } 67 | 68 | if (!argv.uninstall) { 69 | 70 | try { 71 | fs.chmodSync(file, 493); // 0755 72 | } catch (e) { 73 | console.error("[Github-Todos] WARNING: Failed to `chmod 755 " + file + "`. Not that this file *must* be executable, you may want to fix this manually."); 74 | } 75 | 76 | console.log("[Github-Todos] Hook installed"); 77 | 78 | return config.get("repo", "local") 79 | .then(function (repo) { 80 | if (!repo) { 81 | console.log("[Github-Todos] Option 'repo' is not set"); 82 | return guessRepo(); 83 | } else { 84 | console.log("[Github-Todos] Option 'repo' is already set. Using '" + repo + "'"); 85 | console.log("[Github-Todos] OK"); 86 | } 87 | }) 88 | .then(null, function (err) { 89 | console.error("[Github-Todos] %s", err); 90 | console.error("[Github-Todos] Failed to fetch 'repo' option"); 91 | return guessRepo(); 92 | }); 93 | 94 | } else { 95 | console.log("[Github-Todos] OK"); 96 | } 97 | }); 98 | }; 99 | 100 | 101 | function guessRepo () { 102 | console.log("[Github-Todos] Now guessing initial configuration from remote 'origin'…"); 103 | 104 | function fail (msg) { 105 | return function (err) { 106 | if (err) { 107 | console.error("[Github-Todos] %s", err); 108 | if (process.env.DEBUG) { 109 | console.error(err.stack || err); 110 | } 111 | } 112 | 113 | console.error("[Github-Todos] Initial configuration failed: %s", msg); 114 | console.error("[Github-Todos] Run 'github-todos config repo \"/\"' to enable hook"); 115 | 116 | throw err || new Error("Interrupt"); 117 | }; 118 | } 119 | 120 | function ok (repo) { 121 | return function () { 122 | console.log("[Github-Todos] Will use repository '%s'", repo); 123 | console.log("[Github-Todos] Run 'github-todos config' to check configuration, you may want to customize 'repo' option"); 124 | console.log("[Github-Todos] OK"); 125 | }; 126 | } 127 | 128 | function getRepoFromUrl (url) { 129 | return config.get("service") 130 | .then(null, fail("could not fetch option 'service'")) 131 | .then(function (serviceName) { 132 | return Promise.resolve((service(serviceName).guessRepoFromUrl || _.noop)(url)).then(function (repo) { 133 | return [url, repo]; 134 | }); 135 | }); 136 | } 137 | 138 | function saveRepo (url, repo) { 139 | if (!repo) { 140 | return fail("could not guess repo from url '" + url + "'"); 141 | } 142 | 143 | return config.set("repo", repo, "local") 144 | .then(null, fail("could not save configuration, please run 'github-todos config repo \"" + repo + "\"'")) 145 | .then(ok(repo)); 146 | } 147 | 148 | return git.run("config --local remote.origin.url") 149 | .then(null, fail("could not fetch remote 'origin' url")) 150 | .then(getRepoFromUrl) 151 | .spread(saveRepo); 152 | } 153 | 154 | function createHook (file) { 155 | console.log("[Github-Todos] Hook file not found, create new one…"); 156 | fs.writeFileSync(file, prePushScript); 157 | } 158 | 159 | function addToHook (file, content, unsafe) { 160 | if (unsafe) { 161 | console.error("[Github-Todos] Hook file found, github-todos command found."); 162 | console.error("[Github-Todos] Execution forced by option --force: add github-todos command on top anyway…"); 163 | } else { 164 | console.log("[Github-Todos] Hook file found, add github-todos command on top…"); 165 | } 166 | 167 | var lines = content.split("\n"); 168 | lines.splice(1, 0, "\n" + prePushCommand + "\n"); 169 | content = lines.join("\n"); 170 | 171 | fs.writeFileSync(file, content); 172 | } 173 | 174 | function removeFromHook (file, content) { 175 | console.log("[Github-Todos] Hook file found, removing github-todos command…"); 176 | 177 | content = content.split("\n").map(function (line, index) { 178 | if (line.indexOf(prePushCommand) !== -1) { 179 | console.log("[Github-Todos] Remove line " + (index + 1) + ": " + line); 180 | return ""; 181 | } 182 | 183 | return line; 184 | }).join("\n"); 185 | 186 | fs.writeFileSync(file, content); 187 | } 188 | 189 | function removeHook (file) { 190 | console.log("[Github-Todos] Hook file found, unmodified, remove hook…"); 191 | 192 | fs.unlinkSync(file); 193 | } 194 | 195 | function commandAlreadyInHook (file) { 196 | console.error("[Github-Todos] Hook file found, github-todos command found."); 197 | console.error("[Github-Todos] Use option --force to add command to hook anyway."); 198 | console.error("[Github-Todos] You may want to insert command '" + prePushCommand + "' manually: edit '" + file + "'"); 199 | 200 | process.exit(5); 201 | } 202 | 203 | function cannotUninstall (file, hasContent) { 204 | if (hasContent) { 205 | console.error("[Github-Todos] Hook file found but command not found, cannot uninstall."); 206 | } else { 207 | console.error("[Github-Todos] Hook file not found, cannot uninstall."); 208 | } 209 | console.error("[Github-Todos] You may want to uninstall manually: edit '" + file + "'"); 210 | 211 | process.exit(5); 212 | } 213 | -------------------------------------------------------------------------------- /lib/issue-service/github.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var Github = require("github"); 4 | var _ = require("lodash"); 5 | var Promise = require("bluebird"); 6 | var debug = require("debug")("github-todos:github"); 7 | 8 | var ask = require("../ask"); 9 | var config = require("../config"); 10 | 11 | /** 12 | * Format of an issue: 13 | * * type: String - "issue" 14 | * * number: Number 15 | * * url: String 16 | * * title: String 17 | * * labels: [String] 18 | * 19 | * Format of a comment: 20 | * * type: String - "comment" 21 | * * issue: Number 22 | * * url: String 23 | **/ 24 | 25 | 26 | module.exports = { 27 | "meta": { 28 | "desc": "Github issue service", 29 | "repo": "user/repository", 30 | "conf": ["github.token"] 31 | }, 32 | 33 | "connect": connect, 34 | "findIssueByTitle": findIssueByTitle, 35 | "allIssues": allIssues, 36 | "getFileUrl": getFileUrl, 37 | "createIssue": createIssue, 38 | "commentIssue": commentIssue, 39 | "tagIssue": tagIssue, 40 | "guessRepoFromUrl": guessRepoFromUrl, 41 | "validateConfig": validateConfig 42 | }; 43 | 44 | 45 | // String → {user, repo} 46 | function extractRepo (repo) { 47 | var parts = repo.split("/"); 48 | 49 | return { 50 | "user": parts[0], 51 | "repo": parts[1] 52 | }; 53 | } 54 | 55 | // Grab first issue with given title 56 | // String, String ~→ Issue 57 | function findIssueByTitle (client, repo, title) { 58 | debug("findIssueByTitle", repo, title); 59 | title = title.toLowerCase(); 60 | return allIssues(client, repo).then(_.partialRight(_.find, function (issue) { 61 | return issue.title.toLowerCase() === title; 62 | })); 63 | } 64 | 65 | // Convert a Github issue into a lighter object 66 | // Object → Issue 67 | function fromGithubIssue (issue) { 68 | if (!issue) { 69 | return null; 70 | } 71 | 72 | return { 73 | "type": "issue", 74 | "number": issue.number, 75 | "url": issue.html_url, 76 | "title": issue.title, 77 | "labels": _.pluck(issue.labels, "name") 78 | }; 79 | } 80 | 81 | // String ~→ [Issue] 82 | function allIssues (client, repo) { 83 | debug("allIssues"); 84 | 85 | return client.repoIssues(extractRepo(repo)).then(_.partialRight(_.map, fromGithubIssue)); 86 | } 87 | 88 | // Generate Github URL to blob 89 | // String, String, Sha, Number → String 90 | function getFileUrl (repo, path, sha, line) { 91 | var url = "https://github.com/" + repo + "/blob/"; 92 | 93 | if (sha) { 94 | url += sha + "/"; 95 | } 96 | 97 | url += path; 98 | 99 | if (line) { 100 | url += "#L" + line; 101 | } 102 | 103 | return url; 104 | } 105 | 106 | // String, String, String, [String] ~→ Issue 107 | function createIssue (client, repo, title, body, labels) { 108 | debug("createIssue", repo, title, body); 109 | 110 | return client.createIssue(_.merge(extractRepo(repo), { 111 | "title": title, 112 | "body": body, 113 | "labels": labels 114 | })).then(fromGithubIssue); 115 | } 116 | 117 | // String, Number, String ~→ Comment 118 | function commentIssue (client, repo, number, comment) { 119 | debug("commentIssue", repo, number, comment); 120 | 121 | return client.commentIssue(_.merge(extractRepo(repo), { 122 | "number": number, 123 | "body": comment 124 | })).then(function (comment) { 125 | return { 126 | "type": "comment", 127 | "issue": number, 128 | "url": comment.html_url 129 | }; 130 | }); 131 | } 132 | 133 | // Add a label (append, not replace) 134 | // String, Number, String ~→ Issue 135 | function tagIssue (client, repo, number, label) { 136 | debug("tagIssue", repo, number, label); 137 | 138 | client.getIssue(_.merge(extractRepo(repo), { 139 | "number": number 140 | })).then(function (issue) { 141 | var labels = _.pluck(issue.labels, "name"); 142 | if (!_.contains(labels, label)) { 143 | return client.updateIssue(_.merge(extractRepo(repo), { 144 | "number": number, 145 | "labels": labels.concat([label]) 146 | })).then(fromGithubIssue); 147 | } else { 148 | return fromGithubIssue(issue); 149 | } 150 | }); 151 | } 152 | 153 | // Authenticate to Github (will enable all other APIs) 154 | // Object ~→ void 155 | function connect (conf) { 156 | debug("connect", conf); 157 | 158 | return config.defaults().then(function (defaults) { 159 | conf = _.merge({}, defaults, conf || {}); 160 | 161 | var client = new Github({ 162 | "debug": false, 163 | "host": conf["github.host"], 164 | "protocol": conf["github.secure"] ? "https" : "http", 165 | "version": conf["github.version"] 166 | }); 167 | 168 | var token = conf["github.token"]; 169 | 170 | if (token) { 171 | debug("token found: authenticate", token); 172 | client.authenticate({ 173 | type: "oauth", 174 | token: token 175 | }); 176 | 177 | return checkToken(client); 178 | } 179 | 180 | return getToken(client); 181 | }); 182 | } 183 | 184 | function promisifyClient (client) { 185 | return { 186 | "repoIssues": Promise.promisify(client.issues.repoIssues, client.issues), 187 | "createIssue": Promise.promisify(client.issues.create, client.issues), 188 | "commentIssue": Promise.promisify(client.issues.createComment, client.issues), 189 | "getIssue": Promise.promisify(client.issues.getRepoIssue, client.issues), 190 | "updateIssue": Promise.promisify(client.issues.edit, client.issues) 191 | }; 192 | } 193 | 194 | // Check if OAuth token is still working with a simple API call 195 | // Sets CLIENT (this enables "requireClient" functions) 196 | // Client ~→ void 197 | function checkToken (client) { 198 | debug("checkToken"); 199 | return Promise.promisify(client.user.get, client.user)({}) 200 | .then(null, function (err) { 201 | console.error("Failed to validate Github OAuth token: please check API access (network?) or force re-authentication with 'github-todos auth --force'"); 202 | throw err; 203 | }) 204 | .then(function () { 205 | // Store client for next API calls 206 | return promisifyClient(client); 207 | }); 208 | } 209 | 210 | // Authenticate then stores OAuth token to user's configuration for later use 211 | // Client ~→ void 212 | function getToken (client) { 213 | debug("getToken"); 214 | console.log("No token found to access Github API. I will now ask for your username and password to generate one."); 215 | console.log("Those information ARE NOT STORED, only the generated token will be stored in your global git configuration."); 216 | console.log("If you don't want to let this process go you'll have to generate a token yourself and then save it with 'github-todos config github.token '."); 217 | 218 | return ask([ 219 | {"type": "input", "message": "Github username", "name": "user"}, 220 | {"type": "password", "message": "Github password", "name": "password"} 221 | ]).then(function (answers) { 222 | 223 | client.authenticate({ 224 | "type": "basic", 225 | "username": answers.user, 226 | "password": answers.password 227 | }); 228 | 229 | var payload = { 230 | "note": "Github-Todos (" + (new Date()) + ")", 231 | "note_url": "https://github.com/naholyr/github-todos", 232 | "scopes": ["user", "repo"] 233 | }; 234 | 235 | return Promise.promisify(client.authorization.create, client.authorization)(payload) 236 | .then(null, function (err) { 237 | if (err && err.code === 401 && err.message && err.message.indexOf("OTP") !== -1) { 238 | // Two-factor authentication 239 | console.log("You are using two-factor authentication, please enter your code to finish:"); 240 | return twoFactorAuth(client, payload); 241 | } else { 242 | throw err; 243 | } 244 | }) 245 | .then(saveToken(client)); 246 | }); 247 | } 248 | 249 | function saveToken (client) { 250 | return function (res) { 251 | if (!res || !res.token) { 252 | throw new Error("No token generated"); 253 | } 254 | 255 | return config.set("github.token", res.token).then(function () { 256 | client.authenticate({ 257 | type: "oauth", 258 | token: res.token 259 | }); 260 | 261 | return promisifyClient(client); 262 | }); 263 | }; 264 | } 265 | 266 | function twoFactorAuth (client, payload) { 267 | return ask([{"type": "input", "message": "Code", "name": "code"}]).then(function (answers) { 268 | _.merge(payload, { 269 | "headers": { 270 | "X-GitHub-OTP": answers.code 271 | } 272 | }); 273 | 274 | return Promise.promisify(client.authorization.create, client.authorization)(payload); 275 | }); 276 | } 277 | 278 | function guessRepoFromUrl (url) { 279 | var match = url.match(/github\.com[:\/]([^\/]+\/[^\/]+?)(?:\.git)?$/); 280 | 281 | return match && match[1]; 282 | } 283 | 284 | function validateConfig (conf) { 285 | if (!conf.repo || !_.isString(conf.repo) || !conf.repo.match(/^[^\/]+\/[^\/]+$/)) { 286 | return Promise.reject(new Error("'repo': expected format '" + module.exports.meta.repo + "'")); 287 | } 288 | 289 | return Promise.resolve(conf); 290 | } 291 | -------------------------------------------------------------------------------- /lib/todos.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * Format of a todo: 5 | * * file: String - Relative path 6 | * * sha: String - Commit's sha 7 | * * line: Number - File line number 8 | * * title: String - Issue's title 9 | * * label: String - Issue's label 10 | * * issue: Number - (optional) Issue's number 11 | **/ 12 | 13 | var path = require("path"); 14 | var _ = require("lodash"); 15 | var Promise = require("bluebird"); 16 | 17 | var ask = require("./ask"); 18 | var fs = require("./fs"); 19 | var config = require("./config"); 20 | var service = require("./issue-service"); 21 | var git = require("./git"); 22 | 23 | 24 | module.exports = { 25 | "fromDiff": fromDiff 26 | }; 27 | 28 | 29 | var SKIP_FILE_NAME = ".github-todos-ignore"; 30 | 31 | 32 | function readIgnoreFile () { 33 | return git.dir(path.join("..", SKIP_FILE_NAME)) 34 | .then(function (filename) { 35 | return fs.readFile(filename).then(function (content) { 36 | var ignored = _.filter(_.invoke((content || "").split("\n"), "trim")); 37 | return [ignored, filename]; 38 | }); 39 | }); 40 | } 41 | 42 | function shouldSkip (title, caseSensitive) { 43 | title = String(title || "").trim(); 44 | if (caseSensitive) { 45 | title = title.toLowerCase(); 46 | } 47 | 48 | return readIgnoreFile().spread(function (ignores) { 49 | if (caseSensitive) { 50 | ignores = _.invoke(ignores, "toLowerCase"); 51 | } 52 | 53 | return _.contains(ignores, title); 54 | }); 55 | } 56 | 57 | function createOrCommentIssue (repo, todo, conf) { 58 | if (todo.issue) { 59 | return commentIssue(todo, conf); 60 | } 61 | 62 | // Create issue? 63 | return shouldSkip(todo.title, conf["case-sensitive"]).then(function (skip) { 64 | if (skip) { 65 | return null; 66 | } 67 | 68 | var Service = service(conf.service); 69 | 70 | return Service.findIssueByTitle(repo, todo.title).then(function (issue) { 71 | if (!issue) { 72 | return createIssue(repo, todo, conf); 73 | } 74 | 75 | // comment issue 76 | todo.issue = issue.number; 77 | 78 | var ops = [commentIssue(repo, todo, conf)]; 79 | 80 | if (!_.contains(issue.labels, todo.label)) { 81 | ops.push(Service.tagIssue(repo, todo.issue, todo.label)); 82 | } 83 | 84 | return Promise.all(ops).spread(function (comment /*, tagresult */) { 85 | return comment; 86 | }); 87 | }); 88 | }); 89 | } 90 | 91 | // Add line to github-todos-ignore 92 | function rememberSkip (title) { 93 | return readIgnoreFile().spread(function (ignores, skipFile) { 94 | if (!_.contains(ignores, title)) { 95 | return fs.writeFile(skipFile, ignores.concat([title]).join("\n")); 96 | } 97 | }); 98 | } 99 | 100 | function createIssue (repo, todo, conf) { 101 | if (!conf["confirm-create"]) { 102 | return create(); 103 | } 104 | 105 | return ask([{ 106 | "type": "expand", 107 | "message": "Create new issue \"" + todo.title + "\" (" + todo.file + ":" + todo.line + ")", 108 | "name": "choice", 109 | "choices": [ 110 | {"key": "y", "name": "Create issue", "value": "create"}, 111 | {"key": "e", "name": "Edit title and create issue", "value": "edit"}, 112 | {"key": "n", "name": "Do not create issue", "value": "skip"}, 113 | {"key": "r", "name": "Do not create issue and remember for next times", "value": "skip_and_remember"}, 114 | {"key": "q", "name": "Abort", "value": "abort"} 115 | ], 116 | "default": 0 117 | }]).choices("choice", { 118 | "create": create, 119 | "edit": edit, 120 | "skip_and_remember": skipAndRemember, 121 | "abort": abort, 122 | "skip": null, // skip 123 | "default": null // skip 124 | }); 125 | 126 | function abort () { 127 | var e = new Error("User aborted"); 128 | e.code = "EINTERRUPT"; 129 | throw e; 130 | } 131 | 132 | function skipAndRemember () { 133 | return rememberSkip(todo.title).then(null, function (err) { 134 | console.error("[Github-Todos] Failed adding info to '" + SKIP_FILE_NAME + "'"); 135 | throw err; 136 | }); 137 | } 138 | 139 | function create (forceTitle) { 140 | return getCommentText(repo, todo, conf).then(function (text) { 141 | var title = (typeof forceTitle === "string" && forceTitle) ? forceTitle : todo.title; 142 | return service(conf.service).createIssue(repo, title, text, [todo.label]).then(function (issue) { 143 | return issue; 144 | }); 145 | }); 146 | } 147 | 148 | function edit () { 149 | return ask([{ 150 | "type": "input", 151 | "message": "Issue title", 152 | "name": "title", 153 | "default": todo.title 154 | }]).then(function (answers) { 155 | return create(answers.title); 156 | }); 157 | } 158 | } 159 | 160 | function commentIssue (repo, todo, conf) { 161 | return getCommentText(repo, todo, conf).then(function (text) { 162 | return service(conf.service).commentIssue(repo, todo.issue, text); 163 | }); 164 | } 165 | 166 | function getCommentText (repo, todo, conf) { 167 | var text = ""; 168 | 169 | // Link to file 170 | text += "Ref. [" + todo.file + ":" + todo.line + "](" + service(conf.service).getFileUrl(repo, todo.file, todo.sha, todo.line) + ")"; 171 | 172 | function generateCommentText (content) { 173 | var lines = content.split(/\r\n|\r|\n/); 174 | 175 | // Remove trailing new lines 176 | while (lines[lines.length - 1] === "") { 177 | lines.pop(); 178 | } 179 | while (lines[0] === "") { 180 | lines.shift(); 181 | } 182 | 183 | if (conf.context > 0) { 184 | // Extract: line to line + conf.context 185 | var extract = lines.slice(todo.line - 1, todo.line + conf.context).join("\n"); 186 | if (todo.line + conf.context < lines.length) { 187 | extract += "\n…"; 188 | } 189 | 190 | text += "\n\n```" + getLanguage(todo.file, content) + "\n" + extract + "\n```\n"; 191 | } 192 | 193 | if (conf.signature) { 194 | text += "\n" + conf.signature; 195 | } 196 | 197 | return text; 198 | } 199 | 200 | // Add code information 201 | return git.dir(path.join("..", todo.file)) 202 | .then(fs.readFileStrict) 203 | .then(generateCommentText); 204 | } 205 | 206 | // Language detection: very naive only based on filename for now 207 | function getLanguage (filename /*, content */) { 208 | var index = filename.lastIndexOf("."); 209 | if (index === -1) { 210 | return ""; 211 | } 212 | 213 | return filename.substring(index + 1); 214 | } 215 | 216 | function fromDiff (repo, diff, sha, conf) { 217 | return config.defaults().then(function (defaults) { 218 | conf = _.merge({ "onProgress": _.noop }, defaults, conf || {}); 219 | 220 | var todos = _.flatten(_.map(diff, function (file) { 221 | var addedLines = _.filter(file.lines, "add"); 222 | var lineTodos = _.map(addedLines, lineToTodoMapper(file.to, sha, conf)); 223 | // keep only those with required field 224 | return _.filter(lineTodos, "title"); 225 | })); 226 | 227 | return todos.reduce(function (previous, todo) { 228 | return previous.then(todoHandler(repo, todo, conf)); 229 | }, Promise.resolve([])).then(function (results) { 230 | return [results, todos]; 231 | }); 232 | }); 233 | } 234 | 235 | function todoHandler (repo, todo, conf) { 236 | return function (results) { 237 | return createOrCommentIssue(repo, todo, conf) 238 | .then(function (result) { 239 | conf.onProgress(null, result, todo); 240 | return results.concat([result]); 241 | }) 242 | .then(null, function (err) { 243 | conf.onProgress(err, null, todo); 244 | throw err; 245 | }); 246 | }; 247 | } 248 | 249 | // String, Sha → String → {file, sha, line, title, label} 250 | function lineToTodoMapper (filename, sha, conf) { 251 | return function lineToTodo (line) { 252 | return _.merge({ 253 | "file": filename, 254 | "sha": sha, 255 | "line": line.ln 256 | }, extractTodoTitle(line.content, conf)); 257 | }; 258 | } 259 | 260 | // String → {title, label} 261 | function extractTodoTitle (content, conf) { 262 | var result = null; 263 | 264 | var labels = {}; 265 | _.each(conf, function (value, key) { 266 | if (value && key.match(/^label\./)) { 267 | var trigger = key.substring(6); 268 | if (conf["label-whitespace"]) { 269 | trigger += " "; 270 | } 271 | labels[trigger] = value; 272 | } 273 | }); 274 | 275 | if (_.isString(content)) { 276 | _.find(Object.keys(labels), function (trigger) { 277 | var index; 278 | if (conf["case-sensitive"]) { 279 | index = content.indexOf(trigger); 280 | } else { 281 | index = content.toUpperCase().indexOf(trigger.toUpperCase()); 282 | } 283 | 284 | if (index !== -1) { 285 | var title = content.substring(index + trigger.length).trim(); 286 | var issue = null; 287 | if (title && !isCode(title)) { 288 | var match = title.match(/^\s+#(\d+)\s+/); 289 | if (match) { 290 | issue = match[1]; 291 | title = title.substring(match[0].length); 292 | } 293 | result = { 294 | "title": title, 295 | "label": labels[trigger], 296 | "issue": Number(issue) 297 | }; 298 | } 299 | return true; // break 300 | } 301 | }); 302 | } 303 | 304 | return result; 305 | } 306 | 307 | // TODO Better heuristic for code vs words detection 308 | 309 | // Simple heuristic to detect if a title is really a title or some valid code 310 | // String → Boolean 311 | function isCode (string) { 312 | // If symbols are more than 20% of the code, it may be code more than human text 313 | var symbols = _.filter(string, isSymbol); 314 | 315 | return symbols.length / string.length > 0.20; 316 | } 317 | 318 | var RE_SYMBOL = /[^\sa-z0-9\u00E0-\u00FC]/i; 319 | // Matches a symbol: non alphanumeric character 320 | // Character → Boolean 321 | function isSymbol (character) { 322 | return RE_SYMBOL.test(character); 323 | } 324 | -------------------------------------------------------------------------------- /lib/cli/_hook.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* eslint no-process-exit:0 */ 4 | 5 | var parseDiff = require("../parse-diff"); 6 | var _ = require("lodash"); 7 | var minimatch = require("minimatch"); 8 | var debug = require("debug")("github-todos"); 9 | var open = require("open"); 10 | var Promise = require("bluebird"); 11 | 12 | var fs = require("../fs"); 13 | var git = require("../git"); 14 | var todos = require("../todos"); 15 | var config = require("../config"); 16 | 17 | 18 | exports.config = function (opts) { 19 | return opts 20 | .string("r") 21 | .default("r", "origin") 22 | .alias("r", "remote") 23 | .describe("r", "Remote to which the push is being done") 24 | .string("R") 25 | .alias("R", "range") 26 | .describe("R", "Commits range to analyze, will expect git-hook data from standard input otherwise"); 27 | }; 28 | 29 | exports.run = function (argv, opts, conf) { 30 | return config.defaults().then(function (defaults) { 31 | conf = _.merge({}, defaults, conf); 32 | 33 | if (process.env.DRY_RUN) { 34 | console.log("[Github-Todos] DRY_RUN set: no modifications to filesystem, no calls to Github API"); 35 | } 36 | 37 | if (!checkRemote(conf.remotes, argv.remote)) { 38 | console.log("[Github-Todos] Hook disabled for remote '" + argv.remote + "' (you may check option 'remotes')"); 39 | return Promise.resolve(); 40 | } 41 | 42 | return checkBranch(conf.branches).spread(function (enabled, branch) { 43 | if (enabled) { 44 | return main(argv, conf); 45 | } else { 46 | console.log("[Github-Todos] Hook disabled for branch '" + branch + "' (you may check option 'branches')"); 47 | } 48 | }); 49 | }); 50 | }; 51 | 52 | // Check if hook is enabled for requested remote 53 | // String, String → Boolean 54 | function checkRemote (remotes, remote) { 55 | if (remotes === "ALL") { 56 | return true; 57 | } 58 | 59 | if (!_.isArray(remotes)) { 60 | remotes = _.invoke(remotes.split(","), "trim"); 61 | } 62 | 63 | return _.any(remotes, _.partial(minimatch, remote)); 64 | } 65 | 66 | // Check if hook is enabled on current branch 67 | // String ~→ Boolean 68 | function checkBranch (branches, cb) { 69 | if (branches === "ALL") { 70 | return cb(null, true); 71 | } 72 | 73 | if (!_.isArray(branches)) { 74 | branches = _.invoke(branches.split(","), "trim"); 75 | } 76 | 77 | return git.currentBranch().then(function (branch) { 78 | return [_.any(branches, _.partial(minimatch, branch)), branch]; 79 | }); 80 | } 81 | 82 | // Main process 83 | // Object, Object → void 84 | function main (argv, conf) { 85 | if (!conf.repo) { 86 | console.error("[Github-Todos] Mandatory option 'repo' not set, where am I supposed to create issues?"); 87 | console.error("[Github-Todos] Run 'github-todos config repo \"/\"' to enable hook"); 88 | process.exit(1); 89 | } 90 | 91 | if (argv.range) { 92 | 93 | // Extract target SHA 94 | var targetCommit = _.last(argv.range.split("..")); 95 | return git.run("rev-parse '" + targetCommit + "'").then(function (sha) { 96 | return analyzeRange(argv.range, sha.trim(), conf); 97 | }); 98 | 99 | } else { 100 | 101 | // Receive git-hook data from stdin 102 | return new Promise(function (resolve, reject) { 103 | var analyzing = []; 104 | 105 | process.stdin.on("data", function (chunk) { 106 | var info = chunk.toString("utf8").split(/\s+/); 107 | var promise = onCommitRange(info[1], info[3], conf); 108 | analyzing.push(promise); 109 | }); 110 | 111 | process.stdin.on("close", function () { 112 | Promise.all(analyzing).then(resolve, reject); 113 | }); 114 | }); 115 | 116 | } 117 | } 118 | 119 | // String, Sha, String, Sha → void 120 | function onCommitRange (localSha, remoteSha, conf) { 121 | var range = localSha; // all local commits until localSha 122 | 123 | if (localSha.match(/^0+$/)) { 124 | // Delete branch: skip 125 | return Promise.resolve(); 126 | } 127 | 128 | if (!remoteSha.match(/^0+$/)) { 129 | // Remote branch exists: build range 130 | range = remoteSha + ".." + localSha; 131 | } 132 | 133 | return analyzeRange(range, localSha, conf); 134 | } 135 | 136 | // Sha..Sha, Sha → void 137 | function analyzeRange (range, targetSha, conf) { 138 | return git.run("diff -u " + range).then(function (diff) { 139 | var filesPatterns = conf.files; 140 | if (!_.isArray(filesPatterns)) { 141 | filesPatterns = filesPatterns.split(","); 142 | } 143 | filesPatterns = _.invoke(filesPatterns, "trim"); 144 | 145 | return analyzeDiff(_.filter(parseDiff(diff), function (file) { 146 | if (!file.to) { 147 | debug("Ignore deletion", file.from); 148 | return false; 149 | } 150 | 151 | var ignored = isIgnored(file.to, filesPatterns); 152 | if (ignored === null) { 153 | // Could not decide: hardcoded default = safe = ignore file 154 | ignored = true; 155 | } 156 | 157 | if (ignored) { 158 | debug("Ignore", file.to); 159 | } 160 | return !ignored; 161 | }), targetSha, conf); 162 | }); 163 | } 164 | 165 | function isIgnored (filename, patterns) { 166 | // from right to left 167 | return _.reduceRight(patterns, function (ignored, pattern) { 168 | if (ignored !== null) { 169 | // already decided previously 170 | return ignored; 171 | } 172 | 173 | // negative pattern? 174 | if (pattern[0] === "-") { 175 | if (minimatch(filename, pattern.substring(1).trimLeft())) { 176 | return true; // ignore file 177 | } 178 | } else { 179 | if (minimatch(filename, pattern)) { 180 | return false; // accept file 181 | } 182 | } 183 | 184 | return null; 185 | }, null); 186 | } 187 | 188 | // String, Sha → void 189 | function analyzeDiff (diff, sha, conf) { 190 | var options = _.merge({ 191 | "onProgress": function onProgress (err, result, todo) { 192 | if (err) { 193 | console.error("[Github-Todos] Error", err); 194 | } else if (result && result.type === "comment") { 195 | console.log("[Github-Todos] Added comment to issue #%s (%s) - %s", result.issue, result.title, result.url); 196 | } else if (result && result.type === "issue") { 197 | console.log("[Github-Todos] Created issue #%s (%s) - %s", result.number, result.title, result.url); 198 | } else if (!result) { 199 | console.log("[Github-Todos] Skipped - \"%s\"", todo.title); 200 | } else { 201 | console.error("[Github-Todos] Unknown result", result); 202 | } 203 | if (result && result.url && conf["open-url"]) { 204 | open(result.url); 205 | } 206 | } 207 | }, conf); 208 | 209 | function done (/* injectedIssues */) { 210 | if (process.env.DRY_RUN) { 211 | console.error("[Github-Todos] Dry Run: Aborting git push"); 212 | process.exit(123); 213 | } else { 214 | console.log("[Github-Todos] OK."); 215 | } 216 | } 217 | 218 | return todos.fromDiff(conf.repo, diff, sha, options).spread(function (results, todos) { 219 | if (!conf["inject-issue"]) { 220 | return false; 221 | } 222 | 223 | console.log("[Github-Todos] Injecting issue numbers to files…"); 224 | 225 | return git.dirty().then(null, function (err) { 226 | // Ignore error but warn user 227 | if (process.env.DEBUG) { 228 | console.error(err.stack || err); 229 | } 230 | console.error("[Github-Todos] Warning: could not check if repository is dirty"); 231 | return false; 232 | }).then(function (dirty) { 233 | var injects = generateInjects(todos, results); 234 | 235 | if (process.env.DRY_RUN) { 236 | throw new Error("Simulated execution (DRY_RUN set)"); 237 | } 238 | 239 | return stash(dirty)() 240 | .then(injectIssues(injects)) 241 | .then(add) 242 | .then(commit) 243 | .then(unstash(dirty)) 244 | .then(null, function (err) { 245 | console.error("[Github-Todos] Warning: failed injecting issues, you may need to do it manually in following files:"); 246 | console.error("[Github-Todos] %s", err); 247 | _.each(injects, function (inject) { 248 | console.log("[Github-Todos] * %s, line %s: Issue #%s", inject.file, inject.line, inject.issue); 249 | }); 250 | }); 251 | }); 252 | }).then(done); 253 | } 254 | 255 | function stash (dirty) { 256 | return function () { 257 | return dirty ? git.stash.save() : Promise.resolve(); 258 | }; 259 | } 260 | 261 | function generateInjects (todos, results) { 262 | return _.map(todos, function (todo, i) { 263 | var result = results[i]; 264 | var issue = (result.type === "issue") ? result.number : result.issue; 265 | 266 | return { 267 | "file": todo.file, 268 | "line": todo.line, 269 | "title": todo.title, 270 | "issue": issue 271 | }; 272 | }); 273 | } 274 | 275 | function injectIssues (injects) { 276 | return function () { 277 | return Promise.all(injects.map(injectIssue)); 278 | }; 279 | } 280 | 281 | function injectIssue (inject) { 282 | return fs.readFile(inject.file).then(function (content) { 283 | var lines = content.split("\n"); 284 | var line = lines[inject.line - 1]; 285 | var index = line.indexOf(inject.title); 286 | if (index === -1) { 287 | return Promise.resolve(); 288 | } 289 | 290 | var head = line.substring(0, index); 291 | var matchIssue = head.match(/#(\d+)(\s+)$/); 292 | if (matchIssue && String(inject.issue) === matchIssue[1]) { 293 | // Already added 294 | return Promise.resolve(); 295 | } 296 | 297 | var rest = line.substring(index); 298 | line = head + "#" + inject.issue + " " + rest; 299 | lines[inject.line - 1] = line; 300 | content = lines.join("\n"); 301 | 302 | return fs.writeFile(inject.file, content); 303 | }); 304 | } 305 | 306 | function add () { 307 | return git.run("add ."); 308 | } 309 | 310 | function commit () { 311 | return git.run("commit -m '[Github-Todos] Inject issue numbers'") 312 | .then(function () { 313 | console.log("[Github-Todos] Added a commit containing issue injections"); 314 | }) 315 | .then(null, function (err) { 316 | console.error("[Github-Todos] Failed to commit"); 317 | throw err; 318 | }); 319 | } 320 | 321 | function unstash (dirty) { 322 | return function () { 323 | return (dirty ? git.stash.pop() : Promise.resolve()) 324 | .then(null, function (err) { 325 | console.error("[Github-Todos] Warning: could not run 'git stash pop'"); 326 | console.error("[Github-Todos] %s", err); 327 | console.error("[Github-Todos] You may want to remove commit (`git reset --soft HEAD~1`) and clean conflicts before running `git stash pop` manually"); 328 | throw err; 329 | }); 330 | }; 331 | } 332 | --------------------------------------------------------------------------------