├── .gitignore ├── .jshintignore ├── .jshintrc ├── README.md ├── example.js ├── index.js ├── package.json ├── router.js └── views ├── index.jade └── repo.jade /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test.js 3 | -------------------------------------------------------------------------------- /.jshintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "asi": false, 3 | "expr": true, 4 | "loopfunc": true, 5 | "curly": false, 6 | "evil": true, 7 | "white": true, 8 | "undef": true, 9 | "browser": true, 10 | "es5": true, 11 | "predef": [ 12 | "app", 13 | "$", 14 | "FormBot", 15 | "socket", 16 | "confirm", 17 | "alert", 18 | "require", 19 | "__dirname", 20 | "process", 21 | "exports", 22 | "console", 23 | "Buffer", 24 | "module", 25 | "describe", 26 | "it", 27 | "before" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | GitEnforcer 2 | ----------- 3 | 4 | GitEnforcer is a small bot that you would run on your own server to monitor github pull requests. It comes with a very basic interface to allow you to watch or unwatch your repos. Any time a pull request is created, updated, or commented on, all defined middleware are run. If any middleware fails, the pull request status is set to failed with the reason returned by that failing middleware. If they all pass, the merge button remains green. 5 | 6 | Configuration 7 | ============= 8 | 9 | Configuration is an object containing the following parameters 10 | * username - the username to authenticate as in github 11 | * password - the password associated with the username (it only uses basic auth) 12 | * organization (optional) - if you want to monitor an organization rather than a single user, specify one here 13 | * baseUrl - the base url (including hostname and port) of gitenforcer, i.e. http://enforcer.yourserver.com:8000 14 | * pollInterval (optional) - if specified, in seconds, how often to poll github for new repositories to add to the admin page. if not specified, polling will not take place. 15 | 16 | Middleware 17 | ========== 18 | 19 | Middleware are functions defined as 20 | 21 | ```javascript 22 | function myMiddleware(pull_request, comments, next) { } 23 | ``` 24 | 25 | The pull_request object contains all the metadata github returns for a pull request as defined [here](http://developer.github.com/v3/pulls/#get-a-single-pull-request) 26 | 27 | Comments is an array of comments on that pull request as defined [here](http://developer.github.com/v3/issues/comments/#list-comments-on-an-issue) 28 | 29 | Next is the callback function you should run when your check is complete. If you return no parameter, GitEnforcer will continue execution on the next middleware. If you specify a paramater (as a string) then execution of middleware stops, and that string is set as the reason for failure on the pull request's status. 30 | 31 | Usage 32 | ===== 33 | 34 | ```javascript 35 | var gitenforcer = require('gitenforcer'), 36 | app = gitenforcer(config); 37 | 38 | app.listen(3000); 39 | ``` 40 | 41 | For basic usage, see example.js 42 | 43 | To watch or unwatch a repo, visit the server in your browser. 44 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | var gitenforcer = require('./index'), 2 | // note that you can specify either 'user' or 'org' depending on if you want to watch a username or an organization 3 | app = gitenforcer({ baseUrl: 'YOURBASEURL', token: 'YOURTOKEN', user: 'YOURUSER' }); 4 | 5 | // middleware to check for occurrences of the word "bacon" in comments 6 | function checkVotes(pull_request, comments, next) { 7 | var count = 0; 8 | // iterate over comments and check the body contents 9 | comments.forEach(function (comment) { 10 | if (comment.body.match('bacon')) { 11 | count += 1; 12 | } 13 | }); 14 | // found less than 5 occurrences, so let's set the status as failed by returning a parameter 15 | if (count < 5) return next('bacon was only found ' + count + ' times'); 16 | // everything's ok, return no parameter and the next middleware will run 17 | next(); 18 | } 19 | 20 | // use the middleware 21 | app.use(checkVotes); 22 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var Github = require('github'); 2 | var express = require('express'); 3 | var router = require('./router'); 4 | 5 | var gitenforcer = module.exports = function (options) { 6 | if (!(this instanceof gitenforcer)) return new gitenforcer(options); 7 | 8 | // validate the options 9 | if (typeof options !== 'object') throw new Error('Must include a configuration object'); 10 | if (typeof options.token !== 'string') throw new Error('Must include a valid oauth token'); 11 | if (typeof options.baseUrl !== 'string') throw new Error('Must include a valid baseUrl'); 12 | 13 | // initialize the empty middleware array 14 | this.middleware = []; 15 | 16 | // save a copy for future reference 17 | this.options = options; 18 | 19 | // setup the github client 20 | this.github = new Github({ version: '3.0.0', debug: false }); 21 | this.github.authenticate({ type: 'oauth', token: this.options.token }); 22 | 23 | // setup the express app 24 | this.app = express(); 25 | this.app.set('view engine', 'jade'); 26 | this.app.use(express.logger()); 27 | this.app.use(express.favicon()); 28 | 29 | this.app.get('/', router.index(this)); 30 | this.app.get('/:repo', router.repo(this)); 31 | this.app.post('/github/callback', express.bodyParser(), router.callback(this)); 32 | this.app.post('/enforce/:repo', router.enforce(this)); 33 | this.app.post('/unenforce/:repo', router.unenforce(this)); 34 | 35 | var self = this; 36 | router.getAllRepos(self, function () { 37 | self.app.listen(options.port || 1337); 38 | }); 39 | } 40 | 41 | // helper to add middleware to the stack 42 | gitenforcer.prototype.use = function _use(fun) { 43 | if (typeof fun !== 'function') throw new Error('Middleware does not appear to be a function'); 44 | this.middleware.push(fun); 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitenforcer", 3 | "version": "0.1.2", 4 | "description": "a small bot to monitor pull requests", 5 | "main": "./lib/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/nlf/gitenforcer.git" 9 | }, 10 | "keywords": [ 11 | "git", 12 | "github", 13 | "pull", 14 | "request" 15 | ], 16 | "author": "Nathan LaFreniere ", 17 | "license": "MIT", 18 | "dependencies": { 19 | "github": "~0.1.10", 20 | "express": "~3.3.5", 21 | "jade": "~0.34.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /router.js: -------------------------------------------------------------------------------- 1 | exports.index = function _index(context) { 2 | return function _index_route(req, res, next) { 3 | getAllRepos(context, function () { 4 | res.locals.repos = context.repos; 5 | res.render('index'); 6 | }); 7 | }; 8 | }; 9 | 10 | exports.repo = function _repo(context) { 11 | return function _repo_route(req, res, next) { 12 | var repo = context.repos.filter(function (thisrepo) { 13 | return thisrepo.name === req.params.repo; 14 | })[0]; 15 | 16 | getHook(context, req.params.repo, function (err, id) { 17 | repo.enforcer = id; 18 | res.locals.repo = repo; 19 | res.render('repo'); 20 | }); 21 | }; 22 | }; 23 | 24 | exports.callback = function _callback(context) { 25 | return function _callback_route(req, res, next) { 26 | var user = req.body.repository.owner.login; 27 | var repo = req.body.repository.name; 28 | var issue; 29 | var pr; 30 | 31 | // we have a comment 32 | if (req.body.comment) { 33 | issue = req.body.issue.number; 34 | if (!req.body.issue.pull_request) return; 35 | } else if (req.body.pull_request) { 36 | // we have a new pull request 37 | issue = req.body.pull_request.number; 38 | pr = req.body.pull_request; 39 | } 40 | 41 | context.github.issues.getComments({ user: user, repo: repo, number: issue, per_page: 100 }, function (err, comments) { 42 | context.github.pullRequests.get({ user: user, repo: repo, number: issue }, function (err, pr) { 43 | runMiddleware(context, comments, pr, req.body.repository); 44 | }); 45 | }); 46 | 47 | res.send(200); 48 | }; 49 | }; 50 | 51 | exports.enforce = function _enforce(context) { 52 | return function _enforce_route(req, res, next) { 53 | var owner = context.options.org || context.options.user; 54 | 55 | context.github.repos.createHook({ user: owner, repo: req.params.repo, name: 'web', config: { content_type: 'json', url: context.options.baseUrl + '/github/callback' }, events: ['pull_request', 'issue_comment'] }, function (err, result) { 56 | res.redirect('/' + req.params.repo); 57 | }); 58 | }; 59 | }; 60 | 61 | exports.unenforce = function _unenforce(context) { 62 | return function _unenforce_route(req, res, next) { 63 | var owner = context.options.org || context.options.user; 64 | 65 | getHook(context, req.params.repo, function (err, id) { 66 | if (!id) res.send(200, { result: 'ok' }); 67 | context.github.repos.deleteHook({ user: owner, repo: req.params.repo, id: id }, function (err, reply) { 68 | res.redirect('/' + req.params.repo); 69 | }); 70 | }); 71 | }; 72 | }; 73 | 74 | // fetch a list of *all* available repos, since we can only fetch up to 100 at a time 75 | var getAllRepos = exports.getAllRepos = function (context, callback) { 76 | context.repos = []; 77 | 78 | var opts = { sort: 'updated', direction: 'desc', per_page: 100 }; 79 | var getRepos; 80 | 81 | // add more options 82 | if (context.options.org) { 83 | opts.org = context.options.org; 84 | getRepos = context.github.repos.getFromOrg; 85 | } else if (context.options.user) { 86 | opts.user = context.options.user; 87 | getRepos = context.github.repos.getFromUser; 88 | } 89 | 90 | function _fetch() { 91 | getRepos(opts, function (err, data) { 92 | context.repos = context.repos.concat(data); 93 | 94 | // no link metadata means no other pages, so we're done 95 | if (!data.meta.link) { 96 | // orgs don't have sorting, so we just reverse the list 97 | if (opts.org) context.repos.reverse(); 98 | return callback(); 99 | } 100 | 101 | // split the link metadata on commas, each is a different page reference 102 | var links = data.meta.link.split(','); 103 | var link; 104 | 105 | // find the "next" page 106 | links.forEach(function (thislink) { 107 | if (thislink.match('rel="next"')) link = thislink; 108 | }); 109 | 110 | // if there was no "next" page, we're done 111 | if (!link) { 112 | if (opts.org) context.repos.reverse(); 113 | return callback(); 114 | } 115 | 116 | // parse out the page number, and call ourselves for more repos 117 | opts.page = /\&page=([\d]+)\>/.exec(link)[1]; 118 | _fetch(); 119 | }); 120 | } 121 | 122 | // start the recursive function 123 | _fetch(); 124 | } 125 | 126 | // check each repo, and add an "enforced" property to them if they already have a hook enabled 127 | // this function is *slow* 128 | function getHook(context, repo, callback) { 129 | var result; 130 | var owner = context.options.org || context.options.user; 131 | 132 | // check for hooks 133 | context.github.repos.getHooks({ user: owner, repo: repo }, function (err, hooks) { 134 | // we have some hooks, so search them to see if they're our app 135 | if (hooks && hooks.length) { 136 | hooks.forEach(function (hook) { 137 | if (hook.name === 'web' && hook.config.url.match(context.options.baseUrl)) { 138 | result = hook.id; 139 | } 140 | }); 141 | } 142 | callback(null, result); 143 | }); 144 | } 145 | 146 | function runMiddleware(context, comments, pr, repo) { 147 | var functions = context.middleware.slice(); 148 | var sha = pr.head.sha; 149 | var owner = repo.owner.login; 150 | var repo = repo.name; 151 | var func; 152 | 153 | function _setStatus(err) { 154 | var status; 155 | var description = 'GitEnforcer: '; 156 | 157 | if (err) { 158 | status = 'failure'; 159 | description += err; 160 | } else { 161 | status = 'success'; 162 | description += 'All tests passed'; 163 | } 164 | 165 | context.github.statuses.create({ user: owner, repo: repo, sha: sha, state: status, description: description }, function (err, reply) { 166 | }); 167 | } 168 | 169 | function _run() { 170 | if (!functions.length) return _setStatus(); 171 | 172 | func = functions.shift(); 173 | 174 | func(pr, comments, function (err) { 175 | if (err) return _setStatus(err); 176 | _run(); 177 | }, context.github); 178 | } 179 | 180 | _run(); 181 | } 182 | -------------------------------------------------------------------------------- /views/index.jade: -------------------------------------------------------------------------------- 1 | !!!5 2 | html 3 | head 4 | body 5 | ul 6 | each repo in repos 7 | li 8 | a(href='/#{repo.name}')= repo.name 9 | -------------------------------------------------------------------------------- /views/repo.jade: -------------------------------------------------------------------------------- 1 | !!!5 2 | html 3 | head 4 | body 5 | h3= repo.name 6 | if repo.enforcer 7 | form(action='/unenforce/#{repo.name}', method='POST') 8 | button(type='submit') unenforce 9 | else 10 | form(action='/enforce/#{repo.name}', method='POST') 11 | button(type='submit') enforce 12 | --------------------------------------------------------------------------------