├── .build ├── counts.json ├── css │ └── app.css ├── favicon.ico ├── js │ └── app.js └── templates │ ├── errors │ ├── 404.js │ ├── 500.js │ └── 503.js │ ├── index.js │ └── layouts │ └── master.js ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .nodemonignore ├── .npmignore ├── .travis.yml ├── .yo-rc.json ├── Gruntfile.js ├── Procfile ├── README.md ├── config └── config.json ├── controllers └── index.js ├── index.js ├── lib ├── DBUtils.js ├── GitHubUtils.js ├── ReviewUtils.js ├── SlackUtils.js ├── github.js └── hookHandlers │ ├── BaseHandler.js │ ├── CommentHandler.js │ ├── NoopHandler.js │ ├── PullRequestHandler.js │ └── index.js ├── models └── index.js ├── package.json ├── public ├── css │ └── app.less ├── favicon.ico ├── js │ └── app.js └── templates │ ├── errors │ ├── 404.dust │ ├── 500.dust │ └── 503.dust │ ├── index.dust │ └── layouts │ └── master.dust ├── server.js ├── tasks ├── clean.js ├── copy-browser-modules.js ├── copyto.js ├── dustjs.js ├── eslint.js ├── less.js ├── mocha_istanbul.js └── mochacli.js └── test ├── fixtures ├── MAINTAINERS └── MAINTAINERS-INACTIVE ├── index.js ├── lib ├── ReviewUtilsTest.js └── hookHandlers │ └── CommentHandlerTest.js └── testUtils └── index.js /.build/counts.json: -------------------------------------------------------------------------------- 1 | { 2 | "repos": { 3 | "lmarkus/hookTest": { 4 | "FOO": "BAR", 5 | "issues": { 6 | "PR_25": { 7 | "reviewers": [ 8 | { 9 | "name": "egualberto", 10 | "count": 1, 11 | "isInactive": false 12 | }, 13 | { 14 | "name": "egualberto", 15 | "count": 2, 16 | "isInactive": false 17 | }, 18 | { 19 | "name": "egualberto", 20 | "count": 3, 21 | "isInactive": false 22 | }, 23 | { 24 | "name": "egualberto", 25 | "count": 4, 26 | "isInactive": false 27 | } 28 | ] 29 | } 30 | }, 31 | "maintainers": { 32 | "mkutty": { 33 | "name": "mkutty", 34 | "count": 0, 35 | "isInactive": false 36 | }, 37 | "suali": { 38 | "name": "suali", 39 | "count": 0, 40 | "isInactive": false 41 | }, 42 | "lbreu": { 43 | "name": "lbreu", 44 | "count": 0, 45 | "isInactive": false 46 | }, 47 | "sukchakraborty": { 48 | "name": "sukchakraborty", 49 | "count": 0, 50 | "isInactive": true 51 | }, 52 | "schappidi": { 53 | "name": "schappidi", 54 | "count": 0, 55 | "isInactive": false 56 | }, 57 | "suppalapati": { 58 | "name": "suppalapati", 59 | "count": 0, 60 | "isInactive": false 61 | }, 62 | "erihe": { 63 | "name": "erihe", 64 | "count": 0, 65 | "isInactive": false 66 | }, 67 | "bhuang1": { 68 | "name": "bhuang1", 69 | "count": 0, 70 | "isInactive": false 71 | }, 72 | "shaorjiang": { 73 | "name": "shaorjiang", 74 | "count": 0, 75 | "isInactive": true 76 | }, 77 | "robasu": { 78 | "name": "robasu", 79 | "count": 0, 80 | "isInactive": false 81 | }, 82 | "jacma": { 83 | "name": "jacma", 84 | "count": 0, 85 | "isInactive": false 86 | }, 87 | "bmanickavasagam": { 88 | "name": "bmanickavasagam", 89 | "count": 0, 90 | "isInactive": false 91 | }, 92 | "lmarkus": { 93 | "name": "lmarkus", 94 | "count": 0, 95 | "isInactive": false 96 | }, 97 | "ranagappa": { 98 | "name": "ranagappa", 99 | "count": 0, 100 | "isInactive": false 101 | }, 102 | "ripandya": { 103 | "name": "ripandya", 104 | "count": 0, 105 | "isInactive": false 106 | }, 107 | "sumepatel": { 108 | "name": "sumepatel", 109 | "count": 0, 110 | "isInactive": false 111 | }, 112 | "davwu": { 113 | "name": "davwu", 114 | "count": 0, 115 | "isInactive": false 116 | }, 117 | "egualberto": { 118 | "name": "egualberto", 119 | "count": 4, 120 | "isInactive": false 121 | } 122 | } 123 | }, 124 | "lmarkus/HookTestA": { 125 | "maintainers": { 126 | "lmarkus": { 127 | "name": "lmarkus", 128 | "count": 1, 129 | "isInactive": false 130 | }, 131 | "lensam69": { 132 | "name": "lensam69", 133 | "count": 1, 134 | "isInactive": false 135 | } 136 | }, 137 | "issues": { 138 | "PR_2": { 139 | "reviewers": [ 140 | { 141 | "name": "lensam69", 142 | "count": 1, 143 | "isInactive": false 144 | } 145 | ] 146 | } 147 | } 148 | } 149 | } 150 | } -------------------------------------------------------------------------------- /.build/css/app.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmarkus/ReviewBot/8ec687d06682ecdc044899015b66af53737ec28a/.build/css/app.css -------------------------------------------------------------------------------- /.build/favicon.ico: -------------------------------------------------------------------------------- 1 |  h&  ��(  %%'����**����'���--���m����a''a����hN��������������M<������������;f;����������;eX�$��۸�ƈ�Nj��ݻ��%�W �P�ą �����V� }1������7xm������n������������!����!N��N]^�o�� ����������?�( @   2 | /.��./�����n����l�����~��Q4o�__�o4S��~T����������Te��f������g��d�������WW���������.#����������������#.��������������������������������������������������������g��bk��������������ka��g��8����������������7��� ������������������ �$���5����������5���$������3����3������#��,������������+��$�����������������m������������m��3������������4�!��������������!�/������������.�������������P����������O ���������� s��������r�������� ������ <����AG��GAA������������?��������a�A�����G�@g�g�O���@��?�������������������������?�������� -------------------------------------------------------------------------------- /.build/js/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | -------------------------------------------------------------------------------- /.build/templates/errors/404.js: -------------------------------------------------------------------------------- 1 | (function(dust){dust.register("errors\/404",body_0);var blocks={"body":body_1};function body_0(chk,ctx){ctx=ctx.shiftBlocks(blocks);return chk.p("layouts/master",ctx,ctx,{});}body_0.__dustBody=!0;function body_1(chk,ctx){ctx=ctx.shiftBlocks(blocks);return chk.w("

File not found

The URL ").f(ctx.get(["url"], false),ctx,"h").w(" did not resolve to a route.

");}body_1.__dustBody=!0;return body_0}(dust)); -------------------------------------------------------------------------------- /.build/templates/errors/500.js: -------------------------------------------------------------------------------- 1 | (function(dust){dust.register("errors\/500",body_0);var blocks={"body":body_1};function body_0(chk,ctx){ctx=ctx.shiftBlocks(blocks);return chk.p("layouts/master",ctx,ctx,{});}body_0.__dustBody=!0;function body_1(chk,ctx){ctx=ctx.shiftBlocks(blocks);return chk.w("

Internal server error

The URL ").f(ctx.get(["url"], false),ctx,"h").w(" had the following error ").f(ctx.get(["err"], false),ctx,"h").w(".

");}body_1.__dustBody=!0;return body_0}(dust)); -------------------------------------------------------------------------------- /.build/templates/errors/503.js: -------------------------------------------------------------------------------- 1 | (function(dust){dust.register("errors\/503",body_0);var blocks={"body":body_1};function body_0(chk,ctx){ctx=ctx.shiftBlocks(blocks);return chk.p("layouts/master",ctx,ctx,{});}body_0.__dustBody=!0;function body_1(chk,ctx){ctx=ctx.shiftBlocks(blocks);return chk.w("

Service unavailable

The service is unavailable. Please try back shortly.

");}body_1.__dustBody=!0;return body_0}(dust)); -------------------------------------------------------------------------------- /.build/templates/index.js: -------------------------------------------------------------------------------- 1 | (function(dust){dust.register("index",body_0);var blocks={"body":body_1};function body_0(chk,ctx){ctx=ctx.shiftBlocks(blocks);return chk.p("layouts/master",ctx,ctx,{});}body_0.__dustBody=!0;function body_1(chk,ctx){ctx=ctx.shiftBlocks(blocks);return chk.w("

Hello, ").f(ctx.get(["name"], false),ctx,"h").w("

");}body_1.__dustBody=!0;return body_0}(dust)); -------------------------------------------------------------------------------- /.build/templates/layouts/master.js: -------------------------------------------------------------------------------- 1 | (function(dust){dust.register("layouts\/master",body_0);function body_0(chk,ctx){return chk.w("").b(ctx.getBlock("title"),ctx,{},{}).w("
").b(ctx.getBlock("body"),ctx,{},{}).w("
");}body_0.__dustBody=!0;return body_0}(dust)); -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indent": [ 4 | 2, 5 | 4 6 | ], 7 | "quotes": [ 8 | 2, 9 | "single" 10 | ], 11 | "linebreak-style": [ 12 | 2, 13 | "unix" 14 | ], 15 | "semi": [ 16 | 2, 17 | "always" 18 | ], 19 | "no-console": 1 20 | }, 21 | "env": { 22 | "node": true, 23 | "browser": true, 24 | "mocha": true 25 | }, 26 | "parserOptions": { 27 | "ecmaVersion": 6, 28 | "sourceType": "module" 29 | }, 30 | "extends": "eslint:recommended" 31 | } 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 20 | .grunt 21 | 22 | # node-waf configuration 23 | .lock-wscript 24 | 25 | # Compiled binary addons (http://nodejs.org/api/addons.html) 26 | build/Release 27 | 28 | # Dependency directory 29 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 30 | node_modules 31 | ### JetBrains template 32 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio 33 | 34 | *.iml 35 | 36 | ## Directory-based project format: 37 | .idea/ 38 | # if you remove the above rule, at least ignore the following: 39 | 40 | # User-specific stuff: 41 | # .idea/workspace.xml 42 | # .idea/tasks.xml 43 | # .idea/dictionaries 44 | 45 | # Sensitive or high-churn files: 46 | # .idea/dataSources.ids 47 | # .idea/dataSources.xml 48 | # .idea/sqlDataSources.xml 49 | # .idea/dynamic.xml 50 | # .idea/uiDesigner.xml 51 | 52 | # Gradle: 53 | # .idea/gradle.xml 54 | # .idea/libraries 55 | 56 | # Mongo Explorer plugin: 57 | # .idea/mongoSettings.xml 58 | 59 | ## File-based project format: 60 | *.ipr 61 | *.iws 62 | 63 | ## Plugin-specific files: 64 | 65 | # IntelliJ 66 | /out/ 67 | 68 | # mpeltonen/sbt-idea plugin 69 | .idea_modules/ 70 | 71 | # JIRA plugin 72 | atlassian-ide-plugin.xml 73 | 74 | # Crashlytics plugin (for Android Studio and IntelliJ) 75 | com_crashlytics_export_strings.xml 76 | crashlytics.properties 77 | crashlytics-build.properties 78 | 79 | public/counts.json 80 | startup.sh 81 | config/development.json 82 | 83 | 84 | test/scratch/ 85 | -------------------------------------------------------------------------------- /.nodemonignore: -------------------------------------------------------------------------------- 1 | /.build/* # Build folder 2 | /public/* # ignore all public resources 3 | /.* # any hidden (dot) files 4 | *.md # Markdown files 5 | *.css # CSS files 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea/ 3 | .build/ 4 | node_modules/ 5 | *.iml 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "7" 4 | -------------------------------------------------------------------------------- /.yo-rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "generator-kraken": { 3 | "taskModule": "grunt", 4 | "lintModule": "eslint", 5 | "description": "GitHub PR Review Helper", 6 | "author": "@lmarkus", 7 | "templateModule": "makara", 8 | "i18n": false, 9 | "componentPackager": false, 10 | "cssModule": "less", 11 | "jsModule": false, 12 | "useJson": null 13 | } 14 | } -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | module.exports = function (grunt) { 5 | 6 | // Load the project's grunt tasks from a directory 7 | require('grunt-config-dir')(grunt, { 8 | configDir: require('path').resolve('tasks') 9 | }); 10 | 11 | 12 | 13 | // Register group tasks 14 | grunt.registerTask('build', ['eslint', 'eslint', 'dustjs', 'less', 'copyto']); 15 | 16 | grunt.registerTask('test', [ 'eslint', 'mochacli' ]); 17 | 18 | grunt.registerTask('coverage', [ 'mocha_istanbul:coverage' ]); 19 | 20 | 21 | }; 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm start 2 | webDebug: node --debug-brk=5858 server.js 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ReviewBot 9000 2 | =========== 3 | ![Build Status](https://travis-ci.org/lmarkus/ReviewBot.svg?branch=master) 4 | 5 | When you have a large group of people working on a project with high velocity, 6 | sometimes it's hard to coordinate reviewers for Pull Requests. This application 7 | keeps track of the maintainers of a repo, and how many reviews each person has done. 8 | 9 | When a new pull request comes in, the app will pick a (configurable) number of people 10 | from the reviewer pool, in a fair manner. The selected people will be assigned to review 11 | the PR, and they will also (optionally) be notified via Slack. 12 | 13 | ## Setup 14 | 15 | You will need to obtain the following three things to get started: 16 | 17 | * GitHub Personal Access Token: (*GH_TOKEN*) This will be used by the app to read from, and post comments to your reapo. The access token 18 | should have `repo` scope. See [How to obtain an access token](https://help.github.com/articles/creating-an-access-token-for-command-line-use/) 19 | * GitHub Hook Secret: (*GH_SECRET*) *Optional*. If provided this will be used by GitHub to sign payloads. The app can then verify authenticiy. 20 | Here's a [quick example](https://repl.it/F75H/0) of how to generate a good, random secret. 21 | * Slack API token: (*SLACK_TOKEN*) Used to post assignment announcements to a Slack channel. See [Slack's API docs](https://get.slack.help/hc/en-us/articles/215770388-Create-and-regenerate-API-tokens) 22 | 23 | ## Deployment 24 | This app needs to be deployed somewhere to run, and GitHub webhooks must be properly configured. 25 | If you don't have any constraints, I highly recommend you deploy this on Heroku. 26 | Also recommended, deploy the service first, and then set up your hooks. 27 | 28 | ### Service Deployment 29 | #### Heroku Setup 30 | * Clone this repo `$ git clone https://github.com/lmarkus/ReviewBot/ && cd ReviewBot` 31 | * Follow the [Setup](#Setup) section above 32 | * Create a [free heroku account](https://signup.heroku.com/) (If you've never worked with Heroku before, you should follow their [NodeJS tutorial](https://devcenter.heroku.com/articles/getting-started-with-nodejs#set-up) first) 33 | * Download the [Heroku CLI tool](https://devcenter.heroku.com/articles/heroku-cli) 34 | * Login through the CLI tool `$ heroku login` (Use your credentials) 35 | * Spin up a new app `$ heroku create` 36 | * Push the app to Heroku `$ git push heroku master` 37 | * Add your private configuration to the app: 38 | ```bash 39 | heroku config:set GH_TOKEN= 40 | heroku config:set GH_SECRET= 41 | heroku config:set SLACK_TOKEN= 42 | ``` 43 | * Launch the app! `$ heroku ps:scale web=1` (Confirm that it works via `$ heroku open`) 44 | **NOTE:** Heroku routes all traffic (NAT) through port 80 45 | 46 | #### Custom setup 47 | If you are an enterprise user, you'll probably need to deploy this behind your firewall. 48 | * Clone this repo `$ git clone https://github.com/lmarkus/ReviewBot/ && cd ReviewBot` 49 | * Install dependencies: `$ npm install` 50 | * Set the environment variables (`GH_TOKEN`, `GH_SECRET`, `SLACK_TOKEN`) (Alternatively, you can directly set them in `./config/development.json` if you're just testing) 51 | * Launch the app: `npm start` (or, `node server.js`) 52 | * You'll probably want to look into writing a [startup script](https://blog.jalada.co.uk/simple-upstart-script-to-keep-a-node-process-alive/), or [forever](https://www.npmjs.com/package/forever) to ensure your app is always up and running 53 | 54 | ### GitHub Setup 55 | * Add a [new GitHub WebHook](https://developer.github.com/webhooks/creating/) 56 | * The default path is `http[s]://:/hooks` 57 | * Your WebHook needs to push specific events: `Pull Request` and `Comments` 58 | * Don't forget to set up your `secret` if your service is expecting it. 59 | * Add a `MAINTAINERS` file at the root of your repo. (See this [example](test/fixtures/MAINTAINERS) for formatting). This file lets the review bot know who's availabale for reviewing a PR 60 | 61 | 62 | ## Configuration 63 | 64 | This is a KrakenJS app. you can read more about the base configuration [here](http://krakenjs.com/index.html#configuration). 65 | The important thing you need to know right now, is that the configuration is environment-aware. 66 | If you have `NODE_ENV` set to production, it will exclusively use `.config/config.json` otherwise, it will merge `.config/config.json` with `.config/development.json`. 67 | This allows you the flexibility to test locally with ease. 68 | 69 | Within either configuration file, look for the `app` section. 70 | Below are the default values: 71 | 72 | ```json 73 | "app": { 74 | "github": { 75 | "api": { 76 | "token": "env:GH_TOKEN", 77 | "protocol": "https" 78 | }, 79 | "hooks": { 80 | "host": "", 81 | "path": "/hooks" 82 | } 83 | }, 84 | "slack": { 85 | "token": "env:SLACK_TOKEN", 86 | "notifyChannel": "general" 87 | }, 88 | "reviewers": 2 89 | } 90 | ``` 91 | 92 | You can choose to save the access tokens directly in the configuration file for convenience, but this is not a good practice 93 | 94 | The **api** section is passed on to the underlying [Node-GitHub Module](https://github.com/mikedeboer/node-github) 95 | 96 | The **hooks** section is passed on to the underlying [GitHubHook Module](https://github.com/nlf/node-github-hook) 97 | 98 | ( You can read their respective docs for more info on available options. 99 | They will work fin out of the box for GitHub.com integrations, but you may need to tweak them a bit for custom and 100 | GitHub Enterprise settings.) 101 | 102 | The **slack** section just needs the access token, and the channel in which to post notifications (The bot *must* be invited into that channel) 103 | 104 | **reviewers** specifies how many people are assigned to a pull request. 105 | 106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "express": { 3 | "view cache": false, 4 | "view engine": "js", 5 | "views": "path:./.build/templates" 6 | }, 7 | "view engines": { 8 | "js": { 9 | "module": "makara", 10 | "renderer": { 11 | "method": "js", 12 | "arguments": [ 13 | { 14 | "cache": true, 15 | "helpers": "config:dust.helpers" 16 | } 17 | ] 18 | } 19 | } 20 | }, 21 | "dust": { 22 | "helpers": [ 23 | ] 24 | }, 25 | "specialization": { 26 | }, 27 | "middleware": { 28 | "makara": { 29 | "priority": 100, 30 | "enabled": true, 31 | "module": { 32 | "name": "makara", 33 | "arguments": [ 34 | { 35 | "i18n": "config:i18n", 36 | "specialization": "config:specialization" 37 | } 38 | ] 39 | } 40 | }, 41 | "appsec": { 42 | "enabled": true, 43 | "priority": 110, 44 | "module": { 45 | "name": "lusca", 46 | "arguments": [ 47 | { 48 | "csrf": false, 49 | "xframe": "SAMEORIGIN", 50 | "p3p": false, 51 | "csp": false 52 | } 53 | ] 54 | } 55 | }, 56 | "static": { 57 | "module": { 58 | "arguments": [ 59 | "path:./.build" 60 | ] 61 | } 62 | }, 63 | "router": { 64 | "module": { 65 | "arguments": [ 66 | { 67 | "directory": "path:./controllers" 68 | } 69 | ] 70 | } 71 | } 72 | }, 73 | "app": { 74 | "github": { 75 | "api": { 76 | "token": "env:GH_TOKEN", 77 | "protocol": "https" 78 | }, 79 | "hooks": { 80 | "host": "", 81 | "path": "/hooks" 82 | } 83 | }, 84 | "slack": { 85 | "token": "env:SLACK_TOKEN", 86 | "notifyChannel": "" 87 | }, 88 | "reviewers": 2 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /controllers/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var IndexModel = require('../models/index'); 3 | const GitHubHook = require('../lib/github').getHookHandlerInstance(); 4 | module.exports = function (router) { 5 | 6 | var model = new IndexModel(); 7 | 8 | 9 | /** HACK ALERT! 10 | * This hijacks the handler function from the githubhooks module, by forcibly drilling 11 | * into it's server (which I never initialized, on purpose). 12 | * 13 | * To make it work properly, we need to emulate (re-emit) the `data` and `end` events. 14 | * 15 | * This is nasty. 16 | * 17 | * Proper solutions: 18 | * 1) Implement my own github event handler. (Not very keen on re-inventing the wheel) 19 | * 2) Send a PR to the author of the GitHub module to expose the function on a more friendly manner 20 | * 3) Find another module that does everything I need. 21 | **/ 22 | router.post('/hooks', function (req, res) { 23 | 24 | GitHubHook.server._events.request(req, res); 25 | req.emit('data', new Buffer(JSON.stringify(req.body))); 26 | req.emit('end'); 27 | 28 | }); 29 | 30 | router.get('/', function (req, res) { 31 | return res.render('index', model); 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express') 4 | , kraken = require('kraken-js') 5 | , github = require('./lib/github') 6 | , debuglog = require('util').debuglog('reviewBot') 7 | ; 8 | 9 | 10 | let options, app; 11 | /* 12 | * Create and configure application. Also exports application instance for use by tests. 13 | * See https://github.com/krakenjs/kraken-js#options for additional configuration options. 14 | */ 15 | options = { 16 | onconfig: function (config, next) { 17 | 18 | github.init(config.get('app') || {}, function () { 19 | next(null, config); 20 | }); 21 | } 22 | }; 23 | 24 | app = module.exports = express(); 25 | app.use(kraken(options)); 26 | app.on('start', function () { 27 | debuglog('Application ready to serve requests.'); 28 | debuglog('Environment: %s', app.kraken.get('env:env')); 29 | }); 30 | -------------------------------------------------------------------------------- /lib/DBUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by lmarkus on 2/3/17. 3 | * Quirk of the lowDB module... needs to be treated as a singleton 4 | */ 5 | 'use strict'; 6 | const low = require('lowdb') 7 | , db = low('public/counts.json') 8 | ; 9 | 10 | db.defaults( 11 | { 12 | repos: {} 13 | } 14 | ).values(); 15 | 16 | module.exports = class DBUtils{ 17 | 18 | static get(query,defaultValue){ 19 | return db.get(query,defaultValue).value(); 20 | } 21 | 22 | static set(path,value){ 23 | return db.set(path,value).value(); 24 | } 25 | 26 | static has(query){ 27 | return db.has(query).value(); 28 | } 29 | 30 | static getInstance(){ 31 | return db; 32 | } 33 | 34 | }; 35 | -------------------------------------------------------------------------------- /lib/GitHubUtils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const _ = require('lodash') 3 | , util = require('util') 4 | , GH = require('./github') 5 | , github = GH.api 6 | , debuglog = require('util').debuglog('GitHubHooks') 7 | ; 8 | 9 | module.exports = class GitHubUtils { 10 | 11 | /** 12 | * Retrieve the MAINTAINERS file for a given repo, and massage it into a usable format. 13 | * @param options 14 | * @param callback 15 | */ 16 | static getMaintainersFile(options = {}, callback) { 17 | GH.authenticate(); 18 | 19 | options = _.merge( 20 | options, 21 | { 22 | path: 'MAINTAINERS' 23 | }); 24 | 25 | github().repos.getContent( 26 | options, 27 | function (err, file) { 28 | debuglog(`Retrieved Maintainers: \n${util.inspect(file)}`); 29 | let maintainersFile = 30 | new Buffer(file.content, 'base64') 31 | .toString('ascii') 32 | .split('\n') 33 | .filter(line => line.length > 0); 34 | return callback(err, maintainersFile); 35 | } 36 | ); 37 | } 38 | 39 | static addComment(options, callback) { 40 | github().issues.createComment( 41 | options, 42 | function (err, result) { 43 | debuglog(`Added Comment ${util.inspect(result)}`); 44 | return callback(err, null); 45 | }); 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /lib/ReviewUtils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const _ = require('lodash') 3 | , db = require('./DBUtils') 4 | , GitHubUtils = require('./GitHubUtils') 5 | ; 6 | 7 | module.exports = class ReviewUtils { 8 | 9 | /** 10 | * For a given repo, retrieve a random set of reviewers, according to the repo configuration. 11 | * @param options 12 | * @param callback 13 | */ 14 | static getReviewers(options = {}, callback) { 15 | ReviewUtils.getRepoMaintainers(options, function (err, maintainers) { 16 | let reviewers, maintainersArray; 17 | 18 | reviewers = []; 19 | maintainersArray = ReviewUtils.getMaintainersArray(maintainers); 20 | 21 | while (reviewers.length < options.appConfig.reviewers && maintainersArray.length > 0) { 22 | let low, lowLevel, reviewer; 23 | 24 | // The default count for new reviewers should match the current low, so they don't start too far behind. 25 | low = ReviewUtils.getHighLow(maintainersArray).low; 26 | 27 | // Get the people with the lowest number of reviews, by count 28 | lowLevel = maintainersArray.filter((maintainer) => maintainer.count <= low); 29 | 30 | //Get a random reviewer 31 | reviewer = lowLevel[Math.floor(Math.random() * lowLevel.length)]; 32 | 33 | // Remove this person from rotation 34 | // (Unlike filter, this mutates the array) 35 | _.pullAllBy(maintainersArray, [reviewer], 'name'); 36 | 37 | // Update the count 38 | maintainers[reviewer.name].count++; 39 | 40 | // If active, assign 41 | // Skip self 42 | // (We do this after updating the count so inactive people get "counted" 43 | // in rotations. Otherwise, they'd need to play catch up. 44 | if (!reviewer.isInactive && reviewer.name !== options.skip) { 45 | reviewers.push(reviewer); 46 | } 47 | } 48 | 49 | // Persist updated counts. 50 | db.set(`repos.${options.owner}/${options.repo}.maintainers`, maintainers); 51 | 52 | callback(null, reviewers); 53 | }); 54 | } 55 | 56 | /** 57 | * Retrieve the maintainers of a repository. 58 | * This retrieves the MAINTAINERS files and updates the local storage based on it. 59 | * @param options 60 | * @param callback 61 | */ 62 | static getRepoMaintainers(options, callback) { 63 | GitHubUtils.getMaintainersFile(options, function (err, maintainersFile) { 64 | let currentMaintainers, newMaintainers = {}, counts; 65 | 66 | currentMaintainers = db.get(`repos.${options.owner}/${options.repo}.maintainers`, {}); 67 | counts = ReviewUtils.getHighLow(ReviewUtils.getMaintainersArray(currentMaintainers)); 68 | 69 | maintainersFile.forEach(maintainer => { 70 | let name = /\(@(\w+)\)/.exec(maintainer); 71 | if (!name) { 72 | return; 73 | } 74 | name = name[1]; 75 | 76 | let isInactive = maintainer.toLowerCase().includes('inactive'); 77 | 78 | // Put them at the bottom of the queue 79 | newMaintainers[name] = { 80 | name, 81 | count: counts.low, 82 | isInactive 83 | }; 84 | }); 85 | 86 | currentMaintainers = ReviewUtils.mergeMaintainers(currentMaintainers, newMaintainers); 87 | return callback(null, currentMaintainers); 88 | }); 89 | } 90 | 91 | /** 92 | * Assign a set of maintainers a to a PR 93 | * @param options 94 | * @param assignees 95 | */ 96 | static setAssigned(options, assignees) { 97 | let issuePath = `repos.${options.owner}/${options.repo}.issues.${options.pr}`; 98 | // Default init 99 | if (!db.has(issuePath)) { 100 | db.set(issuePath, {reviewers: []}); 101 | } 102 | 103 | let currentAssignees = db.get(issuePath + '.reviewers'); 104 | assignees = _.union(currentAssignees, assignees); 105 | db.set(issuePath + '.reviewers', assignees); 106 | } 107 | 108 | /** 109 | * Determines if a maintainer is already assigned to a to a PR 110 | * @param options 111 | * @param assignee 112 | */ 113 | static isAssigned(options, assignee) { 114 | let issuePath = `repos.${options.owner}/${options.repo}.issues.${options.pr}` 115 | , currentAssignees = db.get(issuePath + '.reviewers') 116 | ; 117 | 118 | return currentAssignees && currentAssignees.some(current => current.name === assignee.name); 119 | } 120 | 121 | /** 122 | * Get the highest and lowest counts of reviews performed by the maintainers of a repo. 123 | * @param maintainers 124 | * @returns {*} 125 | */ 126 | static getHighLow(maintainers) { 127 | 128 | let counts = {high: 0, low: 0}; 129 | /* eslint "indent":0 */ 130 | switch (maintainers.length) { 131 | case 0: 132 | return counts; 133 | case 1: 134 | return {high: maintainers[0].count, low: maintainers[0].count}; 135 | default: { 136 | return { 137 | low: (maintainers[0] || {count: 0}).count, // Last Element 138 | high: (maintainers[maintainers.length - 1] || {count: 0}).count // First Element 139 | }; 140 | } 141 | } 142 | } 143 | 144 | /** 145 | * Transforms a nested maintainers object into a sorted (By review count) array. 146 | * @param maintainersObject 147 | * @returns {Array.} 148 | */ 149 | static getMaintainersArray(maintainersObject) { 150 | let maintainers = Object.keys(maintainersObject).map(key => maintainersObject[key]); 151 | return maintainers.sort((a, b) => { 152 | return a.count - b.count; 153 | }); 154 | } 155 | 156 | /** 157 | * Outer Join, to remove deleted maintainers, but keep existing counts. 158 | * @param current 159 | * @param next 160 | */ 161 | static mergeMaintainers(current, next) { 162 | // Update active status before merging 163 | Object.keys(next).forEach(name => { 164 | if (current[name]) { 165 | current[name].isInactive = !!next[name].isInactive; 166 | } 167 | }); 168 | 169 | current = ReviewUtils.getMaintainersArray(current); 170 | next = ReviewUtils.getMaintainersArray(next); 171 | let trimmedCurrent = _.intersectionBy(current, next, 'name'); 172 | current = _.unionBy(trimmedCurrent, next, 'name'); 173 | 174 | //Turn back into an object 175 | return current.reduce((accumulator, maintainer) => { 176 | accumulator[maintainer.name] = maintainer; 177 | return accumulator; 178 | }, 179 | {}); 180 | } 181 | }; 182 | 183 | 184 | -------------------------------------------------------------------------------- /lib/SlackUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by lmarkus on 2/2/17. 3 | */ 4 | 'use strict'; 5 | const WebClient = require('@slack/client').WebClient 6 | , debuglog = require('util').debuglog('GitHubHooks') 7 | ; 8 | 9 | 10 | module.exports = class SlackUtils { 11 | 12 | 13 | static announceAssignees(assignees, pr, slackConfig) { 14 | let web = new WebClient(slackConfig.token); 15 | assignees = assignees.map(a => `<@${a.name}>`).join(', '); 16 | web.chat.postMessage(slackConfig.notifyChannel, `${assignees} : You've been assigned to ${pr}`, {as_user: true}, function (err, res) { 17 | if (err) { 18 | debuglog('Error:', err); 19 | } else { 20 | debuglog('Message sent: ', res); 21 | } 22 | }); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /lib/github.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const GitHubApi = require('github') 4 | , GitHubHook = require('githubhook') 5 | ; 6 | 7 | let options 8 | , gitHubApi 9 | , gitHubHook 10 | ; 11 | 12 | module.exports.init = function init(_options, callback) { 13 | options = _options; 14 | 15 | // Initialize API 16 | gitHubApi = new GitHubApi(options.github.api); 17 | 18 | // Initialize Hooks Listener 19 | const GitHubHookRouter = require('./hookHandlers'); // Initialized here to avoid circular reference 20 | const hookHandlers = new GitHubHookRouter(options); 21 | gitHubHook = GitHubHook(options.github.hooks); 22 | gitHubHook.on('*', hookHandlers.getRouter()); 23 | return callback(); 24 | 25 | }; 26 | 27 | module.exports.getHookHandlerInstance = function getInstance() { 28 | return gitHubHook; 29 | }; 30 | 31 | module.exports.api = function () { 32 | return gitHubApi; 33 | }; 34 | 35 | module.exports.authenticate = function () { 36 | module.exports.api().authenticate({ 37 | type: 'token', 38 | token: options.github.api.token 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /lib/hookHandlers/BaseHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by lmarkus on 1/22/17. 3 | */ 4 | 'use strict'; 5 | const debuglog = require('util').debuglog('GitHubHooks'); 6 | 7 | 8 | class BaseHandler { 9 | 10 | constructor() { 11 | } 12 | 13 | handle() { 14 | debuglog('Handler not implemented'); 15 | } 16 | } 17 | 18 | module.exports = BaseHandler; 19 | -------------------------------------------------------------------------------- /lib/hookHandlers/CommentHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by lmarkus on 1/22/17. 3 | */ 4 | const 5 | _ = require('lodash') 6 | , db = require('../DBUtils') 7 | , ReviewUtils = require('../ReviewUtils') 8 | , SlackUtils = require('../SlackUtils') 9 | , BaseHandler = require('./BaseHandler') 10 | , debuglog = require('util').debuglog('GitHubHooks') 11 | ; 12 | 13 | let config; 14 | 15 | /** 16 | * Listen for incoming "Comment" events, which are used to manually assign a reviewer to a PR. 17 | * @type {CommentHandler} 18 | */ 19 | module.exports = class CommentHandler extends BaseHandler { 20 | constructor(options) { 21 | super(); 22 | config = options; 23 | } 24 | 25 | handle(repo, ref, data, callback) { 26 | if ( // New comment, manually assigning people. 27 | _.get(data, 'action') === 'created' && 28 | _.get(data, 'comment.body', '').match(/^(assign|asign)/i) //Assignment lines must start with "assign" (or common typos) 29 | ) { 30 | debuglog('New Assignment Comment'); 31 | 32 | let assignee, assignees = [], regex = /@(\w+)/g; 33 | 34 | // Filter out assignees from the comment body 35 | /* eslint "no-cond-assign":0 */ 36 | while (assignee = regex.exec(data.comment.body)) { 37 | assignees.push(assignee[1]); 38 | } 39 | 40 | // NoOp 41 | if (assignees.length === 0) { 42 | return callback(); 43 | } 44 | 45 | // Increase count for the assigned reviewers 46 | let options = { 47 | owner: data.repository.owner.login, 48 | repo: data.repository.name, 49 | pr: `PR_${data.issue.number}` 50 | }; 51 | 52 | ReviewUtils.getRepoMaintainers(options, function (err, maintainers) { 53 | assignees = 54 | assignees 55 | .map(assignee => maintainers[assignee]) 56 | .filter(Boolean); 57 | 58 | assignees.forEach(assignee => { 59 | if (!ReviewUtils.isAssigned(options, assignee)) { 60 | assignee.count++; 61 | } 62 | }); 63 | 64 | db.set(`repos.${options.owner}/${options.repo}.maintainers`, maintainers); 65 | ReviewUtils.setAssigned(options, assignees); 66 | SlackUtils.announceAssignees(assignees, data.issue.html_url, config.slack); 67 | 68 | return callback(err); 69 | }); 70 | } 71 | } 72 | }; 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /lib/hookHandlers/NoopHandler.js: -------------------------------------------------------------------------------- 1 | const BaseHandler = require('./BaseHandler') 2 | , debuglog = require('util').debuglog('GitHubHooks') 3 | ; 4 | 5 | module.exports = class NoopHandler extends BaseHandler { 6 | constructor() { 7 | super(); 8 | } 9 | 10 | handle() { 11 | debuglog('No-Op'); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /lib/hookHandlers/PullRequestHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by lmarkus on 1/22/17. 3 | */ 4 | const 5 | _ = require('lodash') 6 | , GitHubUtils = require('../GitHubUtils') 7 | , ReviewUtils = require('../ReviewUtils') 8 | , SlackUtils = require('../SlackUtils') 9 | , BaseHandler = require('./BaseHandler') 10 | , debuglog = require('util').debuglog('GitHubHooks') 11 | ; 12 | 13 | let config; 14 | 15 | module.exports = class PullRequestHandler extends BaseHandler { 16 | 17 | constructor(options) { 18 | super(); 19 | config = options; 20 | } 21 | 22 | handle(repo, ref, data, callback) { 23 | 24 | if ( // New PR 25 | _.get(data, 'action') === 'opened' && 26 | _.get(data, 'pull_request.state') === 'open' 27 | ) { 28 | debuglog('PR Opened!'); 29 | 30 | let options = { 31 | owner: data.repository.owner.login, 32 | repo: data.repository.name, 33 | pr: `PR_${data.pull_request.number}`, 34 | number: data.pull_request.number, 35 | skip: data.pull_request.user.login, 36 | appConfig: config 37 | }; 38 | 39 | ReviewUtils.getReviewers( 40 | options, 41 | function (err, reviewers) { 42 | 43 | //Async, fire & forget 44 | ReviewUtils.setAssigned(options, reviewers); 45 | SlackUtils.announceAssignees(reviewers, data.pull_request.html_url, config.slack); 46 | 47 | options.body = `The following people have been assigned to review:\n ${reviewers.map(r => '@' + r.name).join(', ')}`; 48 | return GitHubUtils.addComment(options, callback); 49 | }); 50 | } 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /lib/hookHandlers/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by lmarkus on 1/22/17. 3 | */ 4 | 'use strict'; 5 | 6 | const 7 | util = require('util'), 8 | debuglog = util.debuglog('GitHubHooks') 9 | ; 10 | 11 | // Handlers 12 | const 13 | PullRequestHandler = require('./PullRequestHandler'), 14 | CommentHandler = require('./CommentHandler'), 15 | NoopHandler = new require('./NoopHandler') 16 | ; 17 | 18 | let 19 | pullRequestHandler, 20 | commentHandler, 21 | noopHandler 22 | ; 23 | 24 | module.exports = class GitHubHookRouter { 25 | 26 | constructor(config) { 27 | pullRequestHandler = new PullRequestHandler(config); 28 | commentHandler = new CommentHandler(config); 29 | noopHandler = new NoopHandler(config); 30 | } 31 | 32 | getRouter() { 33 | return function route(event, repo, ref, data) { 34 | 35 | debuglog(`"${event}" received from ${repo}/${ref}`); 36 | //debuglog(`Payload: \n ${util.inspect(data, {depth: null})}`); 37 | 38 | return getHandler(event).handle(repo, ref, data, function () { 39 | debuglog('routing complete'); 40 | }); 41 | }; 42 | } 43 | }; 44 | 45 | /* eslint "indent":0 */ 46 | function getHandler(event) { 47 | switch (event.toLowerCase()) { 48 | case 'pull_request': { 49 | return pullRequestHandler; 50 | } 51 | case 'issue_comment': { 52 | return commentHandler; 53 | } 54 | default: { 55 | return noopHandler; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /models/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function IndexModel() { 4 | return { 5 | name: 'index' 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reviewme", 3 | "version": "0.1.0", 4 | "description": "GitHub PR Review Helper", 5 | "author": "@lmarkus", 6 | "repository": "https://github.com/lmarkus/ReviewBot/", 7 | "main": "server.js", 8 | "license": "ISC", 9 | "engines": { 10 | "node": "7.4.0", 11 | "npm": "4.2.0" 12 | }, 13 | "scripts": { 14 | "test": "grunt test", 15 | "build": "grunt build && git add .build", 16 | "all": "npm run build && npm run test", 17 | "lint": "grunt eslint", 18 | "validate": "npm ls" 19 | }, 20 | "dependencies": { 21 | "@slack/client": "^3.8.1", 22 | "construx": "^1.0.0", 23 | "construx-copier": "^1.0.0", 24 | "construx-dustjs": "^1.1.0", 25 | "construx-less": "^1.0.0", 26 | "eslint": "^3.15.0", 27 | "express": "^4.12.2", 28 | "github": "^8.1.0", 29 | "githubhook": "^1.7.1", 30 | "kraken-js": "^2.0.0", 31 | "lodash": "^4.17.4", 32 | "lowdb": "^0.14.0", 33 | "makara": "^2.0.3" 34 | }, 35 | "devDependencies": { 36 | "chai": "^3.5.0", 37 | "del": "^2.2.2", 38 | "grunt": "^0.4.5", 39 | "grunt-cli": "^0.1.13", 40 | "grunt-config-dir": "^0.3.2", 41 | "grunt-contrib-clean": "^0.6.0", 42 | "grunt-contrib-less": "^1.4.0", 43 | "grunt-copy-browser-modules": "^6.0.0", 44 | "grunt-copy-to": "0.0.10", 45 | "grunt-dustjs": "^1.4.0", 46 | "grunt-eslint": "^19.0.0", 47 | "grunt-mocha-cli": "^1.14.0", 48 | "grunt-mocha-istanbul": "^5.0.2", 49 | "istanbul": "^0.4.5", 50 | "mocha": "^1.18.0", 51 | "mockery": "^2.0.0", 52 | "pre-commit": "^1.2.2", 53 | "supertest": "^0.9.0" 54 | }, 55 | "pre-commit": [ 56 | "lint", 57 | "validate", 58 | "test", 59 | "build" 60 | ] 61 | } 62 | -------------------------------------------------------------------------------- /public/css/app.less: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmarkus/ReviewBot/8ec687d06682ecdc044899015b66af53737ec28a/public/css/app.less -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- 1 |  h&  ��(  %%'����**����'���--���m����a''a����hN��������������M<������������;f;����������;eX�$��۸�ƈ�Nj��ݻ��%�W �P�ą �����V� }1������7xm������n������������!����!N��N]^�o�� ����������?�( @   2 | /.��./�����n����l�����~��Q4o�__�o4S��~T����������Te��f������g��d�������WW���������.#����������������#.��������������������������������������������������������g��bk��������������ka��g��8����������������7��� ������������������ �$���5����������5���$������3����3������#��,������������+��$�����������������m������������m��3������������4�!��������������!�/������������.�������������P����������O ���������� s��������r�������� ������ <����AG��GAA������������?��������a�A�����G�@g�g�O���@��?�������������������������?�������� -------------------------------------------------------------------------------- /public/js/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | -------------------------------------------------------------------------------- /public/templates/errors/404.dust: -------------------------------------------------------------------------------- 1 | {>"layouts/master" /} 2 | 3 | {File not found 5 |

