├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── daemon.js ├── package-lock.json ├── package.json └── src ├── api ├── db.js ├── github.js └── slack.js ├── channel.js ├── consts.js ├── includes ├── check_defcon.js ├── check_error_prs.js ├── check_forgotten_prs.js ├── debounce.js ├── get_random.js ├── get_random_item.js ├── lock.js ├── logger.js ├── message.js ├── update_channels.js └── update_users.js ├── index.js ├── messages └── section_pr_list.js ├── pr.js ├── runtime.js └── server ├── commands ├── help.js ├── list.js └── roulette.js ├── github_webhook.js ├── index.js ├── slack_command.js └── slack_event.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = false -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # SLACK USER TOKEN 2 | SLACK_USER_TOKEN=xoxp-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxx 3 | 4 | # SLACK BOT TOKEN 5 | SLACK_BOT_TOKEN=xoxb-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxx 6 | 7 | # GITHUB APP ID 8 | APP_ID=11111 9 | APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n....\n-----END RSA PRIVATE KEY-----" 10 | 11 | # GITHUB APP ID FOR DEVELOPMENT 12 | DEV_APP_ID=22222 13 | DEV_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n....\n-----END RSA PRIVATE KEY-----" 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /test/fixtures -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const IS_PROD = process.env.NODE_ENV === 'production'; 2 | 3 | const OFF = 'off'; 4 | const WARN = 'warn'; 5 | const ERROR = 'error'; 6 | 7 | module.exports = { 8 | root: true, 9 | extends: ['eslint:recommended', 'prettier', 'plugin:import/recommended'], 10 | plugins: ['prettier', 'import'], 11 | env: { 12 | node: true, 13 | jest: true, 14 | es6: true, 15 | }, 16 | parserOptions: { 17 | ecmaVersion: 2018, 18 | }, 19 | rules: { 20 | 'no-continue': OFF, 21 | 'no-bitwise': OFF, 22 | 'no-multi-assign': OFF, 23 | semi: [ERROR, 'always'], 24 | 'no-restricted-syntax': OFF, 25 | 'no-extra-semi': ERROR, 26 | 'prefer-template': WARN, 27 | 'no-cond-assign': OFF, 28 | /** Allow to use new for side effects */ 29 | 'no-new': OFF, 30 | /** Disallow 'console.log' on production */ 31 | 'no-console': IS_PROD ? [WARN, { allow: ['info', 'warn', 'error'] }] : OFF, 32 | /** Allow implicit return */ 33 | 'consistent-return': OFF, 34 | /** Allow ++ -- operators */ 35 | 'no-plusplus': OFF, 36 | /** Allow to reassign method parameters */ 37 | 'no-param-reassign': OFF, 38 | /** Allow nested ? ternary : expressions ? ... : ... */ 39 | 'no-nested-ternary': OFF, 40 | /** Allow __variables__ with underscores */ 41 | 'no-underscore-dangle': OFF, 42 | /** Allow both LF and CRLF line endings */ 43 | 'linebreak-style': OFF, 44 | /** Allow not-camelcase properties */ 45 | camelcase: [OFF, { properties: 'never', ignoreDestructuring: true }], 46 | 47 | // ! eslint-plugin-import rules 48 | /** Enforce file extensions on 'import' statements */ 49 | 'import/extensions': [ERROR, 'always', { ignorePackages: true }], 50 | /** Allow to import peer dependencies */ 51 | 'import/no-extraneous-dependencies': [WARN, { peerDependencies: true }], 52 | /** No one prefers the default export... */ 53 | 'import/prefer-default-export': OFF, 54 | 55 | // ! eslint-config-prettier override 56 | /** Require semicolons without enforcing */ 57 | semi: [WARN, 'always'], 58 | quotes: [ 59 | ERROR, 60 | 'single', 61 | { avoidEscape: true, allowTemplateLiterals: true }, 62 | ], 63 | 'comma-dangle': [ 64 | ERROR, 65 | { 66 | arrays: 'always-multiline', 67 | objects: 'always-multiline', 68 | imports: 'always-multiline', 69 | exports: 'always-multiline', 70 | functions: 'always-multiline', 71 | }, 72 | ], 73 | }, 74 | }; 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | daemon.* 4 | !daemon.js 5 | .env 6 | db.json 7 | data.json 8 | installations.json 9 | db/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "printWidth": 80, 4 | "trailingComma": "all", 5 | "bracketSpacing": true, 6 | "jsxBracketSameLine": false, 7 | "singleQuote": true, 8 | "arrowParens": "avoid" 9 | } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pull Request Slack Bot 2 | 3 | ##### TODO 4 | 5 | - [ ] An actual readme 6 | - [ ] Check for git conflict artifacts on changed files 7 | - [ ] Check for `.only` on test files 8 | - [ ] Rewrite eeeeeverything, the current code base is too coupled. 9 | 10 | ## Features 11 | 12 | ### Slash commands 13 | 14 | - `/pr help` - list all emoji meanings and commands; 15 | - `/pr list` - list all open prs on the channel; 16 | - `/pr list mine` - list all of your PRs on the channel; 17 | - `/pr list @user` - list all PRs on the channel from a specific user; 18 | - `/pr list @userGroup` - list all PRs on the channel from a specific user group; 19 | 20 | ### Mentions 21 | 22 | - `@Paul Robotson roulette|random` - on a PR Thread, mention a random person from the channel; 23 | - `@Paul Robotson roulette|random group-name` - on a PR Thread, mention a random person from a specific slack group. No need to prepend the `@`. 24 | 25 | ### Utilities 26 | 27 | - add a `#trivial` to your PR title or body to prevent checking for a `CHANGELOG` update. 28 | 29 | ### Emojis 30 | 31 | - `:pr-small:` - PR of small size (<=80 changes); 32 | - `:pr-medium:` - PR of small size (<=250 changes); 33 | - `:pr-large:` - PR of small size (<=800 changes); 34 | - `:pr-xlarge:` - PR of small size (>801 changes); 35 | - `:eyes:` - Someone is reviewing; 36 | - `:sonic_waiting:` - Awaiting reviews; 37 | - `:speech_balloon:` - Someone has commented; 38 | - `:changes:` - Some changes were requested; 39 | - `:warning:` - The head branch is dirty and may need a rebase with the base branch; 40 | - `:ready-to-merge:` - Ready to be merged without approvals; 41 | - `:white_check_mark:` - Ready to be merged AND approved; 42 | - `:merged:` - PR was merged; 43 | - `:closedpr:` - PR was closed; 44 | - `:shrug:` - Some unknown action was taken. Please report it :robot_face:. 45 | 46 | The code for each emoji interaction can be changed in the `src/consts.js` file. 47 | 48 | ## Developing 49 | 50 | - `npm run dev` - Listen only to messages from the test channels defined on `consts.js` 51 | - `npm run start` - Start the bot on production mode 52 | -------------------------------------------------------------------------------- /daemon.js: -------------------------------------------------------------------------------- 1 | require('./src/index.js'); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-bot", 3 | "version": "1.0.0", 4 | "author": "Christian Kaisermann ", 5 | "engines": { 6 | "node": ">=12.0.0" 7 | }, 8 | "scripts": { 9 | "dev": "cross-env node daemon.js", 10 | "start": "cross-env NODE_ENV=production node daemon.js", 11 | "forever:start": "cross-env NODE_ENV=production forever daemon.js -f", 12 | "forever:stop": "forever stop daemon.js", 13 | "forever:restart": "npm run forever:stop && npm run forever:start", 14 | "test": "jest --no-cache", 15 | "test:watch": "jest --no-cache --watchAll", 16 | "lint": "eslint \"src/**/*.js\"", 17 | "format": "prettier --loglevel silent --write \"src/**/*.js\" && eslint --fix \"src/**/*.js\"" 18 | }, 19 | "devDependencies": { 20 | "cross-env": "^5.2.0", 21 | "eslint": "^5.13.0", 22 | "eslint-config-kaisermann": "^0.0.1", 23 | "prettier": "^1.16.4" 24 | }, 25 | "dependencies": { 26 | "@octokit/app": "^4.1.0", 27 | "@octokit/request": "^5.0.2", 28 | "@polka/send-type": "^0.5.2", 29 | "@slack/rtm-api": "^5.0.1", 30 | "@slack/web-api": "^5.0.1", 31 | "body-parser": "^1.19.0", 32 | "colorette": "^1.1.0", 33 | "dotenv": "^8.0.0", 34 | "fast-deep-equal": "^2.0.1", 35 | "forever": "^1.0.0", 36 | "immer": "^3.2.0", 37 | "lowdb": "^1.0.0", 38 | "memoizee": "^0.4.14", 39 | "node-cron": "^2.0.3", 40 | "node-fetch": "^2.6.0", 41 | "polka": "^0.5.2", 42 | "ramda": "^0.26.1", 43 | "smart-request-balancer": "^2.0.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/api/db.js: -------------------------------------------------------------------------------- 1 | const low = require('lowdb'); 2 | const FileSyncAdapter = require('lowdb/adapters/FileSync'); 3 | 4 | // todo transform into file async 5 | const channels = low(new FileSyncAdapter('./db/channels.json')); 6 | 7 | const installations = low(new FileSyncAdapter('./db/installations.json')); 8 | 9 | installations.get_id = name => installations.get(name).value(); 10 | installations.set_id = (name, id) => installations.set([name], id).write(); 11 | installations.unset_id = name => installations.unset([name]).write(); 12 | 13 | const users = low(new FileSyncAdapter('./db/users.json')); 14 | users.get_by_github_user = github_user => { 15 | return users 16 | .get('members') 17 | .values() 18 | .find({ github_user }) 19 | .value(); 20 | }; 21 | 22 | users.defaults({ groups: {}, members: {} }).write(); 23 | 24 | exports.channels = channels; 25 | exports.users = users; 26 | exports.installations = installations; 27 | -------------------------------------------------------------------------------- /src/api/github.js: -------------------------------------------------------------------------------- 1 | const { App } = require('@octokit/app'); 2 | const { request } = require('@octokit/request'); 3 | 4 | const Logger = require('../includes/logger.js'); 5 | const db = require('../api/db.js'); 6 | 7 | const github_app = new App( 8 | process.env.NODE_ENV === 'production' 9 | ? { 10 | id: process.env.APP_ID, 11 | privateKey: process.env.APP_PRIVATE_KEY, 12 | } 13 | : { 14 | id: process.env.DEV_APP_ID, 15 | privateKey: process.env.DEV_APP_PRIVATE_KEY, 16 | }, 17 | ); 18 | 19 | let jwt_token = github_app.getSignedJsonWebToken(); 20 | // renew after 9:30 mins 21 | setInterval(() => { 22 | jwt_token = github_app.getSignedJsonWebToken(); 23 | }, 1000 * (60 * 10 - 30)); 24 | 25 | const REQUEST_SIGNATURES = {}; 26 | const get_request_signature = options => { 27 | if (options.etag_signature) { 28 | return JSON.stringify(options.etag_signature); 29 | } 30 | return JSON.stringify(options); 31 | }; 32 | 33 | const get_cached_etag = (url, signature) => { 34 | if (!(url in REQUEST_SIGNATURES && signature in REQUEST_SIGNATURES[url])) 35 | return null; 36 | return REQUEST_SIGNATURES[url][signature]; 37 | }; 38 | 39 | exports.invalidate_etag_signature = signature_props => { 40 | const signature = get_request_signature(signature_props); 41 | Object.values(REQUEST_SIGNATURES).forEach(signatures => { 42 | if (signature in signatures) { 43 | delete signatures[signature]; 44 | } 45 | }); 46 | }; 47 | 48 | const get_installation_id = async repo_full_name => { 49 | const { data } = await request(`GET /repos/${repo_full_name}/installation`, { 50 | headers: { 51 | authorization: `Bearer ${jwt_token}`, 52 | accept: 'application/vnd.github.machine-man-preview+json', 53 | }, 54 | }); 55 | return data.id; 56 | }; 57 | 58 | const gh_request = async (url, options) => { 59 | const full_name = `${options.owner}/${options.repo}`; 60 | const request_headers = { ...options.headers }; 61 | let installationId = db.installations.get_id(full_name); 62 | 63 | const request_signature = get_request_signature(options); 64 | 65 | if (!(url in REQUEST_SIGNATURES)) { 66 | REQUEST_SIGNATURES[url] = {}; 67 | } 68 | 69 | const cached_signature = get_cached_etag(url, request_signature); 70 | if (cached_signature != null) { 71 | request_headers['If-None-Match'] = cached_signature; 72 | } 73 | 74 | if (installationId == null) { 75 | installationId = await get_installation_id(full_name); 76 | db.installations.set_id(full_name, installationId); 77 | } 78 | 79 | try { 80 | const installationAccessToken = await github_app.getInstallationAccessToken( 81 | { installationId }, 82 | ); 83 | 84 | const response = await request(url, { 85 | ...options, 86 | headers: { 87 | ...request_headers, 88 | authorization: `token ${installationAccessToken}`, 89 | }, 90 | }); 91 | 92 | const { etag } = response.headers; 93 | REQUEST_SIGNATURES[url][request_signature] = etag; 94 | 95 | return response; 96 | } catch (e) { 97 | return e; 98 | } 99 | }; 100 | 101 | exports.get_pr_data = ({ owner, repo, pr_id: pull_number, etag_signature }) => { 102 | return gh_request('GET /repos/:owner/:repo/pulls/:pull_number', { 103 | owner, 104 | repo, 105 | pull_number, 106 | etag_signature, 107 | }) 108 | .then(({ status, data }) => { 109 | Logger.add_call(`github.pulls.get.${status}`); 110 | return { status, data }; 111 | }) 112 | .catch(({ status, name }) => ({ status, name })); 113 | }; 114 | 115 | exports.get_review_data = ({ 116 | owner, 117 | repo, 118 | pr_id: pull_number, 119 | etag_signature, 120 | }) => { 121 | return gh_request('GET /repos/:owner/:repo/pulls/:pull_number/reviews', { 122 | owner, 123 | repo, 124 | pull_number, 125 | etag_signature, 126 | }) 127 | .then(({ status, data }) => { 128 | Logger.add_call(`github.pulls.listReviews.${status}`); 129 | return { status, data }; 130 | }) 131 | .catch(({ status, name }) => ({ status, name })); 132 | }; 133 | 134 | exports.get_files_data = ({ 135 | owner, 136 | repo, 137 | pr_id: pull_number, 138 | etag_signature, 139 | }) => { 140 | return gh_request('GET /repos/:owner/:repo/pulls/:pull_number/files', { 141 | owner, 142 | repo, 143 | pull_number, 144 | etag_signature, 145 | per_page: 300, 146 | }) 147 | .then(({ status, data }) => { 148 | Logger.add_call(`github.pulls.listFiles.${status}`); 149 | return { status, data }; 150 | }) 151 | .catch(({ status, name }) => ({ status, name })); 152 | }; 153 | -------------------------------------------------------------------------------- /src/api/slack.js: -------------------------------------------------------------------------------- 1 | const { RTMClient } = require('@slack/rtm-api'); 2 | const { WebClient, retryPolicies } = require('@slack/web-api'); 3 | const Queue = require('smart-request-balancer'); 4 | const memoize = require('memoizee'); 5 | 6 | const Logger = require('../includes/logger.js'); 7 | const DB = require('./db.js'); 8 | const { PRIVATE_TEST_CHANNELS } = require('../consts.js'); 9 | 10 | const { SLACK_BOT_TOKEN, SLACK_USER_TOKEN } = process.env; 11 | const RTM = new RTMClient(SLACK_BOT_TOKEN); 12 | 13 | const PR_REGEX = /github\.com\/([\w-.]*)?\/([\w-.]*?)\/pull\/(\d+)/i; 14 | const PR_REGEX_GLOBAL = new RegExp(PR_REGEX.source, `${PR_REGEX.flags}g`); 15 | 16 | const user_client = new WebClient(SLACK_USER_TOKEN, { 17 | retryConfig: retryPolicies.rapidRetryPolicy, 18 | }); 19 | const bot_client = new WebClient(SLACK_BOT_TOKEN, { 20 | retryConfig: retryPolicies.rapidRetryPolicy, 21 | }); 22 | 23 | exports.bot_client = bot_client; 24 | exports.user_client = user_client; 25 | 26 | const balancer = new Queue({ 27 | rules: { 28 | common: { 29 | rate: 50, 30 | limit: 60, 31 | priority: 5, 32 | }, 33 | send_message: { 34 | rate: 2, 35 | limit: 1, 36 | priority: 2, 37 | }, 38 | get_profile_info: { 39 | rate: 500, 40 | limit: 60, 41 | priority: 10, 42 | }, 43 | conversations_members: { 44 | rate: 500, 45 | limit: 60, 46 | priority: 10, 47 | }, 48 | get_user_group_members: { 49 | rate: 20, 50 | limit: 60, 51 | priority: 10, 52 | }, 53 | get_user_groups: { 54 | rate: 20, 55 | limit: 60, 56 | priority: 10, 57 | }, 58 | }, 59 | retryTime: 300, 60 | }); 61 | 62 | const fetch_all = async fn => { 63 | let results = []; 64 | let cursor; 65 | do { 66 | const response = await fn(cursor); 67 | if (!response.ok) throw response.error; 68 | 69 | const { 70 | members, 71 | response_metadata: { next_cursor }, 72 | } = response; 73 | 74 | cursor = next_cursor; 75 | results.push(...members); 76 | } while (cursor !== ''); 77 | return results; 78 | }; 79 | 80 | const memo_fetch_channel_members = memoize( 81 | (channel_id, cursor) => { 82 | return balancer.request( 83 | () => { 84 | Logger.add_call('slack.conversations.members'); 85 | return bot_client.conversations 86 | .members({ channel: channel_id, cursor }) 87 | .catch(error => { 88 | Logger.error(error); 89 | }); 90 | }, 91 | channel_id + cursor, 92 | 'conversations_members', 93 | ); 94 | }, 95 | { maxAge: 1000 * 60 * 60, preFetch: true }, 96 | ); 97 | 98 | exports.get_channel_info = channel_id => { 99 | return balancer.request( 100 | () => { 101 | Logger.add_call('slack.conversations.info'); 102 | return bot_client.conversations 103 | .info({ channel: channel_id }) 104 | .then(response => response.ok && response.channel) 105 | .catch(error => { 106 | Logger.error(error); 107 | }); 108 | }, 109 | channel_id, 110 | 'get_channel_info', 111 | ); 112 | }; 113 | 114 | exports.get_profile_info = id => { 115 | return balancer.request( 116 | () => { 117 | return user_client.users.profile 118 | .get({ user: id }) 119 | .then(response => response.ok && response.profile) 120 | .catch(error => { 121 | Logger.error(error); 122 | }); 123 | }, 124 | id, 125 | 'get_profile_info', 126 | ); 127 | }; 128 | 129 | exports.get_users = async function*() { 130 | const slack_users = await fetch_all(cursor => 131 | bot_client.users.list({ limit: 0, cursor }), 132 | ); 133 | 134 | const active_users = slack_users.filter(user => user.deleted !== true); 135 | 136 | for await (let user of active_users) { 137 | const full_profile = await exports.get_profile_info(user.id); 138 | Object.assign(user.profile, full_profile); 139 | yield user; 140 | } 141 | }; 142 | 143 | exports.get_user_groups = memoize( 144 | () => { 145 | return balancer.request( 146 | () => { 147 | return user_client.usergroups 148 | .list({ 149 | include_disabled: false, 150 | include_count: false, 151 | include_users: true, 152 | }) 153 | .then(response => response.ok && response.usergroups) 154 | .catch(error => { 155 | Logger.error(error); 156 | }); 157 | }, 158 | new Date().getTime(), 159 | 'get_user_groups', 160 | ); 161 | }, 162 | { maxAge: 1000 * 60 * 60, preFetch: true }, 163 | ); 164 | 165 | exports.get_user_group_members = memoize( 166 | group_id => { 167 | return balancer.request( 168 | () => { 169 | return user_client.usergroups.users 170 | .list({ 171 | usergroup: group_id, 172 | include_disabled: false, 173 | }) 174 | .then(response => response.ok && response.users) 175 | .catch(error => { 176 | Logger.error(error); 177 | }); 178 | }, 179 | new Date().getTime(), 180 | 'get_user_group_members', 181 | ); 182 | }, 183 | { maxAge: 1000 * 60 * 60, preFetch: true }, 184 | ); 185 | 186 | exports.get_channel_members = channel_id => 187 | fetch_all(cursor => memo_fetch_channel_members(channel_id, cursor)); 188 | 189 | exports.on_pr_message = async (on_new_message, on_message_deleted) => { 190 | RTM.on('message', e => { 191 | try { 192 | const { thread_ts, channel, subtype } = e; 193 | 194 | // dont listen to messages not posted directly to a channel 195 | if ( 196 | thread_ts != null || 197 | (subtype != null && 198 | subtype !== 'message_deleted' && 199 | subtype !== 'message_changed') 200 | ) { 201 | return; 202 | } 203 | 204 | // production env should not listen to test channel 205 | if ( 206 | process.env.NODE_ENV === 'production' && 207 | PRIVATE_TEST_CHANNELS.includes(channel) 208 | ) { 209 | return; 210 | } 211 | 212 | // dev env should listen only to test channel 213 | if ( 214 | process.env.NODE_ENV !== 'production' && 215 | !PRIVATE_TEST_CHANNELS.includes(channel) 216 | ) { 217 | return; 218 | } 219 | 220 | let pr_message = e.text; 221 | let ts = e.event_ts; 222 | let poster_id = e.user || (e.message ? e.message.user : null); 223 | 224 | const is_deleted_message = 225 | subtype === 'message_deleted' || 226 | (subtype === 'message_changed' && e.message.subtype === 'tombstone'); 227 | const is_edited_message = 228 | subtype === 'message_changed' && !is_deleted_message; 229 | 230 | if (is_deleted_message) { 231 | // ignore if this is a event dispatched by the bot deleting a message 232 | if ( 233 | 'bot_id' in e.previous_message || 234 | e.previous_message.subtype === 'tombstone' 235 | ) { 236 | return; 237 | } 238 | 239 | return on_message_deleted({ 240 | channel, 241 | deleted_ts: e.deleted_ts || e.previous_message.ts, 242 | }); 243 | } 244 | 245 | if (is_edited_message) { 246 | if (e.previous_message.text === e.message.text) return; 247 | 248 | const previous_match = e.previous_message.text.match(PR_REGEX); 249 | const current_match = e.message.text.match(PR_REGEX); 250 | 251 | if (previous_match != null && current_match == null) { 252 | return on_message_deleted({ 253 | channel, 254 | deleted_ts: e.previous_message.ts, 255 | }); 256 | } 257 | 258 | if ( 259 | previous_match && 260 | current_match && 261 | previous_match[0] === current_match[0] 262 | ) { 263 | return; 264 | } 265 | 266 | pr_message = e.message ? e.message.text : null; 267 | ts = e.message.ts; 268 | } 269 | 270 | if (!pr_message && e.attachments.length) { 271 | const { title_link, pretext, author_name } = e.attachments[0]; 272 | if (typeof pretext === 'string') { 273 | if (pretext.match(/pull request opened/i)) { 274 | pr_message = title_link; 275 | 276 | const user = DB.users.get_by_github_user(author_name); 277 | if (user) { 278 | poster_id = user.id; 279 | } 280 | } 281 | } 282 | } 283 | 284 | if (!pr_message) return; 285 | 286 | const matches = pr_message.match(PR_REGEX_GLOBAL); 287 | if (!matches || matches.length > 1) return; 288 | 289 | const match = pr_message.match(PR_REGEX); 290 | const [, owner, repo, pr_id] = match; 291 | 292 | on_new_message({ 293 | poster_id, 294 | slug: `${owner}/${repo}/${pr_id}`, 295 | owner, 296 | repo, 297 | pr_id, 298 | ts, 299 | channel, 300 | }); 301 | } catch (error) { 302 | Logger.error(error); 303 | } 304 | }); 305 | 306 | await RTM.start(); 307 | }; 308 | 309 | exports.send_message = ({ text, blocks, channel, thread_ts }) => { 310 | return balancer.request( 311 | () => { 312 | Logger.add_call('slack.chat.postMessage'); 313 | return bot_client.chat 314 | .postMessage({ 315 | text, 316 | blocks, 317 | channel, 318 | thread_ts, 319 | unfurl_links: false, 320 | // send as paulo ricardo 321 | as_user: true, 322 | link_names: true, 323 | }) 324 | .catch(e => e); 325 | }, 326 | channel + thread_ts, 327 | 'send_message', 328 | ); 329 | }; 330 | 331 | exports.update_message = ({ channel, ts, text, blocks }) => { 332 | return balancer.request( 333 | () => { 334 | Logger.add_call('slack.chat.update'); 335 | return bot_client.chat 336 | .update({ 337 | text, 338 | blocks, 339 | channel, 340 | ts, 341 | unfurl_links: false, 342 | as_user: true, 343 | link_names: true, 344 | }) 345 | .catch(e => e); 346 | }, 347 | channel + ts, 348 | 'update_message', 349 | ); 350 | }; 351 | 352 | exports.delete_message = ({ channel, ts }) => { 353 | return balancer.request( 354 | () => { 355 | Logger.add_call('slack.chat.delete'); 356 | return bot_client.chat 357 | .delete({ 358 | channel, 359 | ts, 360 | }) 361 | .catch(e => e); 362 | }, 363 | channel + ts, 364 | 'delete_message', 365 | ); 366 | }; 367 | 368 | exports.delete_message_by_url = url => { 369 | const match = url.match( 370 | /archives\/(.*?)\/p(.*?)(?:\/|#|$|\?.*?thread_ts=(.*?)(?:&|$)|\?)/i, 371 | ); 372 | 373 | if (!match) return; 374 | 375 | let [, channel, ts] = match; 376 | 377 | ts = (+ts / 1000000).toFixed(6); 378 | 379 | exports.delete_message({ channel, ts }); 380 | }; 381 | 382 | exports.get_message_url = async (channel, ts) => { 383 | Logger.add_call('slack.chat.getPermalink'); 384 | const response = await bot_client.chat.getPermalink({ 385 | channel, 386 | message_ts: ts, 387 | }); 388 | 389 | return response.permalink.replace(/\?.*$/, ''); 390 | }; 391 | 392 | exports.remove_reaction = (name, channel, ts) => 393 | bot_client.reactions.remove({ name, timestamp: ts, channel }); 394 | 395 | exports.add_reaction = (name, channel, ts) => 396 | bot_client.reactions.add({ name, timestamp: ts, channel }); 397 | -------------------------------------------------------------------------------- /src/channel.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda'); 2 | 3 | const DB = require('./api/db.js'); 4 | const { EMOJIS, FORGOTTEN_PR_HOUR_THRESHOLD } = require('./consts.js'); 5 | const Message = require('./includes/message.js'); 6 | const PR = require('./pr.js'); 7 | const get_sectioned_pr_blocks = require('./messages/section_pr_list.js'); 8 | const Lock = require('./includes/lock.js'); 9 | const Logger = require('./includes/logger.js'); 10 | 11 | exports.create = ({ channel_id, name: channel_name, prs, messages }) => { 12 | const stale_message_lock = new Lock(); 13 | const DB_PR_PATH = [channel_id, 'prs']; 14 | const DB_MSG_PATH = [channel_id, 'messages']; 15 | const get_db_message_path = type => [...DB_MSG_PATH, type].filter(Boolean); 16 | 17 | prs = prs.map(PR.create); 18 | 19 | function remove_message(message) { 20 | const { ts, type } = message; 21 | DB.channels 22 | .get(get_db_message_path(type), []) 23 | .remove({ ts }) 24 | .write(); 25 | } 26 | 27 | function update_message(message) { 28 | const { type, ts } = message; 29 | DB.channels 30 | .get(get_db_message_path(type), []) 31 | .find({ ts }) 32 | .assign(message) 33 | .write(); 34 | } 35 | 36 | function save_message(message, limit) { 37 | const { type } = message; 38 | let messages_of_type = DB.channels 39 | .get(get_db_message_path(type), []) 40 | .push(message); 41 | 42 | if (typeof limit === 'number') { 43 | messages_of_type = messages_of_type.takeRight(limit); 44 | } 45 | 46 | return DB.channels 47 | .get(get_db_message_path(), []) 48 | .set(type, messages_of_type.value()) 49 | .write(); 50 | } 51 | 52 | function get_messages(type) { 53 | return DB.channels.get(get_db_message_path(type), []).value(); 54 | } 55 | 56 | function get_active_prs() { 57 | return prs.filter(pr => pr.state.error == null && pr.is_active()); 58 | } 59 | 60 | async function update() { 61 | Logger.info(`# ${channel_name} ${channel_id} - Initializing PRs`); 62 | 63 | let error; 64 | 65 | const result = await Promise.all( 66 | prs.map(async pr => 67 | pr 68 | .update() 69 | .then(on_pr_updated) 70 | .catch(e => { 71 | // Logger.error(e, `PR: ${pr.slug}`); 72 | if (e.code === 'slack_webapi_platform_error') { 73 | error = { 74 | channel_id, 75 | channel_name, 76 | error: e.data.error, 77 | }; 78 | } 79 | }), 80 | ), 81 | ); 82 | 83 | if (error) throw error; 84 | 85 | return result; 86 | } 87 | 88 | function has_pr(slug) { 89 | return prs.find(pr => pr.slug === slug); 90 | } 91 | 92 | function add_pr(pr_data) { 93 | const pr = PR.create(pr_data); 94 | 95 | prs.push(pr); 96 | DB.channels 97 | .get(DB_PR_PATH, []) 98 | .push(pr.to_json()) 99 | .write(); 100 | 101 | return pr; 102 | } 103 | 104 | function save_pr(pr) { 105 | const index = prs.findIndex(({ slug }) => slug === pr.slug); 106 | if (index < 0) return; 107 | 108 | DB.channels 109 | .get(DB_PR_PATH, []) 110 | .find({ slug: pr.slug }) 111 | .assign(pr.to_json()) 112 | .write(); 113 | 114 | return pr; 115 | } 116 | 117 | function remove_pr({ slug }) { 118 | const index = prs.findIndex(pr => pr.slug === slug); 119 | if (index < 0) return; 120 | 121 | prs[index].invalidate_etag_signature(); 122 | prs.splice(index, 1); 123 | 124 | DB.channels 125 | .get(DB_PR_PATH) 126 | .remove({ slug: slug }) 127 | .write(); 128 | } 129 | 130 | async function remove_pr_by_timestamp(deleted_ts) { 131 | const index = prs.findIndex(({ ts }) => ts === deleted_ts); 132 | if (index < 0) return; 133 | 134 | prs[index].invalidate_etag_signature(); 135 | await prs[index].delete_replies().catch(e => { 136 | Logger.error(e, `Error deleting replies from thread`); 137 | }); 138 | 139 | prs.splice(index, 1); 140 | 141 | return DB.channels 142 | .get(DB_PR_PATH) 143 | .remove({ ts: deleted_ts }) 144 | .write(); 145 | } 146 | 147 | async function on_pr_updated(pr) { 148 | if (!pr.is_active()) return; 149 | if (pr.is_unreachable()) return remove_pr(pr); 150 | if (!pr.is_resolved()) return save_pr(pr); 151 | 152 | await on_pr_resolved(pr); 153 | return remove_pr(pr); 154 | } 155 | 156 | async function on_pr_resolved(pr) { 157 | await stale_message_lock.acquire(); 158 | 159 | const stale_messages = get_messages('forgotten_prs').filter(({ payload }) => 160 | payload.some(slug => pr.slug === slug), 161 | ); 162 | 163 | if (stale_messages.length) { 164 | Logger.info(`- Updating stale PRs message: ${pr.slug}`); 165 | } 166 | 167 | const state_emoji = pr.state.merged 168 | ? EMOJIS.merged 169 | : pr.state.closed 170 | ? EMOJIS.closed 171 | : EMOJIS.unknown; 172 | 173 | const replace_pr_in_text = str => 174 | str.replace( 175 | new RegExp(`^(?::.*?:)\\s*(<.*${pr.repo}/${pr.pr_id}>.*$)`, 'm'), 176 | `:${state_emoji}: ~$1~`, 177 | ); 178 | 179 | for await (const message of stale_messages) { 180 | const updated_message = await Message.update(message, message => { 181 | if (message.text) { 182 | message.text = replace_pr_in_text(message.text); 183 | } 184 | 185 | if (message.blocks) { 186 | message.blocks = message.blocks.map(block => { 187 | if (typeof block.text === 'string') { 188 | block.text = replace_pr_in_text(block.text); 189 | } else if (block.text && block.text.type === 'mrkdwn') { 190 | block.text.text = replace_pr_in_text(block.text.text); 191 | } 192 | return block; 193 | }); 194 | } 195 | 196 | message.payload = message.payload.filter(slug => pr.slug !== slug); 197 | }); 198 | 199 | if (updated_message.payload.length === 0) { 200 | remove_message(updated_message); 201 | } else { 202 | update_message(updated_message); 203 | } 204 | } 205 | await stale_message_lock.release(); 206 | } 207 | 208 | async function check_forgotten_prs() { 209 | const stale_prs = get_active_prs().filter(pr => 210 | pr.needs_attention(FORGOTTEN_PR_HOUR_THRESHOLD), 211 | ); 212 | 213 | if (stale_prs.length === 0) return; 214 | 215 | const link_map = Object.fromEntries( 216 | await Promise.all( 217 | stale_prs.map(async pr => [ 218 | pr.slug, 219 | await pr.get_message_link(pr => `${pr.repo}/${pr.pr_id}`), 220 | ]), 221 | ), 222 | ); 223 | 224 | const now_date = new Date(Date.now()); 225 | const time_of_day = now_date.getHours() < 12 ? 'morning' : 'afternoon'; 226 | const n_prs = stale_prs.length; 227 | 228 | const blocks = [ 229 | Message.blocks.create_markdown_section([ 230 | `Good ${time_of_day}! `, 231 | `There ${n_prs === 1 ? 'is' : 'are'} *${n_prs}* PR${ 232 | n_prs > 1 ? 's' : '' 233 | }`, 234 | ` in need of some love and attention :worry-big:`, 235 | ]), 236 | ].concat(await get_sectioned_pr_blocks(stale_prs)); 237 | 238 | const message = await Message.send({ 239 | type: 'forgotten_prs', 240 | channel: channel_id, 241 | blocks, 242 | payload: stale_prs.map(pr => pr.slug), 243 | replies: {}, 244 | }); 245 | 246 | const owners_text = `Can you help :awthanks:?\n\n${R.pipe( 247 | R.groupBy(pr => pr.poster_id), 248 | R.toPairs, 249 | R.filter(([, list]) => list.length), 250 | R.map( 251 | ([user_id, list]) => 252 | `*${Message.get_user_mention(user_id)}*:\n` + 253 | `${list.map(pr => link_map[pr.slug]).join(', ')}`, 254 | ), 255 | R.join('\n\n'), 256 | )(stale_prs)}`; 257 | 258 | message.replies.mentions = await Message.send({ 259 | channel: channel_id, 260 | thread_ts: message.ts, 261 | text: owners_text, 262 | }); 263 | 264 | save_message(message, 2); 265 | } 266 | 267 | return Object.freeze({ 268 | // props 269 | id: channel_id, 270 | name: channel_name, 271 | get messages() { 272 | return messages; 273 | }, 274 | get prs() { 275 | return prs; 276 | }, 277 | // methods 278 | update, 279 | has_pr, 280 | add_pr, 281 | save_pr, 282 | remove_pr, 283 | remove_pr_by_timestamp, 284 | on_pr_updated, 285 | check_forgotten_prs, 286 | }); 287 | }; 288 | -------------------------------------------------------------------------------- /src/consts.js: -------------------------------------------------------------------------------- 1 | exports.GITHUB_APP_URL = 'https://github.com/apps/paul-robotson-pr-bot'; 2 | exports.GITHUB_FIELD_ID = 'XfCCUXUDPH'; 3 | 4 | exports.BOT_NAME = 'Paul Robotson'; 5 | 6 | exports.EMOJIS = { 7 | approved: 'github-check-done', 8 | ready_to_merge: 'ready-to-merge', 9 | commented: 'speech_balloon', 10 | merged: 'merged', 11 | changes_requested: 'changes', 12 | closed: 'closedpr', 13 | dirty: 'warning', 14 | unknown: 'shrug', 15 | waiting: 'sonic_waiting', 16 | pending_review: 'eyes', 17 | size_small: 'pr-small', 18 | size_medium: 'pr-medium', 19 | size_large: 'pr-large', 20 | size_gigantic: 'pr-xlarge', 21 | info: 'info', 22 | }; 23 | 24 | exports.PR_SIZES = [ 25 | ['small', 80], 26 | ['medium', 250], 27 | ['large', 800], 28 | ['gigantic', Infinity], 29 | ]; 30 | 31 | exports.FORGOTTEN_PR_HOUR_THRESHOLD = 24; 32 | exports.BLOCK_MAX_LEN = 3000; 33 | 34 | // dev 35 | exports.PRIVATE_TEST_CHANNELS = ['GKSCG1GRX', 'GLAM8UANR']; 36 | -------------------------------------------------------------------------------- /src/includes/check_defcon.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); 2 | const memoize = require('memoizee'); 3 | 4 | // const Logger = require('./logger.js'); 5 | 6 | const DEFCON_ENDPOINT = `http://monitoring.vtex.com/api/pvt/defcon`; 7 | 8 | module.exports = memoize( 9 | async () => { 10 | try { 11 | const response = await fetch(DEFCON_ENDPOINT, { 12 | headers: { 13 | 'x-vtex-api-appkey': process.env.VTEX_APP_KEY, 14 | 'x-vtex-api-apptoken': process.env.VTEX_APP_TOKEN, 15 | }, 16 | }); 17 | const { level, message } = await response.json(); 18 | const [, id, msg] = message.match(/DEFCON (\d)\s*-\s*(.*)/i); 19 | return { 20 | level, 21 | message: msg, 22 | id, 23 | }; 24 | } catch (e) { 25 | // Logger.error(e, 'DEFCON request'); 26 | return null; 27 | } 28 | }, 29 | { maxAge: 1000 * 60 * 30, preFetch: true }, 30 | ); 31 | -------------------------------------------------------------------------------- /src/includes/check_error_prs.js: -------------------------------------------------------------------------------- 1 | const runtime = require('../runtime.js'); 2 | 3 | module.exports = async () => { 4 | runtime.prs 5 | .filter(pr => pr.state.error != null && pr.hours_since_post >= 48) 6 | .forEach(pr => { 7 | const channel = runtime.get_channel(pr.channel); 8 | channel.remove_pr(pr); 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /src/includes/check_forgotten_prs.js: -------------------------------------------------------------------------------- 1 | const runtime = require('../runtime.js'); 2 | 3 | module.exports = async () => { 4 | const { channels } = runtime; 5 | channels.forEach(channel => channel.check_forgotten_prs()); 6 | }; 7 | -------------------------------------------------------------------------------- /src/includes/debounce.js: -------------------------------------------------------------------------------- 1 | module.exports = function debounce(f, interval) { 2 | let timer = null; 3 | 4 | return (...args) => { 5 | clearTimeout(timer); 6 | return new Promise(resolve => { 7 | timer = setTimeout(() => resolve(f(...args)), interval); 8 | }); 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /src/includes/get_random.js: -------------------------------------------------------------------------------- 1 | // Kudos for http://dimitri.xyz/random-ints-from-random-bits/ 2 | const crypto = require('crypto'); 3 | 4 | // 32 bit maximum 5 | const maxRange = 4294967296; // 2^32 6 | const getRandSample = () => crypto.randomBytes(4).readUInt32LE(); 7 | const unsafeCoerce = (sample, range) => sample % range; 8 | const inExtendedRange = (sample, range) => 9 | sample < Math.floor(maxRange / range) * range; 10 | 11 | /* extended range rejection sampling */ 12 | const maxIter = 100; 13 | 14 | function rejectionSampling(range, inRange, coerce) { 15 | let sample; 16 | let i = 0; 17 | do { 18 | sample = getRandSample(); 19 | if (i >= maxIter) { 20 | // do some error reporting. 21 | console.log('Too many iterations. Check your source of randomness.'); 22 | break; /* just returns biased sample using remainder */ 23 | } 24 | i++; 25 | } while (!inRange(sample, range)); 26 | return coerce(sample, range); 27 | } 28 | 29 | // returns random value in interval [0,range) -- excludes the upper bound 30 | const getRandIntLessThan = range => 31 | rejectionSampling(Math.ceil(range), inExtendedRange, unsafeCoerce); 32 | 33 | // returned value is in interval [low, high] -- upper bound is included 34 | module.exports = (low, hi) => { 35 | if (low <= hi) { 36 | const l = Math.ceil(low); // make also work for fractional arguments 37 | const h = Math.floor(hi); // there must be an integer in the interval 38 | return l + getRandIntLessThan(h - l + 1); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /src/includes/get_random_item.js: -------------------------------------------------------------------------------- 1 | const random = require('./get_random.js'); 2 | 3 | module.exports = iterable => { 4 | const array = Array.from(iterable); 5 | return array[random(0, array.length - 1)]; 6 | }; 7 | -------------------------------------------------------------------------------- /src/includes/lock.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | 3 | // Async mutex lock 4 | module.exports = class AsyncLock { 5 | constructor() { 6 | this.acquired = false; 7 | this.awaiting_resolvers = []; 8 | } 9 | 10 | acquire() { 11 | if (!this.acquired) { 12 | this.acquired = true; 13 | return Promise.resolve(); 14 | } 15 | 16 | return new Promise(resolve => { 17 | this.awaiting_resolvers.push(resolve); 18 | }); 19 | } 20 | 21 | release() { 22 | assert(this.acquired, 'Trying to release an unacquired lock'); 23 | if (this.awaiting_resolvers.length > 0) { 24 | let resolve = this.awaiting_resolvers.shift(); 25 | resolve(); 26 | } else { 27 | this.acquired = false; 28 | } 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/includes/logger.js: -------------------------------------------------------------------------------- 1 | const { cyan, yellow, red, greenBright } = require('colorette'); 2 | 3 | const now = () => { 4 | const date = new Date(); 5 | return new Date(date.getTime() - date.getTimezoneOffset() * 60000) 6 | .toISOString() 7 | .slice(0, -1); 8 | }; 9 | const log = (...args) => console.log(`[${now()}]`, ...args); 10 | const error = (...args) => console.error(`[${now()}]`, ...args); 11 | 12 | module.exports = { 13 | log: (...args) => log(...args), 14 | info: (...args) => log(cyan(args.join(' '))), 15 | warn: (...args) => log(yellow(args.join(' '))), 16 | error: (err, msg) => 17 | error( 18 | `${red( 19 | `${msg ? `${msg}\n` : ''}${err.stack || 20 | JSON.stringify(err, null, ' ')}`, 21 | )}`, 22 | ), 23 | success: (...args) => log(greenBright(args.join(' '))), 24 | add_call: () => {}, 25 | }; 26 | -------------------------------------------------------------------------------- /src/includes/message.js: -------------------------------------------------------------------------------- 1 | const { produce } = require('immer'); 2 | 3 | const Slack = require('../api/slack.js'); 4 | 5 | exports.send = async ({ text, blocks, channel, thread_ts, ...rest }) => { 6 | const response = await Slack.send_message({ 7 | text, 8 | blocks, 9 | channel, 10 | thread_ts, 11 | }); 12 | 13 | if (!response.ok) throw response; 14 | 15 | const { ts } = response; 16 | return { 17 | ...rest, 18 | thread_ts, 19 | ts, 20 | channel, 21 | blocks, 22 | text, 23 | }; 24 | }; 25 | 26 | exports.update = async (message, fn) => { 27 | const updated_message = produce(message, fn); 28 | 29 | const response = await Slack.update_message(updated_message); 30 | 31 | if (!response.ok) throw response; 32 | 33 | return updated_message; 34 | }; 35 | 36 | exports.delete = async message => { 37 | const response = await Slack.delete_message(message); 38 | 39 | if (!response.ok) throw response; 40 | 41 | return true; 42 | }; 43 | 44 | exports.build_text = parts => { 45 | parts = Array.isArray(parts) ? parts : [parts]; 46 | return parts 47 | .filter(Boolean) 48 | .map(part => (typeof part === 'function' ? part() : part)) 49 | .join(''); 50 | }; 51 | 52 | exports.blocks = { 53 | create_markdown_section: text => ({ 54 | type: 'section', 55 | text: { 56 | type: 'mrkdwn', 57 | text: exports.build_text(text), 58 | }, 59 | }), 60 | }; 61 | 62 | exports.match_user_mention = str => str.match(/^<@(\w*?)\|[\w.-_]*?>$/i); 63 | exports.get_user_mention = id => `<@${id}>`; 64 | 65 | exports.match_group_mention = str => str.match(//i); 66 | exports.get_group_mention = id => ``; 67 | -------------------------------------------------------------------------------- /src/includes/update_channels.js: -------------------------------------------------------------------------------- 1 | const runtime = require('../runtime.js'); 2 | 3 | module.exports = () => { 4 | const { channels } = runtime; 5 | return Promise.all(channels.map(channel => channel.update().catch(e => e))); 6 | }; 7 | -------------------------------------------------------------------------------- /src/includes/update_users.js: -------------------------------------------------------------------------------- 1 | const DB = require('../api/db.js'); 2 | const Slack = require('../api/slack.js'); 3 | const Logger = require('./logger.js'); 4 | 5 | const { GITHUB_FIELD_ID } = require('../consts.js'); 6 | 7 | module.exports = async () => { 8 | let groups_transaction = DB.users.get('groups'); 9 | 10 | const groups = await Slack.get_user_groups(); 11 | for (let group of groups) { 12 | if (group.deleted_by || group.users == null || group.users.length === 0) { 13 | continue; 14 | } 15 | 16 | groups_transaction = groups_transaction.set(group.id, { 17 | id: group.id, 18 | handle: group.handle, 19 | name: group.name, 20 | users: group.users, 21 | }); 22 | } 23 | groups_transaction.write(); 24 | Logger.info(`Groups updated`); 25 | 26 | let users_transaction = DB.users.get('members'); 27 | for await (const user of Slack.get_users()) { 28 | const { 29 | id, 30 | profile: { status_text, display_name, fields }, 31 | } = user; 32 | if (!fields || !fields[GITHUB_FIELD_ID]) continue; 33 | 34 | const github_user = fields[GITHUB_FIELD_ID].value.replace( 35 | /(?:https:\/\/github.com\/|^@)([\w-.]*)?/, 36 | '$1', 37 | ); 38 | 39 | users_transaction = users_transaction.set(id, { 40 | id, 41 | slack_user: display_name, 42 | github_user, 43 | status_text, 44 | }); 45 | } 46 | 47 | Logger.info(`Users updated`); 48 | users_transaction.write(); 49 | }; 50 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv/config'); 2 | const cron = require('node-cron'); 3 | 4 | const Slack = require('./api/slack.js'); 5 | const check_forgotten_prs = require('./includes/check_forgotten_prs.js'); 6 | const check_error_prs = require('./includes/check_error_prs.js'); 7 | const update_users = require('./includes/update_users.js'); 8 | const runtime = require('./runtime.js'); 9 | const server = require('./server/index.js'); 10 | const Logger = require('./includes/logger.js'); 11 | const update_channels = require('./includes/update_channels.js'); 12 | 13 | const CRON_OPTS = { scheduled: true, timezone: 'America/Sao_Paulo' }; 14 | 15 | async function boot() { 16 | // initialize all prs before starting server 17 | await update_channels().then(results => 18 | results.forEach(update_result => { 19 | if (update_result.error) { 20 | const e = update_result; 21 | if (e.error === 'channel_not_found') { 22 | Logger.warn(`Deleting channel: ${e.channel_id} - ${e.channel_name}`); 23 | runtime.delete_channel(e.channel_id); 24 | } 25 | } 26 | }), 27 | ); 28 | 29 | // start the web server 30 | server.start(); 31 | 32 | // send forgotten prs message every work day at 14:00 33 | // check_forgotten_prs(); 34 | cron.schedule('0 15 * * 1-5', check_forgotten_prs, CRON_OPTS); 35 | cron.schedule('0 10 * * 1-5', check_forgotten_prs, CRON_OPTS); 36 | 37 | // update user list every midnight 38 | // update_users(); 39 | cron.schedule('0 0 * * 1-5', update_users, CRON_OPTS); 40 | 41 | // delete prs with errors 42 | cron.schedule('0 0 * * 1-5', check_error_prs, CRON_OPTS); 43 | 44 | Slack.on_pr_message( 45 | // on new pr message 46 | async pr_data => { 47 | const { slug, channel: channel_id, ts } = pr_data; 48 | 49 | const channel = await runtime.get_or_create_channel(channel_id); 50 | 51 | let pr = channel.has_pr(slug); 52 | if (pr) { 53 | Logger.success(`Overwriting PR message: ${slug}`); 54 | pr.change_thread_ts(channel_id, ts); 55 | } else { 56 | Logger.success(`Watching ${slug}`); 57 | pr = channel.add_pr(pr_data); 58 | } 59 | pr.update().then(channel.on_pr_updated); 60 | }, 61 | // on pr message deleted 62 | ({ channel: channel_id, deleted_ts }) => { 63 | const channel = runtime.get_channel(channel_id); 64 | if (channel) { 65 | channel.remove_pr_by_timestamp(deleted_ts); 66 | } 67 | }, 68 | ); 69 | } 70 | 71 | boot(); 72 | -------------------------------------------------------------------------------- /src/messages/section_pr_list.js: -------------------------------------------------------------------------------- 1 | const Message = require('../includes/message.js'); 2 | const { EMOJIS, BLOCK_MAX_LEN } = require('../consts.js'); 3 | 4 | function pluralize(str, n) { 5 | return `${n} ${str}${n > 1 ? 's' : ''}`; 6 | } 7 | 8 | function format_time(n) { 9 | if (n <= 72) return `${n} hours old`; 10 | 11 | n = Math.floor(n / 24); 12 | if (n <= 30) return `${pluralize('day', n)} old`; 13 | 14 | n = Math.floor(n / 30); 15 | return `${pluralize('month', n)} old`; 16 | } 17 | 18 | module.exports = async prs => { 19 | const link_map = Object.fromEntries( 20 | await Promise.all( 21 | prs.map(async pr => [ 22 | pr.slug, 23 | await pr.get_message_link(pr => `${pr.repo}/${pr.pr_id}`), 24 | ]), 25 | ), 26 | ); 27 | 28 | const sections = prs.reduce( 29 | (acc, pr) => { 30 | let section; 31 | if (pr.is_mergeable()) { 32 | section = acc.ready_to_merge; 33 | } else if (pr.is_dirty()) { 34 | section = acc.dirty; 35 | } else if (pr.has_changes_requested()) { 36 | section = acc.changes_requested; 37 | } else { 38 | section = acc.waiting_review; 39 | } 40 | 41 | section.list.push(pr); 42 | 43 | return acc; 44 | }, 45 | { 46 | ready_to_merge: { 47 | title: `:${EMOJIS.ready_to_merge}: Ready to be merged`, 48 | list: [], 49 | }, 50 | changes_requested: { 51 | title: `:${EMOJIS.changes_requested}: Changes requested`, 52 | list: [], 53 | }, 54 | dirty: { 55 | title: `:${EMOJIS.dirty}: Needs rebase`, 56 | list: [], 57 | }, 58 | waiting_review: { 59 | title: `:${EMOJIS.waiting}: Waiting review`, 60 | list: [], 61 | }, 62 | }, 63 | ); 64 | 65 | const blocks = Object.values(sections) 66 | .filter(section => section.list.length) 67 | .map(section => ({ 68 | ...section, 69 | list: section.list.sort( 70 | (pr_a, pr_b) => pr_b.hours_since_post - pr_a.hours_since_post, 71 | ), 72 | })) 73 | .flatMap(({ title, list }) => { 74 | const text = `*${title} (${list.length})*:\n${list 75 | .map(pr => { 76 | const { 77 | hours_since_post, 78 | state: { size }, 79 | } = pr; 80 | 81 | return ( 82 | `:${EMOJIS[`size_${size.label}`]}: ` + 83 | `${link_map[pr.slug]} ` + 84 | `_(${format_time(hours_since_post)})_` 85 | ); 86 | }) 87 | .join('\n')}`; 88 | 89 | if (text.length < BLOCK_MAX_LEN) { 90 | return Message.blocks.create_markdown_section(text); 91 | } 92 | 93 | // if block text is greater than 3000 limit, split it into multiple blocks. 94 | const chunks = []; 95 | let sliced_text = text; 96 | do { 97 | let chunk = sliced_text.slice(0, BLOCK_MAX_LEN); 98 | if (chunk.length === BLOCK_MAX_LEN) { 99 | // slice before the last line break 100 | let cut_index = chunk.lastIndexOf('\n'); 101 | 102 | if (cut_index > -1) { 103 | chunk = chunk.slice(0, cut_index); 104 | } 105 | } 106 | chunks.push(chunk); 107 | sliced_text = sliced_text.slice(chunk.length); 108 | } while (sliced_text.length > 0); 109 | return chunks.map(Message.blocks.create_markdown_section); 110 | }); 111 | 112 | return blocks; 113 | }; 114 | -------------------------------------------------------------------------------- /src/pr.js: -------------------------------------------------------------------------------- 1 | const { basename } = require('path'); 2 | const is_equal = require('fast-deep-equal'); 3 | 4 | const Github = require('./api/github.js'); 5 | const Slack = require('./api/slack.js'); 6 | const Logger = require('./includes/logger.js'); 7 | const DB = require('./api/db.js'); 8 | const Message = require('./includes/message.js'); 9 | const Lock = require('./includes/lock.js'); 10 | const debounce = require('./includes/debounce.js'); 11 | const check_defcon = require('./includes/check_defcon.js'); 12 | 13 | const { EMOJIS, PR_SIZES, GITHUB_APP_URL } = require('./consts.js'); 14 | 15 | const ACTIONS = Object.freeze({ 16 | approved: 'APPROVED', 17 | dismissed: 'DISMISSED', 18 | changes_requested: 'CHANGES_REQUESTED', 19 | pending_review: 'PENDING', 20 | review_requested: 'REVIEW_REQUESTED', 21 | commented: 'COMMENTED', 22 | merged: 'MERGED', 23 | unknown: 'UNKNOWN', 24 | }); 25 | 26 | function get_action(action_list) { 27 | const last_approved_idx = action_list.lastIndexOf(ACTIONS.approved); 28 | const last_change_request_idx = action_list.lastIndexOf( 29 | ACTIONS.changes_requested, 30 | ); 31 | 32 | if (last_change_request_idx > last_approved_idx) { 33 | return ACTIONS.changes_requested; 34 | } 35 | 36 | const last_dismissed_idx = action_list.lastIndexOf(ACTIONS.dismissed); 37 | if (last_dismissed_idx < last_approved_idx) { 38 | return ACTIONS.approved; 39 | } 40 | 41 | if (last_dismissed_idx >= 0) { 42 | return ACTIONS.dismissed; 43 | } 44 | 45 | if (action_list.includes(ACTIONS.review_requested)) { 46 | return ACTIONS.review_requested; 47 | } 48 | 49 | if (action_list.includes(ACTIONS.pending_review)) { 50 | return ACTIONS.pending_review; 51 | } 52 | 53 | if (action_list.includes(ACTIONS.commented)) { 54 | return ACTIONS.commented; 55 | } 56 | return ACTIONS.unknown; 57 | } 58 | 59 | function get_action_label(action) { 60 | if (action === ACTIONS.approved) { 61 | return { label: 'Approved', emoji: EMOJIS.approved }; 62 | } 63 | 64 | if (action === ACTIONS.changes_requested) { 65 | return { label: 'Changes requested', emoji: EMOJIS.changes_requested }; 66 | } 67 | 68 | if (action === ACTIONS.pending_review) { 69 | return { label: 'Is reviewing', emoji: EMOJIS.pending_review }; 70 | } 71 | 72 | if (action === ACTIONS.review_requested) { 73 | return { label: 'Waiting review', emoji: EMOJIS.waiting }; 74 | } 75 | 76 | if (action === ACTIONS.dismissed) { 77 | return { label: 'Outdated review', emoji: EMOJIS.waiting }; 78 | } 79 | 80 | if (action === ACTIONS.commented) { 81 | return { label: 'Commented', emoji: EMOJIS.commented }; 82 | } 83 | 84 | if (action === ACTIONS.merged) { 85 | return { label: 'Merged by', emoji: EMOJIS.merged }; 86 | } 87 | 88 | return { label: 'Unknown action', emoji: EMOJIS.unknown }; 89 | } 90 | 91 | function get_pr_size({ additions, deletions, files }) { 92 | const lock_file_changes = files 93 | .filter(f => { 94 | const filename = basename(f.filename); 95 | return filename === 'package-lock.json' || filename === 'yarn.lock'; 96 | }) 97 | .reduce((acc, file) => acc + file.additions + file.deletions, 0); 98 | 99 | const n_changes = additions + deletions - lock_file_changes; 100 | 101 | let i; 102 | for (i = 0; i < PR_SIZES.length && n_changes > PR_SIZES[i][1]; i++); 103 | 104 | return { 105 | label: PR_SIZES[i][0], 106 | limit: PR_SIZES[i][1], 107 | n_changes, 108 | additions, 109 | deletions, 110 | }; 111 | } 112 | 113 | function get_action_lists(pr_data, review_data) { 114 | let actions = {}; 115 | 116 | pr_data.requested_reviewers.forEach(({ login }) => { 117 | actions[login] = [ACTIONS.review_requested]; 118 | }); 119 | 120 | review_data 121 | .filter( 122 | ({ user }) => pr_data.assignee == null || user !== pr_data.assignee.login, 123 | ) 124 | .forEach(({ user: { login }, state }) => { 125 | if (!(login in actions)) actions[login] = []; 126 | actions[login].push(state); 127 | }); 128 | 129 | return Object.entries(actions); 130 | } 131 | 132 | exports.create = ({ 133 | poster_id, 134 | slug, 135 | owner, 136 | repo, 137 | pr_id, 138 | channel, 139 | ts, 140 | state = {}, 141 | replies = {}, 142 | reactions = {}, 143 | }) => { 144 | let self; 145 | let _cached_remote_state = {}; 146 | let _cached_url = null; 147 | const etag_signature = [channel, owner, repo, pr_id]; 148 | const update_lock = new Lock(); 149 | 150 | function invalidate_etag_signature() { 151 | Github.invalidate_etag_signature(etag_signature); 152 | } 153 | 154 | async function get_message_url() { 155 | if (_cached_url == null) { 156 | try { 157 | _cached_url = await Slack.get_message_url(channel, ts); 158 | 159 | if (has_reply('header_message')) { 160 | const { thread_ts, ts } = replies.header_message; 161 | _cached_url = _cached_url.replace( 162 | /\/p\d*?$/, 163 | `/p${ts * 1000000}?thread_ts=${thread_ts}`, 164 | ); 165 | } 166 | } catch (e) { 167 | Logger.error(e, `Getting "${slug}" message url:`); 168 | } 169 | } 170 | return _cached_url; 171 | } 172 | 173 | function has_reply(id) { 174 | return id in replies; 175 | } 176 | 177 | async function delete_reply(id) { 178 | if (!has_reply(id)) return false; 179 | 180 | Logger.info(`- Deleting reply with id: ${id}`); 181 | 182 | return Message.delete(replies[id]) 183 | .then(() => { 184 | delete replies[id]; 185 | return true; 186 | }) 187 | .catch(e => { 188 | console.log(e.data); 189 | console.log(e.data.error); 190 | if (e.data && e.data.error === 'message_not_found') { 191 | Logger.info(`- Tried to delete an already deleted message`); 192 | delete replies[id]; 193 | return false; 194 | } 195 | 196 | throw e; 197 | }); 198 | } 199 | 200 | async function delete_replies(reply_ids = Object.keys(replies)) { 201 | return Promise.all(reply_ids.map(delete_reply)); 202 | } 203 | 204 | async function update_reply(id, updateFn, payload) { 205 | if (!has_reply(id)) return false; 206 | 207 | const saved_reply = replies[id]; 208 | 209 | if ( 210 | saved_reply.payload != null && 211 | payload != null && 212 | is_equal(saved_reply.payload, payload) 213 | ) 214 | return false; 215 | 216 | const text = Message.build_text(updateFn(saved_reply)); 217 | 218 | if (saved_reply.text === text) return false; 219 | 220 | if (text === '') { 221 | return delete_reply(id); 222 | } 223 | 224 | Logger.info(`- Updating reply: ${text}`); 225 | replies[id] = await Message.update(saved_reply, message => { 226 | message.text = text; 227 | message.payload = payload; 228 | }); 229 | 230 | return true; 231 | } 232 | 233 | async function reply(id, text_parts, payload) { 234 | if (has_reply(id)) { 235 | return update_reply(id, () => text_parts, payload); 236 | } 237 | 238 | const text = Message.build_text(text_parts); 239 | 240 | if (text === '') return false; 241 | 242 | Logger.info(`- Sending reply: ${text}`); 243 | return Message.send({ 244 | text, 245 | channel, 246 | thread_ts: ts, 247 | payload, 248 | }).then(message => { 249 | replies[id] = message; 250 | return true; 251 | }); 252 | } 253 | 254 | async function remove_reaction(type) { 255 | if (!(type in reactions)) { 256 | return false; 257 | } 258 | const name = reactions[type]; 259 | 260 | Logger.info(`- Removing reaction of type: ${type} (${reactions[type]})`); 261 | Logger.add_call('slack.reactions.remove'); 262 | 263 | return Slack.remove_reaction(name, channel, ts) 264 | .then(() => { 265 | delete reactions[type]; 266 | return true; 267 | }) 268 | .catch(e => { 269 | if (e.data && e.data.error === 'no_reaction') { 270 | delete reactions[type]; 271 | return false; 272 | } 273 | throw e; 274 | }); 275 | } 276 | 277 | async function add_reaction(type, name) { 278 | if (type in reactions) { 279 | if (reactions[type] === name) return false; 280 | await remove_reaction(type); 281 | } 282 | 283 | Logger.info(`- Adding reaction of type: ${type} (${name})`); 284 | Logger.add_call('slack.reactions.add'); 285 | 286 | return Slack.add_reaction(name, channel, ts) 287 | .then(() => { 288 | reactions[type] = name; 289 | return true; 290 | }) 291 | .catch(e => { 292 | if (e.data && e.data.error === 'already_reacted') { 293 | reactions[type] = name; 294 | return false; 295 | } 296 | throw e; 297 | }); 298 | } 299 | 300 | async function fetch_remote_state() { 301 | const params = { owner, repo, pr_id, etag_signature }; 302 | 303 | const responses = await Promise.all([ 304 | // we make a promise that only resolves when a PR mergeability is known 305 | new Promise(async res => { 306 | try { 307 | let known_mergeable_state = false; 308 | let data; 309 | let status; 310 | do { 311 | const response = await Github.get_pr_data(params); 312 | data = response.data; 313 | status = response.status; 314 | 315 | if (status === 200 || status === 304) { 316 | if (status === 304) { 317 | data = _cached_remote_state.pr_data; 318 | } else { 319 | _cached_remote_state.pr_data = data; 320 | } 321 | 322 | known_mergeable_state = 323 | data.state === 'closed' || 324 | data.merged || 325 | data.mergeable != null; 326 | } else if (status === 502) { 327 | known_mergeable_state = false; 328 | } else { 329 | break; 330 | } 331 | 332 | if (known_mergeable_state === false) { 333 | Logger.warn( 334 | `[${status}] Unknown mergeable state for ${slug}. Retrying...`, 335 | ); 336 | await new Promise(r => setTimeout(r, 800)); 337 | } 338 | } while (known_mergeable_state === false); 339 | 340 | res({ status, data }); 341 | } catch (e) { 342 | Logger.error(e); 343 | res({ status: 520 }); 344 | } 345 | }), 346 | Github.get_review_data(params), 347 | Github.get_files_data(params), 348 | ]); 349 | 350 | const has_status = status => responses.some(r => r.status === status); 351 | 352 | if (has_status(520)) return { error: { status: 520 } }; 353 | if (has_status(403)) return { error: { status: 403 } }; 354 | if (has_status(404)) return { error: { status: 404 } }; 355 | 356 | const [pr_response, review_response, files_response] = responses; 357 | 358 | let pr_data = pr_response.data; 359 | let review_data = review_response.data; 360 | let files_data = files_response.data; 361 | 362 | // nothing changed, nothing to change 363 | if ( 364 | pr_response.status === 304 && 365 | review_response.status === 304 && 366 | files_response.status === 304 367 | ) { 368 | return _cached_remote_state; 369 | } 370 | 371 | if (review_response.status === 200) { 372 | _cached_remote_state.review_data = review_data; 373 | } else { 374 | review_data = _cached_remote_state.review_data; 375 | } 376 | 377 | if (files_response.status === 200) { 378 | _cached_remote_state.files_data = files_data; 379 | } else { 380 | files_data = _cached_remote_state.files_data; 381 | } 382 | 383 | if (pr_data == null || review_data == null || files_data == null) { 384 | Logger.log( 385 | pr_response.status, 386 | review_response.status, 387 | files_response.status, 388 | ); 389 | Logger.log(!!pr_data, !!review_data, !!files_data); 390 | throw new Error(`Something went wrong with ${slug} github requests.`); 391 | } 392 | 393 | return { pr_data, review_data, files_data }; 394 | } 395 | 396 | async function get_consolidated_state() { 397 | const { 398 | error, 399 | pr_data, 400 | review_data, 401 | files_data, 402 | } = await fetch_remote_state(); 403 | 404 | if (error) return { error }; 405 | 406 | // review data mantains a list of reviews 407 | const action_lists = get_action_lists(pr_data, review_data); 408 | 409 | const actions = [] 410 | .concat( 411 | action_lists.map(([github_user, action_list]) => { 412 | return { 413 | github_user, 414 | action: get_action(action_list), 415 | }; 416 | }), 417 | ) 418 | .concat( 419 | pr_data.merged_by != null 420 | ? [{ github_user: pr_data.merged_by.login, action: ACTIONS.merged }] 421 | : [], 422 | ) 423 | .map(({ github_user, action }) => { 424 | const user = DB.users.get_by_github_user(github_user); 425 | if (user) { 426 | return { ...user, action }; 427 | } 428 | return { github_user, action }; 429 | }); 430 | 431 | const { title, body, additions, deletions, mergeable } = pr_data; 432 | const files = files_data.map( 433 | ({ filename, status, additions, deletions }) => { 434 | return { filename, status, additions, deletions }; 435 | }, 436 | ); 437 | 438 | return { 439 | title, 440 | description: body, 441 | actions, 442 | additions, 443 | deletions, 444 | files, 445 | size: get_pr_size({ additions, deletions, files }), 446 | mergeable, 447 | merged: pr_data.merged, 448 | closed: pr_data.state === 'closed', 449 | mergeable_state: pr_data.mergeable_state, 450 | head_branch: pr_data.head.ref, 451 | base_branch: pr_data.base.ref, 452 | }; 453 | } 454 | 455 | function get_approvals() { 456 | return state.actions.filter(a => a.action === ACTIONS.approved).length; 457 | } 458 | 459 | function has_changelog() { 460 | return state.files.some(f => { 461 | const filename = basename(f.filename).toLowerCase(); 462 | 463 | return ( 464 | filename === 'changelog.md' && 465 | (f.status === 'modified' || f.status === 'added') 466 | ); 467 | }); 468 | } 469 | 470 | function has_comment() { 471 | return state.actions.some(item => item.action === ACTIONS.commented); 472 | } 473 | 474 | function has_changes_requested() { 475 | return state.actions.some( 476 | item => item.action === ACTIONS.changes_requested, 477 | ); 478 | } 479 | 480 | function is_trivial() { 481 | return (state.title + state.description).includes('#trivial'); 482 | } 483 | 484 | function is_draft() { 485 | return state.mergeable_state === 'draft'; 486 | } 487 | 488 | function is_mergeable() { 489 | if (state.closed) return false; 490 | return state.mergeable_state === 'clean'; 491 | } 492 | 493 | function is_dirty() { 494 | return state.mergeable_state === 'dirty'; 495 | } 496 | 497 | function is_unstable() { 498 | return state.mergeable_state === 'unstable'; 499 | } 500 | 501 | function is_resolved() { 502 | return state.closed || state.merged; 503 | } 504 | 505 | function is_active() { 506 | return !is_draft(); 507 | } 508 | 509 | function is_waiting_review() { 510 | return ( 511 | state.actions.length === 0 || 512 | state.actions.some( 513 | item => 514 | item.action === ACTIONS.dismissed || 515 | item.action === ACTIONS.review_requested, 516 | ) 517 | ); 518 | } 519 | 520 | async function can_be_merged() { 521 | const { base_branch } = state; 522 | if (base_branch !== 'master' && base_branch.match(/\d\.x/i) == null) { 523 | return { can_merge: true }; 524 | } 525 | 526 | const defcon_status = await check_defcon(); 527 | if (defcon_status == null) return { can_merge: true }; 528 | 529 | return { 530 | can_merge: 531 | defcon_status.level !== 'critical' && defcon_status.level !== 'warning', 532 | defcon: defcon_status, 533 | }; 534 | } 535 | 536 | function has_pending_review() { 537 | return state.actions.some(item => item.action === ACTIONS.pending_review); 538 | } 539 | 540 | async function update_header_message() { 541 | const { actions, size, title } = state; 542 | 543 | const text_parts = [ 544 | `:${EMOJIS.info}: *Title*: ${title}\n\n`, 545 | `:${EMOJIS[`size_${size.label}`]}: *PR size*: ${size.label} (_${ 546 | size.n_changes 547 | } changes_)\n\n`, 548 | actions.length === 0 549 | ? `:${EMOJIS.waiting}: Waiting for reviewers` 550 | : () => { 551 | const header_text = Object.entries( 552 | actions.reduce((acc, { id, github_user, action }) => { 553 | if (!(action in acc)) acc[action] = []; 554 | 555 | const mention = id ? Message.get_user_mention(id) : github_user; 556 | acc[action].push(mention); 557 | return acc; 558 | }, {}), 559 | ) 560 | .map(([action, mentions]) => { 561 | const { label, emoji } = get_action_label(action); 562 | return `:${emoji}: *${label}*: ${mentions.join(', ')}`; 563 | }) 564 | .join('\n\n'); 565 | 566 | return header_text; 567 | }, 568 | ]; 569 | 570 | return reply('header_message', text_parts, { title, size, actions }); 571 | } 572 | 573 | async function update_reactions() { 574 | const { error, size, merged, closed } = state; 575 | 576 | if (error) return; 577 | 578 | const changes_requested = has_changes_requested(); 579 | 580 | await add_reaction('size', EMOJIS[`size_${size.label}`]); 581 | 582 | if (changes_requested) { 583 | await add_reaction('changes_requested', EMOJIS.changes_requested); 584 | } else { 585 | await remove_reaction('changes_requested'); 586 | } 587 | 588 | if (is_mergeable() && changes_requested === false) { 589 | const n_approvals = get_approvals(); 590 | await add_reaction( 591 | 'approved', 592 | n_approvals > 0 ? EMOJIS.approved : EMOJIS.ready_to_merge, 593 | ); 594 | } else { 595 | await remove_reaction('approved'); 596 | } 597 | 598 | if (has_comment()) { 599 | await add_reaction('has_comment', EMOJIS.commented); 600 | } else { 601 | await remove_reaction('has_comment'); 602 | } 603 | 604 | if (is_waiting_review()) { 605 | await add_reaction('is_waiting_review', EMOJIS.waiting); 606 | } else { 607 | await remove_reaction('is_waiting_review'); 608 | } 609 | 610 | if (has_pending_review()) { 611 | await add_reaction('pending_review', EMOJIS.pending_review); 612 | } else { 613 | await remove_reaction('pending_review'); 614 | } 615 | 616 | if (is_dirty()) { 617 | await add_reaction('dirty', EMOJIS.dirty); 618 | } else { 619 | await remove_reaction('dirty'); 620 | } 621 | 622 | if (merged) { 623 | await add_reaction('merged', EMOJIS.merged); 624 | } else { 625 | await remove_reaction('merged'); 626 | } 627 | 628 | if (closed && !merged) { 629 | await add_reaction('closed', EMOJIS.closed); 630 | } else { 631 | await remove_reaction('closed'); 632 | } 633 | } 634 | 635 | async function update_replies() { 636 | const { error, head_branch, base_branch } = state; 637 | 638 | if (error) { 639 | if (error.status === 404 || error.status === 403) { 640 | await reply( 641 | 'error', 642 | `Sorry, but I think my <${GITHUB_APP_URL}|Github App> is not installed on this repository :thinking_face:. Please post this pull request again after installing the app (•ᴥ•)`, 643 | ); 644 | } else if (error.status === 520) { 645 | await reply( 646 | 'error', 647 | `Sorry, but something awful happened :scream:. I can't see this PR status...`, 648 | ); 649 | } 650 | return; 651 | } else { 652 | await delete_reply('error'); 653 | } 654 | 655 | await update_header_message(); 656 | 657 | if (is_dirty()) { 658 | await reply( 659 | 'is_dirty', 660 | `The branch \`${head_branch}\` is dirty. It may need a rebase with \`${base_branch}\`.`, 661 | ); 662 | } else { 663 | await delete_reply('is_dirty'); 664 | } 665 | 666 | if (is_trivial() === false && has_changelog() === false) { 667 | await reply( 668 | 'modified_changelog', 669 | `I couln't find an addition to the \`CHANGELOG.md\`.\n\nDid you forget to add it :notsure:?`, 670 | ); 671 | } else { 672 | await delete_reply('modified_changelog'); 673 | } 674 | 675 | if (is_mergeable() === false || has_changes_requested()) { 676 | await delete_reply('ready_to_merge'); 677 | } else { 678 | let text; 679 | const { can_merge, defcon } = await can_be_merged(); 680 | if (can_merge === false) { 681 | text = `This PR would be ready to be merged, but we're at *DEFCON ${defcon.id}* :harold-pain:. ${defcon.message}.`; 682 | } else { 683 | const n_approvals = get_approvals(); 684 | const is_release_branch = !!base_branch.match( 685 | /^(?:master|release[/-]?|(?:\d\.)+x)/i, 686 | ); 687 | if (n_approvals === 0 && is_release_branch) { 688 | text = `PR is ready to be merged, but I can't seem to find any reviews approving it :notsure-left:.\n\nIs there a merge protection rule configured for the \`${base_branch}\` branch?`; 689 | } else { 690 | text = 'PR is ready to be merged :doit:!'; 691 | } 692 | 693 | if (defcon && defcon.level === 'info') { 694 | text += `\n\nRemember that we're at *DEFCON ${defcon.id}* :apruved:. ${defcon.message}.`; 695 | } 696 | } 697 | 698 | await reply('ready_to_merge', text); 699 | } 700 | } 701 | 702 | function is_unreachable() { 703 | return ( 704 | state.error && 705 | (state.error.status === 403 || 706 | state.error.status === 404 || 707 | state.error.status === 520) 708 | ); 709 | } 710 | 711 | async function update() { 712 | try { 713 | await update_lock.acquire(); 714 | state = await get_consolidated_state(); 715 | 716 | if (is_unreachable()) { 717 | Logger.info(`Can't update: ${slug}. Forbidden or not found.`); 718 | } else { 719 | Logger.info(`Updated state: ${slug}`); 720 | await Promise.all([update_reactions(), update_replies()]); 721 | } 722 | } catch (e) { 723 | Logger.error(e, `Something went wrong with "${slug}":`); 724 | throw e; 725 | } finally { 726 | update_lock.release(); 727 | } 728 | 729 | return self; 730 | } 731 | 732 | function to_json() { 733 | return { 734 | poster_id, 735 | slug, 736 | owner, 737 | repo, 738 | pr_id, 739 | channel, 740 | ts, 741 | replies, 742 | reactions, 743 | state, 744 | }; 745 | } 746 | 747 | self = Object.freeze({ 748 | // props 749 | poster_id, 750 | slug, 751 | owner, 752 | repo, 753 | pr_id, 754 | channel, 755 | ts, 756 | get state() { 757 | return state; 758 | }, 759 | get minutes_since_post() { 760 | return Math.abs(new Date(ts * 1000) - new Date()) / (1000 * 60); 761 | }, 762 | get hours_since_post() { 763 | return ~~(this.minutes_since_post / 60); 764 | }, 765 | // methods 766 | change_thread_ts(new_channel, new_ts) { 767 | channel = new_channel; 768 | ts = new_ts; 769 | replies = {}; 770 | reactions = {}; 771 | }, 772 | // we debounce the update method so many consecutive updates fire just once 773 | update: debounce(update, 400), 774 | get_message_url, 775 | get_message_link: async fn => `<${[await get_message_url()]}|${fn(self)}>`, 776 | reply, 777 | update_reply, 778 | delete_reply, 779 | delete_replies, 780 | has_changes_requested, 781 | has_comment, 782 | is_trivial, 783 | is_draft, 784 | is_mergeable, 785 | is_dirty, 786 | is_unstable, 787 | is_resolved, 788 | is_active, 789 | is_unreachable, 790 | needs_attention(hours) { 791 | return is_active() && this.minutes_since_post >= 60 * hours; 792 | }, 793 | invalidate_etag_signature, 794 | to_json, 795 | }); 796 | 797 | return self; 798 | }; 799 | -------------------------------------------------------------------------------- /src/runtime.js: -------------------------------------------------------------------------------- 1 | const DB = require('./api/db.js'); 2 | const Slack = require('./api/slack.js'); 3 | const Channel = require('./channel.js'); 4 | 5 | const channels = DB.channels 6 | .values() 7 | .value() 8 | .map(Channel.create); 9 | 10 | module.exports = { 11 | get_channel(id) { 12 | return channels.find(channel => channel.id === id); 13 | }, 14 | async create_channel(id) { 15 | const channel_info = await Slack.get_channel_info(id); 16 | const channel_data = { 17 | channel_id: id, 18 | name: channel_info.name, 19 | prs: [], 20 | messages: {}, 21 | }; 22 | const channel = Channel.create(channel_data); 23 | 24 | channels.push(channel); 25 | DB.channels.set(id, channel_data).write(); 26 | return channel; 27 | }, 28 | async get_or_create_channel(id) { 29 | return this.get_channel(id) || this.create_channel(id); 30 | }, 31 | delete_channel(id) { 32 | const index = channels.findIndex(channel => channel.channel_id === id); 33 | channels.splice(index, 1); 34 | return DB.channels.unset(id).write(); 35 | }, 36 | get channels() { 37 | return channels; 38 | }, 39 | get prs() { 40 | return channels.flatMap(channel => channel.prs); 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /src/server/commands/help.js: -------------------------------------------------------------------------------- 1 | const { EMOJIS, PR_SIZES } = require('../../consts.js'); 2 | 3 | const commands = [ 4 | ['`/pr help`', 'list all emoji meanings and commands'], 5 | ['`/pr list`', 'list all open prs on the channel'], 6 | ['`/pr list mine`', 'list all of your PRs on the channel'], 7 | ['`/pr list @user`', 'list all PRs on the channel from a specific user'], 8 | [ 9 | '`/pr list @userGroup`', 10 | 'list all PRs on the channel from a specific user group', 11 | ], 12 | [ 13 | '`@Paul Robotson roulette|random`', 14 | 'on a PR Thread, mention a random person from the channel', 15 | ], 16 | [ 17 | '`@Paul Robotson roulette|random group-name`', 18 | 'on a PR Thread, mention a random person from a specific slack group', 19 | ], 20 | ]; 21 | 22 | const utils = [ 23 | [ 24 | 'add a `#trivial` to your PR title or body to prevent checking for a CHANGELOG update', 25 | ], 26 | ]; 27 | 28 | const emojis = [ 29 | ...PR_SIZES.map(([label, max], i) => [ 30 | EMOJIS[`size_${label}`], 31 | `PR of small size _(${ 32 | max !== Infinity ? `<=${max}` : `>${PR_SIZES[i - 1][1] + 1}` 33 | } changes)_`, 34 | ]), 35 | [EMOJIS.pending_review, 'Someone is reviewing'], 36 | [EMOJIS.waiting, 'Awaiting reviews'], 37 | [EMOJIS.commented, 'Someone has commented'], 38 | [EMOJIS.changes_requested, 'Some changes were requested'], 39 | 40 | [ 41 | EMOJIS.dirty, 42 | 'The head branch is dirty and may need a rebase with the base branch', 43 | ], 44 | [EMOJIS.ready_to_merge, 'Ready to be merged without approvals'], 45 | [EMOJIS.approved, ' Ready to be merged AND approved'], 46 | [EMOJIS.merged, 'PR was merged'], 47 | [EMOJIS.closed, 'PR was closed'], 48 | [ 49 | EMOJIS.unknown, 50 | 'Some unknown action was taken. Please report it :robot_face:', 51 | ], 52 | ].map(([emoji, label]) => [`:${emoji}:`, label]); 53 | 54 | function format_help_section(items, title) { 55 | return `*${title}*:\n${items 56 | .map(([cmd, str]) => (str ? `${cmd} - ${str}` : `- ${cmd}`)) 57 | .join(';\n')}.`; 58 | } 59 | 60 | module.exports = () => 61 | [ 62 | format_help_section(commands, 'Available commands'), 63 | format_help_section(utils, 'Utilities'), 64 | format_help_section(emojis, 'Emojis'), 65 | ].join('\n\n\n'); 66 | -------------------------------------------------------------------------------- /src/server/commands/list.js: -------------------------------------------------------------------------------- 1 | const DB = require('../../api/db.js'); 2 | const runtime = require('../../runtime.js'); 3 | const Message = require('../../includes/message.js'); 4 | const get_sectioned_pr_blocks = require('../../messages/section_pr_list.js'); 5 | 6 | module.exports = async ({ channel_id, user_id, params }) => { 7 | const channel = runtime.get_channel(channel_id); 8 | 9 | if (!channel) { 10 | return `Sorry, but it seems I'm not tracking any PR from this channel.`; 11 | } 12 | 13 | if (params.length === 0) { 14 | return [ 15 | Message.blocks.create_markdown_section( 16 | `Here's all PRs listed on this channel:`, 17 | ), 18 | ].concat(await get_sectioned_pr_blocks(channel.prs)); 19 | } 20 | 21 | if (params === 'mine') { 22 | const prs = channel.prs.filter(pr => pr.poster_id === user_id); 23 | 24 | if (prs.length === 0) { 25 | return "You don't have any pull requests listed on this channel"; 26 | } 27 | 28 | return [ 29 | Message.blocks.create_markdown_section(`Here's all PRs owned by you:`), 30 | ].concat(await get_sectioned_pr_blocks(prs)); 31 | } 32 | 33 | const group_match = Message.match_group_mention(params); 34 | if (group_match) { 35 | const matched_group_id = group_match[1]; 36 | const members = new Set( 37 | DB.users.get(['groups', matched_group_id, 'users'], []).value(), 38 | ); 39 | const prs = channel.prs.filter(pr => members.has(pr.poster_id)); 40 | 41 | if (prs.length === 0) { 42 | return `${Message.get_group_mention( 43 | matched_group_id, 44 | )} don't have any pull requests listed on this channel`; 45 | } 46 | 47 | return [ 48 | Message.blocks.create_markdown_section( 49 | `Here's all PRs owned by ${Message.get_group_mention( 50 | matched_group_id, 51 | )}:`, 52 | ), 53 | ].concat(await get_sectioned_pr_blocks(prs)); 54 | } 55 | 56 | const user_match = Message.match_user_mention(params); 57 | if (user_match) { 58 | const matched_user_id = user_match[1]; 59 | const prs = channel.prs.filter(pr => pr.poster_id === matched_user_id); 60 | 61 | if (prs.length === 0) { 62 | return `${Message.get_user_mention( 63 | matched_user_id, 64 | )} don't have any pull requests listed on this channel`; 65 | } 66 | 67 | return [ 68 | Message.blocks.create_markdown_section( 69 | `Here's all PRs owned by ${Message.get_user_mention(matched_user_id)}:`, 70 | ), 71 | ].concat(await get_sectioned_pr_blocks(prs)); 72 | } 73 | 74 | return 'Invalid command parameters: `/pr list [ |mine|@user|@userGroup]`'; 75 | }; 76 | -------------------------------------------------------------------------------- /src/server/commands/roulette.js: -------------------------------------------------------------------------------- 1 | const get_random_item = require('../../includes/get_random_item.js'); 2 | const Message = require('../../includes/message.js'); 3 | const Logger = require('../../includes/logger.js'); 4 | const Slack = require('../../api/slack.js'); 5 | const DB = require('../../api/db.js'); 6 | 7 | const possible_emojis = [ 8 | 'worry-pls', 9 | 'eyesright', 10 | 'call_me_hand', 11 | 'mini-hangloose', 12 | 'awthanks', 13 | 'gun', 14 | 'pray', 15 | 'eyes-inside', 16 | 'pokebola', 17 | 'drake-yes', 18 | 'worry-mad', 19 | 'mutley_laugh', 20 | 'flushed-cross-eye-transparent', 21 | 'eyes', 22 | 'developers', 23 | 'bushes_uncertain', 24 | 'worry-glasses', 25 | 'harold-pain', 26 | 'this-is-fine-fire', 27 | 'doge2', 28 | 'worry-anime', 29 | ]; 30 | 31 | const wait = delay => new Promise(res => setTimeout(res, delay)); 32 | 33 | const get_member_list = async (channel, params) => { 34 | if (!params) { 35 | return Slack.get_channel_members(channel.id); 36 | } 37 | 38 | const group_match = Message.match_group_mention(params); 39 | if (group_match) { 40 | return Slack.get_user_group_members(group_match[1]); 41 | } 42 | 43 | const group_name = params; 44 | return DB.users 45 | .get('groups') 46 | .find({ handle: group_name }) 47 | .get('users', []) 48 | .value(); 49 | }; 50 | 51 | module.exports = async ({ channel, ts, thread_ts, user_id, params }) => { 52 | const pr = channel.prs.find(pr => pr.ts === thread_ts); 53 | 54 | if (pr == null) return; 55 | 56 | // await pr.reply(`roulette_${ts}`, `:think-360:`); 57 | await pr.reply(`roulette_${ts}`, `:kuchiyose:`); 58 | 59 | const [member_list] = await Promise.all([ 60 | get_member_list(channel, params), 61 | wait(1900), 62 | ]); 63 | 64 | const member_set = new Set(member_list); 65 | member_set.delete(user_id); 66 | member_set.delete(pr.poster_id); 67 | 68 | let chosen_member; 69 | let retry_count = -1; 70 | 71 | // await pr.reply(`roulette_${ts}`, `:thinking-face-fast:`); 72 | 73 | do { 74 | if (retry_count++ >= 20 || member_set.size === 0) { 75 | Logger.error( 76 | { channel, ts, thread_ts }, 77 | 'Max members shuffling attempts reached', 78 | ); 79 | chosen_member = null; 80 | break; 81 | } 82 | 83 | chosen_member = DB.users 84 | .get(['members', get_random_item(member_set)]) 85 | .value(); 86 | 87 | Logger.log(`Roulette: ${JSON.stringify(chosen_member)}`); 88 | 89 | // do not mention people on vacation 90 | if ( 91 | chosen_member && 92 | chosen_member.status_text.match(/vacation|f[ée]rias/gi) 93 | ) { 94 | member_set.delete(chosen_member.id); 95 | chosen_member = null; 96 | } 97 | } while (!chosen_member); 98 | 99 | await pr.reply(`roulette_${ts}`, `:kuchiyose_smoke:`); 100 | await wait(250); 101 | 102 | const text = chosen_member 103 | ? `:${get_random_item(possible_emojis)}: ${Message.get_user_mention( 104 | chosen_member.id, 105 | )}` 106 | : `For some reason I couldn't choose a random channel member... :sob:`; 107 | 108 | await pr.reply(`roulette_${ts}`, text, chosen_member); 109 | channel.save_pr(pr); 110 | }; 111 | -------------------------------------------------------------------------------- /src/server/github_webhook.js: -------------------------------------------------------------------------------- 1 | const DB = require('../api/db.js'); 2 | const runtime = require('../runtime.js'); 3 | const Logger = require('../includes/logger.js'); 4 | 5 | const handle_channel_not_found = e => { 6 | Logger.warn(`Deleting channel: ${e.channel_id} - ${e.channel_name}`); 7 | runtime.delete_channel(e.channel_id); 8 | }; 9 | 10 | const update_prs = async prs => { 11 | if (prs.length === 0) return; 12 | 13 | let error; 14 | 15 | await Promise.all( 16 | prs.map(pr => { 17 | const channel = runtime.get_channel(pr.channel); 18 | return pr 19 | .update() 20 | .then(pr => channel && channel.on_pr_updated(pr)) 21 | .catch(e => { 22 | error = e; 23 | }); 24 | }), 25 | ); 26 | 27 | if (error) { 28 | if (error.error === 'channel_not_found') { 29 | return handle_channel_not_found(error); 30 | } 31 | } 32 | }; 33 | 34 | function on_installation({ req }) { 35 | const { 36 | installation, 37 | repositories_removed: removed, 38 | repositories_added: added, 39 | } = req.body; 40 | 41 | if (removed) { 42 | removed.forEach(repo => DB.installations.unset_id(repo.full_name)); 43 | } 44 | 45 | if (added) { 46 | added.forEach(repo => 47 | DB.installations.set_id(repo.full_name, installation.id), 48 | ); 49 | const added_set = new Set(added.map(repo => repo.full_name)); 50 | const related_prs = runtime.prs.filter(pr => 51 | added_set.has(`${pr.owner}/${pr.repo}`), 52 | ); 53 | 54 | return update_prs(related_prs); 55 | } 56 | return; 57 | } 58 | 59 | async function on_pull_request_change({ event, req }) { 60 | const { action, repository } = req.body; 61 | let pull_request = req.body.pull_request; 62 | if (event === 'check_suite') { 63 | pull_request = req.body.check_suite.pull_requests[0]; 64 | } 65 | 66 | if (!pull_request) { 67 | // Logger.warn(`Couldn't find pull request for "${event}/${action}"`); 68 | return; 69 | } 70 | 71 | const pr_slug = `${repository.full_name}/${pull_request.number}`; 72 | Logger.success(`Triggered "${event}/${action}" on "${pr_slug}"`); 73 | 74 | const prs = runtime.prs.filter(pr => pr.slug === pr_slug); 75 | if (prs.length === 0) return; 76 | 77 | return update_prs(prs); 78 | } 79 | 80 | async function on_push({ req }) { 81 | const { ref, repository } = req.body; 82 | const branch = ref.split('/').pop(); 83 | 84 | const related_prs = runtime.prs.filter( 85 | pr => 86 | pr.repo === repository.name && 87 | pr.owner === repository.owner.name && 88 | pr.state.base_branch === branch, 89 | ); 90 | 91 | if (related_prs.length) { 92 | Logger.success( 93 | `Triggered "push" on "${repository.owner.name}/${ 94 | repository.name 95 | }": ${related_prs.map(pr => pr.pr_id).join(', ')}`, 96 | ); 97 | } 98 | 99 | update_prs(related_prs); 100 | } 101 | 102 | exports.parse_github_webhook = async (req, res) => { 103 | const event = req.headers['x-github-event']; 104 | const { action } = req.body; 105 | 106 | res.statusCode = 200; 107 | res.end('ok'); 108 | 109 | if (event === 'installation_repositories') { 110 | return on_installation({ event, req, res }); 111 | } 112 | 113 | if ( 114 | event === 'pull_request' || 115 | event === 'pull_request_review' || 116 | event === 'pull_request_review_comment' || 117 | event === 'check_suite' 118 | ) { 119 | return on_pull_request_change({ event, req, res }); 120 | } 121 | 122 | if (event === 'push') { 123 | return on_push({ event, req, res }); 124 | } 125 | 126 | // if (process.env.NODE_ENV !== 'production') { 127 | Logger.warn(`Ignoring event: "${event}/${action}"`); 128 | // } 129 | }; 130 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | const polka = require('polka'); 2 | const { urlencoded, json } = require('body-parser'); 3 | 4 | const { parse_github_webhook } = require('./github_webhook.js'); 5 | const { parse_slack_command } = require('./slack_command.js'); 6 | const { parse_slack_event } = require('./slack_event.js'); 7 | 8 | const Logger = require('../includes/logger.js'); 9 | 10 | exports.start = () => { 11 | polka() 12 | .use(urlencoded({ extended: true })) 13 | .use(json()) 14 | .post('/github/webhooks', parse_github_webhook) 15 | .post('/slack/command', parse_slack_command) 16 | .post('/slack/event', parse_slack_event) 17 | .listen(12345, err => { 18 | if (err) throw err; 19 | Logger.info(`Server running on 12345`); 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /src/server/slack_command.js: -------------------------------------------------------------------------------- 1 | const send = require('@polka/send-type'); 2 | 3 | const Logger = require('../includes/logger.js'); 4 | 5 | exports.parse_slack_command = async (req, res) => { 6 | const { text, channel_id, response_url, user_id } = req.body; 7 | const [command, ...params] = text.split(' '); 8 | 9 | if (!command) { 10 | res.end('Please type `/pr help` to see available commands'); 11 | return; 12 | } 13 | 14 | const command_obj = { 15 | text, 16 | channel_id, 17 | response_url, 18 | user_id, 19 | command, 20 | params: params.join(' '), 21 | }; 22 | 23 | let response_data; 24 | try { 25 | response_data = await require(`./commands/${command}.js`)(command_obj); 26 | if (typeof response_data !== 'string') { 27 | response_data = { 28 | blocks: response_data, 29 | }; 30 | } 31 | } catch (e) { 32 | response_data = `No command \`${text}\`.\n\nPlease type \`/pr help\` to see available commands.`; 33 | Logger.error(e, 'Slack command response'); 34 | } 35 | 36 | send(res, 200, response_data); 37 | }; 38 | -------------------------------------------------------------------------------- /src/server/slack_event.js: -------------------------------------------------------------------------------- 1 | const send = require('@polka/send-type'); 2 | 3 | const runtime = require('../runtime.js'); 4 | const roulette = require(`./commands/roulette.js`); 5 | 6 | exports.parse_slack_event = async (req, res) => { 7 | const { type: request_type } = req.body; 8 | 9 | if (request_type === 'url_verification') { 10 | const { challenge } = req.body; 11 | return send(res, 200, challenge); 12 | } 13 | 14 | if (request_type === 'event_callback') { 15 | const { 16 | // authed_users: [bot_id], 17 | event, 18 | } = req.body; 19 | 20 | if (event.type === 'app_mention') { 21 | const { ts, thread_ts, channel: channel_id, user: user_id, text } = event; 22 | const channel = runtime.get_channel(channel_id); 23 | 24 | if (!channel || !thread_ts) return; 25 | 26 | const match = text.match(/(?:roulette|random)(?: +(.*)$)?/); 27 | if (match) { 28 | roulette({ 29 | channel, 30 | ts, 31 | thread_ts, 32 | user_id, 33 | params: match && match[1], 34 | }); 35 | } 36 | } 37 | } 38 | send(res, 200); 39 | }; 40 | --------------------------------------------------------------------------------