├── .editorconfig ├── .env.example ├── .eslintrc ├── .gitignore ├── .prettierrc ├── .travis.yml ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── app.json ├── index.js ├── jest.config.js ├── lib ├── config.js ├── defaults.js ├── index.js ├── mailer.js ├── report.js ├── reporter.js ├── slack.js └── utils.js ├── package.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | 9 | [*.{js,json,jsx,css,scss,less,yml}] 10 | indent_style = space 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Configuration of your GitHub App 2 | APP_ID= 3 | WEBHOOK_SECRET=development 4 | 5 | # Uncomment this to get verbose logging 6 | # LOG_LEVEL=trace # or `info` to show less 7 | 8 | # Subdomain to use for localtunnel server. Defaults to your local username. 9 | # SUBDOMAIN= 10 | 11 | # Repository to use for storing configs of your account. Defaults to "probot-settings" 12 | # SETTINGS_REPO= 13 | 14 | # Disables all side effects 15 | DRY_RUN=true 16 | 17 | # Slack integration 18 | # SLACK_TOKEN= 19 | 20 | # E-Mail integration 21 | # SENDGRID_TOKEN= 22 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | npm-debug.log 4 | *.pem 5 | .env 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: yarn 3 | 4 | git: 5 | depth: 1 6 | 7 | node_js: 8 | - "node" 9 | - "10" 10 | - "8" 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint", 6 | "EditorConfig.editorconfig", 7 | "esbenp.prettier-vscode", 8 | "Orta.vscode-jest" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // General Settings 3 | "editor.formatOnType": true, 4 | "editor.formatOnPaste": true, 5 | "editor.formatOnSave": true, 6 | "editor.rulers": [80], 7 | "files.autoSave": "onWindowChange", 8 | "files.trimTrailingWhitespace": true, 9 | "files.insertFinalNewline": true, 10 | 11 | // Plugin Settings 12 | "eslint.autoFixOnSave": true 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Sentry (https://sentry.io/) and individual contributors. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Probot: Report 2 | 3 | > a GitHub App built with [probot](https://github.com/probot/probot) that sends 4 | > out periodic reports 5 | 6 | ![](https://user-images.githubusercontent.com/1433023/32178159-57580bd0-bd8c-11e7-9dfd-995ff69d446b.png) 7 | 8 | **Disclaimer: This report Bot is heavily focused on our setup, so in order to 9 | use it you probably need to fork it or help us make it more general purpose.** 10 | 11 | ## Usage 12 | 13 | The bot is activated by the file `.github/report.yml` in the settings 14 | repository. If not configured otherwise, this is the `probot-settings` 15 | repository in your organization. 16 | 17 | The file can be empty, or it can override any of these default settings: 18 | 19 | ```yaml 20 | # Local times at which the bot will send automatic reports 21 | reportTimes: 22 | - 09:00 23 | - 12:30 24 | # Timezone offset for all users where the timezone cannot be inferred 25 | # Defaults to PDT (UTC-07:00) 26 | defaultTimezone: -420 27 | # Maximum number of days to report new issues 28 | newIssueDays: 7 29 | # Ignores all issues that match this regular expression in their title 30 | ignoreRegex: "\\bwip\\b" 31 | # Ignores all issues with these lables 32 | ignoreLabels: 33 | - duplicate 34 | - wontfix 35 | - invalid 36 | # Mailer configuration, can be omitted to disable email 37 | email: 38 | # Name of the email sender 39 | sender: '"🤖 Eos - Github Bot" ' 40 | # E-Mail subject 41 | subject: "Github needs your attention" 42 | # Template for the entire email body 43 | bodyTemplate: > 44 | Hi <%- user.name %>, 45 | <%= toReview %> 46 | <%= toComplete %> 47 | <%= newIssues %> 48 | # Template to render a single issue 49 | issueTemplate: > 50 | <% _.forEach(issues, function (issue) { %> 51 |
  • 52 | 53 | <%- issue.repository_url.match('[^/]+/[^/]+$')[0] %>#<%- issue.number %> 54 | : 55 | <%- issue.title %>
    56 | 57 | opened <%- moment(issue.created_at).fromNow() %>, 58 | updated <%- moment(issue.updated_at).fromNow() %> 59 | by <%- issue.user.login %> 60 | 61 |
  • 62 | <% }) %> 63 | # Template to format the section of PRs to review 64 | toReviewTemplate: > 65 |

    These pull requests need to be reviewed:

    66 | 69 | # Template to format the section of PRs to complete 70 | toCompleteTemplate: > 71 |

    These pull requests need to be handled:

    72 | 75 | # Template to format the section of new issues 76 | newIssuesTemplate: > 77 |

    There are issues you could label and assign:

    78 | 81 | ``` 82 | 83 | ## Setup 84 | 85 | This Probot app requires authentication tokens and credentials for third party 86 | apps in environment variables. The project contains a template for environment 87 | variables located at `.env.example`. Copy this file to `.env` in the project 88 | root and adjust all environment variables. 89 | 90 | ### Github App 91 | 92 | First, create a GitHub App by following the instructions 93 | [here](https://probot.github.io/docs/deployment/#create-the-github-app). Then, 94 | make sure to download the private key and place it in the root directory of this 95 | application or set it via the `PRIVATE_KEY` environment variable. Finally, set 96 | the following environment variables: 97 | 98 | | Name | Description | 99 | | ---------------- | ---------------------------------------------------- | 100 | | `APP_ID` | Unique ID of the GitHub App | 101 | | `WEBHOOK_SECRET` | Random webhook secret configured during app creation | 102 | | `SETTINGS_REPO` | **optional**. Repository to store configs in. | 103 | 104 | Within your organization, create a repository to store the configuration file. 105 | If not configured otherwise, it defaults to `probot-settings`. 106 | 107 | ### Sendgrid mailing 108 | 109 | The bot can send report emails to all organization members with configured email 110 | addresses (defaulting to their public email address). This requires a 111 | [Sendgrid](https://sendgrid.com/) account. Once created, configure the API token 112 | as `SENDGRID_TOKEN` environment variable. 113 | 114 | Leave this value empty to skip report emails. 115 | 116 | ### Slack 117 | 118 | The bot can connect to a Slack team and send summaries there. To do so, it needs 119 | to be registered as Slack bot. Once it has been created, configure its token in 120 | the `SLACK_TOKEN` environment variable. 121 | 122 | Leave this value empty to skip connection to Slack. 123 | 124 | ### Development 125 | 126 | To start the development server, make sure the following environment variables 127 | are set: 128 | 129 | | Name | Description | 130 | | ----------- | ------------------------------------------------- | 131 | | `DRY_RUN` | Disables actual releases. Set to `true` | 132 | | `SUBDOMAIN` | Subdomain for localtunnel to receive webhooks | 133 | | `LOG_LEVEL` | Sets the loggers output verbosity. Set to `debug` | 134 | 135 | Then, install dependencies and run the bot with: 136 | 137 | ```sh 138 | # Install dependencies 139 | yarn 140 | 141 | # Run the bot 142 | yarn start 143 | 144 | # Run test watchers 145 | yarn test:watch 146 | ``` 147 | 148 | We highly recommend to use VSCode and install the recommended extensions. They 149 | will configure your IDE to match the coding style, invoke auto formatters every 150 | time you save and run tests in the background for you. No need to run the 151 | watchers manually. 152 | 153 | ### Testing 154 | 155 | The bot includes an automated test suite that includes unit tests, linting and 156 | formating checks. Additionally, this command generates a coverage report in 157 | `coverage/`. You can run it with npm: 158 | 159 | ```sh 160 | yarn test 161 | ``` 162 | 163 | We use [prettier](https://prettier.io/) for auto-formatting and 164 | [eslint](https://eslint.org/) as linter. Both tools can automatically fix most 165 | issues for you. To invoke them, simply run: 166 | 167 | ```sh 168 | yarn fix 169 | ``` 170 | 171 | ## Deployment 172 | 173 | If you would like to run your own instance of this app, see the 174 | [docs for deployment](https://probot.github.io/docs/deployment/). 175 | 176 | This app requires these **Permissions** for the GitHub App: 177 | 178 | * **Repository contents**: Read & write 179 | * **Organization members**: Read-only 180 | 181 | Also, the following **Events** need to be subscribed: 182 | 183 | * **Push**: Git push to a repository 184 | * **Membership**: Team membership added or removed 185 | 186 | Also, make sure all required environment variables are present in the production 187 | environment. 188 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "PRIVATE_KEY": { 4 | "description": "the private key you downloaded when creating the GitHub App" 5 | }, 6 | "APP_ID": { 7 | "description": "the ID of your GitHub App" 8 | }, 9 | "WEBHOOK_SECRET": { 10 | "description": "the secret configured for your GitHub App" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib'); 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | testEnvironment: 'node', 4 | }; 5 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | const { isDryRun } = require('dryrun'); 2 | const _ = require('lodash'); 3 | const yaml = require('js-yaml'); 4 | const defaults = require('./defaults'); 5 | 6 | /** 7 | * Default repository to look for organization-wide settings. 8 | * Can be overridden with the SETTINGS_REPO environment variable. 9 | */ 10 | const DEFAULT_SETTINGS_REPO = 'probot-settings'; 11 | 12 | /** 13 | * Default path of the config file within the settings repo. 14 | * Can be overridden with the SETTINGS_PATH environment variable. 15 | */ 16 | const DEFAULT_SETTINGS_PATH = '.github/report.yml'; 17 | 18 | /** 19 | * Delay before writing the config to the settings repo 20 | */ 21 | const WRITE_DELAY = 10000; 22 | 23 | module.exports = class Config { 24 | constructor(robot, installation) { 25 | this.robot = robot; 26 | this.installation = installation; 27 | this.logger = robot.log; 28 | this.data = null; 29 | this.sha = null; 30 | 31 | this.writeDebounced = _.debounce(this.write, WRITE_DELAY); 32 | } 33 | 34 | getContext() { 35 | return { 36 | owner: this.installation.account.login, 37 | repo: process.env.SETTINGS_REPO || DEFAULT_SETTINGS_REPO, 38 | path: process.env.SETTINGS_PATH || DEFAULT_SETTINGS_PATH, 39 | }; 40 | } 41 | 42 | get() { 43 | if (!this.data) { 44 | throw new Error('Config not loaded'); 45 | } 46 | 47 | return { ...this.data }; 48 | } 49 | 50 | getGithub() { 51 | return this.robot.auth(this.installation.id); 52 | } 53 | 54 | async loadChanges(context) { 55 | if (this.getContext().repo === context.repo().repo) { 56 | await this.load(); 57 | return true; 58 | } 59 | 60 | return false; 61 | } 62 | 63 | async load() { 64 | const context = this.getContext(); 65 | const { owner, repo, path } = context; 66 | this.logger.info(`Loading config from ${owner}/${repo}:${path}`); 67 | 68 | try { 69 | const github = await this.getGithub(); 70 | const result = await github.repos.getContent(context); 71 | const config = yaml.safeLoad( 72 | Buffer.from(result.data.content, 'base64').toString() 73 | ); 74 | 75 | this.data = { ...defaults, ...config }; 76 | this.sha = result.data.sha; 77 | } catch (err) { 78 | this.logger.error(`Could not read ${owner}/${repo}:${path}`, err); 79 | this.data = { ...defaults }; 80 | this.sha = null; 81 | } 82 | 83 | return this; 84 | } 85 | 86 | async write() { 87 | if (!this.data) { 88 | throw new Error('Config not loaded'); 89 | } 90 | 91 | const context = this.getContext(); 92 | const { owner, repo, path } = context; 93 | this.logger.info(`Persisting config to ${owner}/${repo}:${path}`); 94 | 95 | if (isDryRun()) { 96 | this.logger.debug('Config write skipped due to dry run'); 97 | return; 98 | } 99 | 100 | try { 101 | const data = yaml.safeDump(this.data, { 102 | styles: { '!!null': 'canonical' }, 103 | sortKeys: true, 104 | }); 105 | 106 | const params = { 107 | ...context, 108 | message: 'meta: Update config', 109 | content: Buffer.from(data).toString('base64'), 110 | sha: this.sha, 111 | }; 112 | 113 | const github = await this.getGithub(); 114 | const response = this.sha 115 | ? await github.repos.updateFile(params) 116 | : await github.repos.createFile(params); 117 | 118 | this.original = this.data; 119 | this.sha = response.data.content.sha; 120 | } catch (err) { 121 | this.logger.error(`Could not write to ${owner}/${repo}:${path}`, err); 122 | } 123 | } 124 | 125 | save() { 126 | if (!this.data) { 127 | throw new Error('Config not loaded'); 128 | } 129 | 130 | this.writeDebounced(); 131 | return this; 132 | } 133 | 134 | mergeIn(path, data) { 135 | if (!this.data) { 136 | throw new Error('Config not loaded'); 137 | } 138 | 139 | if (path.length === 0) { 140 | return this.merge(data); 141 | } 142 | 143 | const keys = _.keys(data).join(','); 144 | this.logger.debug(`Merging keys {${keys}} into config.${path.join('.')}`); 145 | const nested = _.get(this.data, path); 146 | _.set(this.data, path, { ...nested, ...data }); 147 | return this.save(); 148 | } 149 | 150 | merge(data) { 151 | if (!this.data) { 152 | throw new Error('Config not loaded'); 153 | } 154 | 155 | this.logger.debug(`Merging keys {${_.keys(data).join(',')}} into config`); 156 | this.data = { ...this.data, ...data }; 157 | return this.save(); 158 | } 159 | 160 | mergeUser(id, data) { 161 | return this.mergeIn(['users', id], data); 162 | } 163 | }; 164 | -------------------------------------------------------------------------------- /lib/defaults.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reportTimes: ['09:00', '12:30'], 3 | defaultTimezone: -420, 4 | newIssueDays: 7, 5 | ignoreRegex: '\\bwip\\b', 6 | ignoreLabels: ['duplicate', 'wontfix', 'invalid'], 7 | email: { 8 | sender: '"🤖 Eos - Github Bot" ', 9 | subject: 'Github needs your attention', 10 | issueTemplate: `<% _.forEach(issues, function (issue) { %> 11 |
  • 12 | 13 | <%- issue.repository_url.match('[^/]+/[^/]+$')[0] %>#<%- issue.number %> 14 | : 15 | <%- issue.title %>
    16 | 17 | opened <%- moment(issue.created_at).fromNow() %>, 18 | updated <%- moment(issue.updated_at).fromNow() %> 19 | by <%- issue.user.login %> 20 | 21 |
  • 22 | <% }) %>`, 23 | toReviewTemplate: `

    These pull requests need to be reviewed:

    24 | `, 27 | toCompleteTemplate: `

    These pull requests need to be handled:

    28 | `, 31 | newIssuesTemplate: `

    There are issues you could label and assign:

    32 | `, 35 | bodyTemplate: `Hi <%- user.name %>, 36 | <%= toReview %> 37 | <%= toComplete %> 38 | <%= newIssues %>`, 39 | }, 40 | users: {}, 41 | }; 42 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const Reporter = require('./reporter'); 2 | const Config = require('./config'); 3 | const Mailer = require('./mailer'); 4 | const Slack = require('./slack'); 5 | 6 | /** 7 | * Global registry of instances for each installation. 8 | */ 9 | const instances = {}; 10 | 11 | /** 12 | * Logger obtained from probot. 13 | */ 14 | let logger; 15 | 16 | function sendReport(instance, report) { 17 | const user = report.getUser(); 18 | logger.info(`Sending scheduled report to "${user.login}"`); 19 | logger.debug(`Report: ${report}`); 20 | 21 | if (user.email) { 22 | instance.mailer.sendReport(report); 23 | } 24 | 25 | if (user.slack && user.slack.active) { 26 | instance.slack.sendReport(report); 27 | } else { 28 | const message = user.slack ? 'No slack configuration' : 'Slack disabled'; 29 | logger.debug(`${message} for user "${user.login}"`); 30 | } 31 | } 32 | 33 | async function requestMail(instance, user) { 34 | logger.info(`Sending email report to "${user.login}"`); 35 | const report = await instance.reporter.getReportForUser(user); 36 | instance.mailer.sendReport(report); 37 | } 38 | 39 | async function requestReport(instance, user) { 40 | logger.info(`Sending Slack report to "${user.login}"`); 41 | const report = await instance.reporter.getReportForUser(user); 42 | instance.slack.sendReport(report); 43 | } 44 | 45 | function getReporter(context) { 46 | const { owner } = context.repo(); 47 | return instances[owner].reporter; 48 | } 49 | 50 | async function addReporter(robot, installation) { 51 | const id = installation.account.login; 52 | logger.info(`Adding reporter for account "${id}"`); 53 | 54 | if (instances[id] == null) { 55 | const config = await new Config(robot, installation).load(); 56 | const mailer = new Mailer(config, logger); 57 | const reporter = new Reporter(robot, installation, config).onReport( 58 | (user, report) => sendReport(instances[id], report) 59 | ); 60 | const slack = new Slack(config, logger) 61 | .onRequestMail(user => requestMail(instances[id], user)) 62 | .onRequestReport(user => requestReport(instances[id], user)); 63 | instances[id] = { config, mailer, reporter, slack }; 64 | } else { 65 | logger.warn(`Reporter for account "${id}" had already been added`); 66 | } 67 | } 68 | 69 | function removeReporter(installation) { 70 | const id = installation.account.login; 71 | logger.info(`Removing reporter instance for account "${id}"`); 72 | 73 | const instance = instances[id]; 74 | if (instance) { 75 | instance.reporter.teardown(); 76 | instance.slack.teardown(); 77 | delete instances[id]; 78 | } else { 79 | logger.warn(`There is no reporter instance for account "${id}"`); 80 | } 81 | } 82 | 83 | async function setupRobot(robot) { 84 | logger = robot.log; 85 | logger.info('Report plugin starting up'); 86 | const github = await robot.auth(); 87 | 88 | github.paginate(github.apps.getInstallations({ per_page: 100 }), result => { 89 | logger.debug(`Initializing ${result.data.length} installations...`); 90 | result.data.forEach(installation => addReporter(robot, installation)); 91 | }); 92 | 93 | robot.on('installation.created', context => { 94 | addReporter(robot, context.payload.installation); 95 | }); 96 | 97 | robot.on('installation.deleted', context => { 98 | removeReporter(context.payload.installation); 99 | }); 100 | 101 | robot.on('member.added', context => { 102 | const reporter = getReporter(context); 103 | if (reporter) { 104 | reporter.addUser(context.github, context.payload.member); 105 | } 106 | }); 107 | 108 | robot.on('member.removed', context => { 109 | const reporter = getReporter(context); 110 | if (reporter) { 111 | reporter.removeUser(context.github, context.payload.member); 112 | } 113 | }); 114 | 115 | robot.on('push', async context => { 116 | const reporter = getReporter(context); 117 | if (reporter && (await reporter.config.loadChanges(context))) { 118 | reporter.reloadUsers(); 119 | } 120 | }); 121 | } 122 | 123 | module.exports = setupRobot; 124 | -------------------------------------------------------------------------------- /lib/mailer.js: -------------------------------------------------------------------------------- 1 | const { shouldPerform } = require('dryrun'); 2 | const _ = require('lodash'); 3 | const moment = require('moment'); 4 | const NodeMailer = require('nodemailer'); 5 | const sendgridTransport = require('nodemailer-sendgrid-transport'); 6 | 7 | /** 8 | * The singleton mailer instance 9 | */ 10 | let mailer = null; 11 | 12 | /** 13 | * Tries to create a singleton mailer instance 14 | * 15 | * @param {object} logger A logger instance 16 | * @returns {object} The mailer or null 17 | */ 18 | function getMailer(logger = console) { 19 | if (mailer != null) { 20 | return mailer; 21 | } 22 | 23 | const token = process.env.SENDGRID_TOKEN; 24 | if (!token) { 25 | logger.error('Could not configure mailer: Token missing'); 26 | return null; 27 | } 28 | 29 | try { 30 | // TODO: Allow for other transports than sendgrid 31 | logger.info('Initializing Sendgrid mailer'); 32 | mailer = NodeMailer.createTransport( 33 | sendgridTransport({ auth: { api_key: token } }) 34 | ); 35 | return mailer; 36 | } catch (e) { 37 | logger.error('Could not initialize Sendgrid', e); 38 | mailer = false; 39 | return mailer; 40 | } 41 | } 42 | 43 | /** 44 | * Compiles the given template string 45 | * 46 | * Creates a compiled template function that can interpolate data properties 47 | * in "interpolate" delimiters, HTML-escape interpolated data properties in 48 | * "escape" delimiters, and execute JavaScript in "evaluate" delimiters. Data 49 | * properties may be accessed as free variables in the template. 50 | * 51 | * @param {string} string 52 | */ 53 | function compile(string) { 54 | return _.template(string, { imports: { moment } }); 55 | } 56 | 57 | /** 58 | * A configurable e-mail service 59 | */ 60 | module.exports = class Mailer { 61 | /** 62 | * Creates a new mailer 63 | * 64 | * @param {object} config A mailer configuration object 65 | */ 66 | constructor(config, logger = console) { 67 | this.config = config; 68 | this.logger = logger; 69 | this.templates = this.compileTemplates(); 70 | this.internal = getMailer(logger); 71 | } 72 | 73 | /** 74 | * Compiles all "Template" keys into template functions 75 | * 76 | * The resulting object only contains all template keys mapped to a function 77 | * that accepts parameters and returns a formatted string containing the 78 | * passed parameters. See lodash's template function for more information. 79 | * 80 | * @private 81 | * @returns {object} The compiled templates 82 | */ 83 | compileTemplates() { 84 | const { email } = this.config.get(); 85 | const templates = _.pickBy(email, (i, key) => /Template$/.test(key)); 86 | return _.mapValues(templates, compile); 87 | } 88 | 89 | /** 90 | * Sends an email via the mailer 91 | * 92 | * If no mailer is available (due to authentication errors or a missing token) 93 | * no email is sent. In dry run the email data is logged but not actually sent 94 | * out via the transport. 95 | * 96 | * Mail data must contain a sender ("from"), recipient ("to"), subject and 97 | * body ("html"). All additional data is disregarded. 98 | * 99 | * @param {object} data Mail data to send 100 | * @returns {Promise} A promise that resolves after the mail has been sent 101 | */ 102 | sendMail(data) { 103 | this.logger.debug('Sending email', data); 104 | 105 | if (this.internal && shouldPerform()) { 106 | return new Promise((resolve, reject) => { 107 | this.internal.sendMail(data, e => (e ? reject(e) : resolve())); 108 | }); 109 | } 110 | 111 | this.logger.debug('Skipping email, no mailer configured'); 112 | return Promise.resolve(); 113 | } 114 | 115 | /** 116 | * Formats a list of issues with the default issue template and wraps them in 117 | * the given template. If the issues are empty, an empty string is returned. 118 | * 119 | * This method also checks for the presence of required template functions. If 120 | * absent, an error is logged, but still an empty string returned. 121 | * 122 | * @param {Function} template A template to wrap issues with 123 | * @param {object[]} issues The list of issue to format 124 | */ 125 | formatIssues(template, issues) { 126 | const { issueTemplate } = this.templates; 127 | if (!template || !this.templates) { 128 | this.logger.error('Could not format issues, templates missing'); 129 | return ''; 130 | } 131 | 132 | if (issues.length === 0) { 133 | return ''; 134 | } 135 | 136 | return template({ issues: issueTemplate({ issues }) }); 137 | } 138 | 139 | /** 140 | * Sends a report mail with the given pull requests to the specified user 141 | * 142 | * The email will be formatted to the "bodyTemplate" configuration parameter. 143 | * User and pull requests are interpolated directly into that template. 144 | * 145 | * @param {object} user A user object 146 | * @param {object[]} pullRequests A list of pull requests 147 | */ 148 | async sendReport(report) { 149 | const user = report.getUser(); 150 | if (user.email == null) { 151 | this.logger.error(`Cannot send email to ${user.login}: No email found`); 152 | return; 153 | } 154 | 155 | const from = this.config.get().email.sender; 156 | const to = `"${user.name}" <${user.email}>`; 157 | const { subject } = this.config.get().email; 158 | 159 | const html = this.templates.bodyTemplate({ 160 | user, 161 | toReview: this.formatIssues( 162 | this.templates.toReviewTemplate, 163 | report.getPullRequestsToReview() 164 | ), 165 | toComplete: this.formatIssues( 166 | this.templates.toCompleteTemplate, 167 | report.getPullRequestsToComplete() 168 | ), 169 | newIssues: this.formatIssues( 170 | this.templates.newIssuesTemplate, 171 | report.getNewIssues() 172 | ), 173 | }); 174 | 175 | await this.sendMail({ from, to, subject, html }); 176 | } 177 | }; 178 | -------------------------------------------------------------------------------- /lib/report.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | const KEY_PR_REVIEW = 'pullRequestsToReview'; 4 | const KEY_PR_COMPLETE = 'pullRequestsToComplete'; 5 | const KEY_NEW_ISSUES = 'newIssues'; 6 | 7 | module.exports = class Report { 8 | constructor(user) { 9 | this.user = user; 10 | this.data = {}; 11 | } 12 | 13 | sort(list, by) { 14 | const sorted = _.sortBy(list, by); 15 | return this.user.order === 'desc' ? sorted.reverse() : sorted; 16 | } 17 | 18 | hasData() { 19 | return _.some(this.data, list => list.length > 0); 20 | } 21 | 22 | isEmpty() { 23 | return !this.hasData(); 24 | } 25 | 26 | count() { 27 | return _.reduce(this.data, (sum, list) => sum + list.length, 0); 28 | } 29 | 30 | addPullRequestsToReview(pullRequests) { 31 | this.data[KEY_PR_REVIEW] = [ 32 | ...(this.data[KEY_PR_REVIEW] || []), 33 | ...pullRequests, 34 | ]; 35 | return this; 36 | } 37 | 38 | addPullRequestsToComplete(pullRequests) { 39 | this.data[KEY_PR_COMPLETE] = [ 40 | ...(this.data[KEY_PR_COMPLETE] || []), 41 | ...pullRequests, 42 | ]; 43 | return this; 44 | } 45 | 46 | addNewIssues(issues) { 47 | this.data[KEY_NEW_ISSUES] = [ 48 | ...(this.data[KEY_NEW_ISSUES] || []), 49 | ...issues, 50 | ]; 51 | return this; 52 | } 53 | 54 | getUser() { 55 | return this.user; 56 | } 57 | 58 | getPullRequestsToReview() { 59 | return this.sort(this.data[KEY_PR_REVIEW] || [], pr => pr.created_at); 60 | } 61 | 62 | getPullRequestsToComplete() { 63 | return this.sort(this.data[KEY_PR_COMPLETE] || [], pr => pr.created_at); 64 | } 65 | 66 | // We currently only return 5 random issues, since this potentially can become huge 67 | getNewIssues() { 68 | return this.sort( 69 | _.sampleSize(this.data[KEY_NEW_ISSUES], 5) || [], 70 | issue => issue.created_at 71 | ); 72 | } 73 | }; 74 | -------------------------------------------------------------------------------- /lib/reporter.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | const _ = require('lodash'); 3 | const moment = require('moment'); 4 | const schedule = require('node-schedule'); 5 | const Report = require('./report'); 6 | const { filterAsync, rateLimit } = require('./utils'); 7 | 8 | /** 9 | * Installation target type: User 10 | */ 11 | const TYPE_USER = 'User'; 12 | 13 | /** 14 | * Installation target type: Organization 15 | */ 16 | const TYPE_ORGANIZATION = 'Organization'; 17 | 18 | /** 19 | * Event emitted when generating reports 20 | */ 21 | const EVENT_REPORT = 'report'; 22 | 23 | /** 24 | * Allowed search requests per minute 25 | * TODO: Retrieve this value via GitHub API 26 | */ 27 | const SEARCH_RATE = 25; 28 | 29 | /** 30 | * Cache duration for issues in minutes 31 | */ 32 | const CACHE_ISSUES = 10; 33 | 34 | /** 35 | * Cache duration for watchers in minutes 36 | */ 37 | const CACHE_WATCHERS = 1440; // 1 day 38 | 39 | module.exports = class Reporter { 40 | constructor(robot, installation, config) { 41 | this.robot = robot; 42 | this.installation = installation; 43 | this.config = config; 44 | this.logger = robot.log; 45 | 46 | this.emitter = new EventEmitter(); 47 | this.loadedUsers = {}; 48 | this.jobs = {}; 49 | this.watchers = {}; 50 | 51 | this.searchIssuesRateLimited = rateLimit( 52 | this.searchIssues, 53 | 1000 * (60 / SEARCH_RATE) 54 | ); 55 | 56 | this.setupUsers(); 57 | } 58 | 59 | onReport(callback) { 60 | this.emitter.on(EVENT_REPORT, callback); 61 | return this; 62 | } 63 | 64 | getGithub() { 65 | return this.robot.auth(this.installation.id); 66 | } 67 | 68 | async getDetailsFor(github, user) { 69 | this.logger.debug(`Loading details for user "${user.login}"`); 70 | const response = await github.users.getById({ id: user.id }); 71 | return response.data; 72 | } 73 | 74 | async getLastCommitForUser(github, user) { 75 | const login = user.login.toLowerCase(); 76 | 77 | const params = { 78 | q: `committer:${login}`, 79 | sort: 'committer-date', 80 | order: 'desc', 81 | per_page: 1, 82 | }; 83 | 84 | this.logger.debug(`Loading last commit for user "${login}"`); 85 | const response = await github.search.commits(params); 86 | const item = response.data.items[0]; 87 | return item && item.commit; 88 | } 89 | 90 | async getTimeZoneForUser(github, user) { 91 | const commit = await this.getLastCommitForUser(github, user); 92 | if (!commit) { 93 | this.logger.debug( 94 | `Did not find commits for user "${user.login}, using default timezone` 95 | ); 96 | return this.config.get().defaultTimezone; 97 | } 98 | 99 | const committerDate = commit.committer.date; 100 | return moment.parseZone(committerDate).utcOffset(); 101 | } 102 | 103 | isIgnored(issue) { 104 | const { ignoreRegex, ignoreLabels } = this.config.get(); 105 | if (new RegExp(ignoreRegex, 'i').test(issue.title)) { 106 | return true; 107 | } 108 | 109 | if (issue.labels.some(label => ignoreLabels.includes(label.name))) { 110 | return true; 111 | } 112 | 113 | return false; 114 | } 115 | 116 | searchIssues(github, query) { 117 | return github.paginate( 118 | github.search.issues({ 119 | q: `org:${this.installation.account.login} ${query}`, 120 | per_page: 100, 121 | }), 122 | response => response.data.items.filter(issue => !this.isIgnored(issue)) 123 | ); 124 | } 125 | 126 | async getNewIssues(github) { 127 | if (this.newIssues) { 128 | return this.newIssues; 129 | } 130 | 131 | // Look for all public issues that haven't been assigned or labeled yet 132 | // We should also filter out issues with comments from organization members 133 | const date = moment() 134 | .subtract(this.config.get().newIssueDays, 'days') 135 | .format('YYYY-MM-DD'); 136 | this.newIssues = this.searchIssuesRateLimited( 137 | github, 138 | `is:open is:issue is:public no:assignee no:label created:>=${date}` 139 | ); 140 | 141 | // Automatically clear the issues cache when the timeout expires 142 | setTimeout(() => { 143 | this.newIssues = null; 144 | }, CACHE_ISSUES * 60 * 1000); 145 | return this.newIssues; 146 | } 147 | 148 | async getWatchers(github, repo) { 149 | if (this.watchers[repo]) { 150 | return this.watchers[repo]; 151 | } 152 | 153 | const owner = this.installation.account.login; 154 | this.logger.debug(`Loading watchers for ${owner}/${repo}`); 155 | const request = github.activity.getWatchersForRepo({ 156 | owner, 157 | repo, 158 | per_page: 100, 159 | }); 160 | this.watchers[repo] = github.paginate(request, response => 161 | response.data.map(user => user.login) 162 | ); 163 | 164 | // Automatically clear the watcher cache when the timeout expires 165 | setTimeout(() => { 166 | delete this.watchers[repo]; 167 | }, CACHE_WATCHERS * 60 * 1000); 168 | return this.watchers[repo]; 169 | } 170 | 171 | async isInWatchedRepo(github, issue, user) { 172 | // Since the issue doesn't include repository information, we need to parse it 173 | const repo = /[^/]+$/.exec(issue.repository_url); 174 | if (repo == null) { 175 | return false; 176 | } 177 | 178 | const watchers = await this.getWatchers(github, repo[0]); 179 | return watchers.includes(user.login); 180 | } 181 | 182 | async getReportForUser(user) { 183 | const github = await this.getGithub(); 184 | const login = user.login.toLowerCase(); 185 | 186 | // review:none returns all PRs without a review 187 | // sadly there is a bug in github that if you commit after 188 | // someone requested changes, the pr is still reviewed 189 | // so it will no longer show up whenever someone already reviewed it ever 190 | const toReview = await this.searchIssuesRateLimited( 191 | github, 192 | `is:open is:pr review:none review-requested:${login} -assignee:${login} -author:${login}` 193 | ); 194 | 195 | // This query returns all PRs that already have been reviewed 196 | // and need to be completed by the assignee 197 | const toComplete = await this.searchIssuesRateLimited( 198 | github, 199 | `is:open is:pr -review:none assignee:${login}` 200 | ); 201 | 202 | // Look for all public issues that haven't been handled yet 203 | const newIssues = await filterAsync( 204 | await this.getNewIssues(github), 205 | issue => this.isInWatchedRepo(github, issue, user) 206 | ); 207 | 208 | // We can pull this out of here when we have issue or stuff we query 209 | // for now leave it here 210 | return new Report(user) 211 | .addPullRequestsToReview(toReview) 212 | .addPullRequestsToComplete(toComplete) 213 | .addNewIssues(newIssues); 214 | } 215 | 216 | async sendReport(user) { 217 | this.logger.info(`Generating report for user "${user.login}"...`); 218 | const report = await this.getReportForUser(user); 219 | 220 | if (report.hasData()) { 221 | const weekday = moment() 222 | .utcOffset(user.timezone) 223 | .isoWeekday(); 224 | // if current time of user is a weekday 225 | // we actually send the report 226 | if (weekday <= 5) { 227 | this.emitter.emit(EVENT_REPORT, user, report); 228 | } else { 229 | this.logger.debug('Skipping report due to user time is not a weekday'); 230 | } 231 | } else { 232 | this.logger.debug( 233 | `Skipping report for "${user.login}", no pull requests found` 234 | ); 235 | } 236 | } 237 | 238 | scheduleReport(user, time) { 239 | if (user.enabled === false) { 240 | return; 241 | } 242 | 243 | const login = user.login.toLowerCase(); 244 | if (this.jobs[login] == null) { 245 | this.jobs[login] = []; 246 | } 247 | 248 | const date = moment(time, 'HH:mm') 249 | .utcOffset(user.timezone, true) 250 | .utcOffset(moment().utcOffset()); 251 | 252 | const rule = new schedule.RecurrenceRule( 253 | null, 254 | null, 255 | null, 256 | null, 257 | date.hour(), 258 | date.minute() 259 | ); 260 | const job = schedule.scheduleJob(rule, () => this.sendReport(user)); 261 | this.logger.debug( 262 | `Scheduled job for "${user.login}" job: ${rule.nextInvocationDate()}` 263 | ); 264 | 265 | this.jobs[login].push(job); 266 | } 267 | 268 | async addUser(github, user, writeConfig = true) { 269 | if (user.type !== TYPE_USER) { 270 | return; 271 | } 272 | 273 | const login = user.login.toLowerCase(); 274 | if (this.loadedUsers[login]) { 275 | // This user has already been loaded and scheduled 276 | return; 277 | } 278 | 279 | const config = this.config.get(); 280 | 281 | // TODO: Update the cached timezone 282 | const userConfig = 283 | config.users[login] || (await this.getUser(github, user)); 284 | this.loadedUsers[login] = userConfig; 285 | 286 | if (writeConfig && !_.isEqual(this.loadedUsers, config.users)) { 287 | this.config.merge({ users: this.loadedUsers }); 288 | } 289 | 290 | config.reportTimes.forEach(time => this.scheduleReport(userConfig, time)); 291 | } 292 | 293 | async getUser(github, user) { 294 | const login = user.login.toLowerCase(); 295 | this.logger.info( 296 | `Found new user "${login}", fetching details and timezone` 297 | ); 298 | const [details, timezone] = await Promise.all([ 299 | this.getDetailsFor(github, user), 300 | this.getTimeZoneForUser(github, user), 301 | ]); 302 | 303 | return { 304 | login, 305 | id: user.id, 306 | email: details.email, 307 | name: user.name || user.login, 308 | timezone, 309 | }; 310 | } 311 | 312 | removeUser(user) { 313 | const login = user.login.toLowerCase(); 314 | const jobs = this.jobs[login] || []; 315 | if (jobs.length > 0) { 316 | jobs.forEach(job => job.cancel()); 317 | delete this.loadedUsers[login]; 318 | } 319 | } 320 | 321 | async setupUsers() { 322 | const { account } = this.installation; 323 | const targetType = this.installation.target_type; 324 | const github = await this.getGithub(); 325 | 326 | if (targetType === TYPE_ORGANIZATION) { 327 | this.logger.debug( 328 | `Loading all organization members for "${account.login}"` 329 | ); 330 | const request = github.orgs.getMembers({ 331 | org: account.login, 332 | per_page: 100, 333 | }); 334 | const users = await github.paginate(request, result => result.data); 335 | 336 | this.logger.info( 337 | `Initializing ${users.length} users for organization "${account.login}"` 338 | ); 339 | await Promise.all(users.map(user => this.addUser(github, user, false))); 340 | } else if (targetType === TYPE_USER) { 341 | this.logger.info(`Initializing account "${account.login}" as user`); 342 | await this.addUser(github, account, false); 343 | } else { 344 | this.logger.error(`Unknown installation target type: ${targetType}`); 345 | } 346 | 347 | if (!_.isEqual(this.loadedUsers, this.config.get().users)) { 348 | this.config.merge({ users: this.loadedUsers }); 349 | } 350 | } 351 | 352 | teardown() { 353 | _.forEach(this.loadedUsers, user => this.removeUser(user)); 354 | } 355 | 356 | reloadUsers() { 357 | const { reportTimes, users } = this.config.get(); 358 | if (!_.isEqual(this.loadedUsers, users)) { 359 | _.forEach(this.jobs, jobs => 360 | jobs.splice(0, Infinity).forEach(job => job.cancel()) 361 | ); 362 | _.forEach(users, user => 363 | reportTimes.forEach(time => this.scheduleReport(user, time)) 364 | ); 365 | } 366 | } 367 | }; 368 | -------------------------------------------------------------------------------- /lib/slack.js: -------------------------------------------------------------------------------- 1 | const { shouldPerform } = require('dryrun'); 2 | const _ = require('lodash'); 3 | const Botkit = require('botkit'); 4 | const moment = require('moment'); 5 | const EventEmitter = require('events'); 6 | 7 | /** 8 | * Event emitted when generating reports 9 | */ 10 | const EVENT_REQUEST_REPORT = 'slack.request.report'; 11 | 12 | /** 13 | * Event emitted when generating reports 14 | */ 15 | const EVENT_REQUEST_MAIL = 'slack.request.mail'; 16 | 17 | /** 18 | * Regex used for parsing emails 19 | */ 20 | const REGEX_EMAIL = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}\b/i; 21 | 22 | /** 23 | * Regex used for parsing GitHub profiles 24 | */ 25 | const REGEX_GITHUB = /^]+)\/?>/; 26 | 27 | module.exports = class Slack { 28 | constructor(config, logger = console) { 29 | this.config = config; 30 | this.logger = logger; 31 | 32 | const slackLogger = { 33 | log: (level, ...other) => (logger[level] || _.noop)(...other), 34 | }; 35 | this.controller = Botkit.slackbot({ 36 | logger: slackLogger, 37 | logLevel: process.env.LOG_LEVEL, 38 | }); 39 | this.bot = this.controller.spawn({ token: process.env.SLACK_TOKEN }); 40 | this.emitter = new EventEmitter(); 41 | 42 | this.startRtm(); 43 | this.setupCallbacks(); 44 | } 45 | 46 | teardown() { 47 | this.logger.info('Closing Slack RTM'); 48 | this.bot.closeRTM(); 49 | } 50 | 51 | startRtm() { 52 | this.bot.startRTM(err => { 53 | if (err) { 54 | this.logger.warn('Failed to start Slack RTM'); 55 | setTimeout(this.startRtm, 60000); 56 | } else { 57 | this.logger.info('Slack RTM started successfully'); 58 | } 59 | }); 60 | } 61 | 62 | onRequestReport(callback) { 63 | this.emitter.on(EVENT_REQUEST_REPORT, callback); 64 | return this; 65 | } 66 | 67 | onRequestMail(callback) { 68 | this.emitter.on(EVENT_REQUEST_MAIL, callback); 69 | return this; 70 | } 71 | 72 | getConnectedUser(message) { 73 | const { users } = this.config.get(); 74 | return _.find( 75 | users, 76 | user => user.slack && user.slack.user === message.user 77 | ); 78 | } 79 | 80 | send(src, message, callback) { 81 | this.logger.debug('Sending slack message', message); 82 | if (shouldPerform()) { 83 | this.bot.whisper(src, message, callback); 84 | } 85 | } 86 | 87 | sendHelp(src) { 88 | this.send( 89 | src, 90 | 'Here are a few commands you can run:\n' + 91 | ' :one: `list` _lists your PRs on github to review_\n' + 92 | ' :two: `slack on` / `slack off` _toggle notifications via slack_\n' + 93 | ' :three: `set email ` _update your email address_\n' + 94 | ' :four: `email off` _you will no longer receive emails_\n' + 95 | ' :five: `set first` _configure the order of PRs_\n' + 96 | ' :six: `get config` _list the config for your user_\n' + 97 | '' 98 | ); 99 | } 100 | 101 | handleConnectedMessage(message, user) { 102 | const text = message.text.trim().toLowerCase(); 103 | const emailMatch = REGEX_EMAIL.exec(text); 104 | 105 | if (text === 'slack on') { 106 | this.send( 107 | message, 108 | ':white_check_mark: You will now receive notifications via slack.' 109 | ); 110 | this.config.mergeUser(user.login, { 111 | slack: { ...user.slack, active: true }, 112 | }); 113 | } else if (text === 'slack off') { 114 | this.send( 115 | message, 116 | ':no_entry_sign: You no longer receive notifications via slack.' 117 | ); 118 | this.config.mergeUser(user.login, { 119 | slack: { ...user.slack, active: false }, 120 | }); 121 | } else if (text === 'email off') { 122 | this.send( 123 | message, 124 | ':no_entry_sign: You no longer receive notifications via email.' 125 | ); 126 | this.config.mergeUser(user.login, { 127 | email: null, 128 | }); 129 | } else if (text === 'set oldest first') { 130 | this.send(message, ':white_check_mark: Showing *oldest* issues first.'); 131 | this.config.mergeUser(user.login, { order: 'asc' }); 132 | } else if (text === 'set newest first') { 133 | this.send(message, ':white_check_mark: Showing *newest* issues first.'); 134 | this.config.mergeUser(user.login, { order: 'desc' }); 135 | } else if (text === 'list') { 136 | this.send(message, ':eyes: Gimme a second...'); 137 | this.emitter.emit(EVENT_REQUEST_REPORT, user); 138 | } else if (text === 'mailme') { 139 | this.send(message, ':eyes: Gimme a second...'); 140 | this.emitter.emit(EVENT_REQUEST_MAIL, user); 141 | } else if (emailMatch !== null) { 142 | this.send( 143 | message, 144 | `:white_check_mark: Updated email to *${emailMatch[0]}*` 145 | ); 146 | this.config.mergeUser(user.login, { email: emailMatch[0] }); 147 | } else if (text === 'get config') { 148 | this.send(message, `\`\`\`\n${JSON.stringify(user, null, 4)}\n\`\`\``); 149 | } else if (text === 'get schwifty') { 150 | this.send(message, 'https://www.youtube.com/watch?v=I1188GO4p1E'); 151 | } else { 152 | this.sendHelp(message); 153 | } 154 | } 155 | 156 | handleUnconnectedMessage(message) { 157 | const githubLoginMatch = REGEX_GITHUB.exec(message.text); 158 | if (githubLoginMatch == null) { 159 | this.send( 160 | message, 161 | 'Greetings stranger, to get started I need to identify who you are.\n' + 162 | 'Please paste the url of your Github profile so I can match you to my database. :fine:' 163 | ); 164 | 165 | return; 166 | } 167 | 168 | const slack = { 169 | user: message.user, 170 | channel: message.channel, 171 | active: true, 172 | }; 173 | 174 | this.config.mergeUser(githubLoginMatch[1].toLowerCase(), { slack }); 175 | this.send( 176 | message, 177 | `Ayo *${githubLoginMatch[1].toLowerCase()}*, I know who you are now. :heart:` 178 | ); 179 | this.sendHelp(message); 180 | } 181 | 182 | handleDirectMessage(message) { 183 | const user = this.getConnectedUser(message); 184 | if (user) { 185 | this.handleConnectedMessage(message, user); 186 | } else { 187 | this.handleUnconnectedMessage(message); 188 | } 189 | } 190 | 191 | setupCallbacks() { 192 | this.controller.on('rtm_close', () => this.startRtm()); 193 | this.controller.on('direct_message', (bot, message) => 194 | this.handleDirectMessage(message) 195 | ); 196 | } 197 | 198 | static createIssueAttachment(issue, params) { 199 | const repo = issue.repository_url.match('[^/]+/[^/]+$')[0]; 200 | const opened = moment(issue.created_at).fromNow(); 201 | const updated = moment(issue.updated_at).fromNow(); 202 | 203 | return { 204 | ...params, 205 | fallback: `*${repo}#${issue.number}*: ${issue.title} (${issue.html_url})`, 206 | author_name: `${repo}#${issue.number}`, 207 | title: issue.title, 208 | title_link: issue.html_url, 209 | footer: `opened ${opened}, updated ${updated} by ${issue.user.login}`, 210 | }; 211 | } 212 | 213 | sendReport(report) { 214 | const source = report.getUser().slack; 215 | if (!source.active) { 216 | return; 217 | } 218 | 219 | if (!report.hasData()) { 220 | this.send(source, ':tada: You have no pull requests to review! :beers:'); 221 | return; 222 | } 223 | 224 | const toReview = report.getPullRequestsToReview(); 225 | if (toReview.length > 0) { 226 | this.send(source, { 227 | text: 'Here are pull requests you need to *review*:', 228 | attachments: toReview.map(issue => 229 | Slack.createIssueAttachment(issue, { color: '#36a64f' }) 230 | ), 231 | }); 232 | } 233 | 234 | const toComplete = report.getPullRequestsToComplete(); 235 | if (toComplete.length > 0) { 236 | this.send(source, { 237 | text: 'Here are pull requests you need to *handle*:', 238 | attachments: toComplete.map(issue => 239 | Slack.createIssueAttachment(issue, { color: '#F35A00' }) 240 | ), 241 | }); 242 | } 243 | 244 | const newIssues = report.getNewIssues(); 245 | if (newIssues.length > 0) { 246 | this.send(source, { 247 | text: 'Here are issues you could *label and assign*:', 248 | attachments: newIssues.map(issue => 249 | Slack.createIssueAttachment(issue, { color: '#37d7e4' }) 250 | ), 251 | }); 252 | } 253 | } 254 | }; 255 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a rate limited version of the given function with a cooldown period. 3 | * Every invokation of the result will be passed through to the target function 4 | * in the same order, but after waiting for the cooldown period to expire. 5 | * 6 | * @param {Function} callback A function to be rate limited 7 | * @param {Number} delay The cooldown time in milliseconds 8 | */ 9 | function rateLimit(callback, delay) { 10 | let mutex = Promise.resolve(); 11 | 12 | // We need to return a plain function here since we need to propagate "this" 13 | return function rateLimitedCallback(...params) { 14 | const result = mutex.then(() => callback.apply(this, params)); 15 | mutex = mutex.then( 16 | () => new Promise(resolve => setTimeout(resolve, delay)) 17 | ); 18 | return result; 19 | }; 20 | } 21 | 22 | /** 23 | * Asynchronously calls the predicate on every element of the array and filters 24 | * for all elements where the predicate resolves to true. 25 | * 26 | * @param {Array} array An array to filter 27 | * @param {Function} predicate A predicate function that resolves to a boolean 28 | * @param {any} args Any further args passed to the predicate 29 | */ 30 | async function filterAsync(array, predicate, ...args) { 31 | const verdicts = await Promise.all(array.map(predicate, ...args)); 32 | return array.filter((element, index) => verdicts[index]); 33 | } 34 | 35 | module.exports = { 36 | rateLimit, 37 | filterAsync, 38 | }; 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "probot-report", 3 | "version": "0.1.17", 4 | "description": "Sends out periodic reports via email", 5 | "author": "Jan Michael Auer ", 6 | "license": "BSD-3-Clause", 7 | "repository": "https://github.com/getsentry/probot-report.git", 8 | "scripts": { 9 | "fix:eslint": "eslint --fix lib", 10 | "fix:prettier": "prettier --write 'lib/**/*.js'", 11 | "fix": "npm-run-all fix:eslint fix:prettier", 12 | "start": "probot run ./index.js", 13 | "test:jest": "jest", 14 | "test:eslint": "eslint lib", 15 | "test:prettier": "prettier-check 'lib/**/*.js'", 16 | "test": "npm-run-all test:jest test:eslint test:prettier", 17 | "test:watch": "jest --watch --notify" 18 | }, 19 | "dependencies": { 20 | "botkit": "^0.6.11", 21 | "dryrun": "^1.0.2", 22 | "js-yaml": "^3.10.0", 23 | "lodash": "^4.17.4", 24 | "moment": "^2.19.3", 25 | "node-schedule": "^1.2.5", 26 | "nodemailer": "^4.4.0", 27 | "nodemailer-sendgrid-transport": "^0.2.0", 28 | "probot": "^0.11.0" 29 | }, 30 | "devDependencies": { 31 | "eslint": "^4.12.1", 32 | "eslint-config-airbnb-base": "^12.1.0", 33 | "eslint-config-prettier": "^2.9.0", 34 | "eslint-plugin-import": "^2.8.0", 35 | "jest": "^21.2.1", 36 | "localtunnel": "^1.8.2", 37 | "npm-run-all": "^4.1.2", 38 | "prettier": "^1.9.0", 39 | "prettier-check": "^2.0.0" 40 | }, 41 | "engines": { 42 | "node": ">= 8" 43 | } 44 | } 45 | --------------------------------------------------------------------------------