The URL {url} did not resolve to a route.

6 | {/body} 7 | -------------------------------------------------------------------------------- /public/templates/errors/500.dust: -------------------------------------------------------------------------------- 1 | {>"layouts/master" /} 2 | 3 | {Internal server error 5 |

The URL {url} had the following error {err}.

6 | {/body} 7 | -------------------------------------------------------------------------------- /public/templates/errors/503.dust: -------------------------------------------------------------------------------- 1 | {>"layouts/master" /} 2 | 3 | {Service unavailable 5 |

The service is unavailable. Please try back shortly.

6 | {/body} 7 | -------------------------------------------------------------------------------- /public/templates/index.dust: -------------------------------------------------------------------------------- 1 | {>"layouts/master" /} 2 | 3 | {Hello, {name} 5 | {/body} 6 | -------------------------------------------------------------------------------- /public/templates/layouts/master.dust: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {+title /} 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | {+body /} 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const app = require('./index') 4 | , http = require('http') 5 | , debuglog = require('util').debuglog('GitHubHooks') 6 | ; 7 | 8 | 9 | let server; 10 | 11 | /* 12 | * Create and start HTTP server. 13 | */ 14 | 15 | server = http.createServer(app); 16 | //github.init(config.get('app') || {}); 17 | 18 | server.listen(process.env.PORT || 8000, process.env.HOST); 19 | server.on('listening', function () { 20 | debuglog('Server listening on http://localhost:%d', this.address().port); 21 | }); 22 | -------------------------------------------------------------------------------- /tasks/clean.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | module.exports = function clean(grunt) { 5 | // Load task 6 | grunt.loadNpmTasks('grunt-contrib-clean'); 7 | 8 | // Options 9 | return { 10 | tmp: 'tmp', 11 | build: '.build/templates' 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /tasks/copy-browser-modules.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function dustjs(grunt) { 4 | grunt.loadNpmTasks('grunt-copy-browser-modules'); 5 | 6 | return { 7 | build: { 8 | root: process.cwd(), 9 | dest: 'public/components', 10 | basePath: 'public' 11 | } 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /tasks/copyto.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | module.exports = function copyto(grunt) { 5 | // Load task 6 | grunt.loadNpmTasks('grunt-copy-to'); 7 | 8 | // Options 9 | return { 10 | build: { 11 | files: [{ 12 | cwd: 'public', 13 | src: ['**/*'], 14 | dest: '.build/' 15 | }], 16 | options: { 17 | ignore: [ 18 | 'public/css/**/*', 19 | 20 | 'public/templates/**/*' 21 | ] 22 | } 23 | } 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /tasks/dustjs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path'); 4 | 5 | module.exports = function dustjs(grunt) { 6 | // Load task 7 | grunt.loadNpmTasks('grunt-dustjs'); 8 | 9 | // Options 10 | return { 11 | build: { 12 | files: [ 13 | { 14 | expand: true, 15 | 16 | cwd: 'public/templates/', 17 | 18 | src: '**/*.dust', 19 | dest: '.build/templates', 20 | ext: '.js' 21 | } 22 | ], 23 | options: { 24 | 25 | fullname: function (filepath) { 26 | return path.relative('public/templates/', filepath).replace(/[.]dust$/, ''); 27 | } 28 | 29 | } 30 | } 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /tasks/eslint.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function eslint(grunt) { 4 | // Load task 5 | grunt.loadNpmTasks('grunt-eslint'); 6 | 7 | // Options 8 | return { 9 | options: { 10 | configFile: '.eslintrc', 11 | rulePaths: ['node_modules/eslint/lib/rules'] 12 | }, 13 | target: ['index.js', 14 | 'server.js', 15 | 'controllers/**/*.js', 16 | 'lib/**/*.js', 17 | 'models/**/*.js', 18 | 'public/js/**/*.js' 19 | ] 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /tasks/less.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | module.exports = function less(grunt) { 5 | // Load task 6 | grunt.loadNpmTasks('grunt-contrib-less'); 7 | 8 | // Options 9 | return { 10 | build: { 11 | options: { 12 | cleancss: false 13 | }, 14 | files: [{ 15 | expand: true, 16 | cwd: 'public/css', 17 | src: ['**/*.less'], 18 | dest: '.build/css/', 19 | ext: '.css' 20 | }] 21 | } 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /tasks/mocha_istanbul.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | module.exports = function mocha_istanbul(grunt) { 5 | // Load task 6 | grunt.loadNpmTasks('grunt-mocha-istanbul'); 7 | // Options 8 | return { 9 | coverage: { 10 | src: ['test/**/*.js'/*,'lib', 'controllers'*/], // Where the tests are 11 | options: { 12 | src: ['test/**/*.js'], 13 | options: { 14 | timeout: 6000, 15 | 'check-leaks': true, 16 | ui: 'bdd', 17 | reporter: 'spec' 18 | } 19 | } 20 | } 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /tasks/mochacli.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | module.exports = function mochacli(grunt) { 5 | // Load task 6 | grunt.loadNpmTasks('grunt-mocha-cli'); 7 | 8 | // Options 9 | return { 10 | src: ['test/**/*.js'], 11 | options: { 12 | timeout: 6000, 13 | 'check-leaks': true, 14 | ui: 'bdd', 15 | reporter: 'spec' 16 | } 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /test/fixtures/MAINTAINERS: -------------------------------------------------------------------------------- 1 | Walter White walt@white.com (@heisenberg) 2 | Jessie Pinkman jpinkman@asu.edu (@jpinkman) 3 | Brandon Mayhew bmayhew@asu.edu (@badger) 4 | -------------------------------------------------------------------------------- /test/fixtures/MAINTAINERS-INACTIVE: -------------------------------------------------------------------------------- 1 | Walter White walt@white.com (@heisenberg) 2 | Jessie Pinkman jpinkman@asu.edu (@jpinkman) 3 | Brandon Mayhew bmayhew@asu.edu (@badger) - inactive 4 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /*global describe:false, it:false, beforeEach:false, afterEach:false*/ 2 | 3 | 'use strict'; 4 | 5 | 6 | var kraken = require('kraken-js'), 7 | express = require('express'), 8 | path = require('path'), 9 | request = require('supertest'); 10 | 11 | 12 | describe('index', function () { 13 | 14 | var app, mock; 15 | 16 | 17 | beforeEach(function (done) { 18 | app = express(); 19 | app.on('start', done); 20 | app.use(kraken({ 21 | basedir: path.resolve(__dirname, '..') 22 | })); 23 | 24 | mock = app.listen(1337); 25 | 26 | }); 27 | 28 | 29 | afterEach(function (done) { 30 | mock.close(done); 31 | }); 32 | 33 | 34 | it('should say "hello"', function (done) { 35 | request(mock) 36 | .get('/') 37 | .expect(200) 38 | .expect('Content-Type', /html/) 39 | 40 | .expect(/Hello, /) 41 | 42 | .end(function (err, res) { 43 | done(err); 44 | }); 45 | }); 46 | 47 | }); 48 | -------------------------------------------------------------------------------- /test/lib/ReviewUtilsTest.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by lmarkus on 2/16/17. 3 | */ 4 | 'use strict'; 5 | 6 | const mockery = require('mockery') 7 | , assert = require('chai').assert 8 | , testUtils = require('../testUtils') 9 | ; 10 | 11 | describe('ReviewUtils test suite', function () { 12 | 13 | let mockGHU = new testUtils.GetMaintainersMocker(); 14 | 15 | 16 | it('Does not cache active status', function (next) { 17 | let reviewUtils = require('../../lib/ReviewUtils') 18 | , db = require('../../lib/DBUtils') 19 | , config = {appConfig: {reviewers: 2}} 20 | ; 21 | 22 | // First call: Load from the inactive maintainers file. 23 | mockGHU.use('test/fixtures/MAINTAINERS-INACTIVE'); 24 | reviewUtils.getReviewers(config, function () { 25 | let isInactive = db.get('repos.undefined/undefined.maintainers.badger.isInactive'); 26 | assert.isTrue(isInactive, 'Correctly sets active status'); 27 | 28 | // Second call: Load from the active maintainers file. 29 | mockGHU.use('test/fixtures/MAINTAINERS'); 30 | reviewUtils.getReviewers(config, function () { 31 | let isInactive = db.get('repos.undefined/undefined.maintainers.badger.isInactive'); 32 | assert.isFalse(isInactive, 'Does not cache active status'); 33 | next(); 34 | }); 35 | 36 | }); 37 | 38 | }); 39 | 40 | 41 | before(function (next) { 42 | mockery.enable({ 43 | warnOnReplace: false, 44 | warnOnUnregistered: false, 45 | useCleanCache: true 46 | }); 47 | 48 | let mockLow = new testUtils.LowMocker(); 49 | mockLow.use('test/scratch/counts.json'); 50 | mockery.registerMock('lowdb', mockLow.getMock()); 51 | mockery.registerMock('../SlackUtils', class MockSlackUtils { 52 | static announceAssignees() {/*noop*/ 53 | } 54 | }); 55 | 56 | mockery.registerMock('./GitHubUtils', mockGHU.getMock()); 57 | next(); 58 | }); 59 | 60 | /** 61 | * Reset the mock DB before every test 62 | */ 63 | beforeEach(function (next) { 64 | let db = require('../../lib/DBUtils'); 65 | db.getInstance().setState({}); 66 | next(); 67 | }); 68 | 69 | after(function (next) { 70 | mockery.deregisterAll(); 71 | next(); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /test/lib/hookHandlers/CommentHandlerTest.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by lmarkus on 2/8/17. 3 | */ 4 | const fs = require('fs') 5 | , mockery = require('mockery') 6 | , assert = require('chai').assert 7 | , testUtils = require('../../testUtils') 8 | ; 9 | 10 | let mockData = { 11 | action: 'created', 12 | repository: { 13 | owner: { 14 | login: 'reviewBotOrg' 15 | }, 16 | name: 'reviewBot', 17 | }, 18 | issue: { 19 | number: 42 20 | }, 21 | comment: { 22 | body: 'assign @heisenberg' 23 | } 24 | }; 25 | 26 | describe('Comment Handler Tests', function () { 27 | let CommentHandler; 28 | 29 | it('Does not reassign a person twice', function (next) { 30 | let ch = new CommentHandler({}); 31 | 32 | //Make the same call twice, 33 | ch.handle('myRepo', 'master', mockData, 34 | function () { 35 | assert.equal(getHeisenbergCount(), 1, 'First assignment set correctly'); 36 | ch.handle('myRepo', 'master', mockData, function () { 37 | assert.equal(getHeisenbergCount(), 1, 'Second assignment is ignored'); 38 | return next(); 39 | }); 40 | }); 41 | 42 | }); 43 | 44 | /******* 45 | SETUP 46 | */ 47 | 48 | function getHeisenbergCount() { 49 | let db = require('../../../lib/DBUtils'); 50 | return db.get('repos.reviewBotOrg/reviewBot.maintainers.heisenberg.count'); 51 | } 52 | 53 | before(function (next) { 54 | mockery.enable({ 55 | warnOnReplace: false, 56 | warnOnUnregistered: false, 57 | useCleanCache: true 58 | }); 59 | 60 | let mockLow = new testUtils.LowMocker(); 61 | mockLow.use('test/scratch/counts.json'); 62 | mockery.registerMock('lowdb', mockLow.getMock()); 63 | mockery.registerMock('../SlackUtils', class MockSlackUtils { 64 | static announceAssignees() {/*noop*/ 65 | } 66 | }); 67 | mockery.registerMock('./GitHubUtils', class MockGHU { 68 | static getMaintainersFile(options = {}, callback) { 69 | fs.readFile('test/fixtures/MAINTAINERS', 'utf-8', 70 | function (err, data) { 71 | callback(err, data.split('\n')); 72 | } 73 | ); 74 | } 75 | }); 76 | CommentHandler = require('../../../lib/hookHandlers/CommentHandler'); 77 | return next(); 78 | }); 79 | 80 | /** 81 | * Reset the mock DB before every test 82 | */ 83 | beforeEach(function (next) { 84 | let db = require('../../../lib/DBUtils'); 85 | db.getInstance().setState({}); 86 | next(); 87 | }); 88 | 89 | after(function (next) { 90 | mockery.deregisterAll(); 91 | next(); 92 | }); 93 | 94 | }); 95 | 96 | 97 | -------------------------------------------------------------------------------- /test/testUtils/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by lmarkus on 2/8/17. 3 | */ 4 | const fs = require('fs'); 5 | 6 | let path = `${__dirname}/../scratch`; 7 | if (!fs.existsSync(path)) { 8 | fs.mkdirSync(path); 9 | } 10 | 11 | module.exports = { 12 | GetMaintainersMocker: class GetMaintainersMocker { 13 | constructor() { 14 | this.maintainersFile = ''; 15 | } 16 | 17 | use(path) { 18 | this.maintainersFile = path; 19 | } 20 | 21 | getMock() { 22 | return (function (parent) { 23 | return class MockGHU { 24 | static getMaintainersFile(options = {}, callback) { 25 | fs.readFile(parent.maintainersFile, 'utf-8', 26 | function (err, data) { 27 | callback(err, data.split('\n')); 28 | } 29 | ); 30 | } 31 | }; 32 | }(this)); 33 | } 34 | }, 35 | 36 | LowMocker: class LowMocker { 37 | 38 | constructor() { 39 | this.low = require('lowdb'); 40 | this.dbFile = ''; 41 | } 42 | 43 | use(path) { 44 | this.dbFile = path; 45 | } 46 | 47 | getMock() { 48 | return (function () { 49 | return this.low(this.dbFile); 50 | }).bind(this); 51 | } 52 | } 53 | }; 54 | 55 | 56 | --------------------------------------------------------------------------------