├── .gitignore ├── docs ├── images │ ├── claim-pr.png │ ├── approve-pr.png │ ├── remind-pr.png │ ├── submit-pr.png │ ├── github-token.png │ ├── webhook-events.png │ ├── webhook-settings.png │ ├── organization-webhook.png │ └── karma-monthly-leaderboard.png ├── code-review-karma.md ├── HUBOT_GITHUB_TOKEN.md └── github-webhook.md ├── src ├── CodeReview.coffee ├── CodeReviewsMiddleware.coffee ├── lib │ ├── sendFancyMessage.coffee │ ├── msgRoomName.coffee │ ├── EmojiDataParser.coffee │ ├── roomList.coffee │ └── emoji-data.txt ├── code-review-karma.coffee ├── CodeReviewKarma.coffee ├── code-reviews.coffee └── CodeReviews.coffee ├── test ├── data │ ├── prs.js │ └── users.js ├── lib │ └── util.js ├── codeReviewKarmaSpec.js ├── codeReviewEmojiAcceptSpec.js └── codeReviewSpec.js ├── index.coffee ├── buddy.yml ├── package.json ├── .eslintrc ├── coffeelint.json ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .DS_Store* 4 | .vscode 5 | -------------------------------------------------------------------------------- /docs/images/claim-pr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alleyinteractive/hubot-code-review/HEAD/docs/images/claim-pr.png -------------------------------------------------------------------------------- /docs/images/approve-pr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alleyinteractive/hubot-code-review/HEAD/docs/images/approve-pr.png -------------------------------------------------------------------------------- /docs/images/remind-pr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alleyinteractive/hubot-code-review/HEAD/docs/images/remind-pr.png -------------------------------------------------------------------------------- /docs/images/submit-pr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alleyinteractive/hubot-code-review/HEAD/docs/images/submit-pr.png -------------------------------------------------------------------------------- /docs/images/github-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alleyinteractive/hubot-code-review/HEAD/docs/images/github-token.png -------------------------------------------------------------------------------- /docs/images/webhook-events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alleyinteractive/hubot-code-review/HEAD/docs/images/webhook-events.png -------------------------------------------------------------------------------- /docs/images/webhook-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alleyinteractive/hubot-code-review/HEAD/docs/images/webhook-settings.png -------------------------------------------------------------------------------- /docs/images/organization-webhook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alleyinteractive/hubot-code-review/HEAD/docs/images/organization-webhook.png -------------------------------------------------------------------------------- /docs/images/karma-monthly-leaderboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alleyinteractive/hubot-code-review/HEAD/docs/images/karma-monthly-leaderboard.png -------------------------------------------------------------------------------- /src/CodeReview.coffee: -------------------------------------------------------------------------------- 1 | class CodeReview 2 | constructor: ( 3 | @user, 4 | @slug, 5 | @url, 6 | @room = @user.room, 7 | @channel_id = @user.room, 8 | @status = 'new', 9 | @reviewer = false) -> 10 | @last_updated = Date.now() 11 | @extra_info = "" 12 | @github_pr_submitter = @user 13 | 14 | module.exports = CodeReview -------------------------------------------------------------------------------- /src/CodeReviewsMiddleware.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (robot) -> 2 | robot.listenerMiddleware (context, next, done) -> 3 | unless context.response.message.text and 4 | context.response.message.text.indexOf('help crs') isnt -1 5 | next() 6 | else 7 | # disable default `hubot help ` response when requesting `hubot help crs` 8 | regex = context.listener.regex.toString() 9 | if regex.indexOf('help') > -1 and regex.indexOf('help crs') is -1 10 | done() 11 | else 12 | next() -------------------------------------------------------------------------------- /test/data/prs.js: -------------------------------------------------------------------------------- 1 | const PullRequests = [ 2 | 'https://github.com/alleyinteractive/wp-seo/pull/378', 3 | 'https://github.com/alleyinteractive/wordpress-fieldmanager/pull/559/files', 4 | 'https://github.com/alleyinteractive/searchpress/pull/23', 5 | 'https://github.com/alleyinteractive/huron/pull/567', 6 | 'https://github.com/alleyinteractive/ad-layers/pull/1/files', 7 | 'https://github.com/alleyinteractive/wordpress-fieldmanager/pull/558', 8 | 'https://github.com/alleyinteractive/photonfill/pull/18', 9 | ]; 10 | 11 | module.exports = PullRequests; 12 | -------------------------------------------------------------------------------- /index.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Manage code review reminders 3 | # 4 | # Dependencies: 5 | # None 6 | # 7 | # Configuration: 8 | # HUBOT_GITHUB_TOKEN 9 | # HUBOT_CODE_REVIEW_KARMA_DISABLED (if true, disable karma functionality) 10 | # HUBOT_CODE_REVIEW_EMOJI_APPROVE (if true, approve CRs with emojis in the comment) 11 | # 12 | # Commands: 13 | # hubot help crs - display code review help 14 | # 15 | 16 | Path = require 'path' 17 | 18 | module.exports = (robot) -> 19 | # Unless code review karma is disabled 20 | unless (process.env.HUBOT_CODE_REVIEW_KARMA_DISABLED)? 21 | robot.loadFile(Path.resolve(__dirname, "src"), "code-review-karma.coffee") 22 | robot.loadFile(Path.resolve(__dirname, "src"), "code-reviews.coffee") 23 | 24 | -------------------------------------------------------------------------------- /docs/code-review-karma.md: -------------------------------------------------------------------------------- 1 | Code Review Karma (aka. points/leaderboard) 2 | =================== 3 | 4 | Code Reviews take time and effort to do right; `hubot-code-review` quietly 5 | keeps track of the number of `gives` (reviews) and `takes` (reviews requested) for code reviews across all rooms. 6 | 7 | ## Configuration 8 | 9 | - To automatically display the monthly resetting leaderboard to a particular room, set the `HUBOT_CODE_REVIEW_KARMA_MONTHLY_AWARD_ROOM` parameter to the name of the group or channel you wish to notify. 10 | 11 | ![](/docs/images/karma-monthly-leaderboard.png) 12 | 13 | - You can disable the code review ranking commands by setting the `HUBOT_CODE_REVIEW_KARMA_DISABLED` 14 | environmental variable to `true` 15 | 16 | 17 | ## What can I say? 18 | 19 | hubot: what are the code review rankings? 20 | hubot: what are the monthly code review rankings? 21 | hubot: list all cr scores 22 | hubot: what is my cr score? 23 | hubot: what is [USER]'s cr score? 24 | hubot: remove [USER]'s from cr rankings 25 | hubot: remove [USER]'s from cr rankings 26 | hubot: merge [USER1]'s cr score into [USER2]' 27 | -------------------------------------------------------------------------------- /docs/HUBOT_GITHUB_TOKEN.md: -------------------------------------------------------------------------------- 1 | `HUBOT_GITHUB_TOKEN` for hubot-code-review 2 | =================== 3 | 4 | Adding a `HUBOT_GITHUB_TOKEN` environmental variable to hubot-code-review allows Hubot to 5 | query GitHub for file type information when reporting the PR back to the room. 6 | 7 | ![](/docs/images/remind-pr.png) 8 | 9 | ## Which GitHub account should create the 'Personal Access Token'? 10 | 11 | Some organizations might already have a dedicated user account for operational/deployment 12 | purposes. Whichever account you use to [create the access token](https://help.github.com/articles/creating-an-access-token-for-command-line-use/) should have access to the repositories 13 | you want to enable the filetype checking for. 14 | 15 | ## What scope does the HUBOT_GITHUB_TOKEN need? 16 | 17 | We'll need the `repo` scope. 18 | 19 | ![](/docs/images/github-token.png) 20 | 21 | ## Now what? 22 | 23 | After you've created your token, add it as an environmental variable to your Hubot instance. 24 | Hubot will now identify filetypes included in the PR, saving a curious click-through before 25 | claiming a PR :) 26 | 27 | If you haven't done so already, check out the 28 | [GitHub webhook instructions for hubot-code-review](/docs/github-webhook.md) 29 | -------------------------------------------------------------------------------- /buddy.yml: -------------------------------------------------------------------------------- 1 | - pipeline: "Test all PRs" 2 | trigger_mode: "ON_EVERY_PUSH" 3 | ref_name: "refs/pull/*" 4 | ref_type: "WILDCARD" 5 | priority: "NORMAL" 6 | fail_on_prepare_env_warning: true 7 | trigger_condition: "ALWAYS" 8 | actions: 9 | - action: "npm ci" 10 | type: "BUILD" 11 | working_directory: "/buddy/hubot-code-review" 12 | docker_image_name: "library/node" 13 | docker_image_tag: "16" 14 | execute_commands: 15 | - "npm ci --cache .npm" 16 | volume_mappings: 17 | - "/:/buddy/hubot-code-review" 18 | trigger_condition: "ALWAYS" 19 | shell: "BASH" 20 | - action: "Execute: npm run lint" 21 | type: "BUILD" 22 | working_directory: "/buddy/hubot-code-review" 23 | docker_image_name: "library/node" 24 | docker_image_tag: "16" 25 | execute_commands: 26 | - "npm run lint" 27 | volume_mappings: 28 | - "/:/buddy/hubot-code-review" 29 | trigger_condition: "ALWAYS" 30 | shell: "BASH" 31 | run_next_parallel: true 32 | - action: "Execute: npm run test" 33 | type: "BUILD" 34 | working_directory: "/buddy/hubot-code-review" 35 | docker_image_name: "library/node" 36 | docker_image_tag: "16" 37 | execute_commands: 38 | - "npm run test" 39 | volume_mappings: 40 | - "/:/buddy/hubot-code-review" 41 | trigger_condition: "ALWAYS" 42 | shell: "BASH" 43 | -------------------------------------------------------------------------------- /src/lib/sendFancyMessage.coffee: -------------------------------------------------------------------------------- 1 | # Send Slack formatted message 2 | # @param robot 3 | # @param room String name of room 4 | # @param attachments https://api.slack.com/docs/message-attachments 5 | # @return none 6 | module.exports = (robot, room, attachments, text) -> 7 | if robot.adapterName isnt "slack" 8 | fallback_text = text || '' 9 | for index, attachment of attachments 10 | fallback_text += "\n#{attachment.fallback}" 11 | robot.messageRoom room, fallback_text.replace(/\n$/, "") 12 | else 13 | # Strip any preceeding # from room name 14 | room_name = room.replace /^#/g, "" 15 | # Working around a Slack for Android bug 2016-08-30 by supplying 16 | # text attribute outside of attachment array to allow previews 17 | if text? 18 | try 19 | robot.send { room: room_name }, 20 | as_user: true 21 | channel: room_name 22 | text: text 23 | attachments: attachments 24 | catch sendErr 25 | robot.logger.error "Unable to send message to room: #{room}: ", sendErr, attachments 26 | else 27 | try 28 | robot.send { room: room_name }, 29 | as_user: true 30 | channel: room_name 31 | attachments: attachments 32 | catch sendErr 33 | robot.logger.error "Unable to send message to room: #{room}: ", sendErr, attachments 34 | -------------------------------------------------------------------------------- /docs/github-webhook.md: -------------------------------------------------------------------------------- 1 | GitHub webhook instructions for hubot-code-review 2 | =================== 3 | 4 | Adding a webhook from GitHub makes `hubot-code-review` even better. With the hook in place, 5 | Hubot can DM you when your PR has been approved or when someone has commented 6 | on your PR. 7 | 8 | ## Organization or Repository Webhook? 9 | 10 | While you can add webhooks to individual repositories, adding an organization-wide hook is 11 | a convenient way to include all repositories (current and future) in your organization. 12 | 13 | ![](/docs/images/organization-webhook.png) 14 | 15 | ## Webhook specifics: 16 | 17 | *Payload Url:* 18 | Hubot will have a listener at `{BASEURL}/hubot/hubot-code-review` where `{BASEURL}` is your 19 | Hubot URL (on Heroku, this might be the `HEROKU_URL` environmental variable). 20 | 21 | *Content Type:* 22 | This should be set to `application/json` 23 | 24 | ![](/docs/images/webhook-settings.png) 25 | 26 | *Which Events...:* 27 | 28 | We need to create a hook to the above url that passes along the following events: 29 | 30 | - Issue Comment 31 | - Pull Request 32 | - Pull Request Review 33 | 34 | ![](/docs/images/webhook-events.png) 35 | 36 | 37 | ## What now? 38 | 39 | You're set! This organization (or repository) will tell `hubot-code-review` whenever there 40 | are substantive changes to the PR status! 41 | 42 | If you haven't done so already, configure [`HUBOT_GITHUB_TOKEN` for hubot-code-review](/docs/HUBOT_GITHUB_TOKEN.md) 43 | -------------------------------------------------------------------------------- /src/lib/msgRoomName.coffee: -------------------------------------------------------------------------------- 1 | # Return the human-readable name of the channel the msg was sent in 2 | # for hubot-slack's breaking change from 3->4 of using ID instead of name 3 | # @param {msg Object} 4 | # @param {next Function} callback 5 | # @param {retryCount Number} number of times this function has been retried 6 | # @return String human-readable name of the channel 7 | module.exports = (msg, next, retryCount = 0) -> 8 | if msg.robot.adapterName is "slack" 9 | msg.robot.http("https://slack.com/api/conversations.info") 10 | .query({ 11 | channel: msg.message.room 12 | }) 13 | .header('Authorization', 'Bearer ' + process.env.HUBOT_SLACK_TOKEN) 14 | .get() (err, response, body) -> 15 | body = JSON.parse(body) if body 16 | if err || response.statusCode isnt 200 || !body?.channel 17 | if retryCount < 3 # Retry up to 3 times 18 | console.warn "Retry [#{retryCount + 1}] to get channel info for #{msg.message.room}" 19 | # Retry after 1 second 20 | setTimeout (-> module.exports(msg, next, retryCount + 1)), 1000 21 | else 22 | error = err || (body)?.error 23 | console.error "Failed to get channel name after #{retryCount} retries for " + \ 24 | "#{msg.message.room}. Status: #{response.statusCode}, Error: #{error}" 25 | next null # Signal failure after retries 26 | else 27 | next body.channel.name 28 | else 29 | next msg.message.room -------------------------------------------------------------------------------- /src/lib/EmojiDataParser.coffee: -------------------------------------------------------------------------------- 1 | fs = require 'fs' 2 | path = require 'path' 3 | punycode = require 'punycode' 4 | 5 | class EmojiDataParser 6 | # construct array of Emoji character ranges from source file 7 | constructor: () -> 8 | @ranges = [] 9 | @regex = /^(\w+)(?:\.\.)?(\w+)?/ 10 | 11 | # make array of lines that are not comments or empty 12 | sourceFile = fs.readFileSync path.join(__dirname, 'emoji-data.txt'), 'utf8' 13 | sourceFileLines = sourceFile.split("\n").filter (line) -> 14 | return ! (line.length == 0 || line.charAt(0) == '#') 15 | 16 | # make array of min/max ranges 17 | # but skip low ones like 18 | for line in sourceFileLines 19 | matches = @regex.exec line 20 | min = if matches then parseInt(matches[1], 16) else 0 21 | max = if matches[2] then parseInt(matches[2], 16) else min 22 | if max > 1000 23 | @ranges.push [min, max] 24 | 25 | # Does string contain any emoji? 26 | testString: (string) -> 27 | # convert string to array of decimal int char codes 28 | # including UCS-2 surrogate pairs to Unicode single char 29 | chars = punycode.ucs2.decode string 30 | for char in chars 31 | if @charIsEmoji(char) 32 | return true 33 | return false 34 | 35 | # check if charCode falls into our emoji ranges 36 | # @param int charCode 37 | # @return bool 38 | charIsEmoji: (charCode) -> 39 | for range in @ranges 40 | if charCode >= range[0] and charCode <= range[1] 41 | return true 42 | return false 43 | 44 | module.exports = EmojiDataParser -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hubot-code-review", 3 | "version": "1.4.2", 4 | "author": "Alley Interactive ", 5 | "description": "A Hubot script for GitHub code review on Slack [archived]", 6 | "main": "index.coffee", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/alleyinteractive/hubot-code-review" 10 | }, 11 | "keywords": [ 12 | "hubot", 13 | "code review", 14 | "codereview", 15 | "github", 16 | "slack", 17 | "chatops" 18 | ], 19 | "homepage": "https://github.com/alleyinteractive/hubot-code-review", 20 | "scripts": { 21 | "test": "HUBOT_CODE_REVIEW_EMOJI_APPROVE=true node_modules/.bin/jasmine-node --verbose --coffee --color --forceexit test/", 22 | "lint": "node_modules/.bin/coffeelint src/", 23 | "lint-tests": "eslint -c .eslintrc --ext .js ./test" 24 | }, 25 | "dependencies": { 26 | "coffeescript": "^1.12.7", 27 | "githubot": "^1.0.1", 28 | "hubot": "^3.3.2", 29 | "hubot-redis-brain": "github:hubotio/hubot-redis-brain#2ab8963", 30 | "moment": "^2.29.1", 31 | "node-schedule": "^2.1.0", 32 | "punycode": "^2.1.1" 33 | }, 34 | "peerDependencies": { 35 | "hubot-slack": "^4" 36 | }, 37 | "engines": { 38 | "node": ">= 16", 39 | "npm": ">= 8" 40 | }, 41 | "bugs": { 42 | "url": "https://github.com/alleyinteractive/hubot-code-review/issues" 43 | }, 44 | "license": "GPL-3.0", 45 | "devDependencies": { 46 | "coffeelint": "^2.1.0", 47 | "eslint": "^6.8.0", 48 | "eslint-config-airbnb": "^18.0.1", 49 | "eslint-plugin-import": "^2.20.1", 50 | "eslint-plugin-jsx-a11y": "^6.2.3", 51 | "eslint-plugin-react": "^7.19.0", 52 | "eslint-plugin-react-hooks": "^2.5.0", 53 | "hubot-mock-adapter": "~1.1.1", 54 | "jasmine-custom-message": "^0.8.2", 55 | "jasmine-node": "^3.0.0", 56 | "supertest": "~3.0.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/lib/roomList.coffee: -------------------------------------------------------------------------------- 1 | # Test whether slack room still exists as named 2 | # (eg. when rooms are archived) 3 | # @param {msg Object} 4 | # @return bool true/false whether the slack room exists 5 | 6 | async = require 'async' # Included in slack/client 7 | 8 | module.exports = (robot, next) -> 9 | if robot.adapterName is "slack" 10 | valid_channels = [] 11 | cursor = null # When cursor is null, Slack provides first 'page' 12 | async.whilst (() -> 13 | ! (cursor is '') # Slack uses an empty string cursor to indicate no more 14 | ), ((step) => 15 | robot.adapter.client.web.conversations.list({ 16 | limit: 1000, 17 | types: "public_channel,private_channel,im,mpim", 18 | exclude_archived: true, 19 | cursor 20 | }) 21 | .then (body) => 22 | if body.ok 23 | # Set new cursor 'page' 24 | cursor = body.response_metadata.next_cursor 25 | 26 | valid_channel_ids = body.channels 27 | .filter((each) -> ((! each.is_archived and each.is_member) or 28 | (each.is_im is true))) 29 | .map((each) -> (each.id)) 30 | .filter((each) -> (each)) #filter out undefined 31 | valid_channel_names = body.channels 32 | .filter((each) -> ((! each.is_archived and each.is_member) or 33 | (each.is_im is true))) 34 | .map((each) -> (each.name)) 35 | .filter((each) -> (each)) #filter out undefined 36 | 37 | # Append names and ids to valid_channels 38 | valid_channels.push.apply(valid_channels, valid_channel_ids.concat(valid_channel_names)) 39 | 40 | step() 41 | else 42 | @robot.logger.error "Unable to call conversations.list:", body 43 | ), () -> 44 | next valid_channels 45 | 46 | else 47 | # assume room exists for alternate adapters (eg. local development) 48 | return true 49 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | // Extend the AirBnb lint config 3 | "extends": "airbnb", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "ecmaFeatures": { 7 | "experimentalObjectRestSpread": true, 8 | "globalReturn": true, 9 | "impliedStrict": true, 10 | "jsx": true, 11 | }, 12 | "sourceType": "module", 13 | }, 14 | "env": { 15 | "es6": true, 16 | "browser": true, 17 | "node": true, 18 | "jquery": true, 19 | // Optional Enables 20 | "webextensions": false, // Enable if using Web Extensions 21 | // Optional Testing Frameworks 22 | "jasmine": false, // Enable if using Jasmine testing framework 23 | "protractor": false, // Enable if using Protractor testing framework 24 | "mocha": false // Enable if using Mocha testing framework 25 | }, 26 | "globals": { 27 | "jQuery": true, 28 | "angular": false, // Enable if using Angular 29 | }, 30 | // Do NOT change these rules 31 | "rules": { 32 | "indent": [2, 2, {"SwitchCase": 1}], 33 | "max-len": [2, 80, 4, { 34 | "ignoreComments": true, 35 | "ignoreUrls": true, 36 | "ignoreStrings": true, 37 | "ignoreTemplateLiterals": true 38 | }], 39 | "quotes": [2, "single"], // Allows template literals if they have substitutions or line breaks 40 | "semi": [2, "always"], 41 | "no-multiple-empty-lines": [2, {"max": 1}], 42 | "comma-dangle": [2, "always-multiline"], 43 | "dot-location": [2, "property"], 44 | "one-var": [2, "never"], 45 | "no-var": [2], // Stop using var, use const or let instead 46 | "prefer-const": ["error"], 47 | "no-bitwise": [2], 48 | "id-length": ["error", { 49 | "properties": "never", 50 | "exceptions": ["x", "y", "i", "e", "n", "k"] 51 | }], 52 | "func-names": [1, "always"], // This aids in debugging 53 | "no-use-before-define": [2, "nofunc"], 54 | "yoda": [2, "always"], 55 | "object-curly-spacing": [2, "always"], 56 | "array-bracket-spacing": [2, "never"], 57 | "space-unary-ops": [2, {"words": true, "nonwords": false, "overrides": {"!": true}}], 58 | "keyword-spacing": ["error", {"after": true}], 59 | "space-before-blocks": [2, "always"], 60 | "space-in-parens": [2, "never"], 61 | "spaced-comment": [2, "always"], 62 | "no-confusing-arrow": ["error", {"allowParens": true}], // See eslint config for reasons 63 | "no-constant-condition": ["error"], 64 | "arrow-parens": ["error", "always"], 65 | "operator-linebreak": ["error", "after"] 66 | } 67 | } -------------------------------------------------------------------------------- /test/lib/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper functions 3 | */ 4 | const TextMessage = require('../../node_modules/hubot/src/message').TextMessage; 5 | 6 | module.exports = { 7 | 8 | /** 9 | * get a random item from an array 10 | * @param src array Source array to get a random element from 11 | * @param int exclude Optional index in array to exclude 12 | * @return misc Array element 13 | */ 14 | getRandom: (src, exclude) => { 15 | if ('undefined' === typeof exclude) { 16 | exclude = -1; 17 | } 18 | 19 | // if random index in the excluded index, adjust up or down 20 | let randIndex = Math.floor(Math.random() * src.length); 21 | if (exclude === randIndex) { 22 | if (0 === randIndex) { 23 | randIndex++; 24 | } else { 25 | randIndex--; 26 | } 27 | } 28 | return { 29 | value: src[randIndex], 30 | index: randIndex, 31 | }; 32 | }, 33 | 34 | /** 35 | * use setTimeout to send a message asynchronously 36 | * this gives Redis time to update, etc 37 | * @param object adapter robot.adapter (hubot-mock-adapter) 38 | * @param object user Hubot user object 39 | * @param string text Text of message 40 | * @param int delay Optional delay for setTimeout, defaults to 1ms 41 | * @param function callback Optional callback that can contain one or more assertions. 42 | * Do not use if the same user sends the same message multiple times in one test! 43 | */ 44 | sendMessageAsync: (adapter, user, text, delay, callback) => { 45 | if ('undefined' === typeof delay || 0 >= delay) { 46 | delay = 1; 47 | } 48 | 49 | let messageId = [user.room, user.name, text, delay].join('-'); 50 | messageId = messageId.replace(/\s+/g, ''); 51 | 52 | if ('function' === typeof callback) { 53 | adapter.on('send', (envelope, strings) => { 54 | if (envelope.message.id === messageId) { 55 | callback(envelope, strings); 56 | } 57 | }); 58 | } 59 | 60 | setTimeout(() => { 61 | adapter.receive(new TextMessage(user, text, messageId)); 62 | }, delay); 63 | }, 64 | 65 | /** 66 | * Randomize array element order in-place. 67 | * Using Durstenfeld shuffle algorithm. 68 | * co. http://stackoverflow.com/a/12646864 69 | */ 70 | shuffleArray: (array) => { 71 | for (let i = array.length - 1; 0 < i; i--) { 72 | const j = Math.floor(Math.random() * (i + 1)); 73 | const temp = array[i]; 74 | array[i] = array[j]; 75 | array[j] = temp; 76 | } 77 | return array; 78 | }, 79 | }; 80 | -------------------------------------------------------------------------------- /test/data/users.js: -------------------------------------------------------------------------------- 1 | var Users = function () { 2 | let usersList = [ 3 | { 4 | ID: 'UL7X4TN2AM', 5 | meta: { 6 | name: 'Shell', 7 | }, 8 | }, 9 | { 10 | ID: 'UA8J690401', 11 | meta: { 12 | name: 'Alexis', 13 | }, 14 | }, 15 | { 16 | ID: 'UO6GDK59VL', 17 | meta: { 18 | name: 'Davidson', 19 | }, 20 | }, 21 | { 22 | ID: 'U3DU3VIHQJ', 23 | meta: { 24 | name: 'Leann', 25 | }, 26 | }, 27 | { 28 | ID: 'UBM793RYYB', 29 | meta: { 30 | name: 'Lee', 31 | }, 32 | }, 33 | { 34 | ID: 'UKZBBC96H6', 35 | meta: { 36 | name: 'Welch', 37 | }, 38 | }, 39 | { 40 | ID: 'UQ8SM826CB', 41 | meta: { 42 | name: 'Reynolds', 43 | }, 44 | }, 45 | { 46 | ID: 'U9JWFZTT4X', 47 | meta: { 48 | name: 'Hardy', 49 | }, 50 | }, 51 | { 52 | ID: 'UHZEW2ACFC', 53 | meta: { 54 | name: 'Jacklyn', 55 | }, 56 | }, 57 | { 58 | ID: 'UBI4MVABXT', 59 | meta: { 60 | name: 'Vargas', 61 | }, 62 | }, 63 | { 64 | ID: 'UDMPZU58WB', 65 | meta: { 66 | name: 'Alston', 67 | }, 68 | }, 69 | { 70 | ID: 'U7NAPZ13BW', 71 | meta: { 72 | name: 'Kristina', 73 | }, 74 | }, 75 | { 76 | ID: 'UCGLW5IRKM', 77 | meta: { 78 | name: 'Hilda', 79 | }, 80 | }, 81 | { 82 | ID: 'ULTD5SPV46', 83 | meta: { 84 | name: 'Iva', 85 | }, 86 | }, 87 | { 88 | ID: 'U7W1YSL7L7', 89 | meta: { 90 | name: 'Dianne', 91 | }, 92 | }, 93 | ]; 94 | 95 | const numUsers = usersList.length; 96 | 97 | const defaultRoom = 'test_room'; 98 | 99 | usersList = usersList.map((user, i) => { 100 | user.meta.room = defaultRoom; 101 | user.index = i; 102 | return user; 103 | }); 104 | 105 | /** 106 | * get a specific user by index 107 | * @param int index Index of user in list 108 | * @return Object User JSON object 109 | */ 110 | Users.getUser = function (index) { 111 | return 0 <= index && index < numUsers ? usersList[index] : false; 112 | }; 113 | 114 | /** 115 | * get all users 116 | * @return array List of user JSON objects 117 | */ 118 | Users.getUsers = function () { 119 | return usersList; 120 | }; 121 | 122 | return Users; 123 | }; 124 | 125 | module.exports = Users; 126 | -------------------------------------------------------------------------------- /coffeelint.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrow_spacing": { 3 | "level": "error" 4 | }, 5 | "braces_spacing": { 6 | "level": "error", 7 | "spaces": 1, 8 | "empty_object_spaces": 0 9 | }, 10 | "camel_case_classes": { 11 | "level": "error" 12 | }, 13 | "coffeescript_error": { 14 | "level": "error" 15 | }, 16 | "colon_assignment_spacing": { 17 | "level": "error", 18 | "spacing": { 19 | "left": 0, 20 | "right": 1 21 | } 22 | }, 23 | "cyclomatic_complexity": { 24 | "level": "ignore", 25 | "value": 10 26 | }, 27 | "duplicate_key": { 28 | "level": "error" 29 | }, 30 | "empty_constructor_needs_parens": { 31 | "level": "ignore" 32 | }, 33 | "ensure_comprehensions": { 34 | "level": "warn" 35 | }, 36 | "eol_last": { 37 | "level": "ignore" 38 | }, 39 | "indentation": { 40 | "value": 2, 41 | "level": "error" 42 | }, 43 | "line_endings": { 44 | "level": "ignore", 45 | "value": "unix" 46 | }, 47 | "max_line_length": { 48 | "value": 100, 49 | "level": "warn", 50 | "limitComments": true 51 | }, 52 | "missing_fat_arrows": { 53 | "level": "ignore", 54 | "is_strict": false 55 | }, 56 | "newlines_after_classes": { 57 | "value": 3, 58 | "level": "ignore" 59 | }, 60 | "no_backticks": { 61 | "level": "error" 62 | }, 63 | "no_debugger": { 64 | "level": "warn", 65 | "console": false 66 | }, 67 | "no_empty_functions": { 68 | "level": "error" 69 | }, 70 | "no_empty_param_list": { 71 | "level": "ignore" 72 | }, 73 | "no_implicit_braces": { 74 | "level": "ignore", 75 | "strict": true 76 | }, 77 | "no_implicit_parens": { 78 | "level": "ignore", 79 | "strict": true 80 | }, 81 | "no_interpolation_in_single_quotes": { 82 | "level": "error" 83 | }, 84 | "no_nested_string_interpolation": { 85 | "level": "warn" 86 | }, 87 | "no_plusplus": { 88 | "level": "ignore" 89 | }, 90 | "no_private_function_fat_arrows": { 91 | "level": "warn" 92 | }, 93 | "no_stand_alone_at": { 94 | "level": "error" 95 | }, 96 | "no_tabs": { 97 | "level": "error" 98 | }, 99 | "no_this": { 100 | "level": "warn" 101 | }, 102 | "no_throwing_strings": { 103 | "level": "error" 104 | }, 105 | "no_trailing_semicolons": { 106 | "level": "error" 107 | }, 108 | "no_trailing_whitespace": { 109 | "level": "error", 110 | "allowed_in_comments": false, 111 | "allowed_in_empty_lines": true 112 | }, 113 | "no_unnecessary_double_quotes": { 114 | "level": "ignore" 115 | }, 116 | "no_unnecessary_fat_arrows": { 117 | "level": "warn" 118 | }, 119 | "non_empty_constructor_needs_parens": { 120 | "level": "ignore" 121 | }, 122 | "prefer_english_operator": { 123 | "level": "ignore", 124 | "doubleNotLevel": "ignore" 125 | }, 126 | "space_operators": { 127 | "level": "error" 128 | }, 129 | "spacing_after_comma": { 130 | "level": "error" 131 | }, 132 | "transform_messes_up_line_numbers": { 133 | "level": "warn" 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/code-review-karma.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Keep track of code review karma 3 | # 4 | # Dependencies: 5 | # None 6 | # 7 | # Configuration: 8 | # see README.md -> docs/code-review-karma.md 9 | 10 | CodeReviewKarma = require './CodeReviewKarma' 11 | 12 | module.exports = (robot) -> 13 | unless (process.env.HUBOT_CODE_REVIEW_KARMA_DISABLED)? 14 | code_review_karma = new CodeReviewKarma robot 15 | 16 | robot.respond /(?:what (?:is|are) the )?(?:code review|cr) (?:rankings|leaderboard)\??/i, 17 | (msg) -> 18 | [gives, takes, karmas] = code_review_karma.rankings() 19 | msg.send [ 20 | "#{gives.user} #{if gives.user.indexOf(',') > -1 then 'have' else 'has'}" + 21 | " done the most reviews with #{gives.score}", 22 | "#{takes.user} #{if takes.user.indexOf(',') > -1 then 'have' else 'has'}" + 23 | " asked for the most code reviews with #{takes.score}", 24 | "#{karmas.user} #{if karmas.user.indexOf(',') > -1 then 'have' else 'has'}" + 25 | " the best code karma score with #{karmas.score}" 26 | ].join("\n") 27 | 28 | robot.respond /list (?:all )?(?:code review|cr) scores/i, (msg) -> 29 | output = [] 30 | for user, scores of code_review_karma.scores 31 | output.push "#{user} has received #{scores.take} reviews and given #{scores.give}." + 32 | " Code karma: #{code_review_karma.karma(scores.give, scores.take)}" 33 | msg.send output.join("\n") 34 | 35 | robot.respond /what (?:is|are) (\w+)(?:'s?)? cr scores?\??/i, (msg) -> 36 | if 'my' is msg.match[1].toLowerCase() 37 | # Handle case of msg using user (unit test) vs. user.name (slack adapter) 38 | user = if (msg.message.user.name)? then msg.message.user.name else msg.message.user 39 | else 40 | user = msg.match[1] 41 | scores = code_review_karma.scores_for_user user 42 | 43 | msg.send "#{user} has received #{scores.all_scores.take} reviews " + 44 | "and given #{scores.all_scores.give}. Code karma: " + 45 | "#{code_review_karma.karma(scores.all_scores.give, scores.all_scores.take)}" 46 | 47 | robot.respond /remove ([-_a-z0-9]+) from cr rankings/i, (msg) -> 48 | user = msg.match[1] 49 | scores = code_review_karma.scores_for_user user 50 | if code_review_karma.remove_user user 51 | msg.send "Removing #{user}, who currently has received #{scores.all_scores.take}" + 52 | " reviews and given #{scores.all_scores.give}..." 53 | else 54 | msg.send "I could not remove #{user} from the CR rankings" 55 | 56 | # coffeelint: disable=max_line_length 57 | robot.respond /(?:what (?:is|are) the )?monthly (?:code review|cr) (?:rankings|leaderboard|scores?)\??/i, (msg) -> 58 | # coffeelint: enable=max_line_length 59 | code_review_karma.monthly_rankings(msg) 60 | 61 | # Flush all the scores 62 | robot.respond /flush cr scores, really really/i, (msg) -> 63 | code_review_karma.flush_scores() 64 | msg.send "This house is clear" 65 | 66 | # Flush monthly scores 67 | robot.respond /flush monthly cr scores, really really/i, (msg) -> 68 | code_review_karma.flush_monthly_scores() 69 | msg.send "This house is clear" 70 | 71 | robot.respond /merge ([-_a-z0-9]+)(?:'s?)? cr scores into ([-_a-z0-9]+)/i, (msg) -> 72 | old_user = msg.match[1] 73 | new_user = msg.match[2] 74 | scores = code_review_karma.scores_for_user old_user 75 | msg.send "Removing #{old_user}, who currently has received #{scores.all_scores.take}" + 76 | " reviews and given #{scores.all_scores.give}..." 77 | if code_review_karma.remove_user old_user 78 | msg.send "I removed #{old_user} from the CR rankings" 79 | 80 | code_review_karma.incr_score new_user, 'take', scores.take 81 | code_review_karma.incr_score new_user, 'give', scores.give 82 | msg.send "I added #{scores.give} and #{scores.take} give and" + 83 | " take points respectively to #{old_user}" 84 | 85 | new_score = code_review_karma.scores_for_user new_user 86 | msg.send "#{new_user} has now received #{new_score.take} reviews and given" + 87 | "#{new_score.give}. Code karma: #{code_review_karma.karma(new_score.give, new_score.take)}" 88 | else 89 | msg.send "I could not remove #{user} from the CR rankings" 90 | 91 | # return for use in unit tests 92 | return code_review_karma -------------------------------------------------------------------------------- /test/codeReviewKarmaSpec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jasmine */ 2 | 3 | const Robot = require('../node_modules/hubot/src/robot'); 4 | const util = require('./lib/util'); 5 | const Users = require('./data/users'); 6 | 7 | /** 8 | * Tests the following features of code-review-karma 9 | gives reviews 10 | takes reviews 11 | lists all cr scores 12 | reports my score 13 | reports someones else's score 14 | displays the leaderboard 15 | * TODO: 16 | merge 17 | remove 18 | */ 19 | 20 | describe('Code Review Karma', () => { 21 | let robot = {}; 22 | let adapter = {}; 23 | let codeReviewKarma = {}; 24 | 25 | /** 26 | * @const array List of Hubot User objects 27 | */ 28 | let users = []; 29 | 30 | beforeEach((done) => { 31 | // create new robot, without http, using the mock adapter 32 | robot = new Robot(null, 'mock-adapter', true, 'hubot'); 33 | 34 | robot.adapter.on('connected', () => { 35 | // create a user 36 | Users().getUsers().forEach((user) => { 37 | users.push(robot.brain.userForId(user.ID, { 38 | name: user.meta.name, 39 | room: user.meta.room, 40 | })); 41 | }); 42 | 43 | // shuffle users for fun, variability, and profit 44 | users = util.shuffleArray(users); 45 | 46 | // load the module 47 | codeReviewKarma = require('../src/code-review-karma')(robot); 48 | adapter = robot.adapter; 49 | 50 | // start each test with an empty queue 51 | codeReviewKarma.flush_scores(); 52 | // wait a sec for Redis 53 | setTimeout(() => { 54 | done(); 55 | }, 150); 56 | }); 57 | 58 | robot.run(); 59 | }); 60 | 61 | afterEach(() => { 62 | users = []; 63 | adapter = null; 64 | robot.server.close(); 65 | robot.shutdown(); 66 | }); 67 | 68 | it('gives reviews', (done) => { 69 | codeReviewKarma.incr_score(users[0].name, 'give'); 70 | expect(codeReviewKarma.scores_for_user(users[0].name) 71 | .all_scores.give).toBe(1); 72 | expect(codeReviewKarma.scores_for_user(users[0].name) 73 | .month_scores.give).toBe(1); 74 | done(); 75 | }); 76 | 77 | it('takes reviews', (done) => { 78 | codeReviewKarma.incr_score(users[0].name, 'take'); 79 | expect(codeReviewKarma.scores_for_user(users[0].name) 80 | .all_scores.take).toBe(1); 81 | expect(codeReviewKarma.scores_for_user(users[0].name) 82 | .month_scores.take).toBe(1); 83 | done(); 84 | }); 85 | 86 | it('lists all cr scores', (done) => { 87 | codeReviewKarma.incr_score(users[1].name, 'give'); 88 | codeReviewKarma.incr_score(users[1].name, 'give'); 89 | codeReviewKarma.incr_score(users[1].name, 'take'); 90 | 91 | util.sendMessageAsync( 92 | adapter, users[1].name, 'hubot list all cr scores', 93 | 100, 94 | (envelope, strings) => { 95 | const expectString = 96 | `${users[1].name} has received 1 reviews and given 2. Code karma: 1`; 97 | expect(strings[0]).toBe(expectString); 98 | done(); 99 | } 100 | ); 101 | }); 102 | 103 | it('reports my score', (done) => { 104 | codeReviewKarma.incr_score(users[1].name, 'give'); 105 | codeReviewKarma.incr_score(users[1].name, 'give'); 106 | codeReviewKarma.incr_score(users[1].name, 'take'); 107 | 108 | util.sendMessageAsync( 109 | adapter, users[1].name, 'hubot what is my cr score', 110 | 100, 111 | (envelope, strings) => { 112 | const expectString = 113 | `${users[1].name} has received 1 reviews and given 2. Code karma: 1`; 114 | expect(strings[0]).toBe(expectString); 115 | done(); 116 | } 117 | ); 118 | }); 119 | 120 | it('reports someones else\'s score', (done) => { 121 | codeReviewKarma.incr_score(users[1].name, 'take'); 122 | codeReviewKarma.incr_score(users[1].name, 'take'); 123 | codeReviewKarma.incr_score(users[1].name, 'give'); 124 | 125 | const queryString = 126 | `hubot what is ${users[1].name}'s cr score`; 127 | util.sendMessageAsync( 128 | adapter, users[0].name, queryString, 129 | 100, 130 | (envelope, strings) => { 131 | const expectString = 132 | `${users[1].name} has received 2 reviews and given 1. Code karma: -0.5`; 133 | expect(strings[0]).toBe(expectString); 134 | done(); 135 | } 136 | ); 137 | }); 138 | 139 | it('displays the leaderboard', (done) => { 140 | // Give 3, Take 2 141 | codeReviewKarma.incr_score(users[0].name, 'give'); 142 | codeReviewKarma.incr_score(users[0].name, 'give'); 143 | codeReviewKarma.incr_score(users[0].name, 'give'); 144 | codeReviewKarma.incr_score(users[0].name, 'take'); 145 | codeReviewKarma.incr_score(users[0].name, 'take'); 146 | // Give 2, Take 3 147 | codeReviewKarma.incr_score(users[1].name, 'take'); 148 | codeReviewKarma.incr_score(users[1].name, 'take'); 149 | codeReviewKarma.incr_score(users[1].name, 'take'); 150 | codeReviewKarma.incr_score(users[1].name, 'give'); 151 | codeReviewKarma.incr_score(users[1].name, 'give'); 152 | // Give 2, Take 0 153 | codeReviewKarma.incr_score(users[2].name, 'give'); 154 | codeReviewKarma.incr_score(users[2].name, 'give'); 155 | 156 | util.sendMessageAsync( 157 | adapter, users[1].name, 'hubot what are the cr rankings?', 158 | 100, 159 | (envelope, strings) => { 160 | const expectString = 161 | `${users[0].name} has done the most reviews with 3\n` + 162 | `${users[1].name} has asked for the most code reviews with 3\n` + 163 | `${users[2].name} has the best code karma score with 2`; 164 | 165 | expect(strings[0]).toBe(expectString); 166 | done(); 167 | } 168 | ); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hubot-code-review 2 | 3 | [![Travis branch](https://img.shields.io/travis/alleyinteractive/hubot-code-review/master.svg?maxAge=2592000)](https://travis-ci.org/alleyinteractive/hubot-code-review) 4 | [![npm version](https://badge.fury.io/js/hubot-code-review.svg)](https://badge.fury.io/js/hubot-code-review) 5 | 6 | A Hubot script for GitHub code review on Slack. 7 | 8 | ## NO LONGER MAINTAINED 9 | 10 | This project is no longer maintained. Alley is no longer using this project internally (we moved all code review workflows onto our other (free) Slack app [Helperbot](https://helperbot.alley.com) for a more integrated experience). 11 | 12 | We are leaving it up for historical purposes. If you are interested in taking over maintenance, please reach out to us. 13 | 14 | ## tldr; 15 | 16 | Drop a GitHub pull request url into a room, and Hubot adds the pull request 17 | to the room's queue (each room has its own queue)... 18 | 19 | ![](/docs/images/submit-pr.png) 20 | 21 | Every 5 minutes, Hubot reports the current code review queue to the room (after an hour of no interaction, it messages @here with a warning and switches to hourly reminders)... 22 | 23 | ![](/docs/images/remind-pr.png) 24 | 25 | A co-worker can claim it for review... 26 | 27 | ![](/docs/images/claim-pr.png) 28 | 29 | Once the review is complete, a GitHub webhook listener catches approvals and Direct Messages the submitter: 30 | 31 | ![](/docs/images/approve-pr.png) 32 | 33 | ## Requirements 34 | 35 | - [Hubot](http://hubot.github.com/) ^3 36 | - [hubot-slack](https://github.com/slackapi/hubot-slack) ^4 37 | 38 | ## Installation via NPM 39 | 40 | Run the following command to install this module as a Hubot dependency 41 | 42 | ``` 43 | npm install hubot-code-review --save 44 | ``` 45 | 46 | Confirm that hubot-code-review appears as a dependency in your Hubot package.json file. 47 | 48 | ``` 49 | "dependencies": { 50 | ... 51 | "hubot-code-review": "*", 52 | ... 53 | } 54 | ``` 55 | 56 | To enable the script, add the hubot-code-review entry to the external-scripts.json file (you may need to create this file). 57 | 58 | ``` 59 | [ 60 | ... 61 | "hubot-code-review", 62 | ... 63 | ] 64 | ``` 65 | 66 | ## Configuration 67 | 68 | Code review queuing and notifications will work out of the box, but magic like 69 | file type lookups or DMs when your PR is approved/rejected require 2 things: 70 | 71 | 1. **Create a `hubot-code-review` webhook in GitHub so that Hubot can notice any changes** 72 | 73 | - [GitHub webhook instructions for hubot-code-review](/docs/github-webhook.md) 74 | 75 | 2. **Set Environmental variables:** 76 | 77 | - If you set `HUBOT_ENTERPRISE_GITHUB_URL` to `https://`, Hubot will use it as your enterprise URL instead of the default: `https://github.com` 78 | 79 | - If `HUBOT_GITHUB_TOKEN` is set, Hubot can query the GitHub api for file type information on PR submission. Check out the instructions for configuring 80 | [`HUBOT_GITHUB_TOKEN` for hubot-code-review](/docs/HUBOT_GITHUB_TOKEN.md) 81 | 82 | - `HUBOT_CODE_REVIEW_EMOJI_APPROVE` an [Alley Interactive](https://www.alleyinteractive.com) cultural relic before the days GitHub incorporated [pull request reviews](https://help.github.com/articles/about-pull-request-reviews/). If this variable is `true`, a comment (excluding a `comment.user.type` of `Bot`) on the PR that includes one or more emoji conveys PR approval and will DM the submitter accordingly. 83 | 84 | - Set `HUBOT_CODE_REVIEW_FILE_EXTENSIONS` to a space separated list of file extensions (_default "coffee css html js jsx md php rb scss sh txt yml"_) to configure what file types you prefer to group together in the `HUBOT_CODE_REVIEW_META` information alongside the PR slug in channel. Note that this requires a [`HUBOT_GITHUB_TOKEN` for hubot-code-review](/docs/HUBOT_GITHUB_TOKEN.md) 85 | 86 | - Set `HUBOT_CODE_REVIEW_KARMA_DISABLED` to `true` to prevent Hubot from listening for any 87 | [code review karma](/docs/code-review-karma.md) commands. 88 | 89 | - Set `HUBOT_CODE_REVIEW_KARMA_MONTHLY_AWARD_ROOM` to automatically display a monthly resetting code review leaderboard to a particular room (for more info, check out the [code review karma](/docs/code-review-karma.md) docs) 90 | 91 | - Set `HUBOT_CODE_REVIEW_META` to one of [ `files`, `title`, `none`, `both` (_default `files`_) ] to configure whether to display the pull request title, file types, or both alongside the PR slug in channel. Note that this requires a [`HUBOT_GITHUB_TOKEN` for hubot-code-review](/docs/HUBOT_GITHUB_TOKEN.md) 92 | 93 | - Set `HUBOT_CODE_REVIEW_REMINDER_MINUTES` to customize the number of minutes between pending code review prompts during the first hour of inactivity (the default is 5, and the effective max is 60). Note that hourly reminders will still take effect after the first 60 minutes. 94 | 95 | - Set `HUBOT_CODE_REVIEW_HOUR_MESSAGE` to customize the message that is displayed for the first-hour warning. 96 | 97 | ## Usage 98 | 99 | `hubot help crs` - See a help document explaining how to use. 100 | 101 | {GitHub pull request URL} [@user] Add PR to queue and (optionally) notify @user or #channel 102 | [hubot ]on it Claim the oldest _new_ PR in the queue 103 | [hubot ]userName is on it Tell hubot that userName has claimed the oldest _new_ PR in the queue 104 | on * Claim all _new_ PRs 105 | [userName is ]on cool-repo/123 Claim cool-repo/123 if no one else has claimed it 106 | [userName is ]on cool Claim a _new_ PR whose slug matches cool 107 | hubot (nm|ignore) cool-repo/123 Delete cool-repo/123 from queue regardless of status 108 | hubot (nm|ignore) cool Delete most recently added PR whose slug matches cool 109 | hubot (nm|ignore) Delete most recently added PR from the queue regardless of status 110 | hubot redo cool-repo/123 Allow another review _without_ decrementing previous reviewer's score 111 | hubot (unclaim|reset) cool-repo/123 Reset CR status to new/unclaimed _and_ decrement reviewer's score 112 | hubot list crs List all _unclaimed_ CRs in the queue 113 | hubot list [status] crs List CRs with matching optional status 114 | 115 | _Note that some commands require direct @hubot, some don't, and some work either way._ 116 | 117 | _Code review statuses_ 118 | 119 | new PR has just been added to the queue, no one is on it. 120 | claimed Someone is on this PR 121 | approved PR was approved. Requires GitHub webhook. 122 | merged PR was merged and closed. Requires GitHub webhook. 123 | closed PR was closed without merging. Requires GitHub webhook. 124 | -------------------------------------------------------------------------------- /src/CodeReviewKarma.coffee: -------------------------------------------------------------------------------- 1 | sendFancyMessage = require './lib/sendFancyMessage' 2 | 3 | class CodeReviewKarma 4 | constructor: (@robot) -> 5 | @scores = {} 6 | @monthly_scores = {} 7 | 8 | # Note: 'Schedule Monthly Award and Score Reset' 9 | # cron task managed by CodeReviews 10 | 11 | @robot.brain.on 'loaded', => 12 | if @robot.brain.data.code_review_karma 13 | cache = @robot.brain.data.code_review_karma 14 | @scores = cache.scores || {} 15 | @monthly_scores = cache.monthly_scores || {} 16 | 17 | # Update Redis store of CR queues and karma scores 18 | # @return none 19 | update_redis: -> 20 | @robot.brain.data.code_review_karma = { scores: @scores, monthly_scores: @monthly_scores } 21 | 22 | # Increment user's karma score 23 | # @param string user Name of user 24 | # @param string dir Direction property, 'take' or 'give' 25 | # @param int qty Amount to increment by 26 | # @return none 27 | incr_score: (user, dir, qty = 1) -> 28 | qty -= 0 29 | @scores[user] ||= { give: 0, take: 0 } 30 | @scores[user][dir] += qty 31 | @monthly_scores[user] ||= { give: 0, take: 0 } 32 | @monthly_scores[user][dir] += qty 33 | 34 | @update_redis() 35 | 36 | # Decrement user's karma score 37 | # @param string user Name of user 38 | # @param string dir Direction property, 'take' or 'give' 39 | # @param int qty Amount to decrement by 40 | # @return none 41 | decr_score: (user, dir, qty = 1) -> 42 | qty -= 0 43 | if @scores[user] and @scores[user][dir] 44 | @scores[user][dir] -= qty 45 | @scores[user][dir] = 0 if @scores[user][dir] < 0 46 | if @monthly_scores[user] and @monthly_scores[user][dir] 47 | @monthly_scores[user][dir] -= qty 48 | @monthly_scores[user][dir] = 0 if @monthly_scores[user][dir] < 0 49 | @update_redis() 50 | 51 | # Calculate karm score 52 | # @param int give CRs given 53 | # @param int take CRs taken 54 | # @return int Karma score 55 | karma: (give, take) -> 56 | if take is 0 57 | return give 58 | return Math.round( ( give / take - 1 ) * 100 ) / 100 59 | 60 | # Get leaderboard of gives, takes, and karma score 61 | # @return array Array of most CRs given, most CRs taken, best karma score 62 | rankings: -> 63 | gives = takes = karmas = { score: -1, user: 'Nobody' } 64 | for user, scores of @scores 65 | if scores.give > gives.score 66 | gives = { score: scores.give, user: user } 67 | else if scores.give == gives.score 68 | gives.user = gives.user + ', ' + user 69 | 70 | if scores.take > takes.score 71 | takes = { score: scores.take, user: user } 72 | else if scores.take == takes.score 73 | takes.user = takes.user + ', ' + user 74 | 75 | karma = @karma(scores.give, scores.take) 76 | if karma > karmas.score 77 | karmas = { score: karma, user: user } 78 | else if karma == karmas.score 79 | karmas.user = karmas.user + ', ' + user 80 | 81 | [gives, takes, karmas ] 82 | 83 | # Get user's score 84 | # @param string user User name 85 | # @return obj Key-value of CRs given and taken 86 | scores_for_user: (user) -> 87 | all_scores = @scores[user] || { give: 0, take: 0 } 88 | month_scores = @monthly_scores[user] || { give: 0, take: 0 } 89 | return { 90 | all_scores, 91 | month_scores 92 | } 93 | 94 | # Remove user from scores 95 | # @return bool True if user was found; false if user not found 96 | remove_user: (user) -> 97 | if @scores[user] || @monthly_scores[user] 98 | if @scores[user] 99 | delete @scores[user] 100 | if @monthly_scores[user] 101 | delete @monthly_scores[user] 102 | @update_redis() 103 | return true 104 | return false 105 | 106 | # Reset all scores 107 | # @return none 108 | flush_scores: -> 109 | @robot.logger.info "CodeReviewKarma.flush_scores: resetting all scores..." 110 | @scores = {} 111 | @monthly_scores = {} 112 | @update_redis() 113 | 114 | # Reset monthly scores 115 | # @return none 116 | flush_monthly_scores: -> 117 | @robot.logger.info "CodeReviewKarma.flush_monthly_scores: resetting monthly_scores..." 118 | # Explicitly delete all monthly score entries 119 | if Object.keys(@monthly_scores).length 120 | for user of @monthly_scores 121 | delete @monthly_scores[user] 122 | delete @robot.brain.data.code_review_karma.monthly_scores[user] 123 | 124 | # Announce top reviewers this month: 125 | # Most reviews (up to three) 126 | # Most requests (up to three) 127 | # Best karma (up to 1) 128 | # 129 | # If called: 130 | # via cron: to HUBOT_CODE_REVIEW_KARMA_MONTHLY_AWARD_ROOM (if set) 131 | # Note: cron scheduled from CodeReviews to avoid duplicate schedules 132 | # via msg: to the original room 133 | # 134 | # @return none 135 | monthly_rankings: (msg = null) -> 136 | msg_prefix = "" 137 | attachments = [] 138 | if (msg)? 139 | # function start from message (not cron) 140 | msg_prefix = "Here's how things stand this month:" 141 | announce_room = msg.message.room 142 | else if (process.env.HUBOT_CODE_REVIEW_KARMA_MONTHLY_AWARD_ROOM)? 143 | # Triggered from cron and HUBOT_CODE_REVIEW_KARMA_MONTHLY_AWARD_ROOM set 144 | msg_prefix = "Here's the final code review leaderboard for last month:" 145 | announce_room = "\##{process.env.HUBOT_CODE_REVIEW_KARMA_MONTHLY_AWARD_ROOM}" 146 | else 147 | # Triggered from cron, no room set... clear monthly_scores and return 148 | @flush_monthly_scores() 149 | return 150 | reviews_this_month = Object.keys(@monthly_scores).length 151 | 152 | if reviews_this_month is 0 153 | attachments.push 154 | fallback: "No code reviews seen this month yet." 155 | text: "No code reviews seen this month yet. :cricket:" 156 | color: "#C0C0C0" 157 | else 158 | attachments.push 159 | fallback: msg_prefix 160 | pretext: msg_prefix 161 | # Top three most reviews given followed by karma 162 | top_3_reviewers = Object.keys(@monthly_scores) 163 | .map((index) => 164 | return { 165 | user: index, 166 | list: 'Most Reviews', 167 | give: @monthly_scores[index].give, 168 | take: @monthly_scores[index].take, 169 | karma: @karma(@monthly_scores[index].give, @monthly_scores[index].take) 170 | } 171 | ).sort((a, b) -> 172 | if b.give is a.give 173 | return b.karma - a.karma 174 | else 175 | return b.give - a.give 176 | ).map((winner, rank) -> 177 | return Object.assign({}, winner, { placement: rank + 1 }) 178 | ).slice(0, 3) 179 | # Top three most reviews requested followed by karma 180 | top_3_requesters = Object.keys(@monthly_scores) 181 | .map((index) => 182 | return { 183 | user: index, 184 | list: 'Most Requests', 185 | give: @monthly_scores[index].give, 186 | take: @monthly_scores[index].take, 187 | karma: @karma(@monthly_scores[index].give, @monthly_scores[index].take) 188 | } 189 | ).sort((a, b) -> # Sort by most reviews given followed by karma 190 | if b.take is a.take 191 | return b.karma - a.karma 192 | else 193 | return b.take - a.take 194 | ).map((winner, rank) -> 195 | return Object.assign({}, winner, { placement: rank + 1 }) 196 | ).slice(0, 3) 197 | # Top three best karma followed by reviews 198 | top_1_karma = Object.keys(@monthly_scores) 199 | .map((index) => 200 | return { 201 | user: index, 202 | list: 'Best Karma' 203 | give: @monthly_scores[index].give, 204 | take: @monthly_scores[index].take, 205 | karma: @karma(@monthly_scores[index].give, @monthly_scores[index].take) 206 | } 207 | ).sort((a, b) -> # Sort by most reviews given followed by karma 208 | if b.karma is a.karma 209 | return b.give - a.give 210 | else 211 | return b.karma - a.karma 212 | ).map((winner, rank) -> 213 | return Object.assign({}, winner, { placement: rank + 1 }) 214 | ).slice(0, 1) 215 | 216 | monthly_leaderboard = [top_3_reviewers..., top_3_requesters..., top_1_karma...] 217 | for index of monthly_leaderboard 218 | entry = monthly_leaderboard[index] 219 | switch(entry.placement) 220 | when 1 then medal_color = "#D4AF37" # gold 221 | when 2 then medal_color = "#BCC6CC" # silver 222 | when 3 then medal_color = "#5B391E" # bronze 223 | else medal_color = "#FFFFFF" # white 224 | user_detail = @robot.brain.userForName("#{entry.user}") 225 | if (user_detail)? and (user_detail.slack)? # if slack, add some deeper data 226 | gravatar = user_detail.slack.profile.image_72 227 | full_name = user_detail.slack.real_name 228 | else 229 | full_name = entry.user 230 | score_field_array = [] 231 | switch (entry.list) 232 | when 'Most Reviews' 233 | reviewed_requested_text = "*#{entry.give}* / #{entry.take}" 234 | karma_text = "#{entry.karma}" 235 | when 'Most Requests' 236 | reviewed_requested_text = "#{entry.give} / *#{entry.take}*" 237 | karma_text = "#{entry.karma}" 238 | when 'Best Karma' 239 | reviewed_requested_text = "#{entry.give} / #{entry.take}" 240 | karma_text = "*#{entry.karma}*" 241 | score_field_array.push 242 | title: "Reviewed / Requested", 243 | value: reviewed_requested_text, 244 | short: true 245 | score_field_array.push 246 | title: "Karma Score", 247 | value: karma_text, 248 | short: true 249 | attachments.push 250 | fallback: 251 | "#{full_name}: Reviewed #{entry.give}, Requested #{entry.take}, Karma: #{entry.karma}" 252 | text: "\#*_#{entry.placement}_ #{entry.list}* - *#{full_name}* (@#{entry.user}): " 253 | fields: score_field_array 254 | mrkdwn_in: ["text", "fields"] 255 | color: medal_color 256 | thumb_url: gravatar 257 | 258 | sendFancyMessage(@robot, "#{announce_room}", attachments) 259 | # If triggered by monthly cron task, reset the monthly scores 260 | unless (msg)? 261 | @flush_monthly_scores() 262 | 263 | module.exports = CodeReviewKarma -------------------------------------------------------------------------------- /test/codeReviewEmojiAcceptSpec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jasmine*/ 2 | 3 | // Allows 'since' custom messages for unit test failures 4 | require('jasmine-custom-message'); 5 | 6 | let path = require('path'), 7 | Robot = require('../node_modules/hubot/src/robot'), 8 | TextMessage = require('../node_modules/hubot/src/message').TextMessage, 9 | util = require('./lib/util'), 10 | Users = require('./data/users'), 11 | PullRequests = require('./data/prs'), 12 | CodeReview = require('../src/CodeReview'), 13 | request = require('supertest'); 14 | schedule = require('node-schedule'); 15 | 16 | /** 17 | * Tests the following features of code-review 18 | receives GitHub webhook to approve a PR by emoji in multiple rooms 19 | does not approve a CR by emoji when GitHub comment does not contain emoji 20 | approves a CR when GitHub comment contains github-style emoji 21 | approves a CR when GitHub comment contains unicode emoji 22 | DMs user when CR is approved by emoji 23 | */ 24 | 25 | describe('Code Review Emoji Approval', () => { 26 | let robot; 27 | let adapter; 28 | let code_reviews; 29 | 30 | /** 31 | * @var array List of Hubot User objects 32 | */ 33 | let users = []; 34 | 35 | beforeEach((done) => { 36 | // create new robot, without http, using the mock adapter 37 | robot = new Robot(null, 'mock-adapter', true, 'hubot'); 38 | 39 | robot.adapter.on('connected', () => { 40 | // create a user 41 | Users().getUsers().forEach((user) => { 42 | users.push(robot.brain.userForId(user.ID, { 43 | name: user.meta.name, 44 | room: user.meta.room, 45 | })); 46 | }); 47 | 48 | // load the module 49 | code_reviews = require('../src/code-reviews')(robot); 50 | 51 | adapter = robot.adapter; 52 | // start each test with an empty queue 53 | code_reviews.flush_queues(); 54 | // wait a sec for Redis 55 | setTimeout(() => { 56 | done(); 57 | }, 150); 58 | }); 59 | 60 | robot.run(); 61 | }); 62 | 63 | afterEach(() => { 64 | users = []; 65 | adapter = null; 66 | robot.server.close(); 67 | robot.shutdown(); 68 | }); 69 | 70 | /** 71 | * Webhooks for issue_comment when HUBOT_CODE_REVIEW_EMOJI_APPROVE 72 | */ 73 | 74 | if (process.env.HUBOT_CODE_REVIEW_EMOJI_APPROVE) { 75 | it('receives GitHub webhook to approve a PR by emoji in multiple rooms', (done) => { 76 | const rooms = ['alley', 'codereview', 'learnstuff', 'nycoffice']; 77 | const approvedUrl = 'https://github.com/alleyinteractive/special/pull/456'; 78 | const otherUrl = 'https://github.com/alleyinteractive/special/pull/123'; 79 | // add prs to different rooms 80 | rooms.forEach((room) => { 81 | addNewCR(`${approvedUrl}/files`, { room }); 82 | addNewCR(otherUrl, { room }); 83 | }); 84 | 85 | // setup the data we want to pretend that Github is sending 86 | const requestBody = { 87 | issue: { html_url: approvedUrl }, 88 | comment: { 89 | body: 'I give it a :horse:, great job!', 90 | user: { login: 'bcampeau' }, 91 | }, 92 | }; 93 | 94 | // expect the approved pull request to be approved in all rooms 95 | // and the other pull request to be unchanged 96 | testWebhook('issue_comment', requestBody, (err, res) => { 97 | expect(res.text).toBe(`issue_comment approved ${approvedUrl}`); 98 | rooms.forEach((room) => { 99 | const queue = code_reviews.room_queues[room]; 100 | expect(queue.length).toBe(2); 101 | expect(queue[0].url).toBe(otherUrl); 102 | expect(queue[0].status).toBe('new'); 103 | expect(queue[1].url).toBe(`${approvedUrl}/files`); 104 | expect(queue[1].status).toBe('approved'); 105 | done(); 106 | }); 107 | }); 108 | }); 109 | 110 | it('does not approve a CR by emoji when GitHub comment does not contain emoji', (done) => { 111 | testCommentText({ 112 | comment: 'This needs more work, sorry.', 113 | expectedRes: 'issue_comment did not yet approve ', 114 | expectedStatus: 'new', 115 | }, done); 116 | }); 117 | 118 | it('approves a CR when GitHub comment contains github-style emoji', (done) => { 119 | testCommentText({ 120 | comment: ':pizza: :pizza: :100:', 121 | expectedRes: 'issue_comment approved ', 122 | expectedStatus: 'approved', 123 | }, done); 124 | }); 125 | 126 | it('approves a CR when GitHub comment contains unicode emoji', (done) => { 127 | testCommentText({ 128 | comment: 'nice work pal 🍾', 129 | expectedRes: 'issue_comment approved ', 130 | expectedStatus: 'approved', 131 | }, done); 132 | }); 133 | 134 | it('DMs user when CR is approved by emoji', (done) => { 135 | const url = 'https://github.com/alleyinteractive/huron/pull/567'; 136 | addNewCR(url); 137 | 138 | // setup the data we want to pretend that Github is sending 139 | const requestBody = { 140 | issue: { 141 | html_url: url, 142 | }, 143 | comment: { 144 | body: 'Nice job!:tada:\nMake these tweaks then :package: it!', 145 | user: { 146 | login: 'gfargo', 147 | }, 148 | }, 149 | }; 150 | 151 | adapter.on('send', (envelope, strings) => { 152 | expect(strings[0]).toBe(`hey ${envelope.room}! gfargo approved ${url}:` + 153 | '\nNice job!:tada:\nMake these tweaks then :package: it!'); 154 | const cr = code_reviews.room_queues.test_room[0]; 155 | expect(envelope.room).toBe(`@${cr.user.name}`); 156 | expect(cr.url).toBe(url); 157 | expect(cr.status).toBe('approved'); 158 | done(); 159 | }); 160 | 161 | testWebhook('issue_comment', requestBody, (err, res) => { 162 | expect(res.text).toBe(`issue_comment approved ${url}`); 163 | }); 164 | }); 165 | } 166 | 167 | /** 168 | * Helper functions 169 | */ 170 | 171 | /** 172 | * test a request to CR webhook 173 | * @param string event 'issue_comment' or 'pull_request' 174 | * @param object requestBody Body of request as JSON object 175 | * @param function callback Takes error and result arguments 176 | */ 177 | function testWebhook(eventType, requestBody, callback) { 178 | request(robot.router.listen()) 179 | .post('/hubot/hubot-code-review') 180 | .set({ 181 | 'Content-Type': 'application/json', 182 | 'X-Github-Event': eventType, 183 | }) 184 | .send(requestBody) 185 | .end((err, res) => { 186 | expect(err).toBeFalsy(); 187 | callback(err, res); 188 | }); 189 | } 190 | 191 | /** 192 | * Test correct handing of a comment from Github 193 | * @param object args 194 | * string comment 195 | * string expectedRes 196 | * string expectedStatus 197 | */ 198 | function testCommentText(args, done) { 199 | const url = 'https://github.com/alleyinteractive/huron/pull/567'; 200 | addNewCR(url); 201 | 202 | // setup the data we want to pretend that Github is sending 203 | const requestBody = { 204 | issue: { html_url: url }, 205 | comment: { 206 | body: args.comment, 207 | user: { login: 'bcampeau' }, 208 | }, 209 | }; 210 | 211 | // not approved 212 | testWebhook('issue_comment', requestBody, (err, res) => { 213 | expect(res.text).toBe(args.expectedRes + url); 214 | expect(code_reviews.room_queues.test_room[0].status).toBe(args.expectedStatus); 215 | done(); 216 | }); 217 | } 218 | 219 | /** 220 | * Test selectively updating status to merged or closed 221 | * @param string githubStatus 'merged' or 'closed' 222 | * @param string localStatus Current status in code review queue 223 | * @param string expectedStatus Status we expect to change to (or not) 224 | * @param function done Optional done() function for the test 225 | */ 226 | function testMergeClose(githubStatus, localStatus, expectedStatus, done) { 227 | const updatedUrl = 'https://github.com/alleyinteractive/special/pull/456'; 228 | addNewCR(updatedUrl); 229 | code_reviews.room_queues.test_room[0].status = localStatus; 230 | code_reviews.room_queues.test_room[0].reviewer = 'jaredcobb'; 231 | 232 | // setup the data we want to pretend that Github is sending 233 | const requestBody = { 234 | action: 'closed', 235 | pull_request: { 236 | merged: 'merged' === githubStatus, 237 | html_url: updatedUrl, 238 | }, 239 | }; 240 | 241 | // expect the closed pull request to be closed in all rooms 242 | // and the other pull request to be unchanged 243 | testWebhook('pull_request', requestBody, (err, res) => { 244 | expect(code_reviews.room_queues.test_room[0].status).toBe(expectedStatus); 245 | if (done) { 246 | done(); 247 | } 248 | }); 249 | } 250 | 251 | /** 252 | * Make a CR slug from a URL 253 | * @param string url 254 | * @return string slug 255 | */ 256 | function makeSlug(url) { 257 | return code_reviews.matches_to_slug(code_reviews.pr_url_regex.exec(url)); 258 | } 259 | 260 | /** 261 | * Create a new CR with a random user and add it to the queue 262 | * @param string url URL of GitHub PR 263 | * @param object userMeta Optional metadata to override GitHub User object 264 | * @param int randExclude Optional index in users array to exclude from submitters 265 | */ 266 | function addNewCR(url, userMeta, randExclude) { 267 | const submitter = util.getRandom(users, randExclude).value; 268 | if (userMeta) { 269 | // shallow "extend" submitter 270 | Object.keys(userMeta).forEach((key) => { 271 | submitter[key] = userMeta[key]; 272 | }); 273 | } 274 | code_reviews.add(new CodeReview(submitter, makeSlug(url), url), submitter.room, submitter.room); 275 | } 276 | 277 | /** 278 | * Get number of reviews in a room by status 279 | * @param string room The room to search 280 | * @param string status The status to search for 281 | * @return int|null Number of CRs matching status, or null if room not found 282 | */ 283 | function roomStatusCount(room, status) { 284 | if (! code_reviews.room_queues[room]) { 285 | return null; 286 | } 287 | let counter = 0; 288 | code_reviews.room_queues[room].forEach((cr) => { 289 | if (cr.status === status) { 290 | counter++; 291 | } 292 | }); 293 | return counter; 294 | } 295 | 296 | function populateTestRoomCRs() { 297 | const statuses = { 298 | new: [], 299 | claimed: [], 300 | approved: [], 301 | closed: [], 302 | merged: [], 303 | }; 304 | // add a bunch of new CRs 305 | PullRequests.forEach((url, i) => { 306 | addNewCR(url); 307 | }); 308 | 309 | // make sure there's at least one CR with each status 310 | code_reviews.room_queues.test_room.forEach((review, i) => { 311 | if (i < Object.keys(statuses).length) { 312 | status = Object.keys(statuses)[i]; 313 | // update the CR's status 314 | code_reviews.room_queues.test_room[i].status = status; 315 | // add to array of expected results 316 | statuses[status].push(code_reviews.room_queues.test_room[i].slug); 317 | } 318 | }); 319 | } 320 | }); 321 | -------------------------------------------------------------------------------- /src/code-reviews.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Manage code review reminders 3 | # 4 | # Configuration: 5 | # see README.md 6 | # 7 | # Commands: 8 | # hubot help crs - display code review help 9 | 10 | CodeReviews = require './CodeReviews' 11 | CodeReview = require './CodeReview' 12 | CodeReviewKarma = require './CodeReviewKarma' 13 | msgRoomName = require './lib/msgRoomName' 14 | 15 | module.exports = (robot) -> 16 | 17 | code_review_karma = new CodeReviewKarma robot 18 | code_reviews = new CodeReviews robot 19 | 20 | enqueue_code_review = (msg) -> 21 | # Ignore all bot messages 22 | if msg.message.user?.slack?.is_bot 23 | return 24 | url = msg.match[1] 25 | slug = code_reviews.matches_to_slug msg.match 26 | msgRoomName msg, (room_name) -> 27 | if slug and room_name 28 | cr = new CodeReview msg.message.user, slug, url, room_name, msg.message.room 29 | found = code_reviews.find_slug_index room_name, slug 30 | if found is false 31 | # 'Take' a code review for karma 32 | code_review_karma.incr_score msg.message.user.name, 'take' 33 | 34 | if (msg.match[5])? and msg.match[5].length 35 | notification_string = msg.match[5].replace /^\s+|\s+$/g, "" 36 | else 37 | notification_string = null 38 | # Add any extra info to the cr, seng extra notifications, and add it to the room_queue 39 | code_reviews.add_cr_with_extra_info(cr, msg, notification_string) 40 | 41 | else 42 | if code_reviews.room_queues[room_name][found].status != 'new' 43 | statusMsg = "#{code_reviews.room_queues[room_name][found].status}" 44 | else 45 | statusMsg = 'added' 46 | if code_reviews.room_queues[room_name][found].reviewer 47 | reviewerMsg = " and was #{statusMsg} by" + 48 | " @#{code_reviews.room_queues[room_name][found].reviewer}" 49 | else 50 | reviewerMsg = '' 51 | msg.send "*#{slug}* is already in the queue#{reviewerMsg}" 52 | else 53 | msg.send "Unable to add #{url} to queue. Please try again." 54 | 55 | # Respond to message with matching slug names 56 | # 57 | # @param slugs matching slugs 58 | # @param msg message to reply to 59 | # @return none 60 | send_be_more_specific = (slugs, msg) -> 61 | # Bold the slugs 62 | slugs = ("`#{slug}`" for slug in slugs) 63 | lastSlug = 'or ' + slugs.pop() 64 | slugs.push lastSlug 65 | msg.send "You're gonna have to be more specific: " + slugs.join(', ') + '?' 66 | 67 | # Return a single matching CR for slug match or alert the user to match status 68 | # 69 | # @param slugs matching slugs 70 | # @param msg message to reply to 71 | # @return none 72 | single_matching_cr = (slug_to_search_for, room_name, msg, status = false, no_reply = false) -> 73 | # search for matching slugs whether a fragment or full slug is provided 74 | found_crs = code_reviews.search_room_by_slug room_name, slug_to_search_for, status 75 | 76 | # no matches 77 | if found_crs.length is 0 78 | unless no_reply 79 | status_prs = if status then "#{status} " else '' 80 | msg.send "Sorry, I couldn't find any #{status_prs}PRs" + 81 | " in this room matching `#{slug_to_search_for}`." 82 | return 83 | # multiple matches 84 | else if found_crs.length > 1 85 | foundSlugs = for cr in found_crs 86 | cr.slug 87 | unless no_reply 88 | send_be_more_specific foundSlugs, msg 89 | return 90 | # There's a single matching slug in this room to redo 91 | else 92 | return found_crs[0] 93 | 94 | dequeue_code_review = (cr, reviewer, msg) -> 95 | if cr and cr.slug 96 | code_review_karma.incr_score reviewer, 'give' 97 | msg.send "Thanks, #{reviewer}! I removed *#{cr.slug}* from the code review queue." 98 | 99 | ### 100 | @command hubot: help crs 101 | @desc Display help docs for code review system 102 | ### 103 | robot.respond /help crs(?: --(flush))?$/i, id: 'crs.help', (msg) -> 104 | if ! code_reviews.help_text or (msg.match[1] and msg.match[1].toLowerCase() is 'flush') 105 | code_reviews.set_help_text() 106 | 107 | msg.send code_reviews.help_text 108 | 109 | ### 110 | @command {GitHub pull request URL} [@user] 111 | @desc Add PR to queue and (optionally) notify @user or #channel 112 | ### 113 | robot.hear code_reviews.pr_url_regex, enqueue_code_review 114 | 115 | ### 116 | @command [hubot: ]on it 117 | @desc Claim the oldest _new_ PR in the queue 118 | @command [hubot: ]userName is on it 119 | @desc Tell hubot that userName has claimed the oldest _new_ PR in the queue 120 | ### 121 | # Claim first PR in queue by directly addressing hubot 122 | robot.respond /(?:([-_a-z0-9]+) is )?on it/i, (msg) -> 123 | reviewer = msg.match[1] or msg.message.user.name 124 | msgRoomName msg, (room_name) -> 125 | if room_name 126 | cr = code_reviews.claim_first room_name, reviewer 127 | dequeue_code_review cr, reviewer, msg 128 | 129 | # Claim first PR in queue wihout directly addressing hubot 130 | # Note the this is a `hear` listener and previous is a `respond` 131 | robot.hear /^(?:([-_a-z0-9]+) is )?on it$/, (msg) -> 132 | # Ignore all bot messages 133 | if msg.message.user?.slack?.is_bot 134 | return 135 | reviewer = msg.match[1] or msg.message.user.name 136 | msgRoomName msg, (room_name) -> 137 | if room_name 138 | cr = code_reviews.claim_first room_name, reviewer 139 | dequeue_code_review cr, reviewer, msg 140 | 141 | ### 142 | @command on * 143 | @desc Claim all _new_ PRs 144 | ### 145 | robot.hear /^on \*$/i, (msg) -> 146 | # Ignore all bot messages 147 | if msg.message.user?.slack?.is_bot 148 | return 149 | msg.emote ":tornado2:" 150 | reviewer = msg.message.user.name 151 | msgRoomName msg, (room_name) -> 152 | if room_name 153 | until false is cr = code_reviews.claim_first room_name, reviewer 154 | dequeue_code_review cr, reviewer, msg 155 | 156 | ### 157 | @command [userName is ]on cool-repo/123 158 | @desc Claim `cool-repo/123` if no one else has claimed it 159 | @command [userName is ]on cool 160 | @desc Claim a _new_ PR whose slug matches `cool` 161 | ### 162 | robot.hear /^(?:([-_a-z0-9]+) is )?(?:on) ([-_\/a-z0-9]+|\d+|[-_\/a-z0-9]+\/\d+)$/i, (msg) -> 163 | # Ignore all bot messages 164 | if msg.message.user?.slack?.is_bot 165 | return 166 | reviewer = msg.match[1] or msg.message.user.name 167 | slug = msg.match[2] 168 | return if slug.toLowerCase() is 'it' 169 | 170 | msgRoomName msg, (room_name) -> 171 | if room_name 172 | unclaimed_cr = single_matching_cr(slug, room_name, msg, status = "new") 173 | if (unclaimed_cr)? 174 | code_reviews.claim_by_slug room_name, unclaimed_cr.slug, reviewer 175 | dequeue_code_review unclaimed_cr, reviewer, msg 176 | 177 | # none of the matches have "new" status 178 | else 179 | cr = single_matching_cr(slug, room_name, msg, status = false, no_output = true) 180 | # When someone attempts to claim a PR 181 | # that was already reviewed, merged, or closed outside of the queue 182 | if (cr)? 183 | response = "It looks like *#{cr.slug}* (@#{cr.user.name}) has already been #{cr.status}" 184 | msg.send response 185 | 186 | ### 187 | @command hubot (nm|ignore) cool-repo/123 188 | @desc Delete `cool-repo/123` from queue regardless of status 189 | @command hubot (nm|ignore) cool 190 | @desc Delete most recently added PR whose slug matches `cool` regardless of status 191 | ### 192 | robot.respond /(?:nm|ignore) ([-_\/a-z0-9]+|\d+|[-_\/a-z0-9]+\/\d+)$/i, (msg) -> 193 | slug = msg.match[1] 194 | return if slug.toLowerCase() is 'it' 195 | 196 | msgRoomName msg, (room_name) -> 197 | if room_name 198 | found_ignore_cr = single_matching_cr(slug, room_name, msg) 199 | if (found_ignore_cr)? 200 | code_reviews.remove_by_slug room_name, found_ignore_cr.slug 201 | #decrement scores 202 | code_review_karma.decr_score found_ignore_cr.user.name, 'take' 203 | if found_ignore_cr.reviewer 204 | code_review_karma.decr_score found_ignore_cr.reviewer, 'give' 205 | msg.send "Sorry for eavesdropping. I removed *#{found_ignore_cr.slug}* from the queue." 206 | return 207 | 208 | ### 209 | @command hubot: (nm|ignore) 210 | @desc Delete most recently added PR from the queue regardless of status 211 | ### 212 | robot.respond /(?:\s*)(?:nm|ignore)(?:\s*)$/i, (msg) -> 213 | msgRoomName msg, (room_name) -> 214 | if room_name 215 | cr = code_reviews.remove_last_new room_name 216 | if cr and cr.slug 217 | code_review_karma.decr_score cr.user.name, 'take' 218 | if cr.reviewer 219 | code_review_karma.decr_score cr.reviewer, 'give' 220 | msg.send "Sorry for eavesdropping. I removed *#{cr.slug}* from the queue." 221 | else 222 | msg.send "There might not be a new PR to remove. Try specifying a slug." 223 | 224 | ### 225 | @command hubot: redo cool-repo/123 226 | @desc Allow another review _without_ decrementing previous reviewer's score 227 | ### 228 | robot.respond /(?:redo)(?: ([-_\/a-z0-9]+|\d+|[-_\/a-z0-9]+\/\d+))/i, (msg) -> 229 | msgRoomName msg, (room_name) -> 230 | if room_name 231 | found_redo_cr = single_matching_cr(msg.match[1], room_name, msg) 232 | if (found_redo_cr)? 233 | index = code_reviews.find_slug_index room_name, found_redo_cr.slug 234 | code_reviews.reset_cr code_reviews.room_queues[room_name][index] 235 | msg.send "You got it, #{found_redo_cr.slug} is ready for a new review." 236 | 237 | ### 238 | @command hubot: (unclaim|reset) cool-repo/123 239 | @desc Reset CR status to new/unclaimed _and_ decrement reviewer's score 240 | ### 241 | robot.respond /(unclaim|reset)(?: ([-_\/a-z0-9]+|\d+|[-_\/a-z0-9]+\/\d+))?/i, (msg) -> 242 | msgRoomName msg, (room_name) -> 243 | if room_name 244 | found_reset_cr = single_matching_cr(msg.match[2], room_name, msg) 245 | if (found_reset_cr)? 246 | # decrement reviewers CR score 247 | if found_reset_cr.reviewer 248 | code_review_karma.decr_score found_reset_cr.reviewer, 'give' 249 | 250 | index = code_reviews.find_slug_index room_name, found_reset_cr.slug 251 | code_reviews.reset_cr code_reviews.room_queues[room_name][index] 252 | msg.match[1] += 'ed' if msg.match[1].toLowerCase() is 'unclaim' 253 | msg.send "You got it, I've #{msg.match[1]} *#{found_reset_cr.slug}* in the queue." 254 | 255 | ### 256 | @command hubot: list crs 257 | @desc List all _unclaimed_ CRs in the queue 258 | @command hubot: list [status] crs 259 | @desc List CRs with matching optional status 260 | ### 261 | robot.respond /list(?: (all|new|claimed|approved|closed|merged))? CRs/i, (msg) -> 262 | status = msg.match[1] || 'new' 263 | msgRoomName msg, (room_name) -> 264 | if room_name 265 | code_reviews.send_list room_name, false, status 266 | 267 | # Flush all CRs in all rooms 268 | robot.respond /flush the cr queue, really really/i, (msg) -> 269 | code_reviews.flush_queues() 270 | msg.send "This house is clear" 271 | 272 | # Display JSON of all CR queues 273 | robot.respond /debug the cr queue ?(?:for #?([a-z0-9\-_]+))?$/i, (msg) -> 274 | if !msg.match[1] 275 | msg.send code_reviews.queues_debug_stats() 276 | else 277 | msg.send code_reviews.queues_debug_room(msg.match[1]) 278 | 279 | # Mark a CR as approved or closed when webhook received from GitHub 280 | robot.router.post '/hubot/hubot-code-review', (req, res) -> 281 | # check header 282 | unless req.headers['x-github-event'] 283 | res.statusCode = 400 284 | res.send 'x-github-event is required' 285 | return 286 | 287 | # Check if PR was approved (via emoji in issue_comment body) 288 | if req.headers['x-github-event'] is 'issue_comment' and 289 | req.body.comment.user.type != 'Bot' # Commenter is not a bot 290 | if ((process.env.HUBOT_CODE_REVIEW_EMOJI_APPROVE?) and 291 | process.env.HUBOT_CODE_REVIEW_EMOJI_APPROVE) 292 | if (code_reviews.emoji_regex.test(req.body.comment.body) or 293 | code_reviews.emoji_unicode_test(req.body.comment.body)) 294 | code_reviews.approve_cr_by_url( 295 | req.body.issue.html_url, 296 | req.body.comment.user.login, 297 | req.body.comment.body 298 | ) 299 | response = "issue_comment approved #{req.body.issue.html_url}" 300 | else 301 | code_reviews.comment_cr_by_url( 302 | req.body.issue.html_url, 303 | req.body.comment.user.login, 304 | req.body.comment.body 305 | ) 306 | response = "issue_comment did not yet approve #{req.body.issue.html_url}" 307 | else 308 | code_reviews.comment_cr_by_url( 309 | req.body.issue.html_url, 310 | req.body.comment.user.login, 311 | req.body.comment.body 312 | ) 313 | response = "issue_comment did not yet approve #{req.body.issue.html_url}" 314 | # Check if PR was merged or closed 315 | else if req.headers['x-github-event'] is 'pull_request' 316 | if req.body.action is 'closed' 317 | # update CRs 318 | status = if req.body.pull_request.merged then 'merged' else 'closed' 319 | updated = code_reviews.handle_close req.body.pull_request.html_url, status 320 | # build response message 321 | if updated.length 322 | response = "set status of #{updated[0].slug} to " 323 | rooms = for cr in updated 324 | "#{cr.status} in #{cr.room}" 325 | response += rooms.join(', ') 326 | else 327 | response = "#{req.body.pull_request.html_url} not found in any queue" 328 | else 329 | response = "#{req.body.pull_request.html_url} is still open" 330 | 331 | # Check if PR was approved via GitHub's Pull Request Review 332 | else if req.headers['x-github-event'] is 'pull_request_review' and 333 | req.body.review.user.type != 'Bot' # not a bot 334 | if req.body.action? and req.body.action is 'dismissed' 335 | response = "pull_request_review dismissed #{req.body.pull_request.html_url}" 336 | code_reviews.dismiss_cr_by_url( 337 | req.body.pull_request.html_url, 338 | req.body.review.user.login 339 | ) 340 | else 341 | if req.body.review.state is 'approved' 342 | response = "pull_request_review approved #{req.body.pull_request.html_url}" 343 | code_reviews.approve_cr_by_url( 344 | req.body.pull_request.html_url, 345 | req.body.review.user.login, 346 | req.body.review.body 347 | ) 348 | else if req.body.review.state is 'changes_requested' 349 | response = "pull_request_review changes requested #{req.body.pull_request.html_url}" 350 | 351 | # Send the changes requested comment to the submitter 352 | if req.body.review.body? 353 | code_reviews.comment_cr_by_url( 354 | req.body.pull_request.html_url, 355 | req.body.review.user.login, 356 | req.body.review.body 357 | ) 358 | # Close up the PR and notify the submitter to resubmit when changes are made 359 | code_reviews.handle_close( 360 | req.body.pull_request.html_url, 361 | 'changes_requested' 362 | ) 363 | else 364 | response = "pull_request_review not yet approved #{req.body.pull_request.html_url}" 365 | if req.body.review.body? 366 | code_reviews.comment_cr_by_url( 367 | req.body.pull_request.html_url, 368 | req.body.review.user.login, 369 | req.body.review.body 370 | ) 371 | else 372 | res.statusCode = 400 373 | response = "invalid x-github-event #{req.headers['x-github-event']}" 374 | 375 | # useful for testing 376 | res.send response 377 | 378 | # return for use in unit tests 379 | return code_reviews 380 | -------------------------------------------------------------------------------- /src/lib/emoji-data.txt: -------------------------------------------------------------------------------- 1 | # Emoji Data for UTR #51 2 | # 3 | # File: emoji-data.txt 4 | # Version: 2.0 5 | # Date: 2015-11-11 6 | # 7 | # Copyright (c) 2015 Unicode, Inc. 8 | # For terms of use, see http://www.unicode.org/terms_of_use.html 9 | # For documentation and usage, see http://www.unicode.org/reports/tr51/ 10 | # 11 | 12 | # Warning: the format has changed from Version 1.0 13 | # Format: 14 | # codepoint(s) ; property=Yes # [count] (character(s)) name 15 | 16 | # ================================================ 17 | 18 | # All omitted code points have Emoji=No 19 | # @missing: 0000..10FFFF ; Emoji ; No 20 | 21 | 0023 ; Emoji # [1] (#️) NUMBER SIGN 22 | 002A ; Emoji # [1] (*) ASTERISK 23 | 0030..0039 ; Emoji # [10] (0️..9️) DIGIT ZERO..DIGIT NINE 24 | 00A9 ; Emoji # [1] (©️) COPYRIGHT SIGN 25 | 00AE ; Emoji # [1] (®️) REGISTERED SIGN 26 | 203C ; Emoji # [1] (‼️) DOUBLE EXCLAMATION MARK 27 | 2049 ; Emoji # [1] (⁉️) EXCLAMATION QUESTION MARK 28 | 2122 ; Emoji # [1] (™️) TRADE MARK SIGN 29 | 2139 ; Emoji # [1] (ℹ️) INFORMATION SOURCE 30 | 2194..2199 ; Emoji # [6] (↔️..↙️) LEFT RIGHT ARROW..SOUTH WEST ARROW 31 | 21A9..21AA ; Emoji # [2] (↩️..↪️) LEFTWARDS ARROW WITH HOOK..RIGHTWARDS ARROW WITH HOOK 32 | 231A..231B ; Emoji # [2] (⌚️..⌛️) WATCH..HOURGLASS 33 | 2328 ; Emoji # [1] (⌨) KEYBOARD 34 | 23CF ; Emoji # [1] (⏏) EJECT SYMBOL 35 | 23E9..23F3 ; Emoji # [11] (⏩..⏳) BLACK RIGHT-POINTING DOUBLE TRIANGLE..HOURGLASS WITH FLOWING SAND 36 | 23F8..23FA ; Emoji # [3] (⏸..⏺) DOUBLE VERTICAL BAR..BLACK CIRCLE FOR RECORD 37 | 24C2 ; Emoji # [1] (Ⓜ️) CIRCLED LATIN CAPITAL LETTER M 38 | 25AA..25AB ; Emoji # [2] (▪️..▫️) BLACK SMALL SQUARE..WHITE SMALL SQUARE 39 | 25B6 ; Emoji # [1] (▶️) BLACK RIGHT-POINTING TRIANGLE 40 | 25C0 ; Emoji # [1] (◀️) BLACK LEFT-POINTING TRIANGLE 41 | 25FB..25FE ; Emoji # [4] (◻️..◾️) WHITE MEDIUM SQUARE..BLACK MEDIUM SMALL SQUARE 42 | 2600..2604 ; Emoji # [5] (☀️..☄) BLACK SUN WITH RAYS..COMET 43 | 260E ; Emoji # [1] (☎️) BLACK TELEPHONE 44 | 2611 ; Emoji # [1] (☑️) BALLOT BOX WITH CHECK 45 | 2614..2615 ; Emoji # [2] (☔️..☕️) UMBRELLA WITH RAIN DROPS..HOT BEVERAGE 46 | 2618 ; Emoji # [1] (☘) SHAMROCK 47 | 261D ; Emoji # [1] (☝️) WHITE UP POINTING INDEX 48 | 2620 ; Emoji # [1] (☠) SKULL AND CROSSBONES 49 | 2622..2623 ; Emoji # [2] (☢..☣) RADIOACTIVE SIGN..BIOHAZARD SIGN 50 | 2626 ; Emoji # [1] (☦) ORTHODOX CROSS 51 | 262A ; Emoji # [1] (☪) STAR AND CRESCENT 52 | 262E..262F ; Emoji # [2] (☮..☯) PEACE SYMBOL..YIN YANG 53 | 2638..263A ; Emoji # [3] (☸..☺️) WHEEL OF DHARMA..WHITE SMILING FACE 54 | 2648..2653 ; Emoji # [12] (♈️..♓️) ARIES..PISCES 55 | 2660 ; Emoji # [1] (♠️) BLACK SPADE SUIT 56 | 2663 ; Emoji # [1] (♣️) BLACK CLUB SUIT 57 | 2665..2666 ; Emoji # [2] (♥️..♦️) BLACK HEART SUIT..BLACK DIAMOND SUIT 58 | 2668 ; Emoji # [1] (♨️) HOT SPRINGS 59 | 267B ; Emoji # [1] (♻️) BLACK UNIVERSAL RECYCLING SYMBOL 60 | 267F ; Emoji # [1] (♿️) WHEELCHAIR SYMBOL 61 | 2692..2694 ; Emoji # [3] (⚒..⚔) HAMMER AND PICK..CROSSED SWORDS 62 | 2696..2697 ; Emoji # [2] (⚖..⚗) SCALES..ALEMBIC 63 | 2699 ; Emoji # [1] (⚙) GEAR 64 | 269B..269C ; Emoji # [2] (⚛..⚜) ATOM SYMBOL..FLEUR-DE-LIS 65 | 26A0..26A1 ; Emoji # [2] (⚠️..⚡️) WARNING SIGN..HIGH VOLTAGE SIGN 66 | 26AA..26AB ; Emoji # [2] (⚪️..⚫️) MEDIUM WHITE CIRCLE..MEDIUM BLACK CIRCLE 67 | 26B0..26B1 ; Emoji # [2] (⚰..⚱) COFFIN..FUNERAL URN 68 | 26BD..26BE ; Emoji # [2] (⚽️..⚾️) SOCCER BALL..BASEBALL 69 | 26C4..26C5 ; Emoji # [2] (⛄️..⛅️) SNOWMAN WITHOUT SNOW..SUN BEHIND CLOUD 70 | 26C8 ; Emoji # [1] (⛈) THUNDER CLOUD AND RAIN 71 | 26CE..26CF ; Emoji # [2] (⛎..⛏) OPHIUCHUS..PICK 72 | 26D1 ; Emoji # [1] (⛑) HELMET WITH WHITE CROSS 73 | 26D3..26D4 ; Emoji # [2] (⛓..⛔️) CHAINS..NO ENTRY 74 | 26E9..26EA ; Emoji # [2] (⛩..⛪️) SHINTO SHRINE..CHURCH 75 | 26F0..26F5 ; Emoji # [6] (⛰..⛵️) MOUNTAIN..SAILBOAT 76 | 26F7..26FA ; Emoji # [4] (⛷..⛺️) SKIER..TENT 77 | 26FD ; Emoji # [1] (⛽️) FUEL PUMP 78 | 2702 ; Emoji # [1] (✂️) BLACK SCISSORS 79 | 2705 ; Emoji # [1] (✅) WHITE HEAVY CHECK MARK 80 | 2708..270D ; Emoji # [6] (✈️..✍) AIRPLANE..WRITING HAND 81 | 270F ; Emoji # [1] (✏️) PENCIL 82 | 2712 ; Emoji # [1] (✒️) BLACK NIB 83 | 2714 ; Emoji # [1] (✔️) HEAVY CHECK MARK 84 | 2716 ; Emoji # [1] (✖️) HEAVY MULTIPLICATION X 85 | 271D ; Emoji # [1] (✝) LATIN CROSS 86 | 2721 ; Emoji # [1] (✡) STAR OF DAVID 87 | 2728 ; Emoji # [1] (✨) SPARKLES 88 | 2733..2734 ; Emoji # [2] (✳️..✴️) EIGHT SPOKED ASTERISK..EIGHT POINTED BLACK STAR 89 | 2744 ; Emoji # [1] (❄️) SNOWFLAKE 90 | 2747 ; Emoji # [1] (❇️) SPARKLE 91 | 274C ; Emoji # [1] (❌) CROSS MARK 92 | 274E ; Emoji # [1] (❎) NEGATIVE SQUARED CROSS MARK 93 | 2753..2755 ; Emoji # [3] (❓..❕) BLACK QUESTION MARK ORNAMENT..WHITE EXCLAMATION MARK ORNAMENT 94 | 2757 ; Emoji # [1] (❗️) HEAVY EXCLAMATION MARK SYMBOL 95 | 2763..2764 ; Emoji # [2] (❣..❤️) HEAVY HEART EXCLAMATION MARK ORNAMENT..HEAVY BLACK HEART 96 | 2795..2797 ; Emoji # [3] (➕..➗) HEAVY PLUS SIGN..HEAVY DIVISION SIGN 97 | 27A1 ; Emoji # [1] (➡️) BLACK RIGHTWARDS ARROW 98 | 27B0 ; Emoji # [1] (➰) CURLY LOOP 99 | 27BF ; Emoji # [1] (➿) DOUBLE CURLY LOOP 100 | 2934..2935 ; Emoji # [2] (⤴️..⤵️) ARROW POINTING RIGHTWARDS THEN CURVING UPWARDS..ARROW POINTING RIGHTWARDS THEN CURVING DOWNWARDS 101 | 2B05..2B07 ; Emoji # [3] (⬅️..⬇️) LEFTWARDS BLACK ARROW..DOWNWARDS BLACK ARROW 102 | 2B1B..2B1C ; Emoji # [2] (⬛️..⬜️) BLACK LARGE SQUARE..WHITE LARGE SQUARE 103 | 2B50 ; Emoji # [1] (⭐️) WHITE MEDIUM STAR 104 | 2B55 ; Emoji # [1] (⭕️) HEAVY LARGE CIRCLE 105 | 3030 ; Emoji # [1] (〰️) WAVY DASH 106 | 303D ; Emoji # [1] (〽️) PART ALTERNATION MARK 107 | 3297 ; Emoji # [1] (㊗️) CIRCLED IDEOGRAPH CONGRATULATION 108 | 3299 ; Emoji # [1] (㊙️) CIRCLED IDEOGRAPH SECRET 109 | 1F004 ; Emoji # [1] (🀄️) MAHJONG TILE RED DRAGON 110 | 1F0CF ; Emoji # [1] (🃏) PLAYING CARD BLACK JOKER 111 | 1F170..1F171 ; Emoji # [2] (🅰️..🅱️) NEGATIVE SQUARED LATIN CAPITAL LETTER A..NEGATIVE SQUARED LATIN CAPITAL LETTER B 112 | 1F17E..1F17F ; Emoji # [2] (🅾️..🅿️) NEGATIVE SQUARED LATIN CAPITAL LETTER O..NEGATIVE SQUARED LATIN CAPITAL LETTER P 113 | 1F18E ; Emoji # [1] (🆎) NEGATIVE SQUARED AB 114 | 1F191..1F19A ; Emoji # [10] (🆑..🆚) SQUARED CL..SQUARED VS 115 | 1F1E6..1F1FF ; Emoji # [26] (🇦..🇿) REGIONAL INDICATOR SYMBOL LETTER A..REGIONAL INDICATOR SYMBOL LETTER Z 116 | 1F201..1F202 ; Emoji # [2] (🈁..🈂️) SQUARED KATAKANA KOKO..SQUARED KATAKANA SA 117 | 1F21A ; Emoji # [1] (🈚️) SQUARED CJK UNIFIED IDEOGRAPH-7121 118 | 1F22F ; Emoji # [1] (🈯️) SQUARED CJK UNIFIED IDEOGRAPH-6307 119 | 1F232..1F23A ; Emoji # [9] (🈲..🈺) SQUARED CJK UNIFIED IDEOGRAPH-7981..SQUARED CJK UNIFIED IDEOGRAPH-55B6 120 | 1F250..1F251 ; Emoji # [2] (🉐..🉑) CIRCLED IDEOGRAPH ADVANTAGE..CIRCLED IDEOGRAPH ACCEPT 121 | 1F300..1F321 ; Emoji # [34] (🌀..🌡) CYCLONE..THERMOMETER 122 | 1F324..1F393 ; Emoji # [112] (🌤..🎓) WHITE SUN WITH SMALL CLOUD..GRADUATION CAP 123 | 1F396..1F397 ; Emoji # [2] (🎖..🎗) MILITARY MEDAL..REMINDER RIBBON 124 | 1F399..1F39B ; Emoji # [3] (🎙..🎛) STUDIO MICROPHONE..CONTROL KNOBS 125 | 1F39E..1F3F0 ; Emoji # [83] (🎞..🏰) FILM FRAMES..EUROPEAN CASTLE 126 | 1F3F3..1F3F5 ; Emoji # [3] (🏳..🏵) WAVING WHITE FLAG..ROSETTE 127 | 1F3F7..1F4FD ; Emoji # [263] (🏷..📽) LABEL..FILM PROJECTOR 128 | 1F4FF..1F53D ; Emoji # [63] (📿..🔽) PRAYER BEADS..DOWN-POINTING SMALL RED TRIANGLE 129 | 1F549..1F54E ; Emoji # [6] (🕉..🕎) OM SYMBOL..MENORAH WITH NINE BRANCHES 130 | 1F550..1F567 ; Emoji # [24] (🕐..🕧) CLOCK FACE ONE OCLOCK..CLOCK FACE TWELVE-THIRTY 131 | 1F56F..1F570 ; Emoji # [2] (🕯..🕰) CANDLE..MANTELPIECE CLOCK 132 | 1F573..1F579 ; Emoji # [7] (🕳..🕹) HOLE..JOYSTICK 133 | 1F587 ; Emoji # [1] (🖇) LINKED PAPERCLIPS 134 | 1F58A..1F58D ; Emoji # [4] (🖊..🖍) LOWER LEFT BALLPOINT PEN..LOWER LEFT CRAYON 135 | 1F590 ; Emoji # [1] (🖐) RAISED HAND WITH FINGERS SPLAYED 136 | 1F595..1F596 ; Emoji # [2] (🖕..🖖) REVERSED HAND WITH MIDDLE FINGER EXTENDED..RAISED HAND WITH PART BETWEEN MIDDLE AND RING FINGERS 137 | 1F5A5 ; Emoji # [1] (🖥) DESKTOP COMPUTER 138 | 1F5A8 ; Emoji # [1] (🖨) PRINTER 139 | 1F5B1..1F5B2 ; Emoji # [2] (🖱..🖲) THREE BUTTON MOUSE..TRACKBALL 140 | 1F5BC ; Emoji # [1] (🖼) FRAME WITH PICTURE 141 | 1F5C2..1F5C4 ; Emoji # [3] (🗂..🗄) CARD INDEX DIVIDERS..FILE CABINET 142 | 1F5D1..1F5D3 ; Emoji # [3] (🗑..🗓) WASTEBASKET..SPIRAL CALENDAR PAD 143 | 1F5DC..1F5DE ; Emoji # [3] (🗜..🗞) COMPRESSION..ROLLED-UP NEWSPAPER 144 | 1F5E1 ; Emoji # [1] (🗡) DAGGER KNIFE 145 | 1F5E3 ; Emoji # [1] (🗣) SPEAKING HEAD IN SILHOUETTE 146 | 1F5E8 ; Emoji # [1] (🗨) LEFT SPEECH BUBBLE 147 | 1F5EF ; Emoji # [1] (🗯) RIGHT ANGER BUBBLE 148 | 1F5F3 ; Emoji # [1] (🗳) BALLOT BOX WITH BALLOT 149 | 1F5FA..1F64F ; Emoji # [86] (🗺..🙏) WORLD MAP..PERSON WITH FOLDED HANDS 150 | 1F680..1F6C5 ; Emoji # [70] (🚀..🛅) ROCKET..LEFT LUGGAGE 151 | 1F6CB..1F6D0 ; Emoji # [6] (🛋..🛐) COUCH AND LAMP..PLACE OF WORSHIP 152 | 1F6E0..1F6E5 ; Emoji # [6] (🛠..🛥) HAMMER AND WRENCH..MOTOR BOAT 153 | 1F6E9 ; Emoji # [1] (🛩) SMALL AIRPLANE 154 | 1F6EB..1F6EC ; Emoji # [2] (🛫..🛬) AIRPLANE DEPARTURE..AIRPLANE ARRIVING 155 | 1F6F0 ; Emoji # [1] (🛰) SATELLITE 156 | 1F6F3 ; Emoji # [1] (🛳) PASSENGER SHIP 157 | 1F910..1F918 ; Emoji # [9] (🤐..🤘) ZIPPER-MOUTH FACE..SIGN OF THE HORNS 158 | 1F980..1F984 ; Emoji # [5] (🦀..🦄) CRAB..UNICORN FACE 159 | 1F9C0 ; Emoji # [1] (🧀) CHEESE WEDGE 160 | 161 | # Total code points: 1051 162 | 163 | # ================================================ 164 | 165 | # All omitted code points have Emoji_Presentation=No 166 | # @missing: 0000..10FFFF ; Emoji_Presentation ; No 167 | 168 | 231A..231B ; Emoji_Presentation # [2] (⌚️..⌛️) WATCH..HOURGLASS 169 | 23E9..23EC ; Emoji_Presentation # [4] (⏩..⏬) BLACK RIGHT-POINTING DOUBLE TRIANGLE..BLACK DOWN-POINTING DOUBLE TRIANGLE 170 | 23F0 ; Emoji_Presentation # [1] (⏰) ALARM CLOCK 171 | 23F3 ; Emoji_Presentation # [1] (⏳) HOURGLASS WITH FLOWING SAND 172 | 25FD..25FE ; Emoji_Presentation # [2] (◽️..◾️) WHITE MEDIUM SMALL SQUARE..BLACK MEDIUM SMALL SQUARE 173 | 2614..2615 ; Emoji_Presentation # [2] (☔️..☕️) UMBRELLA WITH RAIN DROPS..HOT BEVERAGE 174 | 2648..2653 ; Emoji_Presentation # [12] (♈️..♓️) ARIES..PISCES 175 | 267F ; Emoji_Presentation # [1] (♿️) WHEELCHAIR SYMBOL 176 | 2693 ; Emoji_Presentation # [1] (⚓️) ANCHOR 177 | 26A1 ; Emoji_Presentation # [1] (⚡️) HIGH VOLTAGE SIGN 178 | 26AA..26AB ; Emoji_Presentation # [2] (⚪️..⚫️) MEDIUM WHITE CIRCLE..MEDIUM BLACK CIRCLE 179 | 26BD..26BE ; Emoji_Presentation # [2] (⚽️..⚾️) SOCCER BALL..BASEBALL 180 | 26C4..26C5 ; Emoji_Presentation # [2] (⛄️..⛅️) SNOWMAN WITHOUT SNOW..SUN BEHIND CLOUD 181 | 26CE ; Emoji_Presentation # [1] (⛎) OPHIUCHUS 182 | 26D4 ; Emoji_Presentation # [1] (⛔️) NO ENTRY 183 | 26EA ; Emoji_Presentation # [1] (⛪️) CHURCH 184 | 26F2..26F3 ; Emoji_Presentation # [2] (⛲️..⛳️) FOUNTAIN..FLAG IN HOLE 185 | 26F5 ; Emoji_Presentation # [1] (⛵️) SAILBOAT 186 | 26FA ; Emoji_Presentation # [1] (⛺️) TENT 187 | 26FD ; Emoji_Presentation # [1] (⛽️) FUEL PUMP 188 | 2705 ; Emoji_Presentation # [1] (✅) WHITE HEAVY CHECK MARK 189 | 270A..270B ; Emoji_Presentation # [2] (✊..✋) RAISED FIST..RAISED HAND 190 | 2728 ; Emoji_Presentation # [1] (✨) SPARKLES 191 | 274C ; Emoji_Presentation # [1] (❌) CROSS MARK 192 | 274E ; Emoji_Presentation # [1] (❎) NEGATIVE SQUARED CROSS MARK 193 | 2753..2755 ; Emoji_Presentation # [3] (❓..❕) BLACK QUESTION MARK ORNAMENT..WHITE EXCLAMATION MARK ORNAMENT 194 | 2757 ; Emoji_Presentation # [1] (❗️) HEAVY EXCLAMATION MARK SYMBOL 195 | 2795..2797 ; Emoji_Presentation # [3] (➕..➗) HEAVY PLUS SIGN..HEAVY DIVISION SIGN 196 | 27B0 ; Emoji_Presentation # [1] (➰) CURLY LOOP 197 | 27BF ; Emoji_Presentation # [1] (➿) DOUBLE CURLY LOOP 198 | 2B1B..2B1C ; Emoji_Presentation # [2] (⬛️..⬜️) BLACK LARGE SQUARE..WHITE LARGE SQUARE 199 | 2B50 ; Emoji_Presentation # [1] (⭐️) WHITE MEDIUM STAR 200 | 2B55 ; Emoji_Presentation # [1] (⭕️) HEAVY LARGE CIRCLE 201 | 1F004 ; Emoji_Presentation # [1] (🀄️) MAHJONG TILE RED DRAGON 202 | 1F0CF ; Emoji_Presentation # [1] (🃏) PLAYING CARD BLACK JOKER 203 | 1F18E ; Emoji_Presentation # [1] (🆎) NEGATIVE SQUARED AB 204 | 1F191..1F19A ; Emoji_Presentation # [10] (🆑..🆚) SQUARED CL..SQUARED VS 205 | 1F1E6..1F1FF ; Emoji_Presentation # [26] (🇦..🇿) REGIONAL INDICATOR SYMBOL LETTER A..REGIONAL INDICATOR SYMBOL LETTER Z 206 | 1F201 ; Emoji_Presentation # [1] (🈁) SQUARED KATAKANA KOKO 207 | 1F21A ; Emoji_Presentation # [1] (🈚️) SQUARED CJK UNIFIED IDEOGRAPH-7121 208 | 1F22F ; Emoji_Presentation # [1] (🈯️) SQUARED CJK UNIFIED IDEOGRAPH-6307 209 | 1F232..1F236 ; Emoji_Presentation # [5] (🈲..🈶) SQUARED CJK UNIFIED IDEOGRAPH-7981..SQUARED CJK UNIFIED IDEOGRAPH-6709 210 | 1F238..1F23A ; Emoji_Presentation # [3] (🈸..🈺) SQUARED CJK UNIFIED IDEOGRAPH-7533..SQUARED CJK UNIFIED IDEOGRAPH-55B6 211 | 1F250..1F251 ; Emoji_Presentation # [2] (🉐..🉑) CIRCLED IDEOGRAPH ADVANTAGE..CIRCLED IDEOGRAPH ACCEPT 212 | 1F300..1F320 ; Emoji_Presentation # [33] (🌀..🌠) CYCLONE..SHOOTING STAR 213 | 1F32D..1F335 ; Emoji_Presentation # [9] (🌭..🌵) HOT DOG..CACTUS 214 | 1F337..1F37C ; Emoji_Presentation # [70] (🌷..🍼) TULIP..BABY BOTTLE 215 | 1F37E..1F393 ; Emoji_Presentation # [22] (🍾..🎓) BOTTLE WITH POPPING CORK..GRADUATION CAP 216 | 1F3A0..1F3CA ; Emoji_Presentation # [43] (🎠..🏊) CAROUSEL HORSE..SWIMMER 217 | 1F3CF..1F3D3 ; Emoji_Presentation # [5] (🏏..🏓) CRICKET BAT AND BALL..TABLE TENNIS PADDLE AND BALL 218 | 1F3E0..1F3F0 ; Emoji_Presentation # [17] (🏠..🏰) HOUSE BUILDING..EUROPEAN CASTLE 219 | 1F3F4 ; Emoji_Presentation # [1] (🏴) WAVING BLACK FLAG 220 | 1F3F8..1F43E ; Emoji_Presentation # [71] (🏸..🐾) BADMINTON RACQUET AND SHUTTLECOCK..PAW PRINTS 221 | 1F440 ; Emoji_Presentation # [1] (👀) EYES 222 | 1F442..1F4FC ; Emoji_Presentation # [187] (👂..📼) EAR..VIDEOCASSETTE 223 | 1F4FF..1F53D ; Emoji_Presentation # [63] (📿..🔽) PRAYER BEADS..DOWN-POINTING SMALL RED TRIANGLE 224 | 1F54B..1F54E ; Emoji_Presentation # [4] (🕋..🕎) KAABA..MENORAH WITH NINE BRANCHES 225 | 1F550..1F567 ; Emoji_Presentation # [24] (🕐..🕧) CLOCK FACE ONE OCLOCK..CLOCK FACE TWELVE-THIRTY 226 | 1F595..1F596 ; Emoji_Presentation # [2] (🖕..🖖) REVERSED HAND WITH MIDDLE FINGER EXTENDED..RAISED HAND WITH PART BETWEEN MIDDLE AND RING FINGERS 227 | 1F5FB..1F64F ; Emoji_Presentation # [85] (🗻..🙏) MOUNT FUJI..PERSON WITH FOLDED HANDS 228 | 1F680..1F6C5 ; Emoji_Presentation # [70] (🚀..🛅) ROCKET..LEFT LUGGAGE 229 | 1F6CC ; Emoji_Presentation # [1] (🛌) SLEEPING ACCOMMODATION 230 | 1F6D0 ; Emoji_Presentation # [1] (🛐) PLACE OF WORSHIP 231 | 1F6EB..1F6EC ; Emoji_Presentation # [2] (🛫..🛬) AIRPLANE DEPARTURE..AIRPLANE ARRIVING 232 | 1F910..1F918 ; Emoji_Presentation # [9] (🤐..🤘) ZIPPER-MOUTH FACE..SIGN OF THE HORNS 233 | 1F980..1F984 ; Emoji_Presentation # [5] (🦀..🦄) CRAB..UNICORN FACE 234 | 1F9C0 ; Emoji_Presentation # [1] (🧀) CHEESE WEDGE 235 | 236 | # Total code points: 838 237 | 238 | # ================================================ 239 | 240 | # All omitted code points have Emoji_Modifier=No 241 | # @missing: 0000..10FFFF ; Emoji_Modifier ; No 242 | 243 | 1F3FB..1F3FF ; Emoji_Modifier # [5] (🏻..🏿) EMOJI MODIFIER FITZPATRICK TYPE-1-2..EMOJI MODIFIER FITZPATRICK TYPE-6 244 | 245 | # Total code points: 5 246 | 247 | # ================================================ 248 | 249 | # All omitted code points have Emoji_Modifier_Base=No 250 | # @missing: 0000..10FFFF ; Emoji_Modifier_Base ; No 251 | 252 | 261D ; Emoji_Modifier_Base # [1] (☝️) WHITE UP POINTING INDEX 253 | 26F9 ; Emoji_Modifier_Base # [1] (⛹) PERSON WITH BALL 254 | 270A..270D ; Emoji_Modifier_Base # [4] (✊..✍) RAISED FIST..WRITING HAND 255 | 1F385 ; Emoji_Modifier_Base # [1] (🎅) FATHER CHRISTMAS 256 | 1F3C3..1F3C4 ; Emoji_Modifier_Base # [2] (🏃..🏄) RUNNER..SURFER 257 | 1F3CA..1F3CB ; Emoji_Modifier_Base # [2] (🏊..🏋) SWIMMER..WEIGHT LIFTER 258 | 1F442..1F443 ; Emoji_Modifier_Base # [2] (👂..👃) EAR..NOSE 259 | 1F446..1F450 ; Emoji_Modifier_Base # [11] (👆..👐) WHITE UP POINTING BACKHAND INDEX..OPEN HANDS SIGN 260 | 1F466..1F469 ; Emoji_Modifier_Base # [4] (👦..👩) BOY..WOMAN 261 | 1F46E ; Emoji_Modifier_Base # [1] (👮) POLICE OFFICER 262 | 1F470..1F478 ; Emoji_Modifier_Base # [9] (👰..👸) BRIDE WITH VEIL..PRINCESS 263 | 1F47C ; Emoji_Modifier_Base # [1] (👼) BABY ANGEL 264 | 1F481..1F483 ; Emoji_Modifier_Base # [3] (💁..💃) INFORMATION DESK PERSON..DANCER 265 | 1F485..1F487 ; Emoji_Modifier_Base # [3] (💅..💇) NAIL POLISH..HAIRCUT 266 | 1F4AA ; Emoji_Modifier_Base # [1] (💪) FLEXED BICEPS 267 | 1F575 ; Emoji_Modifier_Base # [1] (🕵) SLEUTH OR SPY 268 | 1F590 ; Emoji_Modifier_Base # [1] (🖐) RAISED HAND WITH FINGERS SPLAYED 269 | 1F595..1F596 ; Emoji_Modifier_Base # [2] (🖕..🖖) REVERSED HAND WITH MIDDLE FINGER EXTENDED..RAISED HAND WITH PART BETWEEN MIDDLE AND RING FINGERS 270 | 1F645..1F647 ; Emoji_Modifier_Base # [3] (🙅..🙇) FACE WITH NO GOOD GESTURE..PERSON BOWING DEEPLY 271 | 1F64B..1F64F ; Emoji_Modifier_Base # [5] (🙋..🙏) HAPPY PERSON RAISING ONE HAND..PERSON WITH FOLDED HANDS 272 | 1F6A3 ; Emoji_Modifier_Base # [1] (🚣) ROWBOAT 273 | 1F6B4..1F6B6 ; Emoji_Modifier_Base # [3] (🚴..🚶) BICYCLIST..PEDESTRIAN 274 | 1F6C0 ; Emoji_Modifier_Base # [1] (🛀) BATH 275 | 1F918 ; Emoji_Modifier_Base # [1] (🤘) SIGN OF THE HORNS 276 | 277 | # Total code points: 64 278 | -------------------------------------------------------------------------------- /src/CodeReviews.coffee: -------------------------------------------------------------------------------- 1 | fs = require 'fs' 2 | path = require 'path' 3 | moment = require 'moment' 4 | schedule = require 'node-schedule' 5 | 6 | CR_Middleware = require './CodeReviewsMiddleware' 7 | CodeReviewKarma = require './CodeReviewKarma' 8 | sendFancyMessage = require './lib/sendFancyMessage' 9 | msgRoomName = require './lib/msgRoomName' 10 | roomList = require './lib/roomList' 11 | EmojiDataParser = require './lib/EmojiDataParser' 12 | 13 | class CodeReviews 14 | constructor: (@robot) -> 15 | # coffeelint: disable=max_line_length 16 | @enterprise_github_url_regex = /^(?:https?:\/\/)?([\w.]+)\/?\s*$/i 17 | @github_url = 'github.com' 18 | if (process.env.HUBOT_ENTERPRISE_GITHUB_URL)? 19 | matches = @enterprise_github_url_regex.exec process.env.HUBOT_ENTERPRISE_GITHUB_URL 20 | if matches 21 | @github_url = matches[0] 22 | @pr_url_regex = /// 23 | ^(https?:\/\/#{@github_url}\/([^\/]+)\/([^\/]+)\/pull\/(\d+))(?:\/files)?\/?(?:\s+)?)?\s*$ 24 | ///i 25 | @room_queues = {} 26 | @current_timeout = null 27 | @reminder_count = 0 28 | @emoji_regex = /(\:[a-z0-9_\-\+]+\:)/mi 29 | @help_text = null 30 | @help_text_timeout = null 31 | 32 | @garbage_expiration = 1296000000 # 15 days in milliseconds 33 | @garbage_cron = '0 0 * * *' # every day at midnight 34 | @garbage_last_collection = 0 # counter for last collection 35 | @garbage_job = null 36 | 37 | @karma_monthly_rankings_schedule = '0 0 1 * *' # midnight on the first of every month 38 | @karma_monthly_rankings_reset = null 39 | 40 | # Set up middleware 41 | CR_Middleware @robot 42 | 43 | # CodeReviewKarma functionality for karma_monthly_rankings_reset 44 | code_review_karma = new CodeReviewKarma @robot 45 | 46 | @robot.brain.on 'loaded', => 47 | if @robot.brain.data.code_reviews 48 | cache = @robot.brain.data.code_reviews 49 | @room_queues = cache.room_queues || {} 50 | @set_help_text() 51 | @collect_garbage() 52 | unless Object.keys(@room_queues).length is 0 53 | @queue() 54 | 55 | # Schedule recurring garbage collection 56 | unless @garbage_job 57 | @garbage_job = schedule.scheduleJob 'CodeReviews.collect_garbage', @garbage_cron, () => 58 | @collect_garbage() 59 | 60 | # Schedule Karma Monthly Score Reset 61 | # (and notice, if HUBOT_CODE_REVIEW_KARMA_MONTHLY_AWARD_ROOM is set) 62 | unless (@karma_monthly_rankings_reset)? 63 | @karma_monthly_rankings_reset = schedule.scheduleJob 'CodeReviewKarma.monthly_rankings', @karma_monthly_rankings_schedule, () -> 64 | code_review_karma.monthly_rankings() 65 | 66 | # coffeelint: enable=max_line_length 67 | 68 | # Garbage collection, removes all CRs older than @garbage_expiration 69 | # 70 | # @return none 71 | collect_garbage: () -> 72 | @garbage_last_collection = 0 73 | # loop through rooms 74 | if Object.keys(@room_queues).length 75 | for room, queue of @room_queues 76 | # loop through queue 77 | for cr, i in queue by -1 78 | # remove if cr is expired or if last_updated time is unknown 79 | if ! cr.last_updated? || (cr.last_updated + @garbage_expiration) < Date.now() 80 | @remove_from_room room, i 81 | @garbage_last_collection++ 82 | @robot.logger.info "CodeReviews.collect garbage found #{@garbage_last_collection} items" 83 | 84 | # Update Redis store of CR queues 85 | # 86 | # @return none 87 | update_redis: -> 88 | @robot.brain.data.code_reviews = { room_queues: @room_queues, help_text: @help_text } 89 | 90 | # Set help text and update Redis with 12 hour lifespan 91 | # 92 | # @param string text Text of `help crs` response 93 | # @return none 94 | set_help_text: () -> 95 | commandRe = /^[ \t]*@command[ \t]+(.*)/ 96 | descRe = /^[ \t]*@desc[ \t]+(.*)/ 97 | src = fs.readFileSync path.resolve(__dirname, 'code-reviews.coffee'), { encoding: 'utf8' } 98 | lines = src.split "\n" 99 | help_text = '' 100 | 101 | # Parse this format from code-reviews.coffee 102 | ### 103 | @command comand example 104 | @desc Command description 105 | ### 106 | for line, i in lines 107 | if commandRe.test(line) and descRe.test(lines[i + 1]) 108 | command = "`#{commandRe.exec(line)[1]}`".replace /hubot./g, @robot.name 109 | spaces = ' ' 110 | 111 | len = 40 112 | if len > command.length 113 | while len > command.length 114 | spaces += ' ' 115 | len-- 116 | 117 | desc = descRe.exec(lines[i + 1])[1] 118 | help_text += "#{command}#{spaces}#{desc}\n" 119 | 120 | # Extra stuff 121 | help_text += "_Note that some commands require direct @#{@robot.name}," + 122 | " some don't, and some work either way._\n" + 123 | "\n\n*Code review statuses*\n" + 124 | "`new`\t\tPR has just been added to the queue, no one is on it.\n" + 125 | "`claimed`\tSomeone is on this PR\n" + 126 | "`approved`\tPR was approved. Requires GitHub webhook.\n" + 127 | "`merged`\tPR was merged and closed. Requires GitHub webhook.\n" + 128 | "`closed`\tPR was closed without merging. Requires GitHub webhook.\n" 129 | 130 | @help_text = help_text 131 | @update_redis() 132 | 133 | # Notify room/user channel of a particular CR 134 | # 135 | # @param CodeReview cr CR to update 136 | # @param String origin_room string of origin room 137 | # @param String channel_to_notify string of the user/room to notify 138 | notify_channel: (cr, origin_room, channel_to_notify) -> 139 | attachments = [] 140 | attachments.push 141 | fallback: "#{cr.url} could use your :eyes: Remember to claim it in ##{origin_room}" 142 | text: "*<#{cr.url}|#{cr.slug}>* could use your :eyes: Remember to claim it" + 143 | " in ##{origin_room}" 144 | mrkdwn_in: ["text"] 145 | color: "#575757" 146 | sendFancyMessage @robot, channel_to_notify, attachments 147 | 148 | # Find index of slug in a room's CR queue 149 | # 150 | # @param string room Room to look in 151 | # @param slug Slug to look for 152 | # @return int|bool Index if found; false if not found 153 | find_slug_index: (room, slug) -> 154 | if @room_queues[room] && @room_queues[room].length 155 | for cr, i in @room_queues[room] 156 | return i if slug == cr.slug 157 | 158 | # if slug wasn't found return false 159 | return false 160 | 161 | # Find a slug by fragment in a queue 162 | # 163 | # @param string room Room to look in 164 | # @param string fragment Fragment to look for 165 | # @param string status Optional CR status to filter by 166 | # @return array Array of matching CR objects, empty if no matches found 167 | search_room_by_slug: (room, fragment, status = false) -> 168 | found = [] 169 | if @room_queues[room] && @room_queues[room].length 170 | for cr, i in @room_queues[room] 171 | if cr.slug.indexOf(fragment) > -1 172 | if ! status 173 | found.push cr 174 | else if cr.status is status 175 | found.push cr 176 | return found 177 | 178 | # Add a CR to a room queue 179 | # 180 | # @param CodeReview cr Code Review object to add 181 | # @return none 182 | add: (cr) -> 183 | return unless cr.room 184 | @room_queues[cr.room] ||= [] 185 | @room_queues[cr.room].unshift(cr) if false == @find_slug_index(cr.room, cr.slug) 186 | @update_redis() 187 | @reminder_count = 0 188 | @queue() 189 | 190 | # Update metadata of CR passed by reference 191 | # 192 | # @param CodeReview cr CR to update 193 | # @param string status Optional new status of CR 194 | # @param string reviewer Optional reviewer name for CR 195 | # @return none 196 | update_cr: (cr, status = false, reviewer = false) -> 197 | if status 198 | cr.status = status 199 | if reviewer 200 | cr.reviewer = reviewer 201 | cr.last_updated = Date.now() 202 | @update_redis() 203 | 204 | # Reset metadata of CR passed by reference 205 | # 206 | # @param CodeReview cr CR to reset 207 | # @return none 208 | reset_cr: (cr) -> 209 | cr.status = 'new' 210 | cr.reviewer = false 211 | cr.last_updated = Date.now() 212 | @update_redis() 213 | 214 | # Update a specific CR to 'claimed' when someone is `on repo/123` 215 | # 216 | # @param string room Name of room to look in 217 | # @param string slug Slug of CR to claim 218 | # @param string reviewer Name of user who claimed the CR 219 | # @return CodeReview|bool CR object, or false if slug was not found or already claimed 220 | claim_by_slug: (room, slug, reviewer) -> 221 | i = @find_slug_index room, slug 222 | if i != false && @room_queues[room][i].status == 'new' 223 | @update_cr @room_queues[room][i], 'claimed', reviewer 224 | return @room_queues[room][i] 225 | else 226 | return false 227 | 228 | # Update earliest added unclaimed CR when someone is `on it` 229 | # 230 | # @param string room Name of room to look in 231 | # @param string reviewer Name of user who claimed the CR 232 | # @return CodeReview|bool CR object, or false if queue has no unclaimed CRs 233 | claim_first: (room, reviewer) -> 234 | # return false if queue is empty 235 | unless @room_queues[room] && @room_queues[room].length 236 | return false 237 | # look for earliest added unclaimed CR 238 | for cr, i in @room_queues[room] by -1 239 | if cr.status == 'new' 240 | @update_cr @room_queues[room][i], 'claimed', reviewer 241 | return @room_queues[room][i] 242 | 243 | # return false if all CRs have been spoken for 244 | return false 245 | 246 | # Remove most recently added *unclaimed* CR from a room 247 | # 248 | # @param string room Room to look in 249 | # @return CodeReview|bool CR object that was removed, or false if queue has no unclaimed CRs 250 | remove_last_new: (room) -> 251 | unless room and @room_queues[room] and @room_queues[room].length 252 | return false 253 | # find first new CR in room and remove it 254 | for cr, i in @room_queues[room] 255 | if cr.status == 'new' 256 | return @remove_from_room room, i 257 | # return false if no new prs 258 | return false 259 | 260 | # Remove a CR with *any status* from a room 261 | # 262 | # @param string room Room to look in 263 | # @param string slug Slug to remove 264 | # @return CodeReview|bool CR object that was removed, or false if slug was not found 265 | remove_by_slug: (room, slug) -> 266 | return unless room 267 | i = @find_slug_index(room, slug) 268 | unless i is false 269 | return @remove_from_room room, i 270 | return false 271 | 272 | # Remove a CR from a room by index 273 | # 274 | # @param string room Room to look in 275 | # @param int index Index to remove from queue 276 | # @return CodeReview|bool CR object that was removed, or false if room or index was invalid 277 | remove_from_room: (room, index) -> 278 | # make sure the queue exists and is longer than the index we're looking for 279 | unless @room_queues[room] && @room_queues[room].length > index 280 | return false 281 | 282 | removed = @room_queues[room].splice index, 1 283 | delete @room_queues[room] if @room_queues[room].length is 0 284 | @update_redis() 285 | @check_queue() 286 | return removed.pop() 287 | 288 | # Clear the reminder timeout if there are no CR queues in any rooms 289 | # 290 | # @return none 291 | check_queue: -> 292 | if Object.keys(@room_queues).length is 0 293 | clearTimeout @current_timeout if @current_timeout 294 | 295 | # Reset all room queues 296 | # 297 | # @return none 298 | flush_queues: -> 299 | @room_queues = {} 300 | @update_redis() 301 | clearTimeout @current_timeout if @current_timeout 302 | 303 | # Return a list of CRs in a queue 304 | # 305 | # @parm string room Name of room 306 | # @param bool verbose Whether to return a message when requested list is empty 307 | # @param string status CR status to list, 308 | # can be 'new', 'all', 'claimed', 'approved', 'closed', 'merged' 309 | # @return hash reviews with contents: 310 | # reviews["pretext"]{string} and reviews["cr"]{array of strings} 311 | list: (room, verbose = false, status = 'new') -> 312 | # Look for CRs with the correct status 313 | reviews = [] 314 | reviews["cr"] = [] 315 | if room and @room_queues[room] and @room_queues[room].length > 0 316 | for cr in @room_queues[room] 317 | if cr.status == status || status == 'all' 318 | fromNowLabel = if cr.status is 'new' then 'added' else cr.status 319 | fromNowLabel += ' ' 320 | timeString = '(_' + fromNowLabel + moment(cr.last_updated).fromNow() + '_)' 321 | if (cr.extra_info? && cr.extra_info.length != 0) 322 | extra_info_text = "#{cr.extra_info} " + timeString 323 | else 324 | extra_info_text = timeString 325 | reviews["cr"].push "*<#{cr.url}|#{cr.slug}>* #{extra_info_text}" 326 | # Return a list of the CRs we found 327 | if reviews["cr"].length != 0 328 | if status == 'new' 329 | reviews["pretext"] = "There are pending code reviews. Any takers?" 330 | else 331 | reviews["pretext"] = "Here's a list of " + status + " code reviews for you." 332 | # If we didn't find any, say so 333 | else if verbose == true 334 | if status == 'new' || status == 'all' 335 | status = 'pending' 336 | reviews["pretext"] = "There are no " + status + " code reviews for this room." 337 | return reviews 338 | 339 | 340 | # Send a fancy message to a room with CRs matching the status 341 | # 342 | # @parm string room Name of room 343 | # @param bool verbose Whether to send a message when requested list is empty 344 | # @param string status CR status to list, 345 | # can be 'new', 'all', 'claimed', 'approved', 'closed', 'merged' 346 | # @return none 347 | send_list: (room, verbose = false, status = 'new') -> 348 | # Look for CRs with the correct status 349 | message = @list room, verbose, status 350 | intro_text = message["pretext"] 351 | if message["cr"].length != 0 or verbose is true 352 | # To handle the special slack case of only showing 5 lines in an attachment, 353 | # we break every CR into its own attachment 354 | attachments = [] 355 | for index, message of message["cr"] 356 | if /day[s]? ago/.test(message) 357 | color = "#4c0000" # blackish/red 358 | else if /hour[s]? ago/.test(message) 359 | color = "#FF0000" #red 360 | else if /[3-5][0-9] minutes ago/.test(message) 361 | color = "#ffb732" #yellowy/orange 362 | else 363 | color = "#cceadb" # triadic green 364 | attachments.push 365 | fallback: message 366 | text: message 367 | mrkdwn_in: ["text"] 368 | color: color 369 | # Send the formatted slack message with attachments 370 | sendFancyMessage @robot, room, attachments, intro_text 371 | 372 | # Recurring reminder when there are *unclaimed* CRs 373 | # 374 | # @param int nag_dealy Optional reminder interval in milliseconds, 375 | # defaults to 5min, but can be overridden with HUBOT_CODE_REVIEW_REMINDER_MINUTES 376 | # Note that the logical maximum is 60m due to hourly reminders 377 | # @return none 378 | queue: (nag_delay = process.env.HUBOT_CODE_REVIEW_REMINDER_MINUTES || 5) -> 379 | minutes = nag_delay * @reminder_count 380 | 381 | clearTimeout @current_timeout if @current_timeout 382 | if Object.keys(@room_queues).length > 0 383 | rooms_have_new_crs = false 384 | 385 | # Get roomList to exclude non-existent or newly archived 386 | # rooms (unless we're not using Slack) 387 | roomList @robot, (valid_rooms) => 388 | trigger = => 389 | for room of @room_queues 390 | if room in valid_rooms or 391 | @robot.adapterName isnt "slack" 392 | active_crs = @list room 393 | if active_crs["cr"].length > 0 394 | rooms_have_new_crs = true 395 | @send_list room 396 | if minutes >= 60 and # Equal to or longer than one hour 397 | minutes < 120 and # Less than 2 hours 398 | (minutes %% 60) < nag_delay # Is the first occurrence after an hour 399 | hour_message = process.env.HUBOT_CODE_REVIEW_HOUR_MESSAGE || 400 | "@here: :siren: This queue has been active for an hour, someone get on this. " + 401 | ":siren:\n_Reminding hourly from now on_" 402 | @robot.send { room: room }, hour_message 403 | else if minutes > 60 404 | @robot.send { room: room }, "This is an hourly reminder." 405 | else 406 | # If room doesn't exist, clear out the queue for it 407 | @robot.logger.warning "Unable to find room #{room}; removing from room_queue" 408 | delete @room_queues[room] 409 | @update_redis() 410 | 411 | @reminder_count++ unless rooms_have_new_crs is false 412 | if minutes >= 60 413 | nag_delay = 60 # set to one hour intervals 414 | @queue(nag_delay) 415 | @current_timeout = setTimeout(trigger, nag_delay * 60000) # milliseconds in a minute 416 | 417 | # Get CR slug from PR URL regex matches 418 | # 419 | # @param array matches Matches array from RegExp.exec() 420 | # @return string Slug for CR queue 421 | matches_to_slug: (matches) -> 422 | if ! matches || matches.length < 5 423 | return null 424 | owner = matches[2] 425 | repo = matches[3] 426 | pr = matches[4] 427 | if 'alleyinteractive' != owner 428 | repo = owner + '/' + repo 429 | return repo + '/' + pr 430 | 431 | # Return github files api request url string from PR url 432 | # 433 | # @param string url PR url 434 | # @return string github_url for CR queue 435 | url_to_github_api_url_files: (url) -> 436 | matches = @pr_url_regex.exec url 437 | if ! matches || matches.length < 5 438 | return null 439 | owner = matches[2] 440 | repo = matches[3] 441 | pr = matches[4] 442 | @github_api_url = 'api.github.com' 443 | if @github_url != 'github.com' 444 | @github_api_url = @github_url + '/api/v3' 445 | return 'https://' + @github_api_url + '/repos/' + owner + '/' + 446 | repo + '/pulls/' + pr + '/files?per_page=100' 447 | 448 | # Return github pr api request url string from PR url 449 | # 450 | # @param string url PR url 451 | # @return string github_url for CR queue 452 | url_to_github_api_url_pr: (url) -> 453 | matches = @pr_url_regex.exec url 454 | if ! matches || matches.length < 5 455 | return null 456 | owner = matches[2] 457 | repo = matches[3] 458 | pr = matches[4] 459 | 460 | @github_api_url = 'api.github.com' 461 | if @github_url != 'github.com' 462 | @github_api_url = @github_url + '/api/v3' 463 | return 'https://' + @github_api_url + '/repos/' + owner + '/' + 464 | repo + '/pulls/' + pr 465 | 466 | # Send a confirmation message to msg for cr 467 | # 468 | # @param cr CodeReview code review to add 469 | # @param msg slack msg object to respond to 470 | # @param notification_string string supplied in PR submission to notifiy channel|name 471 | # @return none 472 | send_submission_confirmation: (cr, msg, notification_string = null) -> 473 | # If our submitter provided a notification individual/channel notify them 474 | if (notification_string)? and notification_string.length 475 | notify_name = notification_string[0...] || null 476 | if (notify_name)? 477 | msgRoomName msg, (room_name) => 478 | if room_name 479 | @notify_channel(cr, room_name, notify_name) 480 | 481 | # If our submitter provided a notification individual/channel, say so. 482 | if (notify_name)? 483 | if notify_name.match(/^#/) # It's a channel, wrap as a link 484 | notify_link = "<#{notify_name}|>" 485 | 486 | msg.send "*#{cr.slug}* is now in the code review queue," + 487 | " and #{notify_link || notify_name} has been notified." 488 | 489 | else 490 | msg.send "*#{cr.slug}* is now in the code review queue." + 491 | " Let me know if anyone starts reviewing this." 492 | 493 | # Add a cr with any GitHub file type information and send applicable notifications 494 | # 495 | # @param cr CodeReview code review object to add 496 | # @param msg slack msg object to respond to 497 | # @param notification_string string supplied in PR submission to notifiy channel|name 498 | # @return none 499 | add_cr_with_extra_info: (cr, msg, notification_string = null) -> 500 | if (process.env.HUBOT_GITHUB_TOKEN)? # If we have GitHub creds... 501 | github = require('githubot') 502 | github_api_files = @url_to_github_api_url_files(cr.url) 503 | github_api_pr = @url_to_github_api_url_pr(cr.url) 504 | github.get (github_api_files), (files) => 505 | files_string = ( @pr_file_types files ) || '' 506 | github.get (github_api_pr), (pr) => 507 | # Populate extra PR metadata based on HUBOT_CODE_REVIEW_META 508 | cr.extra_info = switch process.env.HUBOT_CODE_REVIEW_META 509 | when 'both' then "*_#{pr.title}_*\n#{files_string}" 510 | when 'files' then files_string 511 | when 'title' then "*_#{pr.title}_*\n" 512 | when 'none' then '' 513 | else files_string # Default to files behavior pre 1.0 514 | 515 | if (pr)? and (pr.user)? and (pr.user.login)? 516 | cr.github_pr_submitter = pr.user.login 517 | @add cr 518 | @send_submission_confirmation(cr, msg, notification_string) 519 | 520 | github.handleErrors (response) => 521 | @robot.logger.info "Unable to connect to GitHub's API for #{cr.slug}." + 522 | " Ensure you have access. Response: #{response.statusCode}" 523 | @add cr 524 | @send_submission_confirmation(cr, msg, notification_string) 525 | 526 | else # No GitHub credentials... just add and move on 527 | @add cr 528 | @send_submission_confirmation(cr, msg, notification_string) 529 | 530 | # Return a list of file types and counts (string) from files array 531 | # returned in GitHub api request (limited to first page, ie: 100 files) 532 | # 533 | # @param array files Files array returned from GitHub api 534 | # @return string file_types_string for use in CR extra_info 535 | pr_file_types: (files) -> 536 | if ! files 537 | return null 538 | file_types_string = "" 539 | file_types = [] 540 | counts = {} 541 | other_file_types = {} 542 | for item in files 543 | file_types.push(item.filename.replace /.*?\.((?:(?:min|bundle)\.)?[a-z]+$)/, "$1") 544 | for type in file_types 545 | if process.env.HUBOT_CODE_REVIEW_FILE_EXTENSIONS 546 | extensions_we_care_about = process.env.HUBOT_CODE_REVIEW_FILE_EXTENSIONS.split(' ') 547 | else 548 | extensions_we_care_about = 549 | [ 550 | 'coffee', 551 | 'css', 552 | 'html', 553 | 'js', 554 | 'jsx', 555 | 'md', 556 | 'php', 557 | 'rb', 558 | 'scss', 559 | 'sh', 560 | 'txt', 561 | 'yml' 562 | ] 563 | # When it's a file type we care about, count it specifically 564 | if extensions_we_care_about.includes(type) 565 | if counts["#{type}"]? 566 | counts["#{type}"] = counts["#{type}"] + 1 567 | else 568 | counts["#{type}"] = 1 569 | else 570 | if other_file_types["other"]? 571 | other_file_types["other"] = other_file_types["other"] + 1 572 | else 573 | other_file_types["other"] = 1 574 | # Format and append the counts to the file_types_string 575 | for k, v of counts 576 | file_types_string += " `#{k} (#{v})`" 577 | for k, v of other_file_types 578 | file_types_string += " `#{k} (#{v})`" 579 | return file_types_string 580 | 581 | # Update CR status and notify submitter when PR has been 582 | # approved via GitHub 583 | # 584 | # @param string url URL of PR on GitHub 585 | # @param string commenter GitHub username of person who approved 586 | # @param string string comment Full text of comment 587 | # @return none 588 | approve_cr_by_url: (url, commenter, comment) -> 589 | approved = @update_cr_by_url url, 'approved' 590 | unless approved.length 591 | return 592 | message = commenter + ' approved ' + url + ":\n" + comment 593 | 594 | for cr in approved 595 | # send DM to Slack user who added the PR to the queue (not the Github user who opened the PR) 596 | @robot.messageRoom '@' + cr.user.name, 'hey @' + cr.user.name + '! ' + message 597 | 598 | # Notify submitter when PR has not been approved 599 | # 600 | # @param string url URL of PR on GitHub 601 | # @param string commenter GitHub username of person who approved 602 | # @param string comment Full text of comment 603 | # @return none 604 | comment_cr_by_url: (url, commenter, comment) -> 605 | cr_list = @update_cr_by_url url 606 | unless cr_list.length 607 | return 608 | message = commenter + ' commented on ' + url + ":\n" + comment 609 | 610 | for cr in cr_list 611 | # If the comment wasn't from the Github user who opened the PR 612 | if cr.github_pr_submitter isnt commenter 613 | # send DM to Slack user who added the PR to the queue 614 | @robot.messageRoom '@' + cr.user.name, 'hey @' + cr.user.name + ', ' + message 615 | 616 | # Notify submitter when PR review has been dismissed 617 | # 618 | # @param string url URL of PR on GitHub 619 | # @param string reviewer GitHub username of person who created review 620 | # @return none 621 | dismiss_cr_by_url: (url, reviewer) -> 622 | cr_list = @update_cr_by_url url 623 | unless cr_list.length 624 | return 625 | message = "#{reviewer}'s review for #{url} was *dismissed*\n\n" + 626 | "Consider requesting a new review or resetting the PR if needed" 627 | 628 | for cr in cr_list 629 | # If the review wasn't from the Github user who opened the PR 630 | if cr.github_pr_submitter isnt reviewer 631 | # send DM to Slack user who added the PR to the queue 632 | @robot.messageRoom '@' + cr.user.name, 'hey @' + cr.user.name + ', ' + message 633 | 634 | 635 | # Find and update CRs across all rooms that match a URL 636 | # @param string url URL of GitHub PR 637 | # @param string|bool status Optional new status of CR 638 | # @param string|bool reviwer Optional name of reviewer 639 | # @return array Array of updated CRs; may be empty array if URL not found 640 | update_cr_by_url: (url, status = false, reviewer = false) -> 641 | slug = @matches_to_slug(@pr_url_regex.exec url) 642 | crs_found = [] 643 | for room, queue of @room_queues 644 | i = @find_slug_index room, slug 645 | unless i == false 646 | @update_cr @room_queues[room][i], status, reviewer 647 | crs_found.push @room_queues[room][i] 648 | # continue loop in case same PR is in multiple rooms 649 | return crs_found 650 | 651 | # Selectively update local cr status when a merge, close, or PR rejection event happens on GitHub 652 | # @param string url URL of GitHub PR 653 | # @param string|bool status Status of pull request on Github, either: 654 | # 'merged', 'closed', or 'changes_requested' 655 | # @return array Array of updated CRs; may be empty array if URL not found 656 | handle_close: (url, status) -> 657 | slug = @matches_to_slug(@pr_url_regex.exec url) 658 | crs_found = [] 659 | for room, queue of @room_queues 660 | i = @find_slug_index room, slug 661 | unless i == false 662 | cr = @room_queues[room][i] 663 | messageReceiver = cr.reviewer 664 | # Handle merged 665 | if status is "merged" 666 | switch cr.status 667 | # PR was merged before anyone is on it 668 | when "new" 669 | newStatus = false 670 | message = "*#{cr.slug}* has been merged but still needs to be reviewed, just fyi." 671 | # PR was merged after someone claimed it but before it was approved 672 | when "claimed" 673 | message = "Hey @#{cr.reviewer}, *#{cr.slug}* has been merged" + 674 | " but you should keep reviewing." 675 | newStatus = false 676 | else 677 | newStatus = status 678 | message = false 679 | else if status is "closed" 680 | switch cr.status 681 | # PR was closed before anyone claimed it 682 | when "new" 683 | newStatus = false 684 | message = "Hey @#{cr.user.name}, looks like *#{cr.slug}* was closed on GitHub." + 685 | " Say `ignore #{cr.slug}` to remove it from the queue." 686 | messageReceiver = cr.user.name 687 | # PR was closed after someone claimed it but before it was approved 688 | when "claimed" 689 | newStatus = false 690 | message = "Hey @#{cr.reviewer}, *#{cr.slug}* was closed on GitHub." + 691 | " Maybe ask @#{cr.user.name} if it still needs to be reviewed." 692 | else 693 | newStatus = status 694 | message = false 695 | else if status is "changes_requested" 696 | # PR was reviewed with changes_requested before anyone claimed it in-channel 697 | newStatus = 'closed' 698 | message = "Hey @#{cr.user.name}, looks like the PR for *#{cr.slug}* over in" + 699 | " has some changes" + 700 | " requested on GitHub. I've removed `#{cr.slug}` from the queue, but you should" + 701 | " add it back with a `hubot reset #{cr.slug}` when you need another review." 702 | console.log(message) 703 | messageReceiver = cr.user.name 704 | 705 | # update CR, send message to room, add to results 706 | if newStatus 707 | @update_cr @room_queues[room][i], newStatus 708 | if message 709 | @robot.messageRoom '@' + messageReceiver, message 710 | crs_found.push @room_queues[room][i] 711 | # return results 712 | return crs_found 713 | 714 | # General stats about CR queues, list available rooms 715 | # @return string Message to send back 716 | queues_debug_stats: () -> 717 | response = ["Here's a summary of all code review queues:\n"] 718 | for room, queue of @room_queues 719 | response.push "--- ##{room} ---" 720 | for cr, i in queue 721 | reviewer = cr.reviewer || 'n/a' 722 | lastUpdatedStr = new Date(cr.last_updated).toString() 723 | response.push "#{cr.slug}\t\t#{cr.status}\t\t#{reviewer}\t\t#{lastUpdatedStr}" 724 | response.push "\nFor more detailed info, specify a room like" + 725 | " `hubot: debug the cr queue for #room_name`" 726 | return response.join("\n") 727 | 728 | # Return JSON for specific room's CR queue 729 | # @param string room Chat room name 730 | # @return string JSON string of room's data, or message if room not found 731 | queues_debug_room: (room) -> 732 | if Object.keys(@room_queues).indexOf(room) is -1 733 | return "Sorry, I couldn't find a code review queue for #{room}." 734 | 735 | output = [] 736 | for cr in @room_queues[room] 737 | # make copy of CR object then delete Slack-specific info 738 | # because we don't use it and it makes the debug output hard to read 739 | crCopy = {} 740 | for own key, value of cr 741 | crCopy[key] = value 742 | if crCopy.user.slack 743 | delete crCopy.user.slack 744 | output.push crCopy 745 | 746 | return JSON.stringify output, null, ' ' 747 | 748 | # Test if string contains Unicode emoji char 749 | # @param string str String to test 750 | # @return bool 751 | emoji_unicode_test: (str) -> 752 | unless @emojiDataParser 753 | @emojiDataParser = new EmojiDataParser 754 | return @emojiDataParser.testString str 755 | 756 | module.exports = CodeReviews 757 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | hubot-code-review: A Hubot script for GitHub code review on Slack 2 | 3 | Copyright 2017 Alley Interactive LLC 4 | 5 | This program is free software; you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation; either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program; if not, write to the Free Software 17 | Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA 18 | 19 | 20 | GNU GENERAL PUBLIC LICENSE 21 | Version 3, 29 June 2007 22 | 23 | Copyright (C) 2007 Free Software Foundation, Inc. 24 | Everyone is permitted to copy and distribute verbatim copies 25 | of this license document, but changing it is not allowed. 26 | 27 | Preamble 28 | 29 | The GNU General Public License is a free, copyleft license for 30 | software and other kinds of works. 31 | 32 | The licenses for most software and other practical works are designed 33 | to take away your freedom to share and change the works. By contrast, 34 | the GNU General Public License is intended to guarantee your freedom to 35 | share and change all versions of a program--to make sure it remains free 36 | software for all its users. We, the Free Software Foundation, use the 37 | GNU General Public License for most of our software; it applies also to 38 | any other work released this way by its authors. You can apply it to 39 | your programs, too. 40 | 41 | When we speak of free software, we are referring to freedom, not 42 | price. Our General Public Licenses are designed to make sure that you 43 | have the freedom to distribute copies of free software (and charge for 44 | them if you wish), that you receive source code or can get it if you 45 | want it, that you can change the software or use pieces of it in new 46 | free programs, and that you know you can do these things. 47 | 48 | To protect your rights, we need to prevent others from denying you 49 | these rights or asking you to surrender the rights. Therefore, you have 50 | certain responsibilities if you distribute copies of the software, or if 51 | you modify it: responsibilities to respect the freedom of others. 52 | 53 | For example, if you distribute copies of such a program, whether 54 | gratis or for a fee, you must pass on to the recipients the same 55 | freedoms that you received. You must make sure that they, too, receive 56 | or can get the source code. And you must show them these terms so they 57 | know their rights. 58 | 59 | Developers that use the GNU GPL protect your rights with two steps: 60 | (1) assert copyright on the software, and (2) offer you this License 61 | giving you legal permission to copy, distribute and/or modify it. 62 | 63 | For the developers' and authors' protection, the GPL clearly explains 64 | that there is no warranty for this free software. For both users' and 65 | authors' sake, the GPL requires that modified versions be marked as 66 | changed, so that their problems will not be attributed erroneously to 67 | authors of previous versions. 68 | 69 | Some devices are designed to deny users access to install or run 70 | modified versions of the software inside them, although the manufacturer 71 | can do so. This is fundamentally incompatible with the aim of 72 | protecting users' freedom to change the software. The systematic 73 | pattern of such abuse occurs in the area of products for individuals to 74 | use, which is precisely where it is most unacceptable. Therefore, we 75 | have designed this version of the GPL to prohibit the practice for those 76 | products. If such problems arise substantially in other domains, we 77 | stand ready to extend this provision to those domains in future versions 78 | of the GPL, as needed to protect the freedom of users. 79 | 80 | Finally, every program is threatened constantly by software patents. 81 | States should not allow patents to restrict development and use of 82 | software on general-purpose computers, but in those that do, we wish to 83 | avoid the special danger that patents applied to a free program could 84 | make it effectively proprietary. To prevent this, the GPL assures that 85 | patents cannot be used to render the program non-free. 86 | 87 | The precise terms and conditions for copying, distribution and 88 | modification follow. 89 | 90 | TERMS AND CONDITIONS 91 | 92 | 0. Definitions. 93 | 94 | "This License" refers to version 3 of the GNU General Public License. 95 | 96 | "Copyright" also means copyright-like laws that apply to other kinds of 97 | works, such as semiconductor masks. 98 | 99 | "The Program" refers to any copyrightable work licensed under this 100 | License. Each licensee is addressed as "you". "Licensees" and 101 | "recipients" may be individuals or organizations. 102 | 103 | To "modify" a work means to copy from or adapt all or part of the work 104 | in a fashion requiring copyright permission, other than the making of an 105 | exact copy. The resulting work is called a "modified version" of the 106 | earlier work or a work "based on" the earlier work. 107 | 108 | A "covered work" means either the unmodified Program or a work based 109 | on the Program. 110 | 111 | To "propagate" a work means to do anything with it that, without 112 | permission, would make you directly or secondarily liable for 113 | infringement under applicable copyright law, except executing it on a 114 | computer or modifying a private copy. Propagation includes copying, 115 | distribution (with or without modification), making available to the 116 | public, and in some countries other activities as well. 117 | 118 | To "convey" a work means any kind of propagation that enables other 119 | parties to make or receive copies. Mere interaction with a user through 120 | a computer network, with no transfer of a copy, is not conveying. 121 | 122 | An interactive user interface displays "Appropriate Legal Notices" 123 | to the extent that it includes a convenient and prominently visible 124 | feature that (1) displays an appropriate copyright notice, and (2) 125 | tells the user that there is no warranty for the work (except to the 126 | extent that warranties are provided), that licensees may convey the 127 | work under this License, and how to view a copy of this License. If 128 | the interface presents a list of user commands or options, such as a 129 | menu, a prominent item in the list meets this criterion. 130 | 131 | 1. Source Code. 132 | 133 | The "source code" for a work means the preferred form of the work 134 | for making modifications to it. "Object code" means any non-source 135 | form of a work. 136 | 137 | A "Standard Interface" means an interface that either is an official 138 | standard defined by a recognized standards body, or, in the case of 139 | interfaces specified for a particular programming language, one that 140 | is widely used among developers working in that language. 141 | 142 | The "System Libraries" of an executable work include anything, other 143 | than the work as a whole, that (a) is included in the normal form of 144 | packaging a Major Component, but which is not part of that Major 145 | Component, and (b) serves only to enable use of the work with that 146 | Major Component, or to implement a Standard Interface for which an 147 | implementation is available to the public in source code form. A 148 | "Major Component", in this context, means a major essential component 149 | (kernel, window system, and so on) of the specific operating system 150 | (if any) on which the executable work runs, or a compiler used to 151 | produce the work, or an object code interpreter used to run it. 152 | 153 | The "Corresponding Source" for a work in object code form means all 154 | the source code needed to generate, install, and (for an executable 155 | work) run the object code and to modify the work, including scripts to 156 | control those activities. However, it does not include the work's 157 | System Libraries, or general-purpose tools or generally available free 158 | programs which are used unmodified in performing those activities but 159 | which are not part of the work. For example, Corresponding Source 160 | includes interface definition files associated with source files for 161 | the work, and the source code for shared libraries and dynamically 162 | linked subprograms that the work is specifically designed to require, 163 | such as by intimate data communication or control flow between those 164 | subprograms and other parts of the work. 165 | 166 | The Corresponding Source need not include anything that users 167 | can regenerate automatically from other parts of the Corresponding 168 | Source. 169 | 170 | The Corresponding Source for a work in source code form is that 171 | same work. 172 | 173 | 2. Basic Permissions. 174 | 175 | All rights granted under this License are granted for the term of 176 | copyright on the Program, and are irrevocable provided the stated 177 | conditions are met. This License explicitly affirms your unlimited 178 | permission to run the unmodified Program. The output from running a 179 | covered work is covered by this License only if the output, given its 180 | content, constitutes a covered work. This License acknowledges your 181 | rights of fair use or other equivalent, as provided by copyright law. 182 | 183 | You may make, run and propagate covered works that you do not 184 | convey, without conditions so long as your license otherwise remains 185 | in force. You may convey covered works to others for the sole purpose 186 | of having them make modifications exclusively for you, or provide you 187 | with facilities for running those works, provided that you comply with 188 | the terms of this License in conveying all material for which you do 189 | not control copyright. Those thus making or running the covered works 190 | for you must do so exclusively on your behalf, under your direction 191 | and control, on terms that prohibit them from making any copies of 192 | your copyrighted material outside their relationship with you. 193 | 194 | Conveying under any other circumstances is permitted solely under 195 | the conditions stated below. Sublicensing is not allowed; section 10 196 | makes it unnecessary. 197 | 198 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 199 | 200 | No covered work shall be deemed part of an effective technological 201 | measure under any applicable law fulfilling obligations under article 202 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 203 | similar laws prohibiting or restricting circumvention of such 204 | measures. 205 | 206 | When you convey a covered work, you waive any legal power to forbid 207 | circumvention of technological measures to the extent such circumvention 208 | is effected by exercising rights under this License with respect to 209 | the covered work, and you disclaim any intention to limit operation or 210 | modification of the work as a means of enforcing, against the work's 211 | users, your or third parties' legal rights to forbid circumvention of 212 | technological measures. 213 | 214 | 4. Conveying Verbatim Copies. 215 | 216 | You may convey verbatim copies of the Program's source code as you 217 | receive it, in any medium, provided that you conspicuously and 218 | appropriately publish on each copy an appropriate copyright notice; 219 | keep intact all notices stating that this License and any 220 | non-permissive terms added in accord with section 7 apply to the code; 221 | keep intact all notices of the absence of any warranty; and give all 222 | recipients a copy of this License along with the Program. 223 | 224 | You may charge any price or no price for each copy that you convey, 225 | and you may offer support or warranty protection for a fee. 226 | 227 | 5. Conveying Modified Source Versions. 228 | 229 | You may convey a work based on the Program, or the modifications to 230 | produce it from the Program, in the form of source code under the 231 | terms of section 4, provided that you also meet all of these conditions: 232 | 233 | a) The work must carry prominent notices stating that you modified 234 | it, and giving a relevant date. 235 | 236 | b) The work must carry prominent notices stating that it is 237 | released under this License and any conditions added under section 238 | 7. This requirement modifies the requirement in section 4 to 239 | "keep intact all notices". 240 | 241 | c) You must license the entire work, as a whole, under this 242 | License to anyone who comes into possession of a copy. This 243 | License will therefore apply, along with any applicable section 7 244 | additional terms, to the whole of the work, and all its parts, 245 | regardless of how they are packaged. This License gives no 246 | permission to license the work in any other way, but it does not 247 | invalidate such permission if you have separately received it. 248 | 249 | d) If the work has interactive user interfaces, each must display 250 | Appropriate Legal Notices; however, if the Program has interactive 251 | interfaces that do not display Appropriate Legal Notices, your 252 | work need not make them do so. 253 | 254 | A compilation of a covered work with other separate and independent 255 | works, which are not by their nature extensions of the covered work, 256 | and which are not combined with it such as to form a larger program, 257 | in or on a volume of a storage or distribution medium, is called an 258 | "aggregate" if the compilation and its resulting copyright are not 259 | used to limit the access or legal rights of the compilation's users 260 | beyond what the individual works permit. Inclusion of a covered work 261 | in an aggregate does not cause this License to apply to the other 262 | parts of the aggregate. 263 | 264 | 6. Conveying Non-Source Forms. 265 | 266 | You may convey a covered work in object code form under the terms 267 | of sections 4 and 5, provided that you also convey the 268 | machine-readable Corresponding Source under the terms of this License, 269 | in one of these ways: 270 | 271 | a) Convey the object code in, or embodied in, a physical product 272 | (including a physical distribution medium), accompanied by the 273 | Corresponding Source fixed on a durable physical medium 274 | customarily used for software interchange. 275 | 276 | b) Convey the object code in, or embodied in, a physical product 277 | (including a physical distribution medium), accompanied by a 278 | written offer, valid for at least three years and valid for as 279 | long as you offer spare parts or customer support for that product 280 | model, to give anyone who possesses the object code either (1) a 281 | copy of the Corresponding Source for all the software in the 282 | product that is covered by this License, on a durable physical 283 | medium customarily used for software interchange, for a price no 284 | more than your reasonable cost of physically performing this 285 | conveying of source, or (2) access to copy the 286 | Corresponding Source from a network server at no charge. 287 | 288 | c) Convey individual copies of the object code with a copy of the 289 | written offer to provide the Corresponding Source. This 290 | alternative is allowed only occasionally and noncommercially, and 291 | only if you received the object code with such an offer, in accord 292 | with subsection 6b. 293 | 294 | d) Convey the object code by offering access from a designated 295 | place (gratis or for a charge), and offer equivalent access to the 296 | Corresponding Source in the same way through the same place at no 297 | further charge. You need not require recipients to copy the 298 | Corresponding Source along with the object code. If the place to 299 | copy the object code is a network server, the Corresponding Source 300 | may be on a different server (operated by you or a third party) 301 | that supports equivalent copying facilities, provided you maintain 302 | clear directions next to the object code saying where to find the 303 | Corresponding Source. Regardless of what server hosts the 304 | Corresponding Source, you remain obligated to ensure that it is 305 | available for as long as needed to satisfy these requirements. 306 | 307 | e) Convey the object code using peer-to-peer transmission, provided 308 | you inform other peers where the object code and Corresponding 309 | Source of the work are being offered to the general public at no 310 | charge under subsection 6d. 311 | 312 | A separable portion of the object code, whose source code is excluded 313 | from the Corresponding Source as a System Library, need not be 314 | included in conveying the object code work. 315 | 316 | A "User Product" is either (1) a "consumer product", which means any 317 | tangible personal property which is normally used for personal, family, 318 | or household purposes, or (2) anything designed or sold for incorporation 319 | into a dwelling. In determining whether a product is a consumer product, 320 | doubtful cases shall be resolved in favor of coverage. For a particular 321 | product received by a particular user, "normally used" refers to a 322 | typical or common use of that class of product, regardless of the status 323 | of the particular user or of the way in which the particular user 324 | actually uses, or expects or is expected to use, the product. A product 325 | is a consumer product regardless of whether the product has substantial 326 | commercial, industrial or non-consumer uses, unless such uses represent 327 | the only significant mode of use of the product. 328 | 329 | "Installation Information" for a User Product means any methods, 330 | procedures, authorization keys, or other information required to install 331 | and execute modified versions of a covered work in that User Product from 332 | a modified version of its Corresponding Source. The information must 333 | suffice to ensure that the continued functioning of the modified object 334 | code is in no case prevented or interfered with solely because 335 | modification has been made. 336 | 337 | If you convey an object code work under this section in, or with, or 338 | specifically for use in, a User Product, and the conveying occurs as 339 | part of a transaction in which the right of possession and use of the 340 | User Product is transferred to the recipient in perpetuity or for a 341 | fixed term (regardless of how the transaction is characterized), the 342 | Corresponding Source conveyed under this section must be accompanied 343 | by the Installation Information. But this requirement does not apply 344 | if neither you nor any third party retains the ability to install 345 | modified object code on the User Product (for example, the work has 346 | been installed in ROM). 347 | 348 | The requirement to provide Installation Information does not include a 349 | requirement to continue to provide support service, warranty, or updates 350 | for a work that has been modified or installed by the recipient, or for 351 | the User Product in which it has been modified or installed. Access to a 352 | network may be denied when the modification itself materially and 353 | adversely affects the operation of the network or violates the rules and 354 | protocols for communication across the network. 355 | 356 | Corresponding Source conveyed, and Installation Information provided, 357 | in accord with this section must be in a format that is publicly 358 | documented (and with an implementation available to the public in 359 | source code form), and must require no special password or key for 360 | unpacking, reading or copying. 361 | 362 | 7. Additional Terms. 363 | 364 | "Additional permissions" are terms that supplement the terms of this 365 | License by making exceptions from one or more of its conditions. 366 | Additional permissions that are applicable to the entire Program shall 367 | be treated as though they were included in this License, to the extent 368 | that they are valid under applicable law. If additional permissions 369 | apply only to part of the Program, that part may be used separately 370 | under those permissions, but the entire Program remains governed by 371 | this License without regard to the additional permissions. 372 | 373 | When you convey a copy of a covered work, you may at your option 374 | remove any additional permissions from that copy, or from any part of 375 | it. (Additional permissions may be written to require their own 376 | removal in certain cases when you modify the work.) You may place 377 | additional permissions on material, added by you to a covered work, 378 | for which you have or can give appropriate copyright permission. 379 | 380 | Notwithstanding any other provision of this License, for material you 381 | add to a covered work, you may (if authorized by the copyright holders of 382 | that material) supplement the terms of this License with terms: 383 | 384 | a) Disclaiming warranty or limiting liability differently from the 385 | terms of sections 15 and 16 of this License; or 386 | 387 | b) Requiring preservation of specified reasonable legal notices or 388 | author attributions in that material or in the Appropriate Legal 389 | Notices displayed by works containing it; or 390 | 391 | c) Prohibiting misrepresentation of the origin of that material, or 392 | requiring that modified versions of such material be marked in 393 | reasonable ways as different from the original version; or 394 | 395 | d) Limiting the use for publicity purposes of names of licensors or 396 | authors of the material; or 397 | 398 | e) Declining to grant rights under trademark law for use of some 399 | trade names, trademarks, or service marks; or 400 | 401 | f) Requiring indemnification of licensors and authors of that 402 | material by anyone who conveys the material (or modified versions of 403 | it) with contractual assumptions of liability to the recipient, for 404 | any liability that these contractual assumptions directly impose on 405 | those licensors and authors. 406 | 407 | All other non-permissive additional terms are considered "further 408 | restrictions" within the meaning of section 10. If the Program as you 409 | received it, or any part of it, contains a notice stating that it is 410 | governed by this License along with a term that is a further 411 | restriction, you may remove that term. If a license document contains 412 | a further restriction but permits relicensing or conveying under this 413 | License, you may add to a covered work material governed by the terms 414 | of that license document, provided that the further restriction does 415 | not survive such relicensing or conveying. 416 | 417 | If you add terms to a covered work in accord with this section, you 418 | must place, in the relevant source files, a statement of the 419 | additional terms that apply to those files, or a notice indicating 420 | where to find the applicable terms. 421 | 422 | Additional terms, permissive or non-permissive, may be stated in the 423 | form of a separately written license, or stated as exceptions; 424 | the above requirements apply either way. 425 | 426 | 8. Termination. 427 | 428 | You may not propagate or modify a covered work except as expressly 429 | provided under this License. Any attempt otherwise to propagate or 430 | modify it is void, and will automatically terminate your rights under 431 | this License (including any patent licenses granted under the third 432 | paragraph of section 11). 433 | 434 | However, if you cease all violation of this License, then your 435 | license from a particular copyright holder is reinstated (a) 436 | provisionally, unless and until the copyright holder explicitly and 437 | finally terminates your license, and (b) permanently, if the copyright 438 | holder fails to notify you of the violation by some reasonable means 439 | prior to 60 days after the cessation. 440 | 441 | Moreover, your license from a particular copyright holder is 442 | reinstated permanently if the copyright holder notifies you of the 443 | violation by some reasonable means, this is the first time you have 444 | received notice of violation of this License (for any work) from that 445 | copyright holder, and you cure the violation prior to 30 days after 446 | your receipt of the notice. 447 | 448 | Termination of your rights under this section does not terminate the 449 | licenses of parties who have received copies or rights from you under 450 | this License. If your rights have been terminated and not permanently 451 | reinstated, you do not qualify to receive new licenses for the same 452 | material under section 10. 453 | 454 | 9. Acceptance Not Required for Having Copies. 455 | 456 | You are not required to accept this License in order to receive or 457 | run a copy of the Program. Ancillary propagation of a covered work 458 | occurring solely as a consequence of using peer-to-peer transmission 459 | to receive a copy likewise does not require acceptance. However, 460 | nothing other than this License grants you permission to propagate or 461 | modify any covered work. These actions infringe copyright if you do 462 | not accept this License. Therefore, by modifying or propagating a 463 | covered work, you indicate your acceptance of this License to do so. 464 | 465 | 10. Automatic Licensing of Downstream Recipients. 466 | 467 | Each time you convey a covered work, the recipient automatically 468 | receives a license from the original licensors, to run, modify and 469 | propagate that work, subject to this License. You are not responsible 470 | for enforcing compliance by third parties with this License. 471 | 472 | An "entity transaction" is a transaction transferring control of an 473 | organization, or substantially all assets of one, or subdividing an 474 | organization, or merging organizations. If propagation of a covered 475 | work results from an entity transaction, each party to that 476 | transaction who receives a copy of the work also receives whatever 477 | licenses to the work the party's predecessor in interest had or could 478 | give under the previous paragraph, plus a right to possession of the 479 | Corresponding Source of the work from the predecessor in interest, if 480 | the predecessor has it or can get it with reasonable efforts. 481 | 482 | You may not impose any further restrictions on the exercise of the 483 | rights granted or affirmed under this License. For example, you may 484 | not impose a license fee, royalty, or other charge for exercise of 485 | rights granted under this License, and you may not initiate litigation 486 | (including a cross-claim or counterclaim in a lawsuit) alleging that 487 | any patent claim is infringed by making, using, selling, offering for 488 | sale, or importing the Program or any portion of it. 489 | 490 | 11. Patents. 491 | 492 | A "contributor" is a copyright holder who authorizes use under this 493 | License of the Program or a work on which the Program is based. The 494 | work thus licensed is called the contributor's "contributor version". 495 | 496 | A contributor's "essential patent claims" are all patent claims 497 | owned or controlled by the contributor, whether already acquired or 498 | hereafter acquired, that would be infringed by some manner, permitted 499 | by this License, of making, using, or selling its contributor version, 500 | but do not include claims that would be infringed only as a 501 | consequence of further modification of the contributor version. For 502 | purposes of this definition, "control" includes the right to grant 503 | patent sublicenses in a manner consistent with the requirements of 504 | this License. 505 | 506 | Each contributor grants you a non-exclusive, worldwide, royalty-free 507 | patent license under the contributor's essential patent claims, to 508 | make, use, sell, offer for sale, import and otherwise run, modify and 509 | propagate the contents of its contributor version. 510 | 511 | In the following three paragraphs, a "patent license" is any express 512 | agreement or commitment, however denominated, not to enforce a patent 513 | (such as an express permission to practice a patent or covenant not to 514 | sue for patent infringement). To "grant" such a patent license to a 515 | party means to make such an agreement or commitment not to enforce a 516 | patent against the party. 517 | 518 | If you convey a covered work, knowingly relying on a patent license, 519 | and the Corresponding Source of the work is not available for anyone 520 | to copy, free of charge and under the terms of this License, through a 521 | publicly available network server or other readily accessible means, 522 | then you must either (1) cause the Corresponding Source to be so 523 | available, or (2) arrange to deprive yourself of the benefit of the 524 | patent license for this particular work, or (3) arrange, in a manner 525 | consistent with the requirements of this License, to extend the patent 526 | license to downstream recipients. "Knowingly relying" means you have 527 | actual knowledge that, but for the patent license, your conveying the 528 | covered work in a country, or your recipient's use of the covered work 529 | in a country, would infringe one or more identifiable patents in that 530 | country that you have reason to believe are valid. 531 | 532 | If, pursuant to or in connection with a single transaction or 533 | arrangement, you convey, or propagate by procuring conveyance of, a 534 | covered work, and grant a patent license to some of the parties 535 | receiving the covered work authorizing them to use, propagate, modify 536 | or convey a specific copy of the covered work, then the patent license 537 | you grant is automatically extended to all recipients of the covered 538 | work and works based on it. 539 | 540 | A patent license is "discriminatory" if it does not include within 541 | the scope of its coverage, prohibits the exercise of, or is 542 | conditioned on the non-exercise of one or more of the rights that are 543 | specifically granted under this License. You may not convey a covered 544 | work if you are a party to an arrangement with a third party that is 545 | in the business of distributing software, under which you make payment 546 | to the third party based on the extent of your activity of conveying 547 | the work, and under which the third party grants, to any of the 548 | parties who would receive the covered work from you, a discriminatory 549 | patent license (a) in connection with copies of the covered work 550 | conveyed by you (or copies made from those copies), or (b) primarily 551 | for and in connection with specific products or compilations that 552 | contain the covered work, unless you entered into that arrangement, 553 | or that patent license was granted, prior to 28 March 2007. 554 | 555 | Nothing in this License shall be construed as excluding or limiting 556 | any implied license or other defenses to infringement that may 557 | otherwise be available to you under applicable patent law. 558 | 559 | 12. No Surrender of Others' Freedom. 560 | 561 | If conditions are imposed on you (whether by court order, agreement or 562 | otherwise) that contradict the conditions of this License, they do not 563 | excuse you from the conditions of this License. If you cannot convey a 564 | covered work so as to satisfy simultaneously your obligations under this 565 | License and any other pertinent obligations, then as a consequence you may 566 | not convey it at all. For example, if you agree to terms that obligate you 567 | to collect a royalty for further conveying from those to whom you convey 568 | the Program, the only way you could satisfy both those terms and this 569 | License would be to refrain entirely from conveying the Program. 570 | 571 | 13. Use with the GNU Affero General Public License. 572 | 573 | Notwithstanding any other provision of this License, you have 574 | permission to link or combine any covered work with a work licensed 575 | under version 3 of the GNU Affero General Public License into a single 576 | combined work, and to convey the resulting work. The terms of this 577 | License will continue to apply to the part which is the covered work, 578 | but the special requirements of the GNU Affero General Public License, 579 | section 13, concerning interaction through a network will apply to the 580 | combination as such. 581 | 582 | 14. Revised Versions of this License. 583 | 584 | The Free Software Foundation may publish revised and/or new versions of 585 | the GNU General Public License from time to time. Such new versions will 586 | be similar in spirit to the present version, but may differ in detail to 587 | address new problems or concerns. 588 | 589 | Each version is given a distinguishing version number. If the 590 | Program specifies that a certain numbered version of the GNU General 591 | Public License "or any later version" applies to it, you have the 592 | option of following the terms and conditions either of that numbered 593 | version or of any later version published by the Free Software 594 | Foundation. If the Program does not specify a version number of the 595 | GNU General Public License, you may choose any version ever published 596 | by the Free Software Foundation. 597 | 598 | If the Program specifies that a proxy can decide which future 599 | versions of the GNU General Public License can be used, that proxy's 600 | public statement of acceptance of a version permanently authorizes you 601 | to choose that version for the Program. 602 | 603 | Later license versions may give you additional or different 604 | permissions. However, no additional obligations are imposed on any 605 | author or copyright holder as a result of your choosing to follow a 606 | later version. 607 | 608 | 15. Disclaimer of Warranty. 609 | 610 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 611 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 612 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 613 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 614 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 615 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 616 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 617 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 618 | 619 | 16. Limitation of Liability. 620 | 621 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 622 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 623 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 624 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 625 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 626 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 627 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 628 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 629 | SUCH DAMAGES. 630 | 631 | 17. Interpretation of Sections 15 and 16. 632 | 633 | If the disclaimer of warranty and limitation of liability provided 634 | above cannot be given local legal effect according to their terms, 635 | reviewing courts shall apply local law that most closely approximates 636 | an absolute waiver of all civil liability in connection with the 637 | Program, unless a warranty or assumption of liability accompanies a 638 | copy of the Program in return for a fee. 639 | 640 | END OF TERMS AND CONDITIONS 641 | 642 | How to Apply These Terms to Your New Programs 643 | 644 | If you develop a new program, and you want it to be of the greatest 645 | possible use to the public, the best way to achieve this is to make it 646 | free software which everyone can redistribute and change under these terms. 647 | 648 | To do so, attach the following notices to the program. It is safest 649 | to attach them to the start of each source file to most effectively 650 | state the exclusion of warranty; and each file should have at least 651 | the "copyright" line and a pointer to where the full notice is found. 652 | 653 | {one line to give the program's name and a brief idea of what it does.} 654 | Copyright (C) {year} {name of author} 655 | 656 | This program is free software: you can redistribute it and/or modify 657 | it under the terms of the GNU General Public License as published by 658 | the Free Software Foundation, either version 3 of the License, or 659 | (at your option) any later version. 660 | 661 | This program is distributed in the hope that it will be useful, 662 | but WITHOUT ANY WARRANTY; without even the implied warranty of 663 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 664 | GNU General Public License for more details. 665 | 666 | You should have received a copy of the GNU General Public License 667 | along with this program. If not, see . 668 | 669 | Also add information on how to contact you by electronic and paper mail. 670 | 671 | If the program does terminal interaction, make it output a short 672 | notice like this when it starts in an interactive mode: 673 | 674 | {project} Copyright (C) {year} {fullname} 675 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 676 | This is free software, and you are welcome to redistribute it 677 | under certain conditions; type `show c' for details. 678 | 679 | The hypothetical commands `show w' and `show c' should show the appropriate 680 | parts of the General Public License. Of course, your program's commands 681 | might be different; for a GUI interface, you would use an "about box". 682 | 683 | You should also get your employer (if you work as a programmer) or school, 684 | if any, to sign a "copyright disclaimer" for the program, if necessary. 685 | For more information on this, and how to apply and follow the GNU GPL, see 686 | . 687 | 688 | The GNU General Public License does not permit incorporating your program 689 | into proprietary programs. If your program is a subroutine library, you 690 | may consider it more useful to permit linking proprietary applications with 691 | the library. If this is what you want to do, use the GNU Lesser General 692 | Public License instead of this License. But first, please read 693 | . 694 | -------------------------------------------------------------------------------- /test/codeReviewSpec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jasmine*/ 2 | 3 | // Allows 'since' custom messages for unit test failures 4 | require('jasmine-custom-message'); 5 | 6 | const path = require('path'); 7 | const request = require('supertest'); 8 | const Robot = require('../node_modules/hubot/src/robot'); 9 | const { TextMessage } = require('../node_modules/hubot/src/message'); 10 | const util = require('./lib/util'); 11 | const Users = require('./data/users'); 12 | const PullRequests = require('./data/prs'); 13 | const CodeReview = require('../src/CodeReview'); 14 | schedule = require('node-schedule'); 15 | 16 | /** 17 | * Tests the following features of code-review 18 | Turning the URL of a pull request on GitHub into a code review slug 19 | Flushing the room queues 20 | Adding a CR to an empty queue 21 | Not duplicating a CR in the same queue 22 | Allowing the same CR to be added in different room queues 23 | claiming the oldest CR in the queue ('on it') 24 | claiming a specific CR by slug ('on repo/123') 25 | tests needed claiming all PRs in the queue ('on *') 26 | remove/ignore newest CR in queue 27 | remove/ignore specific CR by slug 28 | list CRs by status 29 | mark CR as approved via GitHub webhook 30 | mark CR as closed via GitHub webhook 31 | garbage collection 32 | * TODO: 33 | GitHub filetype extra info 34 | ... 35 | */ 36 | 37 | describe('Code Review', () => { 38 | let robot; 39 | let adapter; 40 | let code_reviews; 41 | 42 | /** 43 | * @var array List of Hubot User objects 44 | */ 45 | let users = []; 46 | 47 | beforeEach((done) => { 48 | // create new robot, without http, using the mock adapter 49 | robot = new Robot(null, 'mock-adapter', true, 'hubot'); 50 | 51 | robot.adapter.on('connected', () => { 52 | // create a user 53 | Users().getUsers().forEach((user) => { 54 | users.push(robot.brain.userForId(user.ID, { 55 | name: user.meta.name, 56 | room: user.meta.room, 57 | })); 58 | }); 59 | 60 | // load the module 61 | code_reviews = require('../src/code-reviews')(robot); 62 | 63 | adapter = robot.adapter; 64 | // start each test with an empty queue 65 | code_reviews.flush_queues(); 66 | // wait a sec for Redis 67 | setTimeout(() => { 68 | done(); 69 | }, 150); 70 | }); 71 | 72 | robot.run(); 73 | }); 74 | 75 | afterEach(() => { 76 | users = []; 77 | adapter = null; 78 | robot.server.close(); 79 | robot.shutdown(); 80 | }); 81 | 82 | it('turns a GitHub PR URL into a Code Review slug', (done) => { 83 | var slug = code_reviews.matches_to_slug(code_reviews.pr_url_regex.exec('https://github.com/alleyinteractive/wordpress-fieldmanager/pull/558')); 84 | expect(slug).toEqual('wordpress-fieldmanager/558'); 85 | var slug = code_reviews.matches_to_slug(code_reviews.pr_url_regex.exec('https://github.com/alleyinteractive/wordpress-fieldmanager/pull/558/files')); 86 | expect(slug).toEqual('wordpress-fieldmanager/558'); 87 | done(); 88 | }); 89 | 90 | it('flushes the queues', (done) => { 91 | PullRequests.forEach((url, i) => { 92 | const rooms = ['alley', 'codereview', 'learnstuff', 'nycoffice']; 93 | addNewCR(url, { room: rooms[Math.floor(Math.random() * rooms.length)] }); 94 | }); 95 | expect(Object.keys(code_reviews.room_queues).length).toBeGreaterThan(0); 96 | // give Redis 100ms to update 97 | setTimeout(() => { 98 | expect(Object.keys(robot.brain.data.code_reviews.room_queues).length).toBeGreaterThan(0); 99 | code_reviews.flush_queues(); 100 | setTimeout(() => { 101 | expect(Object.keys(code_reviews.room_queues).length).toBe(0); 102 | expect(Object.keys(robot.brain.data.code_reviews.room_queues).length).toBe(0); 103 | done(); 104 | }, 100); 105 | }, 100); 106 | }); 107 | 108 | it('adds a CR to empty queue', (done) => { 109 | // make sure queue is empty 110 | expect(Object.keys(code_reviews.room_queues).length).toEqual(0); 111 | 112 | const currentUser = users[6]; 113 | const currentCR = PullRequests[4]; 114 | const slug = code_reviews.matches_to_slug(code_reviews.pr_url_regex.exec(currentCR)); 115 | const re = new RegExp(`^\\*${slug}\\* is now in the code review queue. Let me know if anyone starts reviewing this\.$`); 116 | 117 | adapter.on('send', (envelope, strings) => { 118 | // there should now be one room from the current user 119 | const rooms = Object.keys(code_reviews.room_queues); 120 | expect(rooms.length).toEqual(1); 121 | expect(rooms[0]).toEqual(code_reviews.room_queues[rooms[0]][0].room); 122 | expect(rooms[0]).toEqual(code_reviews.room_queues[rooms[0]][0].channel_id); 123 | 124 | // there should be one CR in the room queue 125 | expect(code_reviews.room_queues[rooms[0]].length).toEqual(1); 126 | expect(code_reviews.room_queues[rooms[0]][0].url).toEqual('https://github.com/alleyinteractive/ad-layers/pull/1'); 127 | 128 | // hubot replies as expected 129 | expect(strings[0]).toMatch(re); 130 | done(); 131 | }); 132 | 133 | // add a PR URL to the queue 134 | adapter.receive(new TextMessage(currentUser, currentCR)); 135 | }); 136 | 137 | it('will not a allow the same CR in the same room regardless of status', (done) => { 138 | const currentUser = users[7]; 139 | const url = PullRequests[4]; 140 | code_reviews.add(new CodeReview(currentUser, makeSlug(url), url, currentUser.room, currentUser.room)); 141 | 142 | // listener for second time the CR is added 143 | adapter.on('send', (envelope, strings) => { 144 | // should still be one CR in the queue 145 | expect(code_reviews.room_queues[currentUser.room].length).toEqual(1); 146 | 147 | // test different CR status next time 148 | if ('new' === code_reviews.room_queues[currentUser.room][0].status) { 149 | code_reviews.room_queues[currentUser.room][0].status = 'claimed'; 150 | } else if ('claimed' === code_reviews.room_queues[currentUser.room][0].status) { 151 | code_reviews.room_queues[currentUser.room][0].status = 'approved'; 152 | } else if ('approved' === code_reviews.room_queues[currentUser.room][0].status) { 153 | done(); 154 | } 155 | }); 156 | 157 | // try to add the CR again a few times 158 | util.sendMessageAsync(adapter, currentUser, url, 100); 159 | util.sendMessageAsync(adapter, currentUser, url, 200); 160 | util.sendMessageAsync(adapter, currentUser, url, 300); 161 | }); 162 | 163 | it('will allow the same CR in a different room', (done) => { 164 | const currentUser = users[12]; 165 | const url = PullRequests[6]; 166 | const firstRoom = currentUser.room; 167 | code_reviews.add(new CodeReview(currentUser, makeSlug(url), url, firstRoom, firstRoom)); 168 | 169 | // listener for second time the CR is added 170 | adapter.on('send', (envelope, strings) => { 171 | // room names should be different 172 | expect(firstRoom).not.toBe(envelope.room); 173 | 174 | const firstQueue = code_reviews.room_queues[firstRoom]; 175 | const secondQueue = code_reviews.room_queues[envelope.room]; 176 | 177 | // rooms should both have 1 CR 178 | expect(firstQueue.length).toEqual(1); 179 | expect(secondQueue.length).toEqual(1); 180 | // the CR should be the same 181 | expect(firstQueue[0].slug).toBe(secondQueue[0].slug); 182 | done(); 183 | }); 184 | 185 | // add the CR again in a different room 186 | currentUser.room = 'a_different_room'; 187 | util.sendMessageAsync(adapter, currentUser, url, 300); 188 | }); 189 | 190 | it('claims the first CR added to the queue', (done) => { 191 | const reviewer = users[2]; 192 | const urlsToAdd = [PullRequests[0], PullRequests[1], PullRequests[2], PullRequests[3]]; 193 | urlsToAdd.forEach((url, i) => { 194 | addNewCR(url, {}, 2); 195 | }); 196 | 197 | let alreadyReceived = false; 198 | adapter.on('send', (envelope, strings) => { 199 | // make sure we only get one response 200 | expect(alreadyReceived).toBeFalsy(); 201 | if (alreadyReceived) { 202 | done(); 203 | } else { 204 | alreadyReceived = true; 205 | setTimeout(done, 100); 206 | } 207 | 208 | // should still have 4 CRs in the queue 209 | expect(code_reviews.room_queues[reviewer.room].length).toBe(4); 210 | 211 | // first one added should be claimed by reviewer 212 | expect(code_reviews.room_queues[reviewer.room][3].status).toBe('claimed'); 213 | expect(code_reviews.room_queues[reviewer.room][3].reviewer).toBe(reviewer.name); 214 | 215 | // the rest should still be new and have no reviewer 216 | const unclaimedLength = code_reviews.room_queues[reviewer.room].length - 1; 217 | for (let i = 0; i < unclaimedLength; i++) { 218 | expect(code_reviews.room_queues[reviewer.room][i].status).toBe('new'); 219 | expect(code_reviews.room_queues[reviewer.room][i].reviewer).toBeFalsy(); 220 | } 221 | }); 222 | 223 | // wait for all the CRs to be added, then test 224 | util.sendMessageAsync(adapter, reviewer, 'on it', 300); 225 | }); 226 | 227 | it('claims specific CR from queue', (done) => { 228 | const slug = 'wordpress-fieldmanager/559'; 229 | const reviewer = users[9]; 230 | const urlsToAdd = [PullRequests[1], PullRequests[2], PullRequests[3]]; 231 | urlsToAdd.forEach((url, i) => { 232 | addNewCR(url, {}, 9); 233 | }); 234 | 235 | adapter.on('send', (envelope, strings) => { 236 | for (let i = 0; i < code_reviews.room_queues[reviewer.room].length; i++) { 237 | // the right one should be claimed 238 | if (slug === code_reviews.room_queues[reviewer.room][i].slug) { 239 | expect(code_reviews.room_queues[reviewer.room][i].status).toBe('claimed'); 240 | expect(code_reviews.room_queues[reviewer.room][i].reviewer).toBe(reviewer.name); 241 | } 242 | // the rest should still be new and have no reviewer 243 | else { 244 | expect(code_reviews.room_queues[reviewer.room][i].status).toBe('new'); 245 | expect(code_reviews.room_queues[reviewer.room][i].reviewer).toBeFalsy(); 246 | } 247 | } 248 | done(); 249 | }); 250 | 251 | // claim the CR 252 | util.sendMessageAsync(adapter, reviewer, `on ${slug}`, 300); 253 | }); 254 | 255 | it('resets a PR', (done) => { 256 | // add a bunch of new CRs 257 | PullRequests.forEach((url, i) => { 258 | addNewCR(url); 259 | }); 260 | 261 | // be unspecific 262 | util.sendMessageAsync(adapter, users[1], 'unclaim', 1, (envelope, strings) => { 263 | expect(strings[0]).toBe('Sorry, can you be more specific?'); 264 | }); 265 | 266 | // claim a CR 267 | util.sendMessageAsync(adapter, users[0], 'on ad-layers/1', 1, () => { 268 | expect(code_reviews.room_queues.test_room[2].status).toBe('claimed'); 269 | expect(code_reviews.room_queues.test_room[2].reviewer).toBe(users[0].name); 270 | }); 271 | 272 | // be wrong 273 | util.sendMessageAsync(adapter, users[0], 'hubot: reset foo/99', 50, (envelope, strings) => { 274 | expect(strings[0]).toBe('Sorry, I couldn\'t find any PRs in this room matching `foo/99`.'); 275 | }); 276 | 277 | // unclaim the CR 278 | util.sendMessageAsync(adapter, users[0], 'hubot: unclaim ad-layers/1', 100, (envelope, strings) => { 279 | expect(code_reviews.room_queues.test_room[2].status).toBe('new'); 280 | expect(code_reviews.room_queues.test_room[2].reviewer).toBe(false); 281 | expect(strings[0]).toBe('You got it, I\'ve unclaimed *ad-layers/1* in the queue.'); 282 | done(); 283 | }); 284 | }); 285 | 286 | it('sets a PR for a new review without a score penalty for original reviewer', (done) => { 287 | // someone else adds a CR 288 | addNewCR(PullRequests[0], null, 1); 289 | 290 | // user claims the CR 291 | util.sendMessageAsync(adapter, users[1], 'on wp-seo/378', 1, (envelope, strings) => { 292 | // should be claimed by that user 293 | expect(code_reviews.room_queues.test_room[0].status).toBe('claimed'); 294 | expect(code_reviews.room_queues.test_room[0].reviewer).toBe(users[1].name); 295 | 296 | // "redo" should reset the CR without decrementing user's score 297 | util.sendMessageAsync(adapter, users[1], 'hubot: redo wp-seo/378', 1, (envelope, strings) => { 298 | expect(code_reviews.room_queues.test_room[0].status).toBe('new'); 299 | expect(code_reviews.room_queues.test_room[0].reviewer).toBe(false); 300 | expect(strings[0]).toBe('You got it, wp-seo/378 is ready for a new review.'); 301 | done(); 302 | }); 303 | }); 304 | }); 305 | 306 | it('claims a review by searching for its slug', (done) => { 307 | const reviewer = users[9]; 308 | // add a bunch of new CRs 309 | PullRequests.forEach((url, i) => { 310 | addNewCR(url, {}, 9); 311 | }); 312 | // simulate a PR that was approved and updated by webhook before being claimed from queue 313 | code_reviews.room_queues.test_room[0].status = 'approved'; 314 | 315 | // 0 matches 316 | util.sendMessageAsync(adapter, users[7], 'on foobar', 50, (envelope, strings) => { 317 | expect(strings[0]).toBe('Sorry, I couldn\'t find any new PRs in this room matching `foobar`.'); 318 | }); 319 | 320 | // multiple unclaimed matches 321 | util.sendMessageAsync(adapter, users[7], 'on fieldmanager', 100, (envelope, strings) => { 322 | expect(strings[0]).toBe('You\'re gonna have to be more specific: `wordpress-fieldmanager/558`, or `wordpress-fieldmanager/559`?'); 323 | }); 324 | 325 | // 1 match, unclaimed 326 | util.sendMessageAsync(adapter, users[7], 'on 559', 300, (envelope, strings) => { 327 | expect(strings[0]).toBe(`Thanks, ${users[7].name}! I removed *wordpress-fieldmanager/559* from the code review queue.`); 328 | }); 329 | 330 | // 1 match, claimed 331 | util.sendMessageAsync(adapter, users[8], 'on 559', 500, (envelope, strings) => { 332 | const bothResponses = new RegExp('Sorry, I couldn\'t find any new PRs in this room matching `559`.' + 333 | '|It looks like \\*wordpress-fieldmanager\/559\\* \\(@[a-zA-Z]+\\) has already been claimed'); 334 | expect(strings[0]).toMatch(bothResponses); 335 | }); 336 | 337 | // multiple matches, only 1 is unclaimed 338 | util.sendMessageAsync(adapter, users[8], 'on fieldmanager', 700, (envelope, strings) => { 339 | expect(strings[0]).toBe(`Thanks, ${users[8].name}! I removed *wordpress-fieldmanager/558* from the code review queue.`); 340 | }); 341 | 342 | // multiple matches, all claimed 343 | util.sendMessageAsync(adapter, users[8], 'on fieldmanager', 800, (envelope, strings) => { 344 | expect(strings[0]).toBe('Sorry, I couldn\'t find any new PRs in this room matching `fieldmanager`.'); 345 | }); 346 | 347 | // matches CR that was updated (e.g. by webhook) before it was claimed 348 | util.sendMessageAsync(adapter, users[8], 'on photon', 1000, (envelope, strings) => { 349 | const theCr = code_reviews.room_queues.test_room[0]; 350 | const bothResponses = new RegExp(`${'Sorry, I couldn\'t find any new PRs in this room matching `photon`.' + 351 | '|It looks like \\*'}${theCr.slug}\\* \\(@${theCr.user.name}\\) has already been ${theCr.status}`); 352 | expect(strings[0]).toMatch(bothResponses); 353 | done(); 354 | }); 355 | }); 356 | 357 | it('claims all new CRs in the queue', (done) => { 358 | // add 7 PR across two rooms 359 | code_reviews.room_queues.test_room = []; 360 | code_reviews.room_queues.second_room = []; 361 | PullRequests.forEach((url, i) => { 362 | const room = 3 >= i ? 'test_room' : 'second_room'; 363 | const cr = new CodeReview(users[6], makeSlug(url), url, room, room); 364 | code_reviews.room_queues[room].unshift(cr); 365 | }); 366 | expect(roomStatusCount('test_room', 'new')).toBe(4); 367 | expect(roomStatusCount('second_room', 'new')).toBe(3); 368 | 369 | let responsesReceived = 0; 370 | util.sendMessageAsync(adapter, users[0], 'on *', 1000, (envelope, strings) => { 371 | if (0 === responsesReceived) { 372 | expect(strings[0]).toMatch(/:tornado2?:/); 373 | } else { 374 | slug = makeSlug(PullRequests[responsesReceived - 1]); 375 | expect(strings[0]).toBe(`Thanks, ${users[0].name}! I removed *${slug}* from the code review queue.`); 376 | } 377 | responsesReceived++; 378 | 379 | if (5 === responsesReceived) { // 5 = :tornado2: + 4 unclaimed reviews 380 | // should have claimed all reviews in test_room and none of the reviews in second_room 381 | expect(roomStatusCount('test_room', 'claimed')).toBe(4); 382 | expect(roomStatusCount('test_room', 'new')).toBe(0); 383 | expect(roomStatusCount('second_room', 'new')).toBe(3); 384 | testSecondRoom(); 385 | } 386 | }); 387 | 388 | // test `on *` in a room after claiming a PR 389 | var testSecondRoom = () => { 390 | // claim the most recently added PR 391 | code_reviews.update_cr(code_reviews.room_queues.second_room[0], 'claimed', users[2].name); 392 | expect(roomStatusCount('second_room', 'claimed')).toBe(1); 393 | users[3].room = 'second_room'; 394 | responsesReceived = 0; 395 | util.sendMessageAsync(adapter, users[3], 'on *', 1000, (envelope, strings) => { 396 | responsesReceived++; 397 | if (3 === responsesReceived) { // 3 = :tornado2: + 2 unclaimed reviews 398 | expect(roomStatusCount('second_room', 'new')).toBe(0); 399 | expect(roomStatusCount('second_room', 'claimed')).toBe(3); 400 | done(); 401 | } 402 | }); 403 | }; 404 | }); 405 | 406 | it('ignores timer start command', (done) => { 407 | let receivedMessage = false; 408 | adapter.on('send', (envelope, strings) => { 409 | // we received a message when we shouldn't have 410 | receivedMessage = true; 411 | }); 412 | util.sendMessageAsync(adapter, users[0], 'working on staff'); 413 | 414 | setTimeout(() => { 415 | expect(receivedMessage).toBe(false); 416 | done(); 417 | }, 150); 418 | }); 419 | 420 | it('ignores the newest CR', (done) => { 421 | // add a bunch of new CRs 422 | PullRequests.forEach((url, i) => { 423 | addNewCR(url); 424 | }); 425 | 426 | // wait until all 7 are added asynchronously 427 | var addCrsInterval = setInterval(() => { 428 | if (code_reviews.room_queues.test_room.length >= PullRequests.length) { 429 | clearInterval(addCrsInterval); 430 | expect(code_reviews.room_queues.test_room[0].slug).toBe('photonfill/18'); 431 | // ignore newest CR 432 | util.sendMessageAsync(adapter, users[8], 'hubot ignore', 1, (envelope, strings) => { 433 | expect(code_reviews.room_queues.test_room.length).toBe(PullRequests.length - 1); 434 | expect(code_reviews.room_queues.test_room[0].slug).toBe('wordpress-fieldmanager/558'); 435 | done(); 436 | }); 437 | } 438 | }, 50); 439 | }); 440 | 441 | it('ignores specific CR', (done) => { 442 | const reviewer = users[9]; 443 | const urlsToAdd = [PullRequests[1], PullRequests[2], PullRequests[3]]; 444 | urlsToAdd.forEach((url, i) => { 445 | addNewCR(url, {}, 9); 446 | }); 447 | 448 | adapter.on('send', (envelope) => { 449 | const slug = envelope.message.text.match(/ignore (.*)/)[1]; 450 | const slugsInRoom = []; 451 | for (let i = 0; i < code_reviews.room_queues[reviewer.room].length; i++) { 452 | // specific slug should be ignored 453 | slugsInRoom.push(code_reviews.room_queues[reviewer.room][i].slug); 454 | since(`Expect the ignored slug: ${slug} to be gone from the room`) 455 | .expect(slug === code_reviews.room_queues[reviewer.room][i].slug).toBe(false); 456 | } 457 | // unmentioned slug should still be in the room 458 | since('Expected slugs that weren\'t ignored are still present in the room') 459 | .expect(slugsInRoom.includes('searchpress/23')).toBe(true); 460 | }); 461 | 462 | // ignore a couple specific crs 463 | util.sendMessageAsync(adapter, reviewer, 'hubot ignore wordpress-fieldmanager/559', 100, (envelope, strings) => { 464 | expect(strings[0]).toBe('Sorry for eavesdropping. I removed *wordpress-fieldmanager/559* from the queue.'); 465 | }); 466 | util.sendMessageAsync(adapter, reviewer, 'hubot ignore huron', 400, (envelope, strings) => { 467 | expect(strings[0]).toBe('Sorry for eavesdropping. I removed *huron/567* from the queue.'); 468 | done(); 469 | }); 470 | }); 471 | 472 | it('lists all CRs', (done) => { 473 | populateTestRoomCRs(); 474 | adapter.on('send', (envelope, strings) => { 475 | status = 'all'; 476 | // test message preface 477 | if ('new' === status) { 478 | expect(strings[0]).toMatch(/^There are pending code reviews\. Any takers\?/igm); 479 | } else { 480 | expect(strings[0]).toMatch(/^Here\'s a list of .* code reviews for you\./igm); 481 | } 482 | // loop through the room and make sure the list 483 | // that hubot sent back only contains CRs with the correct status 484 | code_reviews.room_queues.test_room.forEach((cr, i) => { 485 | // note that the timeago string is checked in 'includes timeago information when listing crs' 486 | const CRFound = 0 <= strings[0].indexOf(`*<${cr.url}|${cr.slug}>*`); 487 | if (status === cr.status || 'all' === status) { 488 | expect(CRFound).toBe(true); 489 | } else { 490 | expect(CRFound).toBe(false); 491 | } 492 | }); 493 | done(); 494 | }); 495 | adapter.receive(new TextMessage(users[8], 'hubot list all crs')); 496 | }); 497 | 498 | it('lists new CRs', (done) => { 499 | populateTestRoomCRs(); 500 | adapter.on('send', (envelope, strings) => { 501 | status = 'new'; 502 | // test message preface 503 | if ('new' === status) { 504 | expect(strings[0]).toMatch(/^There are pending code reviews\. Any takers\?/igm); 505 | } else { 506 | expect(strings[0]).toMatch(/^Here\'s a list of .* code reviews for you\./igm); 507 | } 508 | // loop through the room and make sure the list 509 | // that hubot sent back only contains CRs with the correct status 510 | code_reviews.room_queues.test_room.forEach((cr, i) => { 511 | // note that the timeago string is checked in 'includes timeago information when listing crs' 512 | const CRFound = 0 <= strings[0].indexOf(`*<${cr.url}|${cr.slug}>*`); 513 | if (status === cr.status || 'all' === status) { 514 | expect(CRFound).toBe(true); 515 | } else { 516 | expect(CRFound).toBe(false); 517 | } 518 | }); 519 | done(); 520 | }); 521 | adapter.receive(new TextMessage(users[8], 'hubot list new crs')); 522 | }); 523 | 524 | it('lists claimed CRs', (done) => { 525 | populateTestRoomCRs(); 526 | adapter.on('send', (envelope, strings) => { 527 | status = 'claimed'; 528 | // test message preface 529 | if ('new' === status) { 530 | expect(strings[0]).toMatch(/^There are pending code reviews\. Any takers\?/igm); 531 | } else { 532 | expect(strings[0]).toMatch(/^Here\'s a list of .* code reviews for you\./igm); 533 | } 534 | // loop through the room and make sure the list 535 | // that hubot sent back only contains CRs with the correct status 536 | code_reviews.room_queues.test_room.forEach((cr, i) => { 537 | // note that the timeago string is checked in 'includes timeago information when listing crs' 538 | const CRFound = 0 <= strings[0].indexOf(`*<${cr.url}|${cr.slug}>*`); 539 | if (status === cr.status || 'all' === status) { 540 | expect(CRFound).toBe(true); 541 | } else { 542 | expect(CRFound).toBe(false); 543 | } 544 | }); 545 | done(); 546 | }); 547 | adapter.receive(new TextMessage(users[8], 'hubot list claimed crs')); 548 | }); 549 | 550 | it('lists approved CRs', (done) => { 551 | populateTestRoomCRs(); 552 | adapter.on('send', (envelope, strings) => { 553 | status = 'approved'; 554 | // test message preface 555 | if ('new' === status) { 556 | expect(strings[0]).toMatch(/^There are pending code reviews\. Any takers\?/igm); 557 | } else { 558 | expect(strings[0]).toMatch(/^Here\'s a list of .* code reviews for you\./igm); 559 | } 560 | // loop through the room and make sure the list 561 | // that hubot sent back only contains CRs with the correct status 562 | code_reviews.room_queues.test_room.forEach((cr, i) => { 563 | // note that the timeago string is checked in 'includes timeago information when listing crs' 564 | const CRFound = 0 <= strings[0].indexOf(`*<${cr.url}|${cr.slug}>*`); 565 | if (status === cr.status || 'all' === status) { 566 | expect(CRFound).toBe(true); 567 | } else { 568 | expect(CRFound).toBe(false); 569 | } 570 | }); 571 | done(); 572 | }); 573 | adapter.receive(new TextMessage(users[8], 'hubot list approved crs')); 574 | }); 575 | 576 | it('lists closed CRs', (done) => { 577 | populateTestRoomCRs(); 578 | adapter.on('send', (envelope, strings) => { 579 | status = 'closed'; 580 | // test message preface 581 | if ('new' === status) { 582 | expect(strings[0]).toMatch(/^There are pending code reviews\. Any takers\?/igm); 583 | } else { 584 | expect(strings[0]).toMatch(/^Here\'s a list of .* code reviews for you\./igm); 585 | } 586 | // loop through the room and make sure the list 587 | // that hubot sent back only contains CRs with the correct status 588 | code_reviews.room_queues.test_room.forEach((cr, i) => { 589 | // note that the timeago string is checked in 'includes timeago information when listing crs' 590 | const CRFound = 0 <= strings[0].indexOf(`*<${cr.url}|${cr.slug}>*`); 591 | if (status === cr.status || 'all' === status) { 592 | expect(CRFound).toBe(true); 593 | } else { 594 | expect(CRFound).toBe(false); 595 | } 596 | }); 597 | done(); 598 | }); 599 | adapter.receive(new TextMessage(users[8], 'hubot list closed crs')); 600 | }); 601 | 602 | it('lists merged CRs', (done) => { 603 | populateTestRoomCRs(); 604 | adapter.on('send', (envelope, strings) => { 605 | status = 'merged'; 606 | // test message preface 607 | if ('new' === status) { 608 | expect(strings[0]).toMatch(/^There are pending code reviews\. Any takers\?/igm); 609 | } else { 610 | expect(strings[0]).toMatch(/^Here\'s a list of .* code reviews for you\./igm); 611 | } 612 | // loop through the room and make sure the list 613 | // that hubot sent back only contains CRs with the correct status 614 | code_reviews.room_queues.test_room.forEach((cr, i) => { 615 | // note that the timeago string is checked in 'includes timeago information when listing crs' 616 | const CRFound = 0 <= strings[0].indexOf(`*<${cr.url}|${cr.slug}>*`); 617 | if (status === cr.status || 'all' === status) { 618 | expect(CRFound).toBe(true); 619 | } else { 620 | expect(CRFound).toBe(false); 621 | } 622 | }); 623 | done(); 624 | }); 625 | adapter.receive(new TextMessage(users[8], 'hubot list merged crs')); 626 | }); 627 | 628 | it('includes timeago information when listing crs', (done) => { 629 | const statuses = ['new', 'claimed', 'approved', 'closed', 'merged']; 630 | const halfHourInMs = 1000 * 60 * 30; 631 | // add CRs with different ages and statuses 632 | statuses.forEach((status, i) => { 633 | const cr = new CodeReview(users[i], makeSlug(PullRequests[i]), PullRequests[i], users[i].room, users[i].room); 634 | cr.status = status; 635 | cr.last_updated += -1 * i * halfHourInMs; 636 | code_reviews.add(cr); 637 | }); 638 | 639 | adapter.on('send', (envelope, strings) => { 640 | const crsList = strings[0].split('\n'); 641 | crsList.reverse(); // since we add to queue by unshift() instead of push() 642 | expect(crsList[0]).toMatch(/added a few seconds ago/); 643 | expect(crsList[1]).toMatch(/claimed 30 minutes ago/); 644 | expect(crsList[2]).toMatch(/approved an hour ago/); 645 | expect(crsList[3]).toMatch(/closed 2 hours ago/); 646 | expect(crsList[4]).toMatch(/merged 2 hours ago/); 647 | expect(crsList[5]).toMatch(/Here's a list of all code reviews for you.$/); 648 | done(); 649 | }); 650 | adapter.receive(new TextMessage(users[0], 'hubot list all crs')); 651 | }); 652 | 653 | it('recognizes strings containing emoji', (done) => { 654 | // valid comments 655 | [ 656 | ':horse:', 657 | ':+1:', 658 | 'nice job!\n:package:\ngo ahead and deploy', 659 | ':pineapple::pizza:', 660 | 'looking good :chart_with_upwards_trend:', 661 | ].forEach((string) => { 662 | if (! code_reviews.emoji_regex.test(string)) { 663 | console.log(string); 664 | } 665 | expect(code_reviews.emoji_regex.test(string)).toBe(true); 666 | }); 667 | 668 | [ 669 | '😘', 670 | '🐩🚢', 671 | 'nice work 🎉 code', 672 | ].forEach((string) => { 673 | expect(code_reviews.emoji_unicode_test(string)).toBe(true); 674 | }); 675 | 676 | // invalid comments 677 | [ 678 | 'this needs some work', 679 | 'note: this:is not finished', 680 | 'nice job:\nyou:', 681 | ].forEach((string) => { 682 | expect(code_reviews.emoji_regex.test(string)).toBe(false); 683 | expect(code_reviews.emoji_unicode_test(string)).toBe(false); 684 | }); 685 | 686 | done(); 687 | }); 688 | 689 | /** 690 | * Webhooks for approval, merging, and closing 691 | */ 692 | 693 | it('does not allow invalid GitHub event webhooks', (done) => { 694 | testWebhook('something_else', { foo: 'bar' }, (err, res) => { 695 | expect(res.status).toBe(400); 696 | expect(res.text).toBe('invalid x-github-event something_else'); 697 | done(); 698 | }); 699 | }); 700 | 701 | it('receives GitHub pull_request_review webhook to handle a PR in multiple rooms', (done) => { 702 | const rooms = ['alley', 'codereview', 'learnstuff', 'nycoffice']; 703 | const approvedUrl = 'https://github.com/alleyinteractive/special/pull/456'; 704 | const otherUrl = 'https://github.com/alleyinteractive/special/pull/123'; 705 | // add prs to different rooms 706 | rooms.forEach((room) => { 707 | addNewCR(`${approvedUrl}/files`, { room }); 708 | addNewCR(otherUrl, { room }); 709 | }); 710 | 711 | // setup the data we want to pretend that Github is sending 712 | const requestBody = { 713 | pull_request: { html_url: approvedUrl }, 714 | review: { 715 | body: 'Awesome!', 716 | state: 'approved', 717 | user: { login: 'jaredcobb' }, 718 | }, 719 | }; 720 | 721 | // setup the data we want to pretend that Github is sending 722 | const notApprovedRequestBody = { 723 | pull_request: { html_url: otherUrl }, 724 | review: { 725 | body: 'Needs some changes', 726 | state: 'rejected', 727 | user: { login: 'mboynes' }, 728 | }, 729 | }; 730 | 731 | // expect the approved pull request to be approved in all rooms 732 | // and the other pull request to be unchanged 733 | testWebhook('pull_request_review', requestBody, (err, res) => { 734 | expect(res.text).toBe(`pull_request_review approved ${approvedUrl}`); 735 | rooms.forEach((room) => { 736 | queue = code_reviews.room_queues[room]; 737 | expect(queue.length).toBe(2); 738 | expect(queue[0].url).toBe(otherUrl); 739 | expect(queue[0].status).toBe('new'); 740 | expect(queue[1].url).toBe(`${approvedUrl}/files`); 741 | expect(queue[1].status).toBe('approved'); 742 | }); 743 | }); 744 | 745 | testWebhook('pull_request_review', notApprovedRequestBody, (err, res) => { 746 | expect(res.text).toBe(`pull_request_review not yet approved ${otherUrl}`); 747 | rooms.forEach((room) => { 748 | queue = code_reviews.room_queues[room]; 749 | expect(queue.length).toBe(2); 750 | expect(queue[0].url).toBe(otherUrl); 751 | expect(queue[0].status).toBe('new'); 752 | expect(queue[1].url).toBe(`${approvedUrl}/files`); 753 | expect(queue[1].status).toBe('approved'); 754 | done(); 755 | }); 756 | }); 757 | }); 758 | 759 | it('DMs user when CR is approved', (done) => { 760 | const url = 'https://github.com/alleyinteractive/huron/pull/567'; 761 | addNewCR(url); 762 | 763 | // setup the data we want to pretend that Github is sending 764 | const requestBody = { 765 | pull_request: { html_url: url }, 766 | review: { 767 | body: 'Nice work thinking through the implications!', 768 | state: 'approved', 769 | user: { login: 'gfargo' }, 770 | }, 771 | }; 772 | adapter.on('send', (envelope, strings) => { 773 | expect(strings[0]).toBe(`hey ${envelope.room 774 | }! gfargo approved ${url}:\nNice work thinking through the implications!`); 775 | const cr = code_reviews.room_queues.test_room[0]; 776 | expect(envelope.room).toBe(`@${cr.user.name}`); 777 | expect(cr.url).toBe(url); 778 | expect(cr.status).toBe('approved'); 779 | done(); 780 | }); 781 | 782 | testWebhook('pull_request_review', requestBody, (err, res) => { 783 | expect(res.text).toBe(`pull_request_review approved ${url}`); 784 | }); 785 | }); 786 | 787 | it('DMs user when CR isn\'t approved', (done) => { 788 | const url = 'https://github.com/alleyinteractive/huron/pull/567'; 789 | addNewCR(url); 790 | 791 | // setup the data we want to pretend that Github is sending 792 | const requestBody = { 793 | pull_request: { html_url: url }, 794 | review: { 795 | body: 'Spaces. Not tabs.', 796 | state: 'rejected', 797 | user: { login: 'zgreen' }, 798 | }, 799 | }; 800 | adapter.on('send', (envelope, strings) => { 801 | expect(strings[0]).toBe(`hey ${envelope.room 802 | }, zgreen commented on ${url}:\nSpaces. Not tabs.`); 803 | const cr = code_reviews.room_queues.test_room[0]; 804 | expect(envelope.room).toBe(`@${cr.user.name}`); 805 | expect(cr.url).toBe(url); 806 | expect(cr.status).toBe('new'); 807 | done(); 808 | }); 809 | 810 | testWebhook('pull_request_review', requestBody, (err, res) => { 811 | expect(res.text).toBe(`pull_request_review not yet approved ${url}`); 812 | }); 813 | }); 814 | 815 | it('updates an approved pull request to merged', (done) => { 816 | testMergeClose('merged', 'approved', 'merged', done); 817 | }); 818 | 819 | it('updates an approved pull request to closed', (done) => { 820 | testMergeClose('closed', 'approved', 'closed', done); 821 | }); 822 | 823 | it('does not update a new PR to merged', (done) => { 824 | adapter.on('send', (envelope, strings) => { 825 | expect(strings[0]).toBe('*special/456* has been merged but still needs to be reviewed, just fyi.'); 826 | expect(envelope.room).toBe('@jaredcobb'); 827 | done(); 828 | }); 829 | testMergeClose('merged', 'new', 'new'); 830 | }); 831 | 832 | it('does not update a claimed PR to merged', (done) => { 833 | adapter.on('send', (envelope, strings) => { 834 | expect(strings[0]).toBe('Hey @jaredcobb, *special/456* has been merged but you should keep reviewing.'); 835 | expect(envelope.room).toBe('@jaredcobb'); 836 | done(); 837 | }); 838 | testMergeClose('merged', 'claimed', 'claimed'); 839 | }); 840 | 841 | it('does not update a new PR to closed', (done) => { 842 | adapter.on('send', (envelope, strings) => { 843 | expect(strings[0]).toMatch(/Hey @(\w+), looks like \*special\/456\* was closed on GitHub\. Say `ignore special\/456` to remove it from the queue\./i); 844 | expect(envelope.room).toMatch(/@(\w+)/i); 845 | done(); 846 | }); 847 | testMergeClose('closed', 'new', 'new'); 848 | }); 849 | 850 | it('does not update a claimed PR to closed', (done) => { 851 | adapter.on('send', (envelope, strings) => { 852 | expect(strings[0]).toMatch(/Hey @jaredcobb, \*special\/456\* was closed on GitHub\. Maybe ask @(\w+) if it still needs to be reviewed\./i); 853 | expect(envelope.room).toBe('@jaredcobb'); 854 | done(); 855 | }); 856 | testMergeClose('closed', 'claimed', 'claimed'); 857 | }); 858 | 859 | /** 860 | * Garbage Collection 861 | */ 862 | 863 | it('collects the garbage', (done) => { 864 | // should start with job scheduled but nothing collected 865 | expect(code_reviews.garbage_job.pendingInvocations.length).toBe(1); 866 | expect(code_reviews.garbage_last_collection).toBe(0); 867 | 868 | // add old and new CRs 869 | addNewCR(PullRequests[0]); 870 | addNewCR(PullRequests[1]); 871 | addNewCR(PullRequests[2], { room: 'otherRoom' }); 872 | addNewCR(PullRequests[3], { room: 'otherRoom' }); 873 | code_reviews.room_queues.test_room[1].last_updated -= (code_reviews.garbage_expiration + 1000); 874 | code_reviews.room_queues.otherRoom[1].last_updated -= (code_reviews.garbage_expiration + 1000); 875 | 876 | // invoke next collection manually 877 | // no need to re-test that node-schedule works as expected 878 | code_reviews.garbage_job.invoke(); 879 | 880 | // should have collected 1 from each room and left the right ones alone 881 | expect(code_reviews.garbage_last_collection).toBe(2); 882 | expect(code_reviews.room_queues.test_room[0].url).toBe(PullRequests[1]); 883 | expect(code_reviews.room_queues.otherRoom[0].url).toBe(PullRequests[3]); 884 | done(); 885 | }); 886 | 887 | /** 888 | * Helper functions 889 | */ 890 | 891 | /** 892 | * test a request to CR webhook 893 | * @param string event 'issue_comment' or 'pull_request' 894 | * @param object requestBody Body of request as JSON object 895 | * @param function callback Takes error and result arguments 896 | */ 897 | function testWebhook(eventType, requestBody, callback) { 898 | request(robot.router.listen()) 899 | .post('/hubot/hubot-code-review') 900 | .set({ 901 | 'Content-Type': 'application/json', 902 | 'X-Github-Event': eventType, 903 | }) 904 | .send(requestBody) 905 | .end((err, res) => { 906 | expect(err).toBeFalsy(); 907 | callback(err, res); 908 | }); 909 | } 910 | 911 | /** 912 | * Test correct handing of a comment from Github 913 | * @param object args 914 | * string comment 915 | * string expectedRes 916 | * string expectedStatus 917 | */ 918 | function testCommentText(args, done) { 919 | const url = 'https://github.com/alleyinteractive/huron/pull/567'; 920 | addNewCR(url); 921 | 922 | // setup the data we want to pretend that Github is sending 923 | const requestBody = { 924 | issue: { html_url: url }, 925 | comment: { 926 | body: args.comment, 927 | user: { login: 'bcampeau' }, 928 | }, 929 | }; 930 | 931 | // not approved 932 | testWebhook('issue_comment', requestBody, (err, res) => { 933 | expect(res.text).toBe(args.expectedRes + url); 934 | expect(code_reviews.room_queues.test_room[0].status).toBe(args.expectedStatus); 935 | done(); 936 | }); 937 | } 938 | 939 | /** 940 | * Test selectively updating status to merged or closed 941 | * @param string githubStatus 'merged' or 'closed' 942 | * @param string localStatus Current status in code review queue 943 | * @param string expectedStatus Status we expect to change to (or not) 944 | * @param function done Optional done() function for the test 945 | */ 946 | function testMergeClose(githubStatus, localStatus, expectedStatus, done) { 947 | const updatedUrl = 'https://github.com/alleyinteractive/special/pull/456'; 948 | addNewCR(updatedUrl); 949 | code_reviews.room_queues.test_room[0].status = localStatus; 950 | code_reviews.room_queues.test_room[0].reviewer = 'jaredcobb'; 951 | 952 | // setup the data we want to pretend that Github is sending 953 | const requestBody = { 954 | action: 'closed', 955 | pull_request: { 956 | merged: 'merged' === githubStatus, 957 | html_url: updatedUrl, 958 | }, 959 | }; 960 | 961 | // expect the closed pull request to be closed in all rooms 962 | // and the other pull request to be unchanged 963 | testWebhook('pull_request', requestBody, (err, res) => { 964 | expect(code_reviews.room_queues.test_room[0].status).toBe(expectedStatus); 965 | if (done) { 966 | done(); 967 | } 968 | }); 969 | } 970 | 971 | /** 972 | * Make a CR slug from a URL 973 | * @param string url 974 | * @return string slug 975 | */ 976 | function makeSlug(url) { 977 | return code_reviews.matches_to_slug(code_reviews.pr_url_regex.exec(url)); 978 | } 979 | 980 | /** 981 | * Create a new CR with a random user and add it to the queue 982 | * @param string url URL of GitHub PR 983 | * @param object userMeta Optional metadata to override GitHub User object 984 | * @param int randExclude Optional index in users array to exclude from submitters 985 | */ 986 | function addNewCR(url, userMeta, randExclude) { 987 | const submitter = util.getRandom(users, randExclude).value; 988 | if (userMeta) { 989 | // shallow "extend" submitter 990 | Object.keys(userMeta).forEach((key) => { 991 | submitter[key] = userMeta[key]; 992 | }); 993 | } 994 | code_reviews.add(new CodeReview(submitter, makeSlug(url), url, submitter.room, submitter.room)); 995 | } 996 | 997 | /** 998 | * Get number of reviews in a room by status 999 | * @param string room The room to search 1000 | * @param string status The status to search for 1001 | * @return int|null Number of CRs matching status, or null if room not found 1002 | */ 1003 | function roomStatusCount(room, status) { 1004 | if (! code_reviews.room_queues[room]) { 1005 | return null; 1006 | } 1007 | let counter = 0; 1008 | code_reviews.room_queues[room].forEach((cr) => { 1009 | if (cr.status === status) { 1010 | counter++; 1011 | } 1012 | }); 1013 | return counter; 1014 | } 1015 | 1016 | function populateTestRoomCRs() { 1017 | const statuses = { 1018 | new: [], 1019 | claimed: [], 1020 | approved: [], 1021 | closed: [], 1022 | merged: [], 1023 | }; 1024 | // add a bunch of new CRs 1025 | PullRequests.forEach((url, i) => { 1026 | addNewCR(url); 1027 | }); 1028 | 1029 | // make sure there's at least one CR with each status 1030 | code_reviews.room_queues.test_room.forEach((review, i) => { 1031 | if (i < Object.keys(statuses).length) { 1032 | status = Object.keys(statuses)[i]; 1033 | // update the CR's status 1034 | code_reviews.room_queues.test_room[i].status = status; 1035 | // add to array of expected results 1036 | statuses[status].push(code_reviews.room_queues.test_room[i].slug); 1037 | } 1038 | }); 1039 | } 1040 | }); 1041 | --------------------------------------------------------------------------------