├── .artifacts.yml ├── .eslintrc.js ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.md ├── MESSAGE-SPEC.md ├── README.md ├── assets ├── dispatch-large.png ├── dispatch-slack-app.jpg └── message-vs-prompt.png ├── incoming ├── function.js └── function.template.js ├── lib ├── github.js ├── pagerduty.js ├── slack.js └── utils.js ├── package-lock.json ├── package.json ├── test ├── fixtures │ ├── github.fixtures.js │ ├── incoming.fixtures.js │ ├── pagerduty.fixtures.js │ ├── slack.fixtures.js │ └── triage.fixtures.js ├── functions │ ├── incoming.test.js │ └── triage.test.js ├── lib │ ├── github.test.js │ ├── pagerduty.test.js │ └── slack.test.js └── scripts │ ├── volumeBroadcastTest.js │ └── volumeSelfServiceTest.js └── triage ├── function.js └── function.template.js /.artifacts.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | defaults: 3 | - bundle 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'node': true, 4 | 'es6': true 5 | }, 6 | 'rules': { 7 | 'quotes': [2, 'single', {'avoidEscape': true, 'allowTemplateLiterals': true}], 8 | 'indent': ['error', 2], 9 | 'no-multi-spaces': [2], 10 | 'no-unused-vars': [1], 11 | 'no-mixed-spaces-and-tabs': [2], 12 | 'no-underscore-dangle': [2], 13 | 'no-loop-func': [2], 14 | 'handle-callback-err': [2], 15 | 'space-unary-ops': [2], 16 | 'space-in-parens': ['error', 'never'], 17 | 'space-before-function-paren': ['error', 'never'], 18 | 'no-trailing-spaces': [2], 19 | 'no-tabs': [2], 20 | 'new-cap': [2], 21 | 'block-spacing': ['error', 'always'], 22 | 'no-multiple-empty-lines': [2], 23 | 'semi': ['error', 'always'] 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - 10 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## [1.0.0] - 2017-11-10 8 | - Initial release -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2017 Mapbox, Inc. 2 | 3 | This code available under the terms of the BSD 2-Clause license. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MESSAGE-SPEC.md: -------------------------------------------------------------------------------- 1 | # Dispatch Message Specification 2 | 3 | To route alarms through Dispatch, send an [AWS SNS](https://aws.amazon.com/sns/) message to your Dispatch SNS topic that follows the Dispatch message specification. 4 | 5 | The Dispatch message specification currently accepts three types of alarm routing: 6 | 7 | * Self-service 8 | * Broadcast 9 | * High priority 10 | 11 | ## Self-service dispatch 12 | 13 | Self-service Dispatch messages do the following for a single user: 14 | 15 | 1. Create a GitHub issue and tag the user's GitHub handle. The issue will be tagged with all provided labels such as `'bug'` or `'question'` . 16 | 2. Send an interactive Slack direct message to the user, which prompts them to respond yes or no. 17 | 18 | If a GitHub username is not provided or could not be found, Dispatch will fall back to the `GitHubDefaultUser` parameter. 19 | If a Slack ID is not provided or could not be found, Dispatch will fall back to the `SlackChannel` parameter. While no longer required by the Slack API, you can still use Slack usernames for readable context and logging. 20 | 21 | The self-service message on Slack includes a link to the associated GitHub issue. The dispatch-triage AWS Lambda function handles the yes or no response from the user in Slack, either closing the GitHub issue or escalating the issue to PagerDuty. 22 | 23 | ### Message specification for `self-service` 24 | 25 | ``` javascript 26 | { 27 | type: 'self-service', // required 28 | requestId: 'STRING_VALUE', // optional, ID for logging - if not passed, a 6 character random hex requestId will be generated and used 29 | githubRepo: 'STRING_VALUE', // optional, specify GitHub repository for Dispatch issue 30 | pagerDutyServiceId: 'STRING_VALUE', // optional, overrides the default PagerDuty service in dispatch-incoming 31 | retrigger: 'BOOLEAN', // optional, defaults to true if not specified - if false Dispatch will not resend a message for a preexisting issue 32 | users: [ // required 33 | { 34 | slack: 'STRING_VALUE', // optional, Slack handle 35 | slackId: 'STRING_VALUE', // required, Slack ID 36 | github: 'STRING_VALUE' // required, GitHub handle 37 | } 38 | ], 39 | body: { // required 40 | github: { 41 | title: 'STRING_VALUE', // required, GitHub issue title 42 | body: 'STRING_VALUE', // required, GitHub issue body 43 | labels: ['STRING_VALUE', 'STRING_VALUE'] // optional, labels to add to Github Issue 44 | }, 45 | slack: { 46 | message: 'STRING_VALUE', // required, Slack message 47 | prompt: 'STRING_VALUE', // required, Slack prompt for yes or no response 48 | actions: { 49 | yes: 'STRING_VALUE', // required, Slack button text for 'yes' action type 50 | yes_response: 'STRING_VALUE', // optional, dispatch-triage response to user after they click yes 51 | no: 'STRING_VALUE', // required, Slack button text for 'no' action type 52 | no_response: 'STRING_VALUE', // optional, dispatch-triage response to user after they click no 53 | } 54 | } 55 | } 56 | } 57 | ``` 58 | 59 | #### Use `yes_response` and `no_response` 60 | 61 | Though `body.slack.actions.yes_response` and `body.slack.actions.no_response` are not required, we recommend you use them in your self service messages so that your users have a friendly experience with Dispatch. If you omit these properties then your users will receive default return values from the `dispatch-triage` AWS Lambda function. 62 | 63 | #### Slack message vs. prompt 64 | 65 | To help explain the difference between `body.slack.message` and `body.slack.prompt`, see this Dispatch self-service alarm in Slack: 66 | 67 | ![messagevsprompt](https://github.com/mapbox/dispatch/blob/master/assets/message-vs-prompt.png) 68 | 69 | In the example above, the text that begins with "It looks like Two-factor authentication (2FA)" is the Slack message (`body.slack.message`). By comparison, "Please confirm whether or not your disabled 2FA below" is the prompt (`body.slack.prompt`). 70 | 71 | ## Broadcast dispatch 72 | 73 | Broadcast Dispatch messages do the following: 74 | 75 | 1. Send a non-interactive Slack direct message to a list of users. 76 | 1. Create a single GitHub issue with the list of notified users for audit log purposes. 77 | 78 | Broadcast messages do not currently support interactive Slack messages. Though they do create a single GitHub issue, this ticket intentionally does not tag the GitHub handles of notified users. Instead, it lists their Slack handles. 79 | 80 | Unlike self-service alarms, broadcast Slack DMs do not include a link to their associated GitHub issue. If you need to provide a link to a GitHub issue for educational or training purposes you should include it in the Slack message via `body.slack.message`. 81 | 82 | ### Message specification for `broadcast` 83 | 84 | ``` javascript 85 | { 86 | type: 'broadcast', // required 87 | requestId: 'STRING_VALUE', // optional, id for logging 88 | githubRepo: 'STRING_VALUE', // optional, specify GitHub repository for Dispatch issue 89 | pagerDutyServiceId: 'STRING_VALUE', // optional, specify Pager Duty Service ID 90 | retrigger: 'BOOLEAN', // optional, if set to false Dispatch will not send a message if an issue has already been reported 91 | users: [ 92 | { 93 | slack: 'STRING_VALUE', // optional, Slack handle 94 | slackId: 'STRING_VALUE' // required, Slack ID 95 | }, 96 | { 97 | slack: 'STRING_VALUE', // optional, Slack handle 98 | slackId: 'STRING_VALUE' // required, Slack ID 99 | } 100 | ], 101 | body: { 102 | github: { 103 | title: 'STRING_VALUE', // required, GitHub issue title 104 | body: 'STRING_VALUE', // required, GitHub issue body 105 | labels: ['STRING_VALUE', 'STRING_VALUE'] // optional, labels to add to Github Issue 106 | }, 107 | slack: { 108 | message: 'STRING_VALUE', // required, Slack message 109 | } 110 | } 111 | } 112 | ``` 113 | 114 | ## High priority dispatch 115 | 116 | High priority dispatch messages open a PagerDuty incident. 117 | 118 | ### Message specification for `high-priority` 119 | 120 | 121 | ``` javascript 122 | { 123 | type: 'high-priority', // required 124 | requestId: 'STRING_VALUE', // optional, id for logging 125 | pagerDutyServiceId: 'STRING_VALUE', // optional, overrides the default PagerDuty service in dispatch-incoming 126 | body: { 127 | pagerduty: { 128 | service: 'STRING_VALUE', // required, PagerDuty service ID to create incident for 129 | title: 'STRING_VALUE', // required, PagerDuty incident title 130 | body: 'STRING_VALUE' // optional, PagerDuty incident body 131 | } 132 | } 133 | } 134 | ``` 135 | 136 | ## Nag dispatch 137 | 138 | This dispatch message open a github issue and send slack notification everytime dispatch gets an sns message 139 | 140 | ### Message specification for `nag` 141 | 142 | ``` javascript 143 | { 144 | type: 'nag', // required 145 | retrigger: 'BOOLEAN', // optional, if set to false Dispatch will not send a message if an issue has already been reported 146 | users: [ 147 | { 148 | slack: 'STRING_VALUE', // optional, Slack handle 149 | slackId: 'STRING_VALUE' // required, Slack ID 150 | }, 151 | { 152 | slack: 'STRING_VALUE', // optional, Slack handle 153 | slackId: 'STRING_VALUE' // required, Slack ID 154 | } 155 | ], 156 | body: { 157 | github: { 158 | title: 'STRING_VALUE', // required, GitHub issue title 159 | body: 'STRING_VALUE', // required, GitHub issue body 160 | labels: ['STRING_VALUE', 'STRING_VALUE'] // optional, labels to add to Github Issue 161 | }, 162 | slack: { 163 | message: 'STRING_VALUE', // required, Slack message 164 | } 165 | } 166 | } 167 | ``` 168 | 169 | ## Low priority dispatch 170 | 171 | Low priority dispatch messages open a GitHub issue. 172 | 173 | ### Message specification for `low-priority` 174 | 175 | ``` javascript 176 | { 177 | type: 'low-priority', // required 178 | requestId: 'STRING_VALUE', // optional, ID for logging - if not passed, a 6 character random hex requestId will be generated and used 179 | githubRepo: 'STRING_VALUE', // optional, specify GitHub repository for Dispatch issue 180 | retrigger: 'BOOLEAN', // optional, defaults to true if not specified - if false Dispatch will not resend a message for a preexisting issue 181 | users: [ 182 | { 183 | github: 'STRING_VALUE' // GitHub handle 184 | } 185 | ], 186 | body: { // required 187 | github: { 188 | title: 'STRING_VALUE', // required, GitHub issue title 189 | body: 'STRING_VALUE', // required, GitHub issue body 190 | labels: ['STRING_VALUE', 'STRING_VALUE'] // optional, labels to add to Github Issue 191 | } 192 | } 193 | } 194 | ``` 195 | 196 | Only the first user of users array will be tagged in the github issue created by the low-priority message. 197 | 198 | ## Users array specification 199 | 200 | Dispatch only processes Slack IDs and GitHub handles in the `users` array. It ignores handles or usernames for other services. This allows you to connect Dispatch to a central API or username mappings file without having to scrub or remove other data. 201 | 202 | Dispatch automatically adds `#` to Slack channels. Do not add the `@` symbol to GitHub handles. 203 | 204 | ``` javascript 205 | users: [ 206 | { 207 | slackId: 'user1SlackId', 208 | github: 'user1GitHubHandle', 209 | google: 'user1GoogleHandle' // ignored 210 | }, 211 | { 212 | slackId: 'user2SlackId', 213 | github: 'user2GitHubHandle', 214 | google: 'user2GoogleHandle' // ignored 215 | }, 216 | ... 217 | ] 218 | ``` 219 | 220 | If a Slack Id or GitHub handle is missing from the `users` array, then Dispatch will fallback to either a Slack channel or GitHub user/team for visibility. These values are set via CloudFormation parameters when deploying `dispatch-incoming` and `dispatch-triage` via [lambda-cfn](https://github.com/mapbox/lambda-cfn). 221 | 222 | * Slack = `SlackDefaultChannel` 223 | * GitHub = `GitHubDefaultUser` 224 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | :warning: DEPRECATED :warning: 2 | 3 | [![Build Status](https://travis-ci.org/mapbox/dispatch.svg?branch=master)](https://travis-ci.org/mapbox/dispatch) 4 | 5 | ![Dispatch logo](https://github.com/mapbox/dispatch/blob/master/assets/dispatch-large.png) 6 | 7 | **Dispatch** is an alarm routing tool for security and platform incident response teams. It dynamically routes alarms to [PagerDuty](https://www.pagerduty.com/) or [Slack](https://slack.com/) based on incident severity, urgency, or type. Dispatch sends interactive Slack direct messages that empower users to self-triage their own security alarms. It also supports emergency broadcast style alerts via Slack, as well as escalating alarms from Slack to PagerDuty. For each alarm, Dispatch creates a GitHub issue for auditing and logging purposes, avoiding the need to maintain a separate database to store state. 8 | 9 | To use Dispatch, have your applications and monitoring systems send [AWS Simple Notification Service](https://aws.amazon.com/sns/) (SNS) messages following the [Dispatch message specification](MESSAGE-SPEC.md) to your Dispatch SNS topic. 10 | 11 | ## Dispatch alert types 12 | 13 | - **Self-service alerts** send interactive Slack messages to users, prompting them to answer yes or no. The user's response is tracked via a GitHub issue for audit purposes. If a user responds yes, it closes the issue. If a user response no, Dispatch escalates the alarm to PagerDuty. 14 | - **Broadcast alerts** are non-interactive messages delivered via Slack to multiple users. These alerts create a single GitHub issue for audit purposes with a list of users that received the message. 15 | - **High priority alerts** are sent directly to PagerDuty. 16 | - **Low priority alerts** are sent directly to a GitHub issue. 17 | 18 | ## Architecture 19 | 20 | Dispatch consists of two separate [AWS Lambda functions](https://aws.amazon.com/lambda/) that leverage the [lambda-cfn](github.com/mapbox/lambda-cfn) framework: 21 | 22 | - **dispatch-incoming**: receives SNS notifications and creates PagerDuty alarms or GitHub issues. 23 | - **dispatch-triage**: uses [API Gateway](https://aws.amazon.com/api-gateway/) to respond to Slack interactive messages, either closing the corresponding GitHub issue or escalating the issue to PagerDuty. 24 | 25 | ## Prerequisites 26 | 27 | ### Lambda-cfn 28 | 29 | To deploy and manage Dispatch you'll need to globally install the latest version of [lambda-cfn](https://github.com/mapbox/lambda-cfn). 30 | 31 | `npm install -g @mapbox/lambda-cfn` 32 | 33 | ### Third party services 34 | 35 | You'll also need a GitHub organization with private repositories, a PagerDuty account, and a Slack workspace in order to run Dispatch. 36 | 37 | ## Set up 38 | 39 | To set up Dispatch for your organization, you'll need to do the following: 40 | 41 | 1. Configure GitHub 42 | 2. Configure PagerDuty 43 | 3. Configure the Dispatch Slack app and bot 44 | 4. Configure AWS Key Management Service (KMS) 45 | 5. Deploy the dispatch-incoming AWS Lambda function 46 | 6. Deploy the dispatch-triage AWS Lambda function 47 | 7. Update the Dispatch Slack app with the dispatch-triage API Gateway URL 48 | 49 | ### 1. Configure GitHub 50 | 51 | To configure GitHub for Dispatch, you'll need to do the following: 52 | 53 | 1. Create or select a default GitHub repository for Dispatch GitHub issues 54 | 2. Select or create a failover default GitHub user or team 55 | 3. Create a machine account or select an existing user account to run Dispatch 56 | 4. Generate a GitHub personal access token with `repo` scope with the account from Step #2 57 | 58 | Dispatch creates a new GitHub issue for each alarm, using the `title` and `body` from the Dispatch message specification to populate the issue. You can use an existing GitHub repository or create a new one. You'll provide the name of the default GitHub repository via the `GitHubRepo` CloudFormation parameter when deploying the `incoming` and `triage` functions via lambda-cfn in steps 3 and 4 of setup. Dispatch will default to creating issues in this repository; however, you can also specify a different destination repository using the `githubRepo` property in the SNS message specification. This allows different alarms to be routed to different GitHub repos. 59 | 60 | When deploying Dispatch you'll also need to provide a GitHub personal access token with a full `repo` scope via the `GitHubToken` CloudFormation parameter. For least privilege we recommend that you use a dedicated GitHub account that only has write access to your Dispatch alerts repository. Dispatch will use the account associated with the access token to create GitHub issues. 61 | 62 | If Dispatch doesn't receive the GitHub handle for the user in the SNS message, then it will fallback to tagging a default GitHub user or GitHub team. Provide this via the `GitHubDefaultUser` CloudFormation parameter. 63 | 64 | It's on our road map to evaluate and possibly switch to [GitHub apps](https://developer.github.com/apps/) instead of personal access tokens. 65 | 66 | ### 2. Configure PagerDuty 67 | 68 | You'll need to create a new PagerDuty service or use an existing one for Dispatch to send alerts to. You'll also need a PagerDuty admin or account owner [to generate a new dedicated API key](https://support.pagerduty.com/docs/using-the-api#section-generating-an-api-key) for Dispatch. 69 | 70 | ### 3. Configure Slack 71 | 72 | You'll need to create a custom Slack app and bot user in your Slack workspace for Dispatch. It's on our road map to eventually publish an installable Slack app in the public Slack App Directory to make this process easier. 73 | 74 | 1. Visit https://api.slack.com/apps/, click **Create an App**. Provide a name, select your Slack workspace, then click **Create App**. 75 | 1. Scroll down to **App Credentials** and save the value for **Verification Token** somewhere safe and secure. You'll need this value later when deploying dispatch-triage for the `SlackVerificationToken` parameter. 76 | 1. Scroll down to **Display Information** and [upload the Dispatch Slack App icon](https://github.com/mapbox/dispatch/blob/master/assets/dispatch-slack-app.jpg) as well as provide a description for your users. We recommend "Security alarm routing bot - https://github.com/mapbox/dispatch" but feel free to use your own! 77 | 1. Click on **Bot Users** under the Features section, then create a **Bot User** named Dispatch and check **Always Show My Bot as Online**. 78 | 1. Click on **OAuth & Permissions** under the Features section, then scroll down to the **Scopes** section. Add the `chat:write:bot` scope. You should already see the `bot` scope added from step 2, but if not then add it. 79 | 1. On the same page, scroll to the top and click on **Install App to Workspace** then **Authorize**. 80 | 1. Save the value for the **Bot User OAuth Access Token** somewhere safe - you'll need it for the `SlackBotToken` parameter later when deploying the dispatch-incoming Lambda function. You can also retrieve this later by clicking on **Install App** under the **Settings** section. 81 | 82 | ### 4. Configure AWS Key Management Service (KMS) 83 | 84 | Dispatch by default uses [cloudformation-kms](https://github.com/mapbox/cloudformation-kms) to decrypt the values of sensitive CloudFormation parameters, such as PagerDuty and Slack API keys, that are encrypted as part of the deploy process with lambda-cfn. [Follow the setup instructions for cloudformation-kms](https://github.com/mapbox/cloudformation-kms#usage). 85 | 86 | If you'd prefer to *not* use [cloudformation-kms](https://github.com/mapbox/cloudformation-kms), then you can also edit the CloudFormation templates for both [incoming](https://github.com/mapbox/dispatch/blob/master/incoming/function.template.js) and [triage](https://github.com/mapbox/dispatch/blob/master/triage/function.template.js) to use raw KMS key ARNs instead of cloudformation-kms stacks. Replace the following `statements` section of `function.template.js` for both the [dispatch-incoming](https://github.com/mapbox/dispatch/blob/master/incoming/function.template.js) and [dispatch-triage](https://github.com/mapbox/dispatch/blob/master/triage/function.template.js) AWS Lambda functions. 87 | 88 | Instead of 89 | 90 | ```js 91 | statements: [ 92 | { 93 | Effect: 'Allow', 94 | Action: [ 95 | 'kms:Decrypt' 96 | ], 97 | Resource: { 98 | 'Fn::ImportValue': { 99 | 'Ref': 'KmsKey' 100 | } 101 | } 102 | } 103 | ], 104 | ``` 105 | 106 | Instead use 107 | 108 | ```js 109 | statements: [ 110 | { 111 | Effect: 'Allow', 112 | Action: [ 113 | 'kms:Decrypt' 114 | ], 115 | Resource: { 116 | 'Ref': 'KmsKey' 117 | } 118 | } 119 | ], 120 | ``` 121 | 122 | This will allow you to pass in a raw KMS key ARN when deploying both Lambda functions instead of a CloudFormation stack name or alias. 123 | 124 | ### 5. Deploy the dispatch-incoming AWS Lambda function 125 | 126 | To deploy dispatch-incoming to your AWS infrastructure you'll need to first clone Dispatch, navigate to the `incoming` directory, then use `lambda-cfn create` to launch a new CloudFormation stack. Since we're providing sensitive credentials as parameter values, to encrypt them in CloudFormation we'll use the `-k` flag with `lambda-cfn create`. 127 | 128 | ```sh 129 | git clone git@github.com:mapbox/dispatch.git 130 | cd dispatch/incoming 131 | lambda-cfn create -k 132 | ``` 133 | 134 | For example, if you run `lambda-cfn create dev -k` this will create a CloudFormation stack named `dispatch-incoming-dev`. 135 | 136 | When deploying or updating dispatch-incoming you'll need to provide values for the following CloudFormation parameters: 137 | 138 | * `GitHubOwner` = Your GitHub organization's name 139 | * `GitHubDefaultUser` = Default GitHub user or team when a user's GitHub handle is missing 140 | * `GitHubRepo` = Default GitHub repository for Dispatch issues 141 | * `GitHubToken` = [sensitive] GitHub personal access token for Dispatch machine account 142 | * `PagerDutyServiceId` = The ID of your Dispatch PagerDuty service, obtained from the service URL in PagerDuty 143 | * `PagerDutyFromAddress` = Email address of a valid PagerDuty user in your team, [required by the PagerDuty API](https://v2.developer.pagerduty.com/docs/incident-creation-api) 144 | * `PagerDutyApiKey` = [sensitive] PagerDuty API key 145 | * `slackDefaultChannel` = Fallback Slack channel for when Dispatch direct messages fail 146 | * `SlackBotToken` = [sensitive] Bot user OAuth access token from your Dispatch Slack app (begins with `xoxb-`) 147 | * `KmsKey` = Cloudformation-kms stack name or AWS KMS key ARN to encrypt sensitive parameter values 148 | 149 | For `CodeS3Bucket`, `CodeS3Prefix`, `GitSha`, and `ServiceAlarmEmail` please see the [lambda-cfn documentation for these parameters](https://github.com/mapbox/lambda-cfn#providing-parameter-values). 150 | 151 | ### 6. Deploy the dispatch-triage AWS Lambda function 152 | 153 | Similar to deploying dispatch-incoming, switch to the `triage` directory then deploy dispatch-triage using `lambda-cfn create -k `. 154 | 155 | You'll need to provide most of the same parameter values from deploying dispatch-incoming. Notably, you'll need to provide the Slack verification token for your Dispatch app (step #2 of configuring Slack) for the `SlackVerificationToken` CloudFormation parameter. 156 | 157 | ### 7. Update the Dispatch Slack app with the dispatch-triage API Gateway URL 158 | 159 | 1. After deploying dispatch-triage, from the `triage` directory run `lambda-cfn info ` then scroll down to the `Outputs` section of the CloudFormation template. 160 | 1. Copy the value for `triageWebhookAPIEndpoint`. It should be an AWS API Gateway URL. 161 | 1. [Visit your Slack Apps](https://api.slack.com/apps), then click on **Interactive Components** under the **Features** section. 162 | 1. Click on **Enable Interactive Components**. 163 | 1. Paste the URL for `triageWebhookAPIEndpoint` under **Request URL** and click on **Save changes**. 164 | 165 | You're done setting up Dispatch! You can now test and verify your installation, [see the Testing section](https://github.com/mapbox/dispatch/blob/master/README.md#testing). 166 | 167 | ## Testing 168 | 169 | You can test your Dispatch installation by using the [AWS CLI](https://aws.amazon.com/cli/) to send SNS messages that follow the Dispatch [message specification](MESSAGE-SPEC.md). For the complete message specification see [`MESSAGE-SPEC.md`](MESSAGE-SPEC.md). 170 | 171 | We've provided examples for each Dispatch alert type - self-service, high priority, and broadcast - below. To obtain your Dispatch SNS topic ARN (`$SNS_ARN` in the examples), from the `incoming` directory: 172 | 173 | 1. Run `lambda-cfn info ` 174 | 1. Scroll down to the `Outputs` section of the CloudFormation template and copy the value for `incomingSNSTopic`. 175 | 176 | ### Self-service example 177 | 178 | This will send a Slack direct message from your Dispatch bot and create a GitHub issue in your Dispatch repo for a user. If the user clicks yes it will close the GitHub issue. If the user clicks no it will trigger a PagerDuty incident. 179 | 180 | Replace `$SNS_ARN` and `$USER` with your SNS topic ARN and your GitHub and Slack usernames. 181 | 182 | ``` 183 | aws sns publish --topic-arn "$SNS_ARN" --subject "test" \ 184 | --message "{\"type\":\"self-service\",\"users\":[{\"slackId\": \"$USER\",\"github\":\"$USER\"}],\"body\":{\"github\":{\"title\":\"self-service title\",\"body\":\"self-service body\"},\"slack\":{\"message\":\"testSlackMessage\",\"prompt\":\"testSlackPrompt\",\"actions\":{\"yes\":\"testYesAction\",\"no\":\"testNoAction\"}}}}" 185 | ``` 186 | 187 | ### Broadcast example 188 | 189 | Broadcast alerts send non-interactive Slack messages to multiple users. They create a single GitHub issue of the broadcast for audit purposes, but do not create a GitHub issue for each user. Replace `$SNS_ARN` with your SNS topic ARN and provide GitHub and Slack usernames for `$USER1` and `$USER2`. 190 | 191 | ``` 192 | aws sns publish --topic-arn "$SNS_ARN" --subject "test" \ 193 | --message "{\"type\":\"broadcast\",\"users\":[{\"slackId\": \"$USER1\"},{\"slackId\": \"$USER2\"}],\"body\":{\"github\":{\"title\":\"broadcast title\",\"body\":\"broadcast body\", \"labels\": [\"broadcast\"]},\"slack\":{\"message\":\"testSlackMessage\"}}}" 194 | ``` 195 | 196 | ### High priority example 197 | 198 | High priority Dispatch alerts create PagerDuty incidents without creating a GitHub issue. Replace `$SNS_ARN` and `$PD_SERVICE_ID` with your SNS topic ARN and PagerDuty service ID. 199 | 200 | ``` 201 | aws sns publish --topic-arn "$SNS_ARN" --subject "test" --message "{\"type\":\"high-priority\",\"body\":{\"pagerduty\":{\"service\":\"$PD_SERVICE_ID\",\"title\":\"testAlert\",\"body\":\"testAlert\"}}}" 202 | ``` 203 | 204 | ### Low priority example 205 | 206 | Low priority Dispatch alerts create a GitHub issue only. Replace `$SNS_ARN` and `$GITHUB_REPO` with your SNS topic ARN and target GitHub repository. 207 | 208 | ``` 209 | aws sns publish --topic-arn "$SNS_ARN" --subject "test" --message "{\"type\":\"low-priority\",\"githubRepo\":\"$GITHUB_REPO\",\"body\":{\"github\":{\"title\":\"low-priority title\",\"body\":\"low-priority body\", \"labels\": [\"low_priority\"]}}}" 210 | ``` 211 | 212 | ### Nag example 213 | 214 | Low priority Dispatch alerts create a GitHub issue only. Replace `$SNS_ARN` and `$GITHUB_REPO` with your SNS topic ARN and target GitHub repository. 215 | 216 | ``` 217 | aws sns publish --topic-arn "$SNS_ARN" --subject "test" --message "{\"type\":\"nag\",\"githubRepo\":\"$GITHUB_REPO\",\"body\":{\"github\":{\"title\":\"nag title\",\"body\":\"low-priority body\", \"labels\": [\"low_priority\"]}}}" 218 | ``` 219 | 220 | 221 | ## Development 222 | 223 | ### Installation 224 | 225 | Make sure you are running Node 6.10.3 with npm 5 installed. 226 | 227 | ```sh 228 | git clone git@github.com:mapbox/dispatch.git 229 | cd dispatch 230 | npm install 231 | ``` 232 | 233 | ### Tests 234 | 235 | Dispatch uses [eslint](https://github.com/eslint/eslint) for linting and [tape](https://github.com/substack/tape) for tests. It mocks HTTP requests with [sinon](https://github.com/sinonjs/sinon) and [nock](https://github.com/node-nock/nock). Tests run on Travis CI after every commit. 236 | 237 | * `npm test` will run eslint then tape. 238 | * `npm lint` will only run eslint. 239 | * `npm unit-test` will only run tape tests. 240 | 241 | ## Feature Roadmap 242 | 243 | The planned features and development roadmap for Dispatch can be found in the [Dispatch Roadmap](https://github.com/mapbox/dispatch/projects/1) GitHub project. 244 | 245 | ## Contributing 246 | 247 | Contributors are welcome! If you want to contribute, please fork this repo then submit a pull request (PR). 248 | 249 | All of your tests should pass both locally and in Travis before we'll accept your PR. We also request that you add additional test coverage and documentation updates in your PR where applicable. 250 | -------------------------------------------------------------------------------- /assets/dispatch-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/DEPRECATED-dispatch/01fc33b5446390e8147ab4f98cdb18b3dcf193f3/assets/dispatch-large.png -------------------------------------------------------------------------------- /assets/dispatch-slack-app.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/DEPRECATED-dispatch/01fc33b5446390e8147ab4f98cdb18b3dcf193f3/assets/dispatch-slack-app.jpg -------------------------------------------------------------------------------- /assets/message-vs-prompt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/DEPRECATED-dispatch/01fc33b5446390e8147ab4f98cdb18b3dcf193f3/assets/message-vs-prompt.png -------------------------------------------------------------------------------- /incoming/function.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const crypto = require('crypto'); 4 | const queue = require('d3-queue').queue; 5 | const WebClient = require('@slack/client').WebClient; 6 | 7 | const utils = require('../lib/utils.js'); 8 | const github = require('../lib/github.js'); 9 | const pagerduty = require('../lib/pagerduty.js'); 10 | const slack = require('../lib/slack.js'); 11 | 12 | const incoming = {}; 13 | 14 | /** 15 | * Lambda function body, triggered by SNS event 16 | * 17 | * @param {object} event - SNS event object, contains message 18 | * @param {object} context - object containing lambda function runtime information 19 | * @param {function} callback - function called when lambda run is complete 20 | */ 21 | incoming.fn = function(event, context, callback) { 22 | utils.decrypt(process.env, (err) => { 23 | if (err) throw err; 24 | 25 | const gitHubDefaultUser = process.env.GitHubDefaultUser; 26 | const gitHubOwner = process.env.GitHubOwner; 27 | const gitHubToken = process.env.GitHubToken; 28 | const pagerDutyApiKey = process.env.PagerDutyApiKey; 29 | const pagerDutyFromAddress = process.env.PagerDutyFromAddress; 30 | const slackBotToken = process.env.SlackBotToken; 31 | const slackDefaultChannel = process.env.SlackDefaultChannel; 32 | 33 | const lambdaFailure = 'Lambda failure'; 34 | const lambdaSuccess = 'Lambda success'; 35 | 36 | incoming.checkEvent(event, (err, message) => { 37 | if (err) { 38 | console.log({ 39 | severity: 'error', 40 | requestId: null, 41 | service: 'lambda', 42 | message: err 43 | }); 44 | return callback(lambdaFailure); 45 | } 46 | 47 | if (!message.type) { 48 | console.log({ 49 | severity: 'error', 50 | requestId: null, 51 | service: 'lambda', 52 | message: `SNS message missing priority: ${JSON.stringify(message)}` 53 | }); 54 | return callback(lambdaFailure); 55 | } 56 | 57 | if (typeof message.retrigger === 'undefined') { 58 | message.retrigger = true; 59 | } 60 | 61 | const gitHubRepo = message.gitHubRepo ? message.gitHubRepo : process.env.GitHubRepo; 62 | const pagerDutyServiceId = message.pagerDutyServiceId ? message.pagerDutyServiceId : process.env.PagerDutyServiceId; 63 | const requestId = message.requestId ? message.requestId : crypto.randomBytes(6).toString('hex'); 64 | 65 | // SELF-SERVICE 66 | if (message.type === 'self-service') { 67 | let user = incoming.checkUser(message.users[0], gitHubDefaultUser, slackDefaultChannel, requestId, message); 68 | 69 | incoming.callGitHub(user, message, requestId, gitHubOwner, gitHubRepo, gitHubToken, (err, res) => { 70 | if (err) { 71 | console.log({ 72 | severity: 'error', 73 | requestId: requestId, 74 | service: 'github', 75 | message: err 76 | }); 77 | return callback(lambdaFailure); 78 | } 79 | 80 | // NOTE: If the GitHub issue already exists and message.retrigger is false, halt alert and return 81 | let isGithubIssueExists = res && res.status === 'exists'; 82 | 83 | if (isGithubIssueExists) { 84 | console.log({ 85 | severity: 'notice', 86 | requestId: requestId, 87 | service: 'github', 88 | message: `issue ${res.issue} already exists` 89 | }); 90 | return callback(null, lambdaSuccess); 91 | } 92 | 93 | incoming.callSlack(user, message, requestId, slackDefaultChannel, slackBotToken, res, (err, status) => { 94 | if (err) { 95 | console.log({ 96 | severity: 'error', 97 | requestId: requestId, 98 | service: 'slack', 99 | message: err 100 | }); 101 | return callback(lambdaFailure); 102 | } 103 | console.log({ 104 | severity: 'info', 105 | requestId: requestId, 106 | service: 'lambda', 107 | message: `self-service routing success - opened GitHub issue ${status.url}` 108 | }); 109 | return callback(null, lambdaSuccess); 110 | }); 111 | }); 112 | } 113 | 114 | // BROADCAST 115 | else if (message.type === 'broadcast') { 116 | incoming.callGitHub(gitHubDefaultUser, message, requestId, gitHubOwner, gitHubRepo, gitHubToken, (err, res) => { 117 | if (err) { 118 | console.log({ 119 | severity: 'error', 120 | requestId: requestId, 121 | service: 'github', 122 | message: err 123 | }); 124 | return callback(lambdaFailure); 125 | } 126 | 127 | // NOTE: If the GitHub issue already exists and message.retrigger is false, halt alert and return 128 | let isGithubIssueExists = res && res.status === 'exists'; 129 | 130 | if (isGithubIssueExists) { 131 | console.log({ 132 | severity: 'notice', 133 | requestId: requestId, 134 | service: 'github', 135 | message: `issue ${res.issue} already exists` 136 | }); 137 | return callback(null, lambdaSuccess); 138 | } 139 | 140 | let q = queue(1); 141 | message.users.forEach((user) => { 142 | user = incoming.checkUser(user, gitHubDefaultUser, slackDefaultChannel, requestId, message); 143 | q.defer(incoming.callSlack, user, message, requestId, slackDefaultChannel, slackBotToken, res); 144 | }); 145 | 146 | q.awaitAll(function(err, status) { 147 | if (err) { 148 | console.log({ 149 | severity: 'error', 150 | requestId: requestId, 151 | service: 'slack', 152 | message: err, 153 | status: status 154 | }); 155 | return callback(lambdaFailure); 156 | } 157 | 158 | console.log({ 159 | severity: 'info', 160 | requestId: requestId, 161 | service: 'lambda', 162 | message: 'broadcast routing success - opened GitHub issue' 163 | }); 164 | return callback(null, lambdaSuccess); 165 | }); 166 | }); 167 | } 168 | 169 | else if (message.type === 'nag') { 170 | incoming.callGitHub(gitHubDefaultUser, message, requestId, gitHubOwner, gitHubRepo, gitHubToken, (err, res) => { 171 | if (err) { 172 | console.log({ 173 | severity: 'error', 174 | requestId: requestId, 175 | service: 'github', 176 | message: err 177 | }); 178 | return callback(lambdaFailure); 179 | } 180 | 181 | let isGithubIssueExists = res && res.status === 'exists'; 182 | 183 | if (isGithubIssueExists) { 184 | console.log({ 185 | severity: 'notice', 186 | requestId: requestId, 187 | service: 'github', 188 | message: `issue ${res.issue} already exists` 189 | }); 190 | } 191 | 192 | let q = queue(1); 193 | message.users.forEach((user) => { 194 | user = incoming.checkUser(user, gitHubDefaultUser, slackDefaultChannel, requestId, message); 195 | q.defer(incoming.callSlack, user, message, requestId, slackDefaultChannel, slackBotToken, res); 196 | }); 197 | 198 | q.awaitAll(function(err, status) { 199 | if (err) { 200 | console.log({ 201 | severity: 'error', 202 | requestId: requestId, 203 | service: 'slack', 204 | message: err, 205 | status: status 206 | }); 207 | return callback(lambdaFailure); 208 | } 209 | 210 | console.log({ 211 | severity: 'info', 212 | requestId: requestId, 213 | service: 'lambda', 214 | message: 'nag routing success - opened GitHub issue' 215 | }); 216 | return callback(null, lambdaSuccess); 217 | }); 218 | }); 219 | } 220 | // HIGH-PRIORITY 221 | else if (message.type === 'high-priority') { 222 | incoming.callPagerDuty(message, requestId, pagerDutyApiKey, pagerDutyServiceId, pagerDutyFromAddress, (err, res) => { 223 | if (err) { 224 | console.log({ 225 | severity: 'error', 226 | requestId: requestId, 227 | service: 'pagerduty', 228 | message: err 229 | }); 230 | return callback(lambdaFailure); 231 | } 232 | console.log({ 233 | severity: 'info', 234 | requestId: requestId, 235 | service: 'lambda', 236 | message: `high-priority routing success - ${res}` 237 | }); 238 | return callback(null, lambdaSuccess); 239 | }); 240 | } 241 | 242 | // LOW-PRIORITY 243 | else if (message.type === 'low-priority') { 244 | let user = undefined; 245 | 246 | if (Array.isArray(message.users)) { 247 | user = message.users[0]; 248 | } 249 | 250 | user = incoming.checkUser(user, gitHubDefaultUser, slackDefaultChannel); 251 | 252 | incoming.callGitHub(user, message, requestId, gitHubOwner, gitHubRepo, gitHubToken, (err, res) => { 253 | if (err) { 254 | console.log({ 255 | severity: 'error', 256 | requestId: requestId, 257 | service: 'github', 258 | message: err 259 | }); 260 | return callback(lambdaFailure); 261 | } 262 | 263 | // NOTE: If the GitHub issue already exists and message.retrigger is false, halt alert and return 264 | let isGithubIssueExists = res && res.status === 'exists'; 265 | 266 | if (isGithubIssueExists) { 267 | console.log({ 268 | severity: 'notice', 269 | requestId: requestId, 270 | service: 'github', 271 | message: `issue ${res.issue} already exists` 272 | }); 273 | return callback(null, lambdaSuccess); 274 | } 275 | 276 | console.log({ 277 | severity: 'info', 278 | requestId: requestId, 279 | service: 'lambda', 280 | message: 'low priority routing success - opened GitHub issue' 281 | }); 282 | return callback(null, lambdaSuccess); 283 | }); 284 | } 285 | 286 | else { 287 | incoming.callPagerDuty(message, requestId, pagerDutyApiKey, pagerDutyServiceId, pagerDutyFromAddress, (err, res) => { 288 | // log that fallback was invoked 289 | console.log({ 290 | severity: 'warning', 291 | requestId: requestId, 292 | service: 'lambda', 293 | message: 'no recognized message priority fallback to PagerDuty alert' 294 | }); 295 | 296 | if (err) { 297 | console.log({ 298 | severity: 'error', 299 | requestId: requestId, 300 | service: 'pagerduty', 301 | message: err 302 | }); 303 | return callback(lambdaFailure); 304 | } 305 | console.log({ 306 | severity: 'info', 307 | requestId: requestId, 308 | service: 'lambda', 309 | message: `fallback routing success - ${res}` 310 | }); 311 | return callback(null, lambdaSuccess); 312 | }); 313 | } 314 | }); 315 | }); 316 | }; 317 | 318 | /** 319 | * Ingest and validate SNS event object 320 | * 321 | * @param {object} event - SNS event object, contains message 322 | * @param {function} callback 323 | */ 324 | incoming.checkEvent = function(event, callback) { 325 | if (event.Records === undefined || !Array.isArray(event.Records)) return callback(`SNS message malformed: ${JSON.stringify(event)} `); 326 | if (event.Records.length > 1) return callback(`SNS message contains more than one record: ${JSON.stringify(event)}`); 327 | else { 328 | let message; 329 | try { 330 | message = JSON.parse(event.Records[0].Sns.Message); 331 | } catch (err) { 332 | return callback(`SNS message contains invalid JSON: ${JSON.stringify(event)}`); 333 | } 334 | return callback(null, message); 335 | } 336 | }; 337 | 338 | /** 339 | * Validate user object, substitute in default values if necessary 340 | * 341 | * @param {object} user - user object, contains usernames 342 | * @param {string} gitHubDefaultUser - default GitHub user or team, substitute if user.github missing 343 | * @param {string} slackDefaultChannel - default Slack channel, substitute if user.slack is missing 344 | */ 345 | incoming.checkUser = function(user, gitHubDefaultUser, slackDefaultChannel, requestId, message) { 346 | if (!user) { 347 | console.log({ 348 | severity: 'error', 349 | requestId: requestId, 350 | service: 'checkUser', 351 | message: `checkUser called with undefined user, defaulting user array. Message: ${JSON.stringify(message)}` 352 | }); 353 | user = { 354 | defaulted: true, 355 | slackId: slackDefaultChannel, 356 | github: gitHubDefaultUser 357 | }; 358 | return user; 359 | } 360 | 361 | user.defaulted = false; 362 | if (!user.slackId) { 363 | // missing Slack ID, fallback to default channel 364 | user.slackId = slackDefaultChannel; 365 | // set defaulted to true 366 | user.defaulted = true; 367 | } 368 | if (!user.github) { 369 | // missing GitHub handle, fallback to default user/team 370 | user.github = gitHubDefaultUser; 371 | } 372 | 373 | return user; 374 | }; 375 | 376 | /** 377 | * Trigger lib/github.js functionality, create GitHub issue for dispatch alert 378 | * 379 | * @param {object} user - user object, contains usernames 380 | * @param {object} message - message object, contains GitHub issue title, body, and labels 381 | * @param {string} requestId - unique ID per dispatch alert 382 | * @param {string} gitHubOwner 383 | * @param {string} gitHubRepo 384 | * @param {string} gitHubToken 385 | * @param {function} callback 386 | */ 387 | incoming.callGitHub = function(user, message, requestId, gitHubOwner, gitHubRepo, gitHubToken, callback) { 388 | let options = { 389 | owner: gitHubOwner, 390 | repo: gitHubRepo, 391 | title: message.body.github.title 392 | }; 393 | 394 | // labels can be passed in as an array of strings, and said labels will be applied to 395 | // the new issue. any labels that don't already exist in that repo will be created 396 | // https://developer.github.com/v3/issues/#create-an-issue 397 | if (message.body.github.labels) { 398 | options.labels = message.body.github.labels; 399 | } else if (message.body.github.label) { 400 | options.labels = [message.body.github.label]; 401 | } 402 | 403 | // BROADCAST 404 | if (message.type === 'broadcast') { 405 | // if broadcast to >1 users, compile list of recipient Slack display_names or IDs 406 | let userArray = message.users.map(function(obj) { 407 | if (obj.slack) return obj.slack; 408 | else return obj.slackId; 409 | }); 410 | // add recipient list wrapped in GitHub MD code tags 411 | options.body = `${message.body.github.body} \n\n \`\`\`\n${userArray.toString()}\n\`\`\``; 412 | } 413 | 414 | // SELF-SERVICE or LOW-PRIORITY 415 | if ((message.type === 'self-service') || (message.type === 'low-priority')) { 416 | options.body = `${message.body.github.body} \n\n @${user.github}`; 417 | } 418 | 419 | if (message.type === 'nag') { 420 | options.body = message.body.github.body; 421 | } 422 | 423 | github.createIssue(options, message.retrigger, gitHubToken) 424 | .then(res => { 425 | return callback(null, res); 426 | }).catch(err => { 427 | callback(err); 428 | }); 429 | }; 430 | 431 | /** 432 | * Trigger lib/pagerduty.js functionality, create PagerDuty incident for dispatch alert 433 | * 434 | * @param {object} message - message object, contains PagerDuty service, title, and incident body 435 | * @param {string} requestId - unique ID per dispatch alert 436 | * @param {string} pagerDutyApiKey 437 | * @param {string} pagerDutyServiceId 438 | * @param {string} pagerDutyFromAddress 439 | * @param {function} callback 440 | */ 441 | incoming.callPagerDuty = function(message, requestId, pagerDutyApiKey, pagerDutyServiceId, pagerDutyFromAddress, callback) { 442 | let options = { 443 | accessToken: pagerDutyApiKey, 444 | title: message.body.pagerduty.title, 445 | serviceId: pagerDutyServiceId, 446 | incidentKey: message.body.pagerduty.title, 447 | from: pagerDutyFromAddress 448 | }; 449 | 450 | if (message.body.pagerduty.body) options.body = message.body.pagerduty.body; 451 | 452 | let incident = pagerduty.createIncident(options); 453 | 454 | incident 455 | .then(value => { callback(null, `PagerDuty incident ${value.body.incident.incident_key} created`); }) 456 | .catch(error => { callback(error); }); 457 | }; 458 | 459 | /** 460 | * Trigger lib/slack.js functionality, send Slack message for dispatch alert 461 | * 462 | * @param {object} user - user object, contains Slack ID (destination) 463 | * @param {object} message - message object, contains Slack message body and interactive options 464 | * @param {string} requestId - unique ID per dispatch alert 465 | * @param {string} slackDefaultChannel - passed again as a fallback for issue with Slack username 466 | * @param {object} resGitHub - response object from callGitHub 467 | * @param {string} slackBotToken 468 | * @param {function} callback 469 | */ 470 | incoming.callSlack = function(user, message, requestId, slackDefaultChannel, slackBotToken, resGitHub, callback) { 471 | const client = new WebClient(slackBotToken); 472 | 473 | message.url = resGitHub.url; 474 | message.number = resGitHub.number; 475 | message.requestId = requestId; 476 | 477 | slack.alertToSlack(user, message, slackDefaultChannel, client, (err, status) => { 478 | if (err) return callback(err); 479 | return callback(null, status); 480 | }); 481 | }; 482 | 483 | module.exports = incoming; 484 | -------------------------------------------------------------------------------- /incoming/function.template.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const lambdaCfn = require('@mapbox/lambda-cfn'); 4 | 5 | const lambdaTemplate = lambdaCfn.build({ 6 | name: 'incoming', 7 | handler: 'incoming/function.fn', 8 | memorySize: '1536', 9 | timeout: '300', 10 | parameters: { 11 | PagerDutyApiKey: { 12 | Type: 'String', 13 | Description: '[secure] PagerDuty API key' 14 | }, 15 | PagerDutyFromAddress: { 16 | Type: 'String', 17 | Description: 'PagerDuty account email address' 18 | }, 19 | PagerDutyServiceId: { 20 | Type: 'String', 21 | Description: 'PagerDuty service ID' 22 | }, 23 | GitHubDefaultUser: { 24 | Type: 'String', 25 | Description: 'Default GitHub user or team to be tagged in dispatch issues' 26 | }, 27 | GitHubOwner: { 28 | Type: 'String', 29 | Description: 'Owner of GitHub repo' 30 | }, 31 | GitHubRepo: { 32 | Type: 'String', 33 | Description: 'Default GitHub repo for dispatch issues' 34 | }, 35 | GitHubToken: { 36 | Type: 'String', 37 | Description: '[secure] GitHub OAuth token' 38 | }, 39 | KmsKey: { 40 | Type: 'String', 41 | Description: 'cloudformation-kms stack name or KMS key ARN' 42 | }, 43 | SlackBotToken: { 44 | Type: 'String', 45 | Description: '[secure] Slack API bot token' 46 | }, 47 | SlackDefaultChannel: { 48 | Type: 'String', 49 | Description: 'Default Slack channel for dispatch, MUST INCLUDE #' 50 | } 51 | }, 52 | statements: [ 53 | { 54 | Effect: 'Allow', 55 | Action: [ 56 | 'kms:Decrypt' 57 | ], 58 | Resource: { 59 | 'Fn::ImportValue': { 60 | 'Ref': 'KmsKey' 61 | } 62 | } 63 | } 64 | ], 65 | eventSources: { 66 | sns: {} 67 | } 68 | }); 69 | 70 | module.exports = lambdaTemplate; 71 | -------------------------------------------------------------------------------- /lib/github.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const GitHubApi = require('@octokit/rest'); 4 | 5 | /** 6 | * Authenticate and initialize GitHub API 7 | * 8 | * @name authenticate 9 | * @param {string} token - GitHub token 10 | */ 11 | function authenticate(token) { 12 | const github = new GitHubApi({}); 13 | 14 | github.authenticate({ 15 | type: 'oauth', 16 | token: token 17 | }); 18 | 19 | return github; 20 | } 21 | 22 | /** 23 | * Check for existing GitHub issue 24 | * 25 | * @name checkForIssue 26 | * @param {object} options - contains owner, repo, issue title, and body 27 | * @param {boolean} retrigger - if true, sends dispatch alert if issue is already open 28 | * if false, does not send a new alert for existing issue 29 | * @param {string} token - GitHub token 30 | */ 31 | const checkForIssue = (options, retrigger, token) => { 32 | return new Promise ((resolve, reject) => { 33 | const github = authenticate(token); 34 | let issues = []; 35 | let issueState = 'open'; 36 | 37 | if (retrigger === false) { 38 | issueState = 'all'; 39 | } 40 | 41 | github.issues.getForRepo({ 42 | owner: options.owner, 43 | repo: options.repo, 44 | state: issueState 45 | }, getIssues); 46 | 47 | function getIssues(err, res) { 48 | if (err) { 49 | // if there are no issues found, resolve empty array 50 | if (err.status === 'Not Found') resolve([]); 51 | reject(err); 52 | } else { 53 | issues = issues.concat(res.data); 54 | if (github.hasNextPage(res)) { 55 | github.getNextPage(res, getIssues); 56 | } else { 57 | const match = issues.filter(issue => issue.title === options.title); 58 | resolve(match); 59 | } 60 | } 61 | } 62 | }); 63 | }; 64 | 65 | /** 66 | * Create a new GitHub issue 67 | * 68 | * @name createIssue 69 | * @param {object} options - contains owner, repo, issue title, and body 70 | * @param {boolean} retrigger - if true, sends dispatch alert if issue is already open 71 | * if false, does not send a new alert for existing issue 72 | * @param {string} token - GitHub token 73 | */ 74 | const createIssue = (options, retrigger, token) => { 75 | return new Promise ((resolve, reject) => { 76 | const github = authenticate(token); 77 | const issueExists = checkForIssue(options, retrigger, token); 78 | 79 | issueExists.then(response => { 80 | if (response.length > 0) { 81 | resolve({ 82 | status: 'exists', 83 | issue: response[0].number, 84 | number: response[0].number, 85 | }); 86 | } else { 87 | let output = options; 88 | 89 | github.issues.create(options, (err, res) => { 90 | if (err) reject(err); 91 | if (res) { 92 | output.number = res.data.number; 93 | output.url = res.data.html_url; 94 | } 95 | 96 | resolve(output); 97 | }); 98 | } 99 | }).catch(err => { 100 | reject(err); 101 | }); 102 | }); 103 | }; 104 | 105 | /** 106 | * Close a GitHub issue 107 | * 108 | * @name closeIssue 109 | * @param {object} options - contains owner, repo, issue number 110 | * @param {string} token - GitHub token 111 | */ 112 | const closeIssue = (options, token) => { 113 | return new Promise ((resolve, reject) => { 114 | const github = authenticate(token); 115 | 116 | github.issues.edit({ 117 | owner: options.owner, 118 | repo: options.repo, 119 | number: options.number, 120 | state: 'closed' 121 | }, (err, res) => { 122 | if (err) reject(err); 123 | else resolve(res); 124 | }); 125 | }); 126 | }; 127 | 128 | module.exports.authenticate = authenticate; 129 | module.exports.checkForIssue = checkForIssue; 130 | module.exports.createIssue = createIssue; 131 | module.exports.closeIssue = closeIssue; 132 | -------------------------------------------------------------------------------- /lib/pagerduty.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const PagerDuty = require('@mapbox/pagerduty'); 4 | 5 | /** 6 | * Create a new PagerDuty incident 7 | * 8 | * @param {object} options - contains accessToken, title, serviceId, incidentKey, and from address 9 | */ 10 | const createIncident = (options) => { 11 | return new Promise ((resolve, reject) => { 12 | const pd = new PagerDuty({pagerDutyToken: options.accessToken}); 13 | 14 | let config = { 15 | path: 'incidents', 16 | body: { 17 | incident: { 18 | type: 'incident', 19 | title: options.title, 20 | service: { 21 | id: options.serviceId, 22 | type: 'service_reference' 23 | }, 24 | incident_key: options.incidentKey 25 | } 26 | }, 27 | headers: { 28 | From: options.from 29 | } 30 | }; 31 | 32 | if (options.body) { 33 | config.body.incident.body = { 34 | type: 'incident_body', 35 | details: options.body 36 | }; 37 | } 38 | 39 | pd.post(config, (err, res) => { 40 | if (err) reject(err); 41 | resolve(res); 42 | }); 43 | }); 44 | }; 45 | 46 | module.exports.createIncident = createIncident; 47 | -------------------------------------------------------------------------------- /lib/slack.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const utils = require('./utils'); 4 | 5 | const slack = {}; 6 | 7 | /** 8 | * Encompasses all Slack alert functionality 9 | * 10 | * @param {object} user - user object, contains Slack ID (destination) 11 | * @param {object} inputMessage - SNS message body from incoming/function.js 12 | * @param {string} slackChannel - Fallback Slack channel in the event of error alerting to destination 13 | * @param {object} client - Slack WebClient, created in incoming/function.js 14 | * @param {function} callback - returns err and status 15 | */ 16 | slack.alertToSlack = function(user, inputMessage, slackChannel, client, callback) { 17 | if (!inputMessage.number) { 18 | return callback(`Error - dispatch ${inputMessage.requestId} message body missing GitHub issue number`); 19 | } 20 | 21 | utils.encode({ github: inputMessage.number, requestId: inputMessage.requestId }, (err, res) => { 22 | if (err) return callback(err); 23 | 24 | // add encode result to SNS message as callback_id for Slack API 25 | inputMessage.callback_id = res; 26 | 27 | slack.formatMessage(inputMessage, (err, message, prompt) => { 28 | if (err) return callback(err); 29 | 30 | // check if user.slackID fell back to slackDefaultChannel 31 | if (user.defaulted) { 32 | // send message 33 | const fallbackChannel = user.slackId; 34 | slack.postAlert(fallbackChannel, message, client, slackChannel, inputMessage.requestId, (err, res) => { 35 | if (err) return callback(err); 36 | 37 | // create status object 38 | let status = { 39 | alert: res.ok, 40 | destination: user.slackId, 41 | message: res.message.text, 42 | url: inputMessage.url 43 | }; 44 | 45 | // send error, do not send question prompt to fallback channel 46 | let fallbackMessage = { 47 | text: `Slack user ID was missing, fellback to \`${fallbackChannel}\` for requestId ${requestId}` 48 | }; 49 | slack.postAlert(fallbackChannel, fallbackMessage, client, slackChannel, inputMessage.requestId, (err, res) => { 50 | if (err) return callback(err); 51 | 52 | // add fallbackMessage to status message 53 | status.message = `${status.message}, Fallback: ${res.message.text}`; 54 | 55 | return callback(null, status); 56 | }); 57 | }); 58 | } 59 | // user.slackId did not default 60 | else { 61 | // send message 62 | slack.postAlert(user.slackId, message, client, slackChannel, inputMessage.requestId, (err, res) => { 63 | if (err) return callback(err); 64 | 65 | let status = { 66 | alert: res.ok, 67 | destination: user.slackId, 68 | message: res.message.text, 69 | url: inputMessage.url 70 | }; 71 | 72 | // send prompt 73 | if (prompt) { 74 | slack.postAlert(user.slackId, prompt, client, slackChannel, inputMessage.requestId, (err, res) => { 75 | if (err) return callback(err); 76 | 77 | // add prompt to status message 78 | status.message = `${status.message}, Prompt: ${res.message.text}`; 79 | 80 | return callback(null, status); 81 | }); 82 | } else { 83 | return callback(null, status); 84 | } 85 | }); 86 | } 87 | }); 88 | }); 89 | }; 90 | 91 | /** 92 | * Formats SNS message contents for posting to Slack 93 | * 94 | * @param {object} inputMessage - SNS message body 95 | * @param {function} callback - returns err, message, and prompt (if needed) 96 | */ 97 | slack.formatMessage = function(inputMessage, callback) { 98 | try { 99 | let message = { 100 | text: inputMessage.body.slack.message, 101 | attachments: [ 102 | { 103 | fallback: 'Could not load GitHub issue.', 104 | callback_id: inputMessage.callback_id, 105 | attachment_type: 'default' 106 | } 107 | ] 108 | }; 109 | 110 | if (inputMessage.url && inputMessage.type != 'broadcast') message.attachments[0].text = inputMessage.url; 111 | 112 | if (inputMessage.body.slack.prompt) { 113 | let prompt = { 114 | text: inputMessage.body.slack.prompt, 115 | attachments: [ 116 | { 117 | fallback: 'You are unable to address this alert via Slack, refer to the GitHub issue.', 118 | callback_id: inputMessage.callback_id, 119 | attachment_type: 'default', 120 | actions: [ 121 | { 122 | name: 'yes', 123 | text: inputMessage.body.slack.actions.yes, 124 | type: 'button', 125 | value: (inputMessage.body.slack.actions.yes_response ? inputMessage.body.slack.actions.yes_response : false) 126 | }, 127 | { 128 | name: 'no', 129 | text: inputMessage.body.slack.actions.no, 130 | type: 'button', 131 | value: (inputMessage.body.slack.actions.no_response ? inputMessage.body.slack.actions.no_response : false), 132 | style: 'danger' 133 | } 134 | ] 135 | } 136 | ] 137 | }; 138 | return callback(null, message, prompt); 139 | } else { 140 | return callback(null, message, null); 141 | } 142 | } catch (err) { 143 | return callback(err); 144 | } 145 | }; 146 | 147 | /** 148 | * Posts an alert to Slack 149 | * 150 | * @param {string} destination - Slack alert destination, either a user or default channel 151 | * @param {object} message - message or prompt body generated by formatMessage 152 | * @param {object} client - Slack WebClient, created in incoming/function.js 153 | * @param {string} slackChannel - Fallback Slack channel in the event of error alerting to destination 154 | * @param {string} requestId - unique ID per dispatch alert 155 | * @param {function} callback - returns err and res 156 | */ 157 | slack.postAlert = function(destination, message, client, slackChannel, requestId, callback) { 158 | if (!message.text) return callback('missing Slack message body'); 159 | 160 | let options; 161 | 162 | if (destination.indexOf('#') > -1) { 163 | // destination is a channel 164 | options = { attachments: message.attachments }; 165 | } else { 166 | // destination is a user 167 | options = { 'as_user': true, attachments: message.attachments }; 168 | } 169 | 170 | client.chat.postMessage(destination, message.text, options, (err, res) => { 171 | if (err) { 172 | // log initial error, do not return until postFailure attempt 173 | console.log({ 174 | severity: 'error', 175 | requestId: requestId, 176 | service: 'slack', 177 | message: `${err} for destination ${destination}` 178 | }); 179 | 180 | // log error to default SlackChannel for visibility 181 | let postFailure = { 182 | text: `Error sending message to \`${destination}\` for requestId ${requestId}`, 183 | attachments: [ 184 | { 185 | title: 'Slack error message', 186 | text: JSON.stringify(res) 187 | } 188 | ] 189 | }; 190 | 191 | client.chat.postMessage(slackChannel, postFailure.text, { attachments: postFailure.attachments }, (err, res) => { 192 | if (err) return callback(`${err} for destination ${slackChannel}`, res); 193 | return callback(null, res); 194 | }); 195 | } else { 196 | return callback(null, res); 197 | } 198 | }); 199 | }; 200 | 201 | module.exports = slack; 202 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const dke = require('decrypt-kms-env'); 4 | const base64url = require('base64url'); 5 | const crypto = require('crypto'); 6 | 7 | /** 8 | * Decrypt environment via dke 9 | * 10 | * @param {object} env - environment 11 | * @param {function} callback - returns err, decrypted env 12 | */ 13 | function decrypt(env, callback) { 14 | if (process.env.NODE_ENV === 'test') { 15 | callback(null, 'scrubbed'); 16 | } else { 17 | dke(env, function(err, scrubbed) { 18 | if (err) throw err; 19 | callback(null, scrubbed); 20 | }); 21 | } 22 | } 23 | 24 | /** 25 | * Encode data via Base64 26 | * 27 | * @param {object} data - data to be encoded 28 | * @param {function} callback - returns err, encoded data 29 | */ 30 | function encode(data, callback) { 31 | if (!data.github) { 32 | return callback('no github issue found for slack callback_id creation'); 33 | } 34 | if (!data.requestId) { 35 | data.requestId = crypto.randomBytes(6).toString('hex'); 36 | } 37 | return callback(null, base64url(JSON.stringify(data))); 38 | } 39 | 40 | /** 41 | * Decode Base64 encoded data 42 | * 43 | * @param {object} slackCallbackId - encoded data 44 | * @param {function} callback - returns err, decoded data 45 | */ 46 | function decode(slackCallbackId, callback) { 47 | let data; 48 | try { 49 | data = JSON.parse(base64url.decode(slackCallbackId)); 50 | } catch (e) { 51 | return callback('parse error on Slack callback_id'); 52 | } 53 | return callback(null, data); 54 | }; 55 | 56 | module.exports.decrypt = decrypt; 57 | module.exports.encode = encode; 58 | module.exports.decode = decode; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mapbox/dispatch", 3 | "version": "1.2.0", 4 | "description": "Alert routing tool", 5 | "main": "index.js", 6 | "engines": { 7 | "node": "^10.15.3" 8 | }, 9 | "scripts": { 10 | "lint": "eslint -c .eslintrc.js **/*.js test/**/*.js", 11 | "unit-test": "NODE_ENV=test tape test/*.test.js test/**/*.test.js", 12 | "test": "eslint -c .eslintrc.js **/*.js test/**/*.js && NODE_ENV=test tape test/*.test.js test/**/*.test.js | tap-spec" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/mapbox/dispatch.git" 17 | }, 18 | "author": "Mapbox", 19 | "license": "BSD-2-Clause", 20 | "bugs": { 21 | "url": "https://github.com/mapbox/dispatch/issues" 22 | }, 23 | "homepage": "https://github.com/mapbox/dispatch#readme", 24 | "dependencies": { 25 | "@mapbox/lambda-cfn": "^3.1.0", 26 | "@mapbox/pagerduty": "^3.0.0", 27 | "@octokit/rest": "^15.8.0", 28 | "@slack/client": "^3.16.0", 29 | "aws-sdk": "^2.536.0", 30 | "base64url": "^3.0.1", 31 | "d3-queue": "^3.0.0", 32 | "decrypt-kms-env": "^3.0.0", 33 | "request": "^2.88.0" 34 | }, 35 | "devDependencies": { 36 | "eslint": "^6.4.0", 37 | "nock": "^10.0.6", 38 | "sinon": "^7.5.0", 39 | "tap-spec": "^5.0.0", 40 | "tape": "^4.11.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/fixtures/github.fixtures.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports.issue1 = issue1; 4 | module.exports.manyIssues = manyIssues; 5 | module.exports.closedIssue = closedIssue; 6 | 7 | module.exports.broadcastIssue = { 8 | url: 'https://api.github.com/repos/testGitHubOwner/testGitHubRepo/issues/7', 9 | repository_url: 'https://api.github.com/repos/testGitHubOwner/testGitHubRepo', 10 | labels_url: 'https://api.github.com/repos/testGitHubOwner/testGitHubRepo/issues/7/labels{/name}', 11 | comments_url: 'https://api.github.com/repos/testGitHubOwner/testGitHubRepo/issues/7/comments', 12 | events_url: 'https://api.github.com/repos/testGitHubOwner/testGitHubRepo/issues/7/events', 13 | html_url: 'https://github.com/testGitHubOwner/testGitHubRepo/issues/7', 14 | id: 123, 15 | number: 7, 16 | title: 'testGitHubTitle', 17 | user: {}, 18 | labels: [], 19 | state: 'open', 20 | locked: false, 21 | assignee: null, 22 | assignees: [], 23 | milestone: null, 24 | comments: 0, 25 | created_at: '2017-08-02T23:36:11Z', 26 | updated_at: '2017-08-02T23:36:11Z', 27 | closed_at: null, 28 | body: 'testGithubBody\n\n testSlackID' 29 | }; 30 | 31 | module.exports.lowPriorityIssue = { 32 | url: 'https://api.github.com/repos/testGitHubOwner/testGitHubRepo/issues/7', 33 | repository_url: 'https://api.github.com/repos/testGitHubOwner/testGitHubRepo', 34 | labels_url: 'https://api.github.com/repos/testGitHubOwner/testGitHubRepo/issues/7/labels{/name}', 35 | comments_url: 'https://api.github.com/repos/testGitHubOwner/testGitHubRepo/issues/7/comments', 36 | events_url: 'https://api.github.com/repos/testGitHubOwner/testGitHubRepo/issues/7/events', 37 | html_url: 'https://github.com/testGitHubOwner/testGitHubRepo/issues/7', 38 | id: 123, 39 | number: 7, 40 | title: 'testGitHubTitle', 41 | user: {}, 42 | labels: [], 43 | state: 'open', 44 | locked: false, 45 | assignee: null, 46 | assignees: [], 47 | milestone: null, 48 | comments: 0, 49 | created_at: '2017-08-02T23:36:11Z', 50 | updated_at: '2017-08-02T23:36:11Z', 51 | closed_at: null, 52 | body: 'testGithubBody' 53 | }; 54 | 55 | module.exports.selfServiceIssue = { 56 | id: 123, 57 | url: 'https://api.github.com/repos/testGitHubOwner/testGitHubRepo/issues/7', 58 | repository_url: 'https://api.github.com/repos/testGitHubOwner/testGitHubRepo', 59 | labels_url: 'https://api.github.com/repos/testGitHubOwner/testGitHubRepo/issues/7/labels{/name}', 60 | comments_url: 'https://api.github.com/repos/testGitHubOwner/testGitHubRepo/issues/7/comments', 61 | events_url: 'https://api.github.com/repos/testGitHubOwner/testGitHubRepo/issues/7/events', 62 | html_url: 'https://github.com/testGitHubOwner/testGitHubRepo/issues/7', 63 | number: 7, 64 | state: 'open', 65 | title: 'testGitHubTitle', 66 | body: 'testGitHubBody\n\n @testGitHubUser', 67 | user: {}, 68 | labels: [], 69 | assignee: null, 70 | assignees: [], 71 | milestone: null, 72 | locked: false, 73 | comments: 0, 74 | closed_at: null, 75 | created_at: '2017-08-02T23:36:11Z', 76 | updated_at: '2017-08-02T23:36:11Z' 77 | }; 78 | 79 | module.exports.noIssueFound = { 80 | message: '{"message":"Not Found","documentation_url":"https://developer.github.com/v3/issues/#list-issues-for-a-repository"}', 81 | code: 404, 82 | status: 'Not Found', 83 | headers: { 84 | 'content-type': 'application/json; charset=utf-8', 85 | status: '404 Not Found' 86 | } 87 | }; 88 | 89 | const dataDump = { 90 | url: 'https://api.github.com/repos/testOwner/testRepo/issues/7', 91 | repository_url: 'https://api.github.com/repos/testOwner/testRepo', 92 | labels_url: 'https://api.github.com/repos/testOwner/testRepo/issues/7/labels{/name}', 93 | comments_url: 'https://api.github.com/repos/testOwner/testRepo/issues/7/comments', 94 | events_url: 'https://api.github.com/repos/testOwner/testRepo/issues/7/events', 95 | html_url: 'https://github.com/testOwner/testRepo/issues/7', 96 | id: 123, 97 | number: 7, 98 | title: 'testTitle', 99 | user: {}, 100 | labels: [], 101 | state: 'open', 102 | locked: false, 103 | assignee: null, 104 | assignees: [], 105 | milestone: null, 106 | comments: 0, 107 | created_at: '2017-08-02T23:36:11Z', 108 | updated_at: '2017-08-02T23:36:11Z', 109 | closed_at: null, 110 | body: 'testBody' 111 | }; 112 | 113 | function issue1() { 114 | let issue1 = dataDump; 115 | return issue1; 116 | } 117 | 118 | function closedIssue() { 119 | let closedIssue = { 120 | url: 'https://api.github.com/repos/testOwner/testRepo/issues/7', 121 | repository_url: 'https://api.github.com/repos/testOwner/testRepo', 122 | labels_url: 'https://api.github.com/repos/testOwner/testRepo/issues/7/labels{/name}', 123 | comments_url: 'https://api.github.com/repos/testOwner/testRepo/issues/7/comments', 124 | events_url: 'https://api.github.com/repos/testOwner/testRepo/issues/7/events', 125 | html_url: 'https://github.com/testOwner/testRepo/issues/7', 126 | id: 123, 127 | number: 7, 128 | title: 'testTitle', 129 | user: {}, 130 | labels: [], 131 | state: 'closed', 132 | locked: false, 133 | assignee: null, 134 | assignees: [], 135 | milestone: null, 136 | comments: 0, 137 | created_at: '2017-08-02T23:36:11Z', 138 | updated_at: '2017-08-02T23:36:11Z', 139 | closed_at: null, 140 | body: 'testBody', 141 | 'closed_by': { 142 | 'login': 'testUser' 143 | } 144 | }; 145 | 146 | return closedIssue; 147 | } 148 | 149 | function manyIssues() { 150 | let issues = []; 151 | 152 | for (var i = 0; i <= 150; i++) { 153 | issues.push(dataDump); 154 | } 155 | 156 | return issues; 157 | } 158 | -------------------------------------------------------------------------------- /test/fixtures/incoming.fixtures.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports.missingPriorityEvent = { 4 | Records: 5 | [ 6 | { EventSource: 'aws:sns', 7 | Sns: { 8 | Message: JSON.stringify( 9 | { 10 | users: [ { github: 'testGitHubUser', slack: 'testSlackUser', slackId: 'testSlackId' }], 11 | body: { 12 | github: { 13 | title: 'testGitHubTitle', 14 | body: 'testGitHubBody' 15 | }, 16 | slack: { 17 | message: 'testSlackMessage', 18 | actions: { 19 | yes: 'testYesAction', 20 | no: 'testNoAction' 21 | } 22 | } 23 | } 24 | } 25 | ) 26 | } 27 | } 28 | ] 29 | }; 30 | 31 | module.exports.broadcastEvent = { 32 | Records: 33 | [ 34 | { EventSource: 'aws:sns', 35 | Sns: { 36 | Message: JSON.stringify( 37 | { 38 | type: 'broadcast', 39 | users: [ 40 | { 41 | slack: 'testSlackUser1', 42 | slackId: 'testSlackId1' 43 | }, 44 | { 45 | slack: 'testSlackUser2', 46 | slackId: 'testSlackId2' 47 | }, 48 | { 49 | slack: 'testSlackUser3', 50 | slackId: 'testSlackId3' 51 | } 52 | ], 53 | body: { 54 | github: { 55 | title: 'testGithubTitle', 56 | body: 'testGithubBody' 57 | }, 58 | pagerduty: { 59 | title: 'testPagerDutyTitle' 60 | }, 61 | slack: { 62 | message: 'testSlackMessage' 63 | } 64 | } 65 | } 66 | ) 67 | } 68 | } 69 | ] 70 | }; 71 | 72 | module.exports.highPriorityEvent = { 73 | Records: 74 | [{ EventSource: 'aws:sns', 75 | Sns: { 76 | Message: JSON.stringify({ 77 | type: 'high-priority', 78 | body: { 79 | pagerduty: { 80 | title: 'testPagerDutyTitle' 81 | } 82 | }, 83 | requestId: 'testRequestId' 84 | }) 85 | } 86 | }] 87 | }; 88 | 89 | module.exports.lowPriorityEvent = { 90 | Records: 91 | [ 92 | { EventSource: 'aws:sns', 93 | Sns: { 94 | Message: JSON.stringify( 95 | { 96 | type: 'low-priority', 97 | users: [ { github: 'testGitHubUser' }], 98 | body: { 99 | github: { 100 | title: 'testGitHubTitle', 101 | body: 'testGitHubBody' 102 | } 103 | } 104 | } 105 | ) 106 | } 107 | } 108 | ] 109 | }; 110 | 111 | module.exports.labelledEvent = { 112 | Records: 113 | [ 114 | { EventSource: 'aws:sns', 115 | Sns: { 116 | Message: JSON.stringify( 117 | { 118 | type: 'low-priority', 119 | users: [ { github: 'testGitHubUser' }], 120 | body: { 121 | github: { 122 | title: 'testGitHubTitle', 123 | body: 'testGitHubBody', 124 | labels: ['low_priority'] 125 | } 126 | } 127 | } 128 | ) 129 | } 130 | } 131 | ] 132 | }; 133 | 134 | module.exports.lowPriorityEventNoUser = { 135 | Records: 136 | [ 137 | { EventSource: 'aws:sns', 138 | Sns: { 139 | Message: JSON.stringify( 140 | { 141 | type: 'low-priority', 142 | body: { 143 | github: { 144 | title: 'testGitHubTitle', 145 | body: 'testGitHubBody' 146 | } 147 | } 148 | } 149 | ) 150 | } 151 | } 152 | ] 153 | }; 154 | 155 | module.exports.nagEvent = { 156 | Records: 157 | [ 158 | { EventSource: 'aws:sns', 159 | Sns: { 160 | Message: JSON.stringify( 161 | { 162 | type: 'nag', 163 | users: [ 164 | { 165 | github: 'testGitHubUser', 166 | slack: 'testSlackUser', 167 | slackId: 'testSlackId' 168 | } 169 | ], 170 | body: { 171 | github: { 172 | title: 'testGitHubTitle', 173 | body: 'testGitHubBody' 174 | }, 175 | slack: { 176 | message: 'testSlackMessage' 177 | } 178 | } 179 | } 180 | ) 181 | } 182 | } 183 | ] 184 | }; 185 | 186 | module.exports.unrecognizedEvent = { 187 | Records: 188 | [{ EventSource: 'aws:sns', 189 | Sns: { 190 | Message: JSON.stringify({ 191 | type: 'unrecognized', 192 | body: { 193 | pagerduty: { 194 | title: 'testPagerDutyTitle' 195 | } 196 | }, 197 | requestId: 'testRequestId' 198 | }) 199 | } 200 | }] 201 | }; 202 | 203 | module.exports.selfServiceEvent = { 204 | Records: 205 | [ 206 | { EventSource: 'aws:sns', 207 | Sns: { 208 | Message: JSON.stringify( 209 | { 210 | type: 'self-service', 211 | users: [ { github: 'testGitHubUser', slack: 'testSlackUser', slackId: 'testSlackId' }], 212 | body: { 213 | github: { 214 | title: 'testGitHubTitle', 215 | body: 'testGitHubBody' 216 | }, 217 | pagerduty: { 218 | title: 'testPagerDutyTitle' 219 | }, 220 | slack: { 221 | message: 'testSlackMessage', 222 | actions: { 223 | yes: 'testYesAction', 224 | no: 'testNoAction' 225 | } 226 | } 227 | } 228 | } 229 | ) 230 | } 231 | } 232 | ] 233 | }; 234 | 235 | module.exports.callGitHubEvent = { 236 | user: 'testGitHubDefaultUser', 237 | requestId: 'testRequestId', 238 | messageBroadcastError: { 239 | type: 'broadcast', 240 | users: [ 241 | { 242 | slackId: 'testSlackDefaultChannel' 243 | }, 244 | { 245 | slack: 'testSlackUser2', 246 | slackId: 'testSlackId2' 247 | }, 248 | { 249 | slack: 'testSlackUser3', 250 | slackId: 'testSlackId3' 251 | } 252 | ], 253 | body: { 254 | github: { 255 | title: 'testGithubTitle', 256 | body: 'testGithubBody' 257 | } 258 | } 259 | }, 260 | res: { 261 | owner: 'testGitHubOwner', 262 | repo: 'testGitHubRepo', 263 | title: 'testGithubTitle', 264 | body: 'testGithubBody \n\n ```\ntestSlackDefaultChannel,testSlackUser2,testSlackUser3\n```', 265 | number: 7, 266 | url: 'https://github.com/testGitHubOwner/testGitHubRepo/issues/7' 267 | } 268 | }; 269 | 270 | module.exports.userMissingGitHub = { 271 | slackId: 'testSlackId' 272 | }; 273 | 274 | module.exports.userDefautGitHub = { 275 | slackId: 'testSlackId', 276 | defaulted: false, 277 | github: 'testGitHubDefaultUser' 278 | }; 279 | 280 | module.exports.userMissingSlack = { 281 | github: 'testGitHubUsername' 282 | }; 283 | 284 | module.exports.userDefautSlack = { 285 | github: 'testGitHubUsername', 286 | defaulted: true, 287 | slackId: '#testSlackDefaultChannel' 288 | }; 289 | 290 | module.exports.malformedSNS = { 291 | Records: 'garbage' 292 | }; 293 | 294 | module.exports.multipleRecordSNS = { 295 | Records: 296 | [ 297 | { EventSource: 'aws:sns', 298 | Sns: { 299 | Message: JSON.stringify( 300 | { 301 | type: 'self-service', 302 | users: [ { github: 'testUser', slack: 'testUser' }], 303 | body: { 304 | github: { 305 | title: 'testGithubTitle', 306 | body: 'testGithubBody' 307 | }, 308 | slack: { 309 | message: 'testSlackMessage', 310 | actions: { 311 | yes: 'testYesAction', 312 | no: 'testNoAction' 313 | } 314 | } 315 | } 316 | } 317 | ) 318 | } 319 | }, 320 | { EventSource: 'aws:sns', 321 | Sns: { 322 | Message: JSON.stringify( 323 | { 324 | type: 'broadcast', 325 | users: [ 326 | { 327 | slack: 'testUser1' 328 | }, 329 | { 330 | slack: 'testUser2' 331 | }, 332 | { 333 | slack: 'testUser3' 334 | } 335 | ], 336 | body: { 337 | github: { 338 | title: 'testGithubTitle', 339 | body: 'testGithubBody' 340 | }, 341 | slack: { 342 | message: 'testSlackMessage' 343 | } 344 | } 345 | } 346 | ) 347 | } 348 | }, 349 | { EventSource: 'aws:sns', 350 | Sns: { 351 | Message: JSON.stringify({ 352 | type: 'high', 353 | body: { 354 | pagerduty: { 355 | title: 'testPagerDutyTitle' 356 | } 357 | } 358 | }) 359 | } 360 | } 361 | ] 362 | }; 363 | 364 | module.exports.invalidJsonSNS = { 365 | Records: 366 | [{ EventSource: 'aws:sns', 367 | Sns: { 368 | Message: 'garbage JSON' 369 | } 370 | }] 371 | }; 372 | -------------------------------------------------------------------------------- /test/fixtures/pagerduty.fixtures.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports.incident = { 4 | 'incident': { 5 | 'incident_number': 123512, 6 | 'title': 'this is a test', 7 | 'description': 'this is a test', 8 | 'created_at': '2017-08-04T22:37:22Z', 9 | 'status': 'triggered', 10 | 'pending_actions': [{ 11 | 'type': 'escalate', 12 | 'at': '2017-08-04T23:07:22Z' 13 | }], 14 | 'incident_key': 'testing', 15 | 'service': { 16 | 'id': 'XXXXXXX', 17 | 'type': 'service_reference', 18 | 'summary': 'test', 19 | 'self': 'https://api.pagerduty.com/services/XXXXXXX', 20 | 'html_url': 'https://mapbox.pagerduty.com/services/XXXXXXX' 21 | }, 22 | 'assignments': [{ 23 | 'at': '2017-08-04T22:37:22Z', 24 | 'assignee': { 25 | 'id': 'XXXXXXX', 26 | 'type': 'user_reference', 27 | 'summary': 'devnull', 28 | 'self': 'https://api.pagerduty.com/users/XXXXXXX', 29 | 'html_url': 'https://mapbox.pagerduty.com/users/XXXXXXX' 30 | } 31 | }], 32 | 'acknowledgements': [], 33 | 'last_status_change_at': '2017-08-04T22:37:22Z', 34 | 'last_status_change_by': { 35 | 'id': 'XXXXXXX', 36 | 'type': 'service_reference', 37 | 'summary': 'test', 38 | 'self': 'https://api.pagerduty.com/services/XXXXXXX', 39 | 'html_url': 'https://mapbox.pagerduty.com/services/XXXXXXX' 40 | }, 41 | 'first_trigger_log_entry': { 42 | 'id': 'ASJDKLAKDLKLSDLKJDWKLANKLS', 43 | 'type': 'trigger_log_entry_reference', 44 | 'summary': 'Triggered through the website', 45 | 'self': 'https://api.pagerduty.com/log_entries/ASJDKLAKDLKLSDLKJDWKLANKLS', 46 | 'html_url': 'https://mapbox.pagerduty.com/incidents/XXXXXXX/log_entries/ASJDKLAKDLKLSDLKJDWKLANKLS' 47 | }, 48 | 'escalation_policy': { 49 | 'id': 'XXXXXXX', 50 | 'type': 'escalation_policy_reference', 51 | 'summary': 'test', 52 | 'self': 'https://api.pagerduty.com/escalation_policies/XXXXXXX', 53 | 'html_url': 'https://mapbox.pagerduty.com/escalation_policies/XXXXXXX' 54 | }, 55 | 'privilege': null, 56 | 'teams': [], 57 | 'alert_counts': { 58 | 'all': 0, 59 | 'triggered': 0, 60 | 'resolved': 0 61 | }, 62 | 'impacted_services': [{ 63 | 'id': 'XXXXXXX', 64 | 'type': 'service_reference', 65 | 'summary': 'test', 66 | 'self': 'https://api.pagerduty.com/services/XXXXXXX', 67 | 'html_url': 'https://mapbox.pagerduty.com/services/XXXXXXX' 68 | }], 69 | 'is_mergeable': true, 70 | 'basic_alert_grouping': null, 71 | 'importance': null, 72 | 'urgency': 'high', 73 | 'id': 'XXXXXXX', 74 | 'type': 'incident', 75 | 'summary': '[#63201] this is a test', 76 | 'self': 'https://api.pagerduty.com/incidents/XXXXXXX', 77 | 'html_url': 'https://mapbox.pagerduty.com/incidents/XXXXXXX' 78 | } 79 | }; 80 | 81 | module.exports.incidentWithBody = { 82 | 'incident': { 83 | 'incident_number': 123512, 84 | 'title': 'this is a test', 85 | 'body': 'testBody', 86 | 'description': 'this is a test', 87 | 'created_at': '2017-08-04T22:37:22Z', 88 | 'status': 'triggered', 89 | 'pending_actions': [{ 90 | 'type': 'escalate', 91 | 'at': '2017-08-04T23:07:22Z' 92 | }], 93 | 'incident_key': 'testing', 94 | 'service': { 95 | 'id': 'XXXXXXX', 96 | 'type': 'service_reference', 97 | 'summary': 'test', 98 | 'self': 'https://api.pagerduty.com/services/XXXXXXX', 99 | 'html_url': 'https://mapbox.pagerduty.com/services/XXXXXXX' 100 | }, 101 | 'assignments': [{ 102 | 'at': '2017-08-04T22:37:22Z', 103 | 'assignee': { 104 | 'id': 'XXXXXXX', 105 | 'type': 'user_reference', 106 | 'summary': 'devnull', 107 | 'self': 'https://api.pagerduty.com/users/XXXXXXX', 108 | 'html_url': 'https://mapbox.pagerduty.com/users/XXXXXXX' 109 | } 110 | }], 111 | 'acknowledgements': [], 112 | 'last_status_change_at': '2017-08-04T22:37:22Z', 113 | 'last_status_change_by': { 114 | 'id': 'XXXXXXX', 115 | 'type': 'service_reference', 116 | 'summary': 'test', 117 | 'self': 'https://api.pagerduty.com/services/XXXXXXX', 118 | 'html_url': 'https://mapbox.pagerduty.com/services/XXXXXXX' 119 | }, 120 | 'first_trigger_log_entry': { 121 | 'id': 'ASJDKLAKDLKLSDLKJDWKLANKLS', 122 | 'type': 'trigger_log_entry_reference', 123 | 'summary': 'Triggered through the website', 124 | 'self': 'https://api.pagerduty.com/log_entries/ASJDKLAKDLKLSDLKJDWKLANKLS', 125 | 'html_url': 'https://mapbox.pagerduty.com/incidents/XXXXXXX/log_entries/ASJDKLAKDLKLSDLKJDWKLANKLS' 126 | }, 127 | 'escalation_policy': { 128 | 'id': 'XXXXXXX', 129 | 'type': 'escalation_policy_reference', 130 | 'summary': 'test', 131 | 'self': 'https://api.pagerduty.com/escalation_policies/XXXXXXX', 132 | 'html_url': 'https://mapbox.pagerduty.com/escalation_policies/XXXXXXX' 133 | }, 134 | 'privilege': null, 135 | 'teams': [], 136 | 'alert_counts': { 137 | 'all': 0, 138 | 'triggered': 0, 139 | 'resolved': 0 140 | }, 141 | 'impacted_services': [{ 142 | 'id': 'XXXXXXX', 143 | 'type': 'service_reference', 144 | 'summary': 'test', 145 | 'self': 'https://api.pagerduty.com/services/XXXXXXX', 146 | 'html_url': 'https://mapbox.pagerduty.com/services/XXXXXXX' 147 | }], 148 | 'is_mergeable': true, 149 | 'basic_alert_grouping': null, 150 | 'importance': null, 151 | 'urgency': 'high', 152 | 'id': 'XXXXXXX', 153 | 'type': 'incident', 154 | 'summary': '[#63201] this is a test', 155 | 'self': 'https://api.pagerduty.com/incidents/XXXXXXX', 156 | 'html_url': 'https://mapbox.pagerduty.com/incidents/XXXXXXX' 157 | } 158 | }; -------------------------------------------------------------------------------- /test/fixtures/slack.fixtures.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports.sns = { 4 | broadcast: { 5 | type: 'broadcast', 6 | users: [ 7 | { 8 | slack: 'testSlackUser1', 9 | slackId: 'testSlackId1' 10 | }, 11 | { 12 | slack: 'testSlackUser2', 13 | slackId: 'testSlackId2' 14 | }, 15 | { 16 | slack: 'testSlackUser3', 17 | slackId: 'testSlackId3' 18 | } 19 | ], 20 | callback_id: 'testCallbackId', 21 | body: { 22 | github: { 23 | title: 'testGitHubTitle', 24 | body: 'testGitHubBody' 25 | }, 26 | slack: { 27 | message: 'testSlackMessage' 28 | } 29 | }, 30 | url: 'https://github.com/testGitHubOwner/testGitHubRepo/issues/7', 31 | number: 7, 32 | requestId: 'testRequestId' 33 | }, 34 | encode: { 35 | type: 'self-service', 36 | users: [ 37 | { 38 | slack: 'testSlackUser', 39 | slackId: 'testSlackId' 40 | } 41 | ], 42 | body: { 43 | github: { 44 | title: 'testGitHubTitle', 45 | body: 'testGitHubBody' 46 | }, 47 | slack: { 48 | message: 'testSlackMessage', 49 | prompt: 'testSlackPrompt', 50 | actions: { 51 | yes: 'testYesAction', 52 | no: 'testNoAction' 53 | } 54 | } 55 | }, 56 | url: 'https://github.com/testGitHubOwner/testGitHubRepo/issues/7', 57 | requestId: 'testRequestId' 58 | }, 59 | encodeError: 'Error - dispatch testRequestId message body missing GitHub issue number', 60 | malformed: { 61 | type: 'self-service', 62 | users: [ 63 | { 64 | slack: 'testSlackUser', 65 | slackId: 'testSlackId' 66 | } 67 | ], 68 | body: {}, 69 | number: 7, 70 | url: 'https://github.com/testGitHubOwner/testGitHubRepo/issues/7', 71 | requestId: 'testRequestId' 72 | }, 73 | malformedStatus: 'dispatch testRequestId - SNS message parsing error', 74 | requestId: 'testRequestId', 75 | success: { 76 | type: 'self-service', 77 | users: [ 78 | { 79 | slack: 'testSlackUser', 80 | slackId: 'testSlackId' 81 | } 82 | ], 83 | callback_id: 'testCallbackId', 84 | body: { 85 | github: { 86 | title: 'testGitHubTitle', 87 | body: 'testGitHubBody' 88 | }, 89 | slack: { 90 | message: 'testSlackMessage', 91 | prompt: 'testSlackPrompt', 92 | actions: { 93 | yes: 'testYesAction', 94 | no: 'testNoAction' 95 | } 96 | } 97 | }, 98 | url: 'https://github.com/testGitHubOwner/testGitHubRepo/issues/7', 99 | number: 7, 100 | requestId: 'testRequestId' 101 | }, 102 | successWithResponse: { 103 | type: 'self-service', 104 | users: [ 105 | { 106 | slack: 'testSlackUser', 107 | slackId: 'testSlackId' 108 | } 109 | ], 110 | callback_id: 'testCallbackId', 111 | body: { 112 | github: { 113 | title: 'testGitHubTitle', 114 | body: 'testGitHubBody' 115 | }, 116 | slack: { 117 | message: 'testSlackMessage', 118 | prompt: 'testSlackPrompt', 119 | actions: { 120 | yes: 'testYesAction', 121 | yes_response: 'yes response', 122 | no: 'testNoAction', 123 | no_response: 'no response' 124 | } 125 | } 126 | }, 127 | url: 'https://github.com/testGitHubOwner/testGitHubRepo/issues/7', 128 | number: 7, 129 | requestId: 'testRequestId' 130 | }, 131 | successNoPrompt: { 132 | type: 'self-service', 133 | users: [ 134 | { 135 | slack: 'testSlackUser', 136 | slackId: 'testSlackId' 137 | } 138 | ], 139 | 'callback_id': 'testCallbackId', 140 | number: 7, 141 | body: { 142 | github: { 143 | title: 'testGitHubTitle', 144 | body: 'testGitHubBody' 145 | }, 146 | slack: { 147 | message: 'testSlackMessage' 148 | } 149 | }, 150 | url: 'https://github.com/testGitHubOwner/testGitHubRepo/issues/7', 151 | requestId: 123 152 | } 153 | }; 154 | 155 | module.exports.slack = { 156 | channel: '#testSlackDefaultChannel', 157 | channelId: 'D6G0UU7MW', 158 | errorNoChannel: { 159 | ok: false, 160 | error: 'channel_not_found', 161 | scopes: [ 'identify', 'bot:basic' ], 162 | acceptedScopes: [ 'chat:write:bot', 'post' ] 163 | }, 164 | errorNoChannelFallback: 'channel_not_found for destination #testSlackDefaultChannel', 165 | message: { 166 | text: 'testSlackMessage', 167 | attachments: [ 168 | { 169 | fallback: 'Could not load GitHub issue.', 170 | callback_id: 'testCallbackId', 171 | attachment_type: 'default', 172 | text: 'https://github.com/testGitHubOwner/testGitHubRepo/issues/7' 173 | } 174 | ] 175 | }, 176 | messageBroadcast: { 177 | text: 'testSlackMessage', 178 | attachments: [ 179 | { 180 | fallback: 'Could not load GitHub issue.', 181 | callback_id: 'testCallbackId', 182 | attachment_type: 'default' 183 | } 184 | ] 185 | }, 186 | missingMessage: { 187 | requestId: 'testRequestId' 188 | }, 189 | missingMessageError: 'missing Slack message body', 190 | prompt: { 191 | text: 'testSlackPrompt', 192 | attachments: [ 193 | { 194 | fallback: 'You are unable to address this alert via Slack, refer to the GitHub issue.', 195 | callback_id: 'testCallbackId', 196 | attachment_type: 'default', 197 | actions: [ 198 | { 199 | name: 'yes', 200 | text: 'testYesAction', 201 | type: 'button', 202 | value: false 203 | }, 204 | { 205 | name: 'no', 206 | text: 'testNoAction', 207 | type: 'button', 208 | value: false, 209 | style: 'danger' 210 | } 211 | ] 212 | } 213 | ] 214 | }, 215 | promptWithResponseText: { 216 | text: 'testSlackPrompt', 217 | attachments: [ 218 | { 219 | fallback: 'You are unable to address this alert via Slack, refer to the GitHub issue.', 220 | callback_id: 'testCallbackId', 221 | attachment_type: 'default', 222 | actions: [ 223 | { 224 | name: 'yes', 225 | text: 'testYesAction', 226 | type: 'button', 227 | value: 'yes response' 228 | }, 229 | { 230 | name: 'no', 231 | text: 'testNoAction', 232 | type: 'button', 233 | value: 'no response', 234 | style: 'danger' 235 | } 236 | ] 237 | } 238 | ] 239 | }, 240 | status: { 241 | alert: true, 242 | destination: 'testSlackId', 243 | message: 'testSlackMessage', 244 | url: 'https://github.com/testGitHubOwner/testGitHubRepo/issues/7' 245 | }, 246 | statusBroadcast: [ 247 | { 248 | alert: true, 249 | destination: 'testSlackId1', 250 | message: 'testSlackMessage', 251 | url: 'https://github.com/testGitHubOwner/testGitHubRepo/issues/7' 252 | }, 253 | { 254 | alert: true, 255 | destination: 'testSlackId2', 256 | message: 'testSlackMessage', 257 | url: 'https://github.com/testGitHubOwner/testGitHubRepo/issues/7' 258 | }, 259 | { 260 | alert: true, 261 | destination: 'testSlackId3', 262 | message: 'testSlackMessage', 263 | url: 'https://github.com/testGitHubOwner/testGitHubRepo/issues/7' 264 | } 265 | ], 266 | statusIncomingSelfService: { 267 | alert: true, 268 | destination: 'testSlackId', 269 | message: 'testSlackMessage', 270 | url: 'https://github.com/testGitHubOwner/testGitHubRepo/issues/7' 271 | }, 272 | statusPrompt: { 273 | alert: true, 274 | destination: 'testSlackId', 275 | message: 'testSlackMessage, Prompt: testSlackPrompt', 276 | url: 'https://github.com/testGitHubOwner/testGitHubRepo/issues/7' 277 | }, 278 | success: { 279 | ok: true, 280 | channel: 'D6G0UU7MW', 281 | ts: '1501777340.256863', 282 | message: 283 | { type: 'message', 284 | user: 'U6GHXJQ1Z', 285 | text: 'testSlackMessage', 286 | bot_id: 'B6G0UU6HW', 287 | attachments: [ [Object] ], 288 | ts: '1501777340.256863' }, 289 | scopes: [ 'identify', 'bot:basic' ], 290 | acceptedScopes: [ 'chat:write:user', 'client' ] 291 | }, 292 | successFallback: { 293 | ok: true, 294 | channel: 'D6G0UU7MW', 295 | ts: '1501777340.256863', 296 | message: { 297 | type: 'message', 298 | user: 'U6GHXJQ1Z', 299 | text: 'testSlackMessage', 300 | bot_id: 'B6G0UU6HW', 301 | attachments: [ [Object] ], 302 | ts: '1501777340.256863' 303 | }, 304 | scopes: [ 'identify', 'bot:basic' ], 305 | acceptedScopes: [ 'chat:write:user', 'client' ] 306 | }, 307 | successPrompt: { 308 | ok: true, 309 | channel: 'D6G0UU7MW', 310 | ts: '1501777340.256863', 311 | message: 312 | { type: 'message', 313 | user: 'U6GHXJQ1Z', 314 | text: 'testSlackPrompt', 315 | bot_id: 'B6G0UU6HW', 316 | attachments: [ [Object] ], 317 | ts: '1501777340.256863' }, 318 | scopes: [ 'identify', 'bot:basic' ], 319 | acceptedScopes: [ 'chat:write:user', 'client' ] 320 | }, 321 | slackId: 'testSlackId', 322 | user: { 323 | github: 'testGitHubUsername', 324 | defaulted: false, 325 | slackId: 'testSlackId' 326 | }, 327 | userDefauted: { 328 | github: 'testGitHubUsername', 329 | defaulted: true, 330 | slackId: '#testSlackDefaultChannel' 331 | } 332 | }; 333 | 334 | module.exports.clients = { 335 | empty: { 336 | _token:'testSlackBotToken', 337 | slackAPIUrl:'testSlackApiUrl', 338 | requestId: 'testRequestId' 339 | }, 340 | errorUser: { 341 | _token:'testSlackBotToken', 342 | slackAPIUrl:'testSlackApiUrl', 343 | chat: { 344 | postMessage: function(destination, message, options, callback) { 345 | if (destination == 'testSlackId') { 346 | return callback('channel_not_found', { 347 | ok: false, 348 | error: 'channel_not_found', 349 | scopes: [ 'identify', 'bot:basic' ], 350 | acceptedScopes: [ 'chat:write:user', 'client' ] 351 | }); 352 | } else { 353 | return callback(null, { 354 | ok: true, 355 | channel: 'D6G0UU7MW', 356 | ts: '1501777340.256863', 357 | message: { 358 | type: 'message', 359 | user: 'U6GHXJQ1Z', 360 | text: 'testSlackMessage', 361 | bot_id: 'B6G0UU6HW', 362 | attachments: [ [Object] ], 363 | ts: '1501777340.256863' 364 | }, 365 | scopes: [ 'identify', 'bot:basic' ], 366 | acceptedScopes: [ 'chat:write:user', 'client' ] 367 | }); 368 | } 369 | } 370 | } 371 | }, 372 | errorChannel: { 373 | _token:'testSlackBotToken', 374 | slackAPIUrl:'testSlackApiUrl', 375 | chat: { 376 | postMessage: function(destination, message, options, callback) { 377 | if (destination == 'testSlackId') { 378 | return callback('channel_not_found', { 379 | ok: false, 380 | error: 'channel_not_found', 381 | scopes: [ 'identify', 'bot:basic' ], 382 | acceptedScopes: [ 'chat:write:user', 'client' ] 383 | }); 384 | } else { 385 | return callback('channel_not_found', { 386 | ok: false, 387 | error: 'channel_not_found', 388 | scopes: [ 'identify', 'bot:basic' ], 389 | acceptedScopes: [ 'chat:write:bot', 'post' ] 390 | }); 391 | } 392 | } 393 | } 394 | }, 395 | success: { 396 | _token:'testSlackBotToken', 397 | slackAPIUrl:'testSlackApiUrl', 398 | chat: { 399 | postMessage: function(destination, message, options, callback) { 400 | return callback(null, { 401 | ok: true, 402 | channel: 'D6G0UU7MW', 403 | ts: '1501777340.256863', 404 | message: { 405 | type: 'message', 406 | user: 'U6GHXJQ1Z', 407 | text: 'testSlackMessage', 408 | bot_id: 'B6G0UU6HW', 409 | attachments: [ [Object] ], 410 | ts: '1501777340.256863' 411 | }, 412 | scopes: [ 'identify', 'bot:basic' ], 413 | acceptedScopes: [ 'chat:write:user', 'client' ] 414 | }); 415 | } 416 | } 417 | } 418 | }; -------------------------------------------------------------------------------- /test/fixtures/triage.fixtures.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports.invalidJSON = { 4 | postBody: '[ this is an invalid JSON payload ]' 5 | }; 6 | 7 | module.exports.badToken = { 8 | postBody: 'payload=%7B%22actions%22%3A%5B%7B%22name%22%3A%22yes%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22Yes%22%7D%5D%2C%22callback_id%22%3A%22eyJnaXRodWIiOiI3IiwicmVxdWVzdElkIjoiOGMzZDdjNmY1NTA0OTZkOTY4MjJkZTQzOTAwNjA4MDcifQ%22%2C%22team%22%3A%7B%22id%22%3A%22T6FD7RSPJ%22%2C%22domain%22%3A%22alarm-herder%22%7D%2C%22channel%22%3A%7B%22id%22%3A%22D6G0UU7MW%22%2C%22name%22%3A%22directmessage%22%7D%2C%22user%22%3A%7B%22id%22%3A%22U6G6XMXHB%22%2C%22name%22%3A%22kara%22%7D%2C%22action_ts%22%3A%221501726241.436435%22%2C%22message_ts%22%3A%221501726233.375111%22%2C%22attachment_id%22%3A%221%22%2C%22token%22%3A%22badToken%22%2C%22is_app_unfurl%22%3Afalse%2C%22original_message%22%3A%7B%22type%22%3A%22message%22%2C%22user%22%3A%22U6GHXJQ1Z%22%2C%22text%22%3A%22Two+factor+authentication+has+been+disabled...%22%2C%22bot_id%22%3A%22B6G0UU6HW%22%2C%22attachments%22%3A%5B%7B%22callback_id%22%3A%22dispatch_callback%22%2C%22fallback%22%3A%22You+are+unable+to+ack+this+alert+via+Slack%2C+refer+to+the+GitHub+issue.%22%2C%22text%22%3A%22Did+you+turn+off+two-factor+authentication+on+your+GitHub+account%3F+...%22%2C%22id%22%3A1%2C%22actions%22%3A%5B%7B%22id%22%3A%221%22%2C%22name%22%3A%22yes%22%2C%22text%22%3A%22Yes%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22Yes%22%2C%22style%22%3A%22%22%7D%2C%7B%22id%22%3A%222%22%2C%22name%22%3A%22no%22%2C%22text%22%3A%22No%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22No%22%2C%22style%22%3A%22danger%22%7D%5D%7D%5D%2C%22ts%22%3A%221501726233.375111%22%7D%2C%22response_url%22%3A%22https%3A%5C%2F%5C%2Fhooks.slack.com%5C%2Factions%5C%2FT6FD7RSPJ%5C%2F220826454016%5C%2Fo43iEPo76zlrFmA9TCa3sgrh%22%2C%22trigger_id%22%3A%22221406808019.219449876800.c18a27ebc48732af6bae2a76a18828d6%22%7D' 9 | }; 10 | 11 | module.exports.extraAction = { 12 | postBody: 'payload=%7B%22actions%22%3A%5B%7B%22name%22%3A%22yes%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22Yes%22%7D%2C%7B%22name%22%3A%22no%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22No%22%7D%5D%2C%22callback_id%22%3A%22eyJnaXRodWIiOiI3IiwicmVxdWVzdElkIjoiOGMzZDdjNmY1NTA0OTZkOTY4MjJkZTQzOTAwNjA4MDcifQ%22%2C%22team%22%3A%7B%22id%22%3A%22T6FD7RSPJ%22%2C%22domain%22%3A%22alarm-herder%22%7D%2C%22channel%22%3A%7B%22id%22%3A%22D6G0UU7MW%22%2C%22name%22%3A%22directmessage%22%7D%2C%22user%22%3A%7B%22id%22%3A%22U6G6XMXHB%22%2C%22name%22%3A%22kara%22%7D%2C%22action_ts%22%3A%221501726241.436435%22%2C%22message_ts%22%3A%221501726233.375111%22%2C%22attachment_id%22%3A%221%22%2C%22token%22%3A%22testSlackVerificationToken%22%2C%22is_app_unfurl%22%3Afalse%2C%22original_message%22%3A%7B%22type%22%3A%22message%22%2C%22user%22%3A%22U6GHXJQ1Z%22%2C%22text%22%3A%22Two+factor+authentication+has+been+disabled...%22%2C%22bot_id%22%3A%22B6G0UU6HW%22%2C%22attachments%22%3A%5B%7B%22callback_id%22%3A%22dispatch_callback%22%2C%22fallback%22%3A%22You+are+unable+to+ack+this+alert+via+Slack%2C+refer+to+the+GitHub+issue.%22%2C%22text%22%3A%22Did+you+turn+off+two-factor+authentication+on+your+GitHub+account%3F+...%22%2C%22id%22%3A1%2C%22actions%22%3A%5B%7B%22id%22%3A%221%22%2C%22name%22%3A%22yes%22%2C%22text%22%3A%22Yes%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22Yes%22%2C%22style%22%3A%22%22%7D%2C%7B%22id%22%3A%222%22%2C%22name%22%3A%22no%22%2C%22text%22%3A%22No%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22No%22%2C%22style%22%3A%22danger%22%7D%5D%7D%5D%2C%22ts%22%3A%221501726233.375111%22%7D%2C%22response_url%22%3A%22https%3A%5C%2F%5C%2Fhooks.slack.com%5C%2Factions%5C%2FT6FD7RSPJ%5C%2F220826454016%5C%2Fo43iEPo76zlrFmA9TCa3sgrh%22%2C%22trigger_id%22%3A%22221406808019.219449876800.c18a27ebc48732af6bae2a76a18828d6%22%7D' 13 | }; 14 | 15 | module.exports.ok = { 16 | postBody: 'payload=%7B%22actions%22%3A%5B%7B%22name%22%3A%22yes%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22Yes%22%7D%5D%2C%22callback_id%22%3A%22eyJnaXRodWIiOiI3IiwicmVxdWVzdElkIjoiOGMzZDdjNmY1NTA0OTZkOTY4MjJkZTQzOTAwNjA4MDcifQ%22%2C%22team%22%3A%7B%22id%22%3A%22T6FD7RSPJ%22%2C%22domain%22%3A%22alarm-herder%22%7D%2C%22channel%22%3A%7B%22id%22%3A%22D6G0UU7MW%22%2C%22name%22%3A%22directmessage%22%7D%2C%22user%22%3A%7B%22id%22%3A%22U6G6XMXHB%22%2C%22name%22%3A%22kara%22%7D%2C%22action_ts%22%3A%221501726241.436435%22%2C%22message_ts%22%3A%221501726233.375111%22%2C%22attachment_id%22%3A%221%22%2C%22token%22%3A%22testSlackVerificationToken%22%2C%22is_app_unfurl%22%3Afalse%2C%22original_message%22%3A%7B%22type%22%3A%22message%22%2C%22user%22%3A%22U6GHXJQ1Z%22%2C%22text%22%3A%22Two+factor+authentication+has+been+disabled...%22%2C%22bot_id%22%3A%22B6G0UU6HW%22%2C%22attachments%22%3A%5B%7B%22callback_id%22%3A%22dispatch_callback%22%2C%22fallback%22%3A%22You+are+unable+to+ack+this+alert+via+Slack%2C+refer+to+the+GitHub+issue.%22%2C%22text%22%3A%22Did+you+turn+off+two-factor+authentication+on+your+GitHub+account%3F+...%22%2C%22id%22%3A1%2C%22actions%22%3A%5B%7B%22id%22%3A%221%22%2C%22name%22%3A%22yes%22%2C%22text%22%3A%22Yes%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22Yes%22%2C%22style%22%3A%22%22%7D%2C%7B%22id%22%3A%222%22%2C%22name%22%3A%22no%22%2C%22text%22%3A%22No%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22No%22%2C%22style%22%3A%22danger%22%7D%5D%7D%5D%2C%22ts%22%3A%221501726233.375111%22%7D%2C%22response_url%22%3A%22https%3A%5C%2F%5C%2Fhooks.slack.com%5C%2Factions%5C%2FT6FD7RSPJ%5C%2F220826454016%5C%2Fo43iEPo76zlrFmA9TCa3sgrh%22%2C%22trigger_id%22%3A%22221406808019.219449876800.c18a27ebc48732af6bae2a76a18828d6%22%7D' 17 | }; 18 | 19 | module.exports.notOk = { 20 | postBody: 'payload=%7B%22actions%22%3A%5B%7B%22name%22%3A%22no%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22No%22%7D%5D%2C%22callback_id%22%3A%22eyJnaXRodWIiOiI3IiwicmVxdWVzdElkIjoiNmNmOTM5N2M3MWUyIn0%22%2C%22team%22%3A%7B%22id%22%3A%22T6FD7RSPJ%22%2C%22domain%22%3A%22alarm-herder%22%7D%2C%22channel%22%3A%7B%22id%22%3A%22D6G0UU7MW%22%2C%22name%22%3A%22directmessage%22%7D%2C%22user%22%3A%7B%22id%22%3A%22U6G6XMXHB%22%2C%22name%22%3A%22kara%22%7D%2C%22action_ts%22%3A%221501726241.436435%22%2C%22message_ts%22%3A%221501726233.375111%22%2C%22attachment_id%22%3A%221%22%2C%22token%22%3A%22testSlackVerificationToken%22%2C%22is_app_unfurl%22%3Afalse%2C%22original_message%22%3A%7B%22type%22%3A%22message%22%2C%22user%22%3A%22U6GHXJQ1Z%22%2C%22text%22%3A%22Two+factor+authentication+has+been+disabled...%22%2C%22bot_id%22%3A%22B6G0UU6HW%22%2C%22attachments%22%3A%5B%7B%22callback_id%22%3A%22dispatch_callback%22%2C%22fallback%22%3A%22You+are+unable+to+ack+this+alert+via+Slack%2C+refer+to+the+GitHub+issue.%22%2C%22text%22%3A%22Did+you+turn+off+two-factor+authentication+on+your+GitHub+account%3F+...%22%2C%22id%22%3A1%2C%22actions%22%3A%5B%7B%22id%22%3A%221%22%2C%22name%22%3A%22yes%22%2C%22text%22%3A%22Yes%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22Yes%22%2C%22style%22%3A%22%22%7D%2C%7B%22id%22%3A%222%22%2C%22name%22%3A%22no%22%2C%22text%22%3A%22No%22%2C%22type%22%3A%22button%22%2C%22value%22%3A%22No%22%2C%22style%22%3A%22danger%22%7D%5D%7D%5D%2C%22ts%22%3A%221501726233.375111%22%7D%2C%22response_url%22%3A%22https%3A%5C%2F%5C%2Fhooks.slack.com%5C%2Factions%5C%2FT6FD7RSPJ%5C%2F220826454016%5C%2Fo43iEPo76zlrFmA9TCa3sgrh%22%2C%22trigger_id%22%3A%22221406808019.219449876800.c18a27ebc48732af6bae2a76a18828d6%22%7D' 21 | }; 22 | 23 | module.exports.responses = { 24 | ok: { 25 | attachments: [ 26 | { 27 | attachment_type: 'default', 28 | fallback: 'Could not load Slack response, 8c3d7c6f550496d96822de4390060807: closed GitHub issue testGitHubRepo/7', 29 | text: 'Yes', 30 | color: '#008E00', 31 | footer: 'Dispatch alert acknowledged', 32 | ts: 'testTimeStamp', 33 | replace_original: false 34 | } 35 | ] 36 | }, 37 | okError: 'Error: dispatch 8c3d7c6f550496d96822de4390060807 failed to close GitHub issue testGitHubRepo/7, Bad request', 38 | notOk: { 39 | attachments: [ 40 | { 41 | attachment_type: 'default', 42 | fallback: 'Could not load Slack response, 6cf9397c71e2: Created PagerDuty incident successfully', 43 | text: 'No', 44 | color: '#CC0000', 45 | footer: 'Dispatch alert escalated', 46 | ts: 'testTimeStamp', 47 | replace_original: false 48 | } 49 | ] 50 | }, 51 | notOkError: 'Error: dispatch 6cf9397c71e2 failed to create PagerDuty incident' 52 | }; 53 | 54 | module.exports.incident = { 55 | 'incident': { 56 | 'incident_number': 123512, 57 | 'title': 'dispatch 6cf9397c71e2: user kara responded \'no\' for self-service issue testGitHubRepo/7', 58 | 'body': 'dispatch 6cf9397c71e2: user kara responded \'no\' for self-service issue testGitHubRepo/7\n\n https://github.com/testGitHubOwner/testGitHubRepo/issues/7', 59 | 'description': 'dispatch 6cf9397c71e2: user kara responded \'no\' for self-service issue testGitHubRepo/7', 60 | 'created_at': '2017-08-04T22:37:22Z', 61 | 'status': 'triggered', 62 | 'pending_actions': [{ 63 | 'type': 'escalate', 64 | 'at': '2017-08-04T23:07:22Z' 65 | }], 66 | 'incident_key': '6cf9397c71e2', 67 | 'service': { 68 | 'id': 'XXXXXXX', 69 | 'type': 'service_reference', 70 | 'summary': 'test', 71 | 'self': 'https://api.pagerduty.com/services/XXXXXXX', 72 | 'html_url': 'https://mapbox.pagerduty.com/services/XXXXXXX' 73 | }, 74 | 'assignments': [{ 75 | 'at': '2017-08-04T22:37:22Z', 76 | 'assignee': { 77 | 'id': 'XXXXXXX', 78 | 'type': 'user_reference', 79 | 'summary': 'devnull', 80 | 'self': 'https://api.pagerduty.com/users/XXXXXXX', 81 | 'html_url': 'https://mapbox.pagerduty.com/users/XXXXXXX' 82 | } 83 | }], 84 | 'acknowledgements': [], 85 | 'last_status_change_at': '2017-08-04T22:37:22Z', 86 | 'last_status_change_by': { 87 | 'id': 'XXXXXXX', 88 | 'type': 'service_reference', 89 | 'summary': 'test', 90 | 'self': 'https://api.pagerduty.com/services/XXXXXXX', 91 | 'html_url': 'https://mapbox.pagerduty.com/services/XXXXXXX' 92 | }, 93 | 'first_trigger_log_entry': { 94 | 'id': 'ASJDKLAKDLKLSDLKJDWKLANKLS', 95 | 'type': 'trigger_log_entry_reference', 96 | 'summary': 'Triggered through the website', 97 | 'self': 'https://api.pagerduty.com/log_entries/ASJDKLAKDLKLSDLKJDWKLANKLS', 98 | 'html_url': 'https://mapbox.pagerduty.com/incidents/XXXXXXX/log_entries/ASJDKLAKDLKLSDLKJDWKLANKLS' 99 | }, 100 | 'escalation_policy': { 101 | 'id': 'XXXXXXX', 102 | 'type': 'escalation_policy_reference', 103 | 'summary': 'test', 104 | 'self': 'https://api.pagerduty.com/escalation_policies/XXXXXXX', 105 | 'html_url': 'https://mapbox.pagerduty.com/escalation_policies/XXXXXXX' 106 | }, 107 | 'privilege': null, 108 | 'teams': [], 109 | 'alert_counts': { 110 | 'all': 0, 111 | 'triggered': 0, 112 | 'resolved': 0 113 | }, 114 | 'impacted_services': [{ 115 | 'id': 'XXXXXXX', 116 | 'type': 'service_reference', 117 | 'summary': 'test', 118 | 'self': 'https://api.pagerduty.com/services/XXXXXXX', 119 | 'html_url': 'https://mapbox.pagerduty.com/services/XXXXXXX' 120 | }], 121 | 'is_mergeable': true, 122 | 'basic_alert_grouping': null, 123 | 'importance': null, 124 | 'urgency': 'high', 125 | 'id': 'XXXXXXX', 126 | 'type': 'incident', 127 | 'summary': '[#63201] dispatch 6cf9397c71e2: user kara responded \'no\' for self-service issue testGitHubRepo/7', 128 | 'self': 'https://api.pagerduty.com/incidents/XXXXXXX', 129 | 'html_url': 'https://mapbox.pagerduty.com/incidents/XXXXXXX' 130 | } 131 | }; -------------------------------------------------------------------------------- /test/functions/incoming.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | const nock = require('nock'); 5 | const sinon = require('sinon'); 6 | 7 | const github = require('./../../lib/github'); 8 | const slack = require('./../../lib/slack'); 9 | const incoming = require('../../incoming/function.js'); 10 | 11 | const incomingFixtures = require('../../test/fixtures/incoming.fixtures.js'); 12 | const githubFixtures = require('../fixtures/github.fixtures.js'); 13 | const pagerDutyFixtures = require('../fixtures/pagerduty.fixtures.js'); 14 | const slackFixtures = require('../../test/fixtures/slack.fixtures.js'); 15 | 16 | process.env.PagerDutyApiKey = 'testPagerDutyApiKey'; 17 | process.env.PagerDutyFromAddress = 'testPagerDutyFromAddress'; 18 | process.env.PagerDutyServiceId = 'testPagerDutyServiceId'; 19 | process.env.GitHubDefaultUser = 'testGitHubDefaultUser'; 20 | process.env.GitHubOwner = 'testGitHubOwner'; 21 | process.env.GitHubRepo = 'testGitHubRepo'; 22 | process.env.GitHubToken = 'testGitHubToken'; 23 | process.env.SlackBotToken = 'testSlackBotToken'; 24 | process.env.SlackDefaultChannel = '#testSlackDefaultChannel'; 25 | 26 | const context = {}; 27 | const gitHubDefaultUser = process.env.GitHubDefaultUser; 28 | const slackDefaultChannel = process.env.SlackDefaultChannel; 29 | 30 | const lambdaFailure = 'Lambda failure'; 31 | const lambdaSuccess = 'Lambda success'; 32 | 33 | test('[incoming] [checkUser] missing GitHub username', (assert) => { 34 | let user = incoming.checkUser(incomingFixtures.userMissingGitHub, gitHubDefaultUser, slackDefaultChannel); 35 | assert.deepEqual(user, incomingFixtures.userDefautGitHub, '-- should replace missing GitHub username with testGitHubDefaultUser'); 36 | assert.end(); 37 | }); 38 | 39 | test('[incoming] [checkUser] missing Slack username', (assert) => { 40 | let user = incoming.checkUser(incomingFixtures.userMissingSlack, gitHubDefaultUser, slackDefaultChannel); 41 | assert.deepEqual(user, incomingFixtures.userDefautSlack, '-- should replace missing Slack username with testSlackDefaultChannel'); 42 | assert.end(); 43 | }); 44 | 45 | test('[incoming] [checkUser] undefined user passed', (assert) => { 46 | let user = incoming.checkUser(undefined, gitHubDefaultUser, slackDefaultChannel, 'testRequestId', 'testMessageBody'); 47 | assert.equal(user.defaulted, true, '-- undefined user defaulted'); 48 | assert.equal(user.slackId, slackDefaultChannel, '-- undefined user set to default Slack channel'); 49 | assert.equal(user.github, gitHubDefaultUser, '-- undefined user set to default GitHub user'); 50 | assert.end(); 51 | }); 52 | 53 | test('[incoming] [callGitHub] missing Slack username, default to id', (assert) => { 54 | nock('https://api.github.com') 55 | .get(`/repos/${process.env.GitHubOwner}/${process.env.GitHubRepo}/issues`) 56 | .query({ state: 'open', access_token: process.env.GitHubToken }) 57 | .reply(200, []); 58 | 59 | nock('https://api.github.com') 60 | .post(`/repos/${process.env.GitHubOwner}/${process.env.GitHubRepo}/issues`) 61 | .query({ access_token: process.env.GitHubToken }) 62 | .reply(201, githubFixtures.broadcastIssue); 63 | 64 | incoming.callGitHub(incomingFixtures.callGitHubEvent.user, incomingFixtures.callGitHubEvent.messageBroadcastError, incomingFixtures.callGitHubEvent.requestId, process.env.GitHubOwner, process.env.GitHubRepo, process.env.GitHubToken, (err, res) => { 65 | assert.ifError(err, '-- should not error'); 66 | assert.deepEqual(res, incomingFixtures.callGitHubEvent.res, '-- Github issue should be created'); 67 | assert.end(); 68 | }); 69 | }); 70 | 71 | test('[incoming] [fn] missing message priority', (assert) => { 72 | incoming.fn(incomingFixtures.missingPriorityEvent, context, (err) => { 73 | assert.deepEqual(err, lambdaFailure, '-- should return error message'); 74 | assert.end(); 75 | }); 76 | }); 77 | 78 | test('[incoming] [fn] [checkEvent] malformed SNS message error', (assert) => { 79 | incoming.fn(incomingFixtures.malformedSNS, context, (err) => { 80 | assert.equal(err, lambdaFailure, '-- should return error message'); 81 | assert.end(); 82 | }); 83 | }); 84 | 85 | test('[incoming] [fn] [checkEvent] > 1 record in SNS message error', (assert) => { 86 | incoming.fn(incomingFixtures.multipleRecordSNS, context, (err) => { 87 | assert.equal(err, lambdaFailure, '-- should return error message'); 88 | assert.end(); 89 | }); 90 | }); 91 | 92 | test('[incoming] [fn] [checkEvent] invalid JSON in SNS message', (assert) => { 93 | incoming.fn(incomingFixtures.invalidJsonSNS, context, (err) => { 94 | assert.equal(err, lambdaFailure, '-- should return error message'); 95 | assert.end(); 96 | }); 97 | }); 98 | 99 | test('[incoming] [fn] self-service event', (assert) => { 100 | nock('https://api.github.com') 101 | .get(`/repos/${process.env.GitHubOwner}/${process.env.GitHubRepo}/issues`) 102 | .query({ state: 'open', access_token: process.env.GitHubToken }) 103 | .reply(200, []); 104 | 105 | nock('https://api.github.com') 106 | .post(`/repos/${process.env.GitHubOwner}/${process.env.GitHubRepo}/issues`) 107 | .query({ access_token: process.env.GitHubToken }) 108 | .reply(201, githubFixtures.selfServiceIssue); 109 | 110 | nock('https://slack.com:443') 111 | .post('/api/chat.postMessage') 112 | .reply(200, slackFixtures.slack.success); 113 | nock('https://slack.com:443') 114 | .post('/api/chat.postMessage') 115 | .reply(200, slackFixtures.slack.success); 116 | 117 | incoming.fn(incomingFixtures.selfServiceEvent, context, (err, res) => { 118 | assert.ifError(err, '-- should not error'); 119 | assert.deepEqual(res, lambdaSuccess, '-- GitHub issue and Slack alert should be created'); 120 | assert.end(); 121 | }); 122 | }); 123 | 124 | test('[incoming] [fn] broadcast event', (assert) => { 125 | nock('https://api.github.com') 126 | .get(`/repos/${process.env.GitHubOwner}/${process.env.GitHubRepo}/issues`) 127 | .query({ state: 'open', access_token: process.env.GitHubToken }) 128 | .reply(200, []); 129 | 130 | nock('https://api.github.com') 131 | .post(`/repos/${process.env.GitHubOwner}/${process.env.GitHubRepo}/issues`) 132 | .query({ access_token: process.env.GitHubToken }) 133 | .reply(201, githubFixtures.broadcastIssue); 134 | 135 | // slack calls for [ 'testSlackUser1', 'testSlackUser2', 'testSlackUser3' ] 136 | nock('https://slack.com:443') 137 | .post('/api/chat.postMessage') 138 | .reply(200, slackFixtures.slack.success); 139 | nock('https://slack.com:443') 140 | .post('/api/chat.postMessage') 141 | .reply(200, slackFixtures.slack.success); 142 | nock('https://slack.com:443') 143 | .post('/api/chat.postMessage') 144 | .reply(200, slackFixtures.slack.success); 145 | nock('https://slack.com:443') 146 | .post('/api/chat.postMessage') 147 | .reply(200, slackFixtures.slack.success); 148 | nock('https://slack.com:443') 149 | .post('/api/chat.postMessage') 150 | .reply(200, slackFixtures.slack.success); 151 | nock('https://slack.com:443') 152 | .post('/api/chat.postMessage') 153 | .reply(200, slackFixtures.slack.success); 154 | 155 | incoming.fn(incomingFixtures.broadcastEvent, context, (err, res) => { 156 | assert.ifError(err, '-- should not error'); 157 | assert.deepEqual(res, lambdaSuccess, '-- Github issue and Slack alerts should be created'); 158 | assert.end(); 159 | }); 160 | }); 161 | 162 | test('[incoming] [fn] high-priority event', (assert) => { 163 | nock('https://api.pagerduty.com:443', { encodedQueryParams: true }) 164 | .post('/incidents', { 165 | incident: { 166 | type: 'incident', 167 | title: 'testPagerDutyTitle', 168 | service: { 169 | id: 'testPagerDutyServiceId', 170 | type: 'service_reference' 171 | }, 172 | incident_key: 'testPagerDutyTitle' 173 | } 174 | }) 175 | .reply(201, pagerDutyFixtures.incident); 176 | 177 | incoming.fn(incomingFixtures.highPriorityEvent, context, (err, res) => { 178 | assert.ifError(err, '-- should not error'); 179 | assert.deepEqual(res, lambdaSuccess, '-- PagerDuty incident should be triggered'); 180 | assert.end(); 181 | }); 182 | }); 183 | 184 | test('[incoming] [fn] low-priority event', (assert) => { 185 | nock('https://api.github.com') 186 | .get(`/repos/${process.env.GitHubOwner}/${process.env.GitHubRepo}/issues`) 187 | .query({ state: 'open', access_token: process.env.GitHubToken }) 188 | .reply(200, []); 189 | 190 | nock('https://api.github.com') 191 | .post(`/repos/${process.env.GitHubOwner}/${process.env.GitHubRepo}/issues`) 192 | .query({ access_token: process.env.GitHubToken }) 193 | .reply(201, githubFixtures.lowPriorityIssue); 194 | 195 | incoming.fn(incomingFixtures.lowPriorityEvent, context, (err, res) => { 196 | assert.ifError(err, '-- should not error'); 197 | assert.deepEqual(res, lambdaSuccess, '-- GitHub issue should be created'); 198 | assert.end(); 199 | }); 200 | }); 201 | 202 | 203 | test('[incoming] [fn] low-priority event with user', (assert) => { 204 | nock('https://api.github.com') 205 | .get(`/repos/${process.env.GitHubOwner}/${process.env.GitHubRepo}/issues`) 206 | .query({ state: 'open', access_token: process.env.GitHubToken }) 207 | .reply(200, []); 208 | 209 | const githubNock = nock('https://api.github.com') 210 | .post(`/repos/${process.env.GitHubOwner}/${process.env.GitHubRepo}/issues`, (req) => { 211 | return /@testGitHubUser/g.test(req.body); 212 | }) 213 | .query({ access_token: process.env.GitHubToken }) 214 | .reply(201, githubFixtures.lowPriorityIssue); 215 | 216 | incoming.fn(incomingFixtures.lowPriorityEvent, context, (err, res) => { 217 | assert.ifError(err, '-- should not error'); 218 | assert.deepEqual(res, lambdaSuccess, '-- GitHub issue should be created'); 219 | assert.ok(githubNock.isDone()); 220 | assert.end(); 221 | }); 222 | }); 223 | 224 | test('[incoming] [fn] low-priority event with labels', (assert) => { 225 | nock('https://api.github.com') 226 | .get(`/repos/${process.env.GitHubOwner}/${process.env.GitHubRepo}/issues`) 227 | .query({ state: 'open', access_token: process.env.GitHubToken }) 228 | .reply(200, []); 229 | 230 | const createIssue = nock('https://api.github.com') 231 | .post(`/repos/${process.env.GitHubOwner}/${process.env.GitHubRepo}/issues`, /low_priority/) 232 | .query({ access_token: process.env.GitHubToken }) 233 | .reply(201, githubFixtures.lowPriorityIssue); 234 | 235 | incoming.fn(incomingFixtures.labelledEvent, context, (err, res) => { 236 | assert.ifError(err, '-- should not error'); 237 | assert.deepEqual(res, lambdaSuccess, '-- GitHub issue should be created'); 238 | assert.ok(createIssue.isDone(), '-- create issue with label called'); 239 | assert.end(); 240 | }); 241 | }); 242 | 243 | test('[incoming] [fn] low-priority event with no users', (assert) => { 244 | nock('https://api.github.com') 245 | .get(`/repos/${process.env.GitHubOwner}/${process.env.GitHubRepo}/issues`) 246 | .query({ state: 'open', access_token: process.env.GitHubToken }) 247 | .reply(200, []); 248 | 249 | nock('https://api.github.com') 250 | .post(`/repos/${process.env.GitHubOwner}/${process.env.GitHubRepo}/issues`) 251 | .query({ access_token: process.env.GitHubToken }) 252 | .reply(201, githubFixtures.lowPriorityIssue); 253 | 254 | incoming.fn(incomingFixtures.lowPriorityEventNoUser, context, (err, res) => { 255 | assert.ifError(err, '-- should not error'); 256 | assert.deepEqual(res, lambdaSuccess, '-- GitHub issue should be created'); 257 | assert.end(); 258 | }); 259 | }); 260 | 261 | test('[incoming] [fn] unrecognized event fallback', (assert) => { 262 | nock('https://api.pagerduty.com:443', { encodedQueryParams: true }) 263 | .post('/incidents', { 264 | incident: { 265 | type: 'incident', 266 | title: 'testPagerDutyTitle', 267 | service: { 268 | id: 'testPagerDutyServiceId', 269 | type: 'service_reference' 270 | }, 271 | incident_key: 'testPagerDutyTitle' 272 | } 273 | }) 274 | .reply(201, pagerDutyFixtures.incident); 275 | 276 | incoming.fn(incomingFixtures.unrecognizedEvent, context, (err, res) => { 277 | assert.ifError(err, '-- should not error'); 278 | assert.deepEqual(res, lambdaSuccess, '-- PagerDuty incident should be triggered'); 279 | assert.end(); 280 | }); 281 | }); 282 | 283 | // TODO: Create a better tests. Checking if github.issues.create is called 284 | test('[incoming] [fn] nag event', (assert) => { 285 | let githubStub = sinon.stub(github, 'createIssue').returns(Promise.resolve({status: null, issue: 4565})); 286 | let alertToSlackStub = sinon.stub(slack, 'alertToSlack').yields(null, 'wat'); 287 | 288 | incoming.fn(incomingFixtures.nagEvent, context, (err, res) => { 289 | assert.ifError(err, '-- should not error'); 290 | assert.deepEqual(res, lambdaSuccess, '-- GitHub issue should be created'); 291 | assert.equals(github.createIssue.callCount, 1, 'Create Issue should be created only once'); 292 | assert.equals(slack.alertToSlack.callCount, 1, 'It should only called once'); 293 | githubStub.restore(); 294 | alertToSlackStub.restore(); 295 | assert.end(); 296 | }); 297 | }); 298 | 299 | test('[incoming] [fn] [nag event] Send slack message event if the github issue is already created', (assert) => { 300 | let githubStub = sinon.stub(github, 'createIssue').returns(Promise.resolve({status: 'exists', issue: 4565})); 301 | let alertToSlackStub = sinon.stub(slack, 'alertToSlack').yields(null, 'wat'); 302 | 303 | incoming.fn(incomingFixtures.nagEvent, context, (err, res) => { 304 | assert.ifError(err, '-- should not error'); 305 | assert.deepEqual(res, lambdaSuccess, '-- GitHub issue should be created'); 306 | assert.equals(github.createIssue.callCount, 1, 'Create Issue should be created only once'); 307 | assert.equals(slack.alertToSlack.callCount, 1, 'It should only called once'); 308 | githubStub.restore(); 309 | alertToSlackStub.restore(); 310 | assert.end(); 311 | }); 312 | }); 313 | -------------------------------------------------------------------------------- /test/functions/triage.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | const nock = require('nock'); 5 | 6 | const triage = require('../../triage/function.js'); 7 | const triageFixtures = require('../fixtures/triage.fixtures.js'); 8 | 9 | process.env.GitHubOwner = 'testGitHubOwner'; 10 | process.env.GitHubRepo = 'testGitHubRepo'; 11 | process.env.GitHubToken = 'testGitHubToken'; 12 | process.env.PagerDutyApiKey = 'testPagerDutyApiKey'; 13 | process.env.PagerDutyFromAddress = 'testPagerDutyFromAddress'; 14 | process.env.PagerDutyServiceId = 'testPagerDutyServiceId'; 15 | process.env.SlackVerificationToken = 'testSlackVerificationToken'; 16 | 17 | const context = {}; 18 | 19 | test('[triage] [fn] [checkEvent] JSON parsing error', (assert) => { 20 | triage.fn(triageFixtures.invalidJSON, context, (err) => { 21 | assert.equal(err, 'Lambda failure', '-- should return error message'); 22 | assert.end(); 23 | }); 24 | }); 25 | 26 | test('[triage] [fn] [checkEvent] invalid Slack verification token', (assert) => { 27 | triage.fn(triageFixtures.badToken, context, (err) => { 28 | assert.equal(err, 'Lambda failure', '-- should return error message'); 29 | assert.end(); 30 | }); 31 | }); 32 | 33 | test('[triage] [fn] [checkEvent] >1 payload actions', (assert) => { 34 | triage.fn(triageFixtures.extraAction, context, (err) => { 35 | assert.equal(err, 'Lambda failure', '-- should return error message'); 36 | assert.end(); 37 | }); 38 | }); 39 | 40 | test('[triage] [fn] response OK, closes GitHub issue', (assert) => { 41 | nock('https://api.github.com:443', { 'encodedQueryParams':true }) 42 | .patch('/repos/testGitHubOwner/testGitHubRepo/issues/7', { 'state': 'closed' }) 43 | .query({ access_token: 'testGitHubToken' }) 44 | .reply(200, {}); 45 | 46 | triage.fn(triageFixtures.ok, context, (err, res) => { 47 | // replace dynamic timestamp for tape deepEqual 48 | res.attachments[0].ts = 'testTimeStamp'; 49 | assert.ifError(err, '-- should not error'); 50 | assert.deepEqual(res, triageFixtures.responses.ok, '-- should return responseObject for Slack'); 51 | assert.end(); 52 | }); 53 | }); 54 | 55 | test('[triage] [fn] response OK, error closing GitHub issue', (assert) => { 56 | nock('https://api.github.com:443', { 'encodedQueryParams':true }) 57 | .patch('/repos/testGitHubOwner/testGitHubRepo/issues/7', { 'state': 'closed' }) 58 | .query({ access_token: 'testGitHubToken' }) 59 | .reply(404, 'Bad request'); 60 | 61 | triage.fn(triageFixtures.ok, context, (err, res) => { 62 | assert.equal(err, null, '-- err should be null, logged to Slack instead'); 63 | assert.deepEqual(res, triageFixtures.responses.okError, '-- should return responseError for Slack'); 64 | assert.end(); 65 | }); 66 | }); 67 | 68 | test('[triage] [fn] response NOT OK, escalates to PagerDuty', (assert) => { 69 | nock('https://api.pagerduty.com:443', { 'encodedQueryParams': true }) 70 | .post('/incidents', { 71 | incident: { 72 | type: 'incident', 73 | title: 'dispatch 6cf9397c71e2: user kara responded \'no\' for self-service issue testGitHubRepo/7', 74 | service: { 75 | id: 'testPagerDutyServiceId', 76 | type: 'service_reference' 77 | }, 78 | incident_key: '6cf9397c71e2', 79 | body: { 80 | type: 'incident_body', 81 | details: 'dispatch 6cf9397c71e2: user kara responded \'no\' for self-service issue testGitHubRepo/7\n\n https://github.com/testGitHubOwner/testGitHubRepo/issues/7' 82 | } 83 | } 84 | }) 85 | .reply(201, triageFixtures.incident); 86 | 87 | triage.fn(triageFixtures.notOk, context, (err, res) => { 88 | // replace dynamic timestamp for tape deepEqual 89 | res.attachments[0].ts = 'testTimeStamp'; 90 | assert.equal(err, null, '-- err should be null, logged to Slack instead'); 91 | assert.deepEqual(res, triageFixtures.responses.notOk, '-- should return responseObject for Slack'); 92 | assert.end(); 93 | }); 94 | }); 95 | 96 | // NOTE: Missing test for duplicate PagerDuty issue error 97 | 98 | test('[triage] [fn] response NOT OK, error escalating to PagerDuty', (assert) => { 99 | nock('https://api.pagerduty.com:443', { 'encodedQueryParams': true }) 100 | .post('/incidents', { 101 | incident: { 102 | type: 'incident', 103 | title: 'dispatch 6cf9397c71e2: user kara responded \'no\' for self-service issue testGitHubRepo/7', 104 | service: { 105 | id: 'testPagerDutyServiceId', 106 | type: 'service_reference' 107 | }, 108 | incident_key: '6cf9397c71e2', 109 | body: { 110 | type: 'incident_body', 111 | details: 'dispatch 6cf9397c71e2: user kara responded \'no\' for self-service issue testGitHubRepo/7\n\n https://github.com/testGitHubOwner/testGitHubRepo/issues/7' 112 | } 113 | } 114 | }) 115 | .reply(400, { 116 | error: { 117 | message: 'Bad request', 118 | code: 400, 119 | errors: [ 'test bad request' ] 120 | } 121 | }); 122 | 123 | triage.fn(triageFixtures.notOk, context, (err, res) => { 124 | assert.equal(err, null, '-- err should be null, logged to Slack instead'); 125 | assert.deepEqual(res, triageFixtures.responses.notOkError, '-- should return responseError for Slack'); 126 | assert.end(); 127 | }); 128 | }); -------------------------------------------------------------------------------- /test/lib/github.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | const nock = require('nock'); 5 | const github = require('../../lib/github.js'); 6 | const githubFixtures = require('../fixtures/github.fixtures.js'); 7 | 8 | const token = 'testToken'; 9 | 10 | test('[github] [authenticate] Receive auth object from request', function(assert) { 11 | let ghAuth = github.authenticate(token); 12 | 13 | const getUserNock = nock('https://api.github.com') 14 | .get('/user') 15 | .query({ access_token: token }) 16 | .reply(200, {}); 17 | 18 | ghAuth.users.get().then(() => { 19 | assert.ok(getUserNock.isDone()); 20 | assert.end(); 21 | }); 22 | }); 23 | 24 | test('[github] [checkForIssue] Finds requested issue', function(assert) { 25 | let issue = [ githubFixtures.issue1() ]; 26 | let retrigger = true; 27 | let options = { 28 | owner: 'testOwner', 29 | repo: 'testRepo', 30 | title: 'testTitle' 31 | }; 32 | 33 | nock('https://api.github.com') 34 | .get('/repos/testOwner/testRepo/issues') 35 | .query({ state: 'open', access_token: token }) 36 | .reply(200, issue); 37 | 38 | github.checkForIssue(options, retrigger, token) 39 | .then(res => { 40 | assert.deepEqual(Array.isArray(res), true, '-- response is an array'); 41 | assert.deepEqual(res, issue, '-- found issue'); 42 | assert.end(); 43 | }) 44 | .catch(err => { console.log(err); }); 45 | }); 46 | 47 | test('[github] [checkForIssue] Does not find a match to the requested issue', function(assert) { 48 | let retrigger = true; 49 | let options = { 50 | owner: 'testOwner', 51 | repo: 'testRepo', 52 | title: 'testTitleNotFound' 53 | }; 54 | 55 | nock('https://api.github.com') 56 | .get('/repos/testOwner/testRepo/issues') 57 | .query({ state: 'open', access_token: token }) 58 | .reply(function(uri, requestBody) { // eslint-disable-line no-unused-vars 59 | return [ 60 | githubFixtures.noIssueFound.code, 61 | githubFixtures.noIssueFound.message, 62 | githubFixtures.noIssueFound.headers 63 | ]; 64 | }); 65 | 66 | github.checkForIssue(options, retrigger, token) 67 | .then(res => { 68 | assert.deepEqual(Array.isArray(res), true, '-- response is an array'); 69 | assert.deepEqual(res, [], '-- returns empty array'); 70 | assert.end(); 71 | }) 72 | .catch(err => { console.log(err); }); 73 | }); 74 | 75 | test('[github] [checkForIssue] Pagination works', function(assert) { 76 | let issues = githubFixtures.manyIssues(); // contains 150 issues 77 | let retrigger = true; 78 | let options = { 79 | owner: 'testOwner', 80 | repo: 'testRepo', 81 | title: 'testTitle' 82 | }; 83 | 84 | nock('https://api.github.com') 85 | .get('/repos/testOwner/testRepo/issues') 86 | .query({ state: 'open', access_token: token }) 87 | .reply(200, issues); 88 | 89 | github.checkForIssue(options, retrigger, token) 90 | .then(res => { 91 | assert.deepEqual(Array.isArray(res), true, '-- response is an array'); 92 | assert.deepEqual(res, issues, '-- returns all 150 issues'); 93 | assert.end(); 94 | }) 95 | .catch(err => { console.log(err); }); 96 | }); 97 | 98 | test('[github] [createIssue] Does not create issue because one exists. Retrigger is on.', function(assert) { 99 | let issue = githubFixtures.issue1(); 100 | let retrigger = true; 101 | let options = { 102 | owner: 'testOwner', 103 | repo: 'testRepo', 104 | title: 'testTitle', 105 | body: 'testBody' 106 | }; 107 | 108 | nock('https://api.github.com') 109 | .get('/repos/testOwner/testRepo/issues') 110 | .query({ state: 'open', access_token: token }) 111 | .reply(200, issue); 112 | 113 | github.createIssue(options, retrigger, token) 114 | .then(res => { 115 | assert.deepEqual(res, { status: 'exists', issue: 7, number: 7 }, '-- does not create issue'); 116 | assert.end(); 117 | }) 118 | .catch(err => { console.log(err); }); 119 | }); 120 | 121 | test('[github] [createIssue] Does not create issue because one exists. Retrigger is off.', function(assert) { 122 | let issue = githubFixtures.closedIssue(); 123 | let retrigger = false; 124 | let options = { 125 | owner: 'testOwner', 126 | repo: 'testRepo', 127 | title: 'testTitle', 128 | body: 'testBody' 129 | }; 130 | 131 | nock('https://api.github.com') 132 | .get('/repos/testOwner/testRepo/issues') 133 | .query({ state: 'all', access_token: token }) 134 | .reply(200, issue); 135 | 136 | github.createIssue(options, retrigger, token) 137 | .then(res => { 138 | assert.deepEqual(res, { status: 'exists', issue: 7, number: 7}, '-- does not create issue'); 139 | assert.end(); 140 | }) 141 | .catch(err => { console.log(err); }); 142 | }); 143 | 144 | test('[github] [createIssue] No existing issue, creates new issue', function(assert) { 145 | let issue = githubFixtures.issue1(); 146 | let retrigger = true; 147 | let options = { 148 | owner: 'testOwner', 149 | repo: 'testRepo', 150 | title: 'testTitle', 151 | body: 'testBody' 152 | }; 153 | 154 | nock('https://api.github.com') 155 | .get('/repos/testOwner/testRepo/issues') 156 | .query({ state: 'open', access_token: token }) 157 | .reply(200, []); 158 | 159 | nock('https://api.github.com', { encodedQueryParams: true }) 160 | .post('/repos/testOwner/testRepo/issues', { title: 'testTitle', body: 'testBody' }) 161 | .query({ access_token: token }) 162 | .reply(201, issue); 163 | 164 | github.createIssue(options, retrigger, token) 165 | .then(res => { 166 | assert.deepEqual(res.number, issue.number, '-- issue is created'); 167 | assert.end(); 168 | }) 169 | .catch(err => { console.log(err); }); 170 | }); 171 | 172 | test('[github] [closeIssue] Closes issue', function(assert) { 173 | let closedIssue = githubFixtures.closedIssue(); 174 | let options = { 175 | owner: 'testOwner', 176 | repo: 'testRepo', 177 | number: '1' 178 | }; 179 | 180 | nock('https://api.github.com', { encodedQueryParams: true }) 181 | .patch('/repos/testOwner/testRepo/issues/1', { state: 'closed' }) 182 | .query({ access_token: token }) 183 | .reply(200, closedIssue); 184 | 185 | github.closeIssue(options, token) 186 | .then(res => { 187 | assert.deepEqual(res.data, closedIssue, '-- issue is closed'); 188 | assert.end(); 189 | }) 190 | .catch(err => { console.log(err); }); 191 | }); 192 | -------------------------------------------------------------------------------- /test/lib/pagerduty.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* eslint-disable no-unused-vars */ 4 | 5 | const tape = require('tape'); 6 | const nock = require('nock'); 7 | 8 | const pagerduty = require('../../lib/pagerduty.js'); 9 | const fixtures = require('../fixtures/pagerduty.fixtures.js'); 10 | 11 | tape('[pagerduty] [createIncident] creates incident', (assert) => { 12 | let options = { 13 | accessToken: 'testPagerDutyApiKey', 14 | title: 'testIncidentTitle', 15 | serviceId: 'testServiceId', 16 | incidentKey: 'testIncidentKey', 17 | from: 'null@foo.bar' 18 | }; 19 | 20 | nock('https://api.pagerduty.com:443', { 'encodedQueryParams': true }) 21 | .post('/incidents', { 22 | incident: { 23 | type: 'incident', 24 | title: 'testIncidentTitle', 25 | service: { 26 | id: 'testServiceId', 27 | type: 'service_reference' 28 | }, 29 | incident_key: 'testIncidentKey' } 30 | }) 31 | .reply(201, fixtures.incident); 32 | 33 | pagerduty.createIncident(options) 34 | .then(res => { 35 | assert.deepEqual(res.body, fixtures.incident, '-- incident should be created'); 36 | assert.end(); 37 | }) 38 | .catch(err => { console.log(err); }); 39 | }); 40 | 41 | tape('[pagerduty] [createIncident] creates incident with body', function(assert) { 42 | let options = { 43 | accessToken: 'testPagerDutyApiKey', 44 | title: 'testIncidentTitle', 45 | body: 'testBody', 46 | serviceId: 'testServiceId', 47 | incidentKey: 'testIncidentKey', 48 | from: 'null@foo.bar' 49 | }; 50 | 51 | nock('https://api.pagerduty.com:443', { 'encodedQueryParams': true }) 52 | .post('/incidents', { 53 | incident: { 54 | type: 'incident', 55 | title: 'testIncidentTitle', 56 | service: { 57 | id: 'testServiceId', 58 | type: 'service_reference' 59 | }, 60 | incident_key: 'testIncidentKey', 61 | body: { 62 | type: 'incident_body', 63 | details: 'testBody' 64 | } 65 | } 66 | }) 67 | .reply(201, fixtures.incidentWithBody); 68 | 69 | pagerduty.createIncident(options) 70 | .then(res => { 71 | assert.deepEqual(res.body, fixtures.incidentWithBody, '-- incident with body should be created'); 72 | assert.end(); 73 | }) 74 | .catch(err => { console.log(err); }); 75 | }); 76 | -------------------------------------------------------------------------------- /test/lib/slack.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | const sinon = require('sinon'); 5 | 6 | const slack = require('../../lib/slack.js'); 7 | const fixtures = require('../../test/fixtures/slack.fixtures.js'); 8 | 9 | test('[slack] [formatMessage] SNS parsing error', (assert) => { 10 | slack.formatMessage(fixtures.sns.malformed, (err) => { 11 | assert.equal(err instanceof TypeError, true, '-- should return TypeError'); 12 | assert.end(); 13 | }); 14 | }); 15 | 16 | test('[slack] [formatMessage] self-service success', (assert) => { 17 | slack.formatMessage(fixtures.sns.success, (err, message, prompt) => { 18 | assert.ifError(err, '-- should not error'); 19 | assert.deepEqual(message, fixtures.slack.message, '-- should return valid message object'); 20 | assert.deepEqual(prompt, fixtures.slack.prompt, '-- should return valid prompt object'); 21 | assert.end(); 22 | }); 23 | }); 24 | 25 | test('[slack] [formatMessage] self-service with response text success', (assert) => { 26 | slack.formatMessage(fixtures.sns.successWithResponse, (err, message, prompt) => { 27 | assert.ifError(err, '-- should not error'); 28 | assert.deepEqual(message, fixtures.slack.message, '-- should return valid message object'); 29 | assert.deepEqual(prompt, fixtures.slack.promptWithResponseText, '-- should return valid prompt object'); 30 | assert.end(); 31 | }); 32 | }); 33 | 34 | test('[slack] [formatMessage] broadcast success', (assert) => { 35 | slack.formatMessage(fixtures.sns.broadcast, (err, message) => { 36 | assert.ifError(err, '-- should not error'); 37 | assert.deepEqual(message, fixtures.slack.messageBroadcast, '-- should return valid message object'); 38 | assert.end(); 39 | }); 40 | }); 41 | 42 | test('[slack] [postAlert] missing message body error', (assert) => { 43 | slack.postAlert(fixtures.slack.slackId, fixtures.slack.missingMessage, fixtures.clients.empty, fixtures.slack.channel, fixtures.sns.requestId, (err) => { 44 | assert.equal(err, fixtures.slack.missingMessageError, '-- should return error message'); 45 | assert.end(); 46 | }); 47 | }); 48 | 49 | test('[slack] [postAlert] destination user error, fallback channel success', (assert) => { 50 | slack.postAlert(fixtures.slack.slackId, fixtures.slack.message, fixtures.clients.errorUser, fixtures.slack.channel, fixtures.sns.requestId, (err, res) => { 51 | assert.ifError(err, '-- should not error'); 52 | assert.deepEqual(res, fixtures.slack.successFallback, '-- should pass through response object'); 53 | assert.equal(res.ok, true, '-- res.ok should be true'); 54 | assert.equal(res.channel, fixtures.slack.channelId, '-- destination error for user should post to fallback channel'); 55 | assert.end(); 56 | }); 57 | }); 58 | 59 | test('[slack] [postAlert] destination user error, fallback channel error', (assert) => { 60 | slack.postAlert(fixtures.slack.slackId, fixtures.slack.message, fixtures.clients.errorChannel, fixtures.slack.channel, fixtures.sns.requestId, (err, res) => { 61 | assert.equal(err, fixtures.slack.errorNoChannelFallback, '-- passes custom error on Slack failure'); 62 | assert.deepEqual(res, fixtures.slack.errorNoChannel, '-- should pass through response object'); 63 | assert.equal(res.ok, false, '-- res.ok should be false'); 64 | assert.end(); 65 | }); 66 | }); 67 | 68 | test('[slack] [postAlert] destination user success', (assert) => { 69 | slack.postAlert(fixtures.slack.slackId, fixtures.slack.message, fixtures.clients.success, fixtures.slack.channel, fixtures.sns.requestId, (err, res) => { 70 | assert.ifError(err, '-- should not error'); 71 | assert.deepEqual(res, fixtures.slack.success, '-- should pass through response object'); 72 | assert.equal(res.ok, true, '-- res.ok should be true'); 73 | assert.end(); 74 | }); 75 | }); 76 | 77 | test('[slack] [alertToSlack] encode error', (assert) => { 78 | slack.alertToSlack(fixtures.slack.user, fixtures.sns.encode, fixtures.clients.empty, fixtures.slack.channel, (err) => { 79 | assert.equal(err, fixtures.sns.encodeError, '-- should return error'); 80 | assert.end(); 81 | }); 82 | }); 83 | 84 | test('[slack] [alertToSlack] formatMessage error', (assert) => { 85 | let formatMessageStub = sinon.stub(slack, 'formatMessage') // eslint-disable-line no-unused-vars 86 | .yields(new TypeError('testError')); 87 | 88 | slack.alertToSlack(fixtures.slack.user, fixtures.sns.malformed, fixtures.clients.empty, fixtures.slack.channel, (err) => { 89 | assert.equal(err instanceof TypeError, true, '-- should return TypeError'); 90 | assert.end(); 91 | }); 92 | 93 | formatMessageStub.restore(); 94 | }); 95 | 96 | test('[slack] [alertToSlack] postAlert error', (assert) => { 97 | let formatMessageStub = sinon.stub(slack, 'formatMessage') // eslint-disable-line no-unused-vars 98 | .yields(null, fixtures.slack.message); 99 | let postAlertStub = sinon.stub(slack, 'postAlert') // eslint-disable-line no-unused-vars 100 | .yields(fixtures.slack.errorNoChannelFallback, fixtures.slack.errorNoChannel); 101 | 102 | slack.alertToSlack(fixtures.slack.user, fixtures.sns.success, fixtures.clients.errorUser, fixtures.slack.channel, (err) => { 103 | assert.equal(err, fixtures.slack.errorNoChannelFallback, '-- should return error'); 104 | assert.end(); 105 | }); 106 | 107 | formatMessageStub.restore(); 108 | postAlertStub.restore(); 109 | }); 110 | 111 | test('[slack] [alertToSlack] postAlert message success, no prompt', (assert) => { 112 | let formatMessageStub = sinon.stub(slack, 'formatMessage') // eslint-disable-line no-unused-vars 113 | .yields(null, fixtures.slack.message); 114 | let postAlertStub = sinon.stub(slack, 'postAlert') // eslint-disable-line no-unused-vars 115 | .yields(null, fixtures.slack.success); 116 | 117 | slack.alertToSlack(fixtures.slack.user, fixtures.sns.successNoPrompt, fixtures.clients.success, fixtures.slack.channel, (err, status) => { 118 | assert.ifError(err, '-- should not error'); 119 | assert.deepEqual(status, fixtures.slack.status, '-- should return status'); 120 | assert.equal(status.alert, true, '-- status.alert should be true'); 121 | assert.end(); 122 | }); 123 | 124 | formatMessageStub.restore(); 125 | postAlertStub.restore(); 126 | }); 127 | 128 | test('[slack] [alertToSlack] postAlert message success, prompt error', (assert) => { 129 | let formatMessageStub = sinon.stub(slack, 'formatMessage') // eslint-disable-line no-unused-vars 130 | .yields(null, fixtures.slack.message, fixtures.slack.prompt); 131 | let postAlertStub = sinon.stub(slack, 'postAlert'); 132 | 133 | postAlertStub.withArgs(fixtures.slack.slackId, fixtures.slack.message, fixtures.clients.success, fixtures.slack.channel, fixtures.sns.requestId) 134 | .yields(null, fixtures.slack.success); 135 | postAlertStub.withArgs(fixtures.slack.slackId, fixtures.slack.prompt, fixtures.clients.success, fixtures.slack.channel, fixtures.sns.requestId) 136 | .yields(fixtures.slack.errorNoChannel.error, fixtures.slack.errorNoChannel); 137 | 138 | slack.alertToSlack(fixtures.slack.user, fixtures.sns.success, fixtures.slack.channel, fixtures.clients.success, (err) => { 139 | assert.equal(err, fixtures.slack.errorNoChannel.error, '-- should return error'); 140 | assert.end(); 141 | }); 142 | 143 | postAlertStub.restore(); 144 | formatMessageStub.restore(); 145 | }); 146 | 147 | test('[slack] [alertToSlack] postAlert message success, prompt success', (assert) => { 148 | let formatMessageStub = sinon.stub(slack, 'formatMessage') // eslint-disable-line no-unused-vars 149 | .yields(null, fixtures.slack.message, fixtures.slack.prompt); 150 | let postAlertStub = sinon.stub(slack, 'postAlert'); 151 | 152 | postAlertStub.withArgs(fixtures.slack.slackId, fixtures.slack.message, fixtures.clients.success) 153 | .yields(null, fixtures.slack.success); 154 | postAlertStub.withArgs(fixtures.slack.slackId, fixtures.slack.prompt, fixtures.clients.success) 155 | .yields(null, fixtures.slack.successPrompt); 156 | 157 | slack.alertToSlack(fixtures.slack.user, fixtures.sns.success, fixtures.slack.channel, fixtures.clients.success, (err, status) => { 158 | assert.ifError(err, '-- should not error'); 159 | assert.deepEqual(status, fixtures.slack.statusPrompt, '-- should return status'); 160 | assert.equal(status.alert, true, '-- status.alert should be true'); 161 | assert.end(); 162 | }); 163 | 164 | formatMessageStub.restore(); 165 | postAlertStub.restore(); 166 | }); 167 | -------------------------------------------------------------------------------- /test/scripts/volumeBroadcastTest.js: -------------------------------------------------------------------------------- 1 | var AWS = require('aws-sdk'); 2 | var crypto = require('crypto'); 3 | 4 | var sns = new AWS.SNS(); 5 | 6 | if (!process.env.SNSARN) { 7 | console.log('Please set $SNSARN before testing'); 8 | } 9 | if (!process.env.VOLUME) { 10 | console.log('Please set $VOLUME before testing'); 11 | } 12 | if (!process.env.TESTUSER) { 13 | console.log('Please set $TESTUSER before testing'); 14 | } 15 | 16 | var arn = process.env.SNSARN; 17 | var volume = process.env.VOLUME; 18 | var testUser = process.env.TESTUSER; 19 | var title = crypto.randomBytes(6).toString('hex'); 20 | 21 | var msg = { 22 | type: 'broadcast', 23 | users: [], 24 | body: { 25 | github: { 26 | body: 'volume broadcast test, please ignore' 27 | }, 28 | slack: { 29 | message: 'This is a test of a broadcast dispatch, please ignore' 30 | } 31 | } 32 | }; 33 | 34 | for (var i=0; i < volume; i++) { 35 | msg.users.push({github: testUser, slack: testUser}); 36 | } 37 | 38 | msg.body.github.title = title; 39 | 40 | var params = { 41 | Message: JSON.stringify(msg), 42 | TopicArn: arn 43 | }; 44 | sns.publish(params, function(err,data) { 45 | if (err) return console.log(err); 46 | console.log(data); 47 | }); 48 | -------------------------------------------------------------------------------- /test/scripts/volumeSelfServiceTest.js: -------------------------------------------------------------------------------- 1 | var d3 = require('d3-queue'); 2 | var AWS = require('aws-sdk'); 3 | var crypto = require('crypto'); 4 | 5 | var sns = new AWS.SNS(); 6 | 7 | if (!process.env.SNSARN) { 8 | console.log('Please set $SNSARN before testing'); 9 | process.exit(1); 10 | } 11 | if (!process.env.VOLUME) { 12 | console.log('Please set $VOLUME before testing'); 13 | process.exit(1); 14 | } 15 | if (!process.env.TESTUSER) { 16 | console.log('Please set $TESTUSER before testing'); 17 | process.exit(1); 18 | } 19 | 20 | var arn = process.env.SNSARN; 21 | var volume = process.env.VOLUME; 22 | var testUser = process.env.TESTUSER; 23 | 24 | var msg = { 25 | type:'self-service', 26 | users: [ 27 | { 28 | slack: testUser, 29 | github: testUser 30 | } 31 | ], 32 | body: { 33 | github: { 34 | body: 'test' 35 | }, 36 | slack: { 37 | message:'This is a test of a self-service dispatch', 38 | actions: { 39 | yes:'Oh yeah!', 40 | no:'Oh no!' 41 | } 42 | } 43 | } 44 | }; 45 | 46 | var q = d3.queue(1); 47 | 48 | function generateMessage(message, title, next) { 49 | message.body.github.title = title; 50 | var params = { 51 | Message: JSON.stringify(message), 52 | TopicArn: arn 53 | }; 54 | sns.publish(params, function(err,data) { 55 | if (err) return next(err); 56 | return next(null, data); 57 | }); 58 | }; 59 | 60 | for (var i = 0; i < volume; i++) { 61 | var title = crypto.randomBytes(6).toString('hex'); 62 | console.log(`Queuing dispatch with GitHub title ${title}`); 63 | q.defer(generateMessage, msg, title); 64 | } 65 | 66 | q.awaitAll(function(err,data) { 67 | console.log(err); 68 | console.log(data); 69 | }); 70 | -------------------------------------------------------------------------------- /triage/function.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const querystring = require('querystring'); 4 | const github = require('../lib/github.js'); 5 | const pagerduty = require('../lib/pagerduty.js'); 6 | const utils = require('../lib/utils.js'); 7 | 8 | const triage = {}; 9 | 10 | /** 11 | * Lambda function body, triggered by SNS event 12 | * 13 | * @param {object} event - SNS event object, contains message 14 | * @param {object} context - object containing lambda function runtime information 15 | * @param {function} callback - function called when lambda run is complete 16 | */ 17 | triage.fn = function(event, context, callback) { 18 | utils.decrypt(process.env, (err) => { 19 | if (err) throw err; 20 | 21 | const gitHubOwner = process.env.GitHubOwner; 22 | const gitHubRepo = process.env.GitHubRepo; 23 | const gitHubToken = process.env.GitHubToken; 24 | const pagerDutyApiKey = process.env.PagerDutyApiKey; 25 | const pagerDutyFromAddress = process.env.PagerDutyFromAddress; 26 | const pagerDutyServiceId = process.env.PagerDutyServiceId; 27 | const slackVerificationToken = process.env.SlackVerificationToken; 28 | 29 | const lambdaFailure = 'Lambda failure'; 30 | 31 | triage.checkEvent(event, slackVerificationToken, (err, payload) => { 32 | if (err) { 33 | console.log({ 34 | severity: 'error', 35 | requestId: null, 36 | service: 'lambda', 37 | message: err 38 | }); 39 | return callback(lambdaFailure); 40 | } 41 | 42 | // validate callback decode for minimum info 43 | utils.decode(payload.callback_id, (err, res) => { 44 | if (err) { 45 | console.log({ 46 | severity: 'error', 47 | requestId: null, 48 | service: 'lambda', 49 | message: err 50 | }); 51 | return callback(lambdaFailure); 52 | } 53 | // check for GitHub issue for continued Slack functionality 54 | if (!res.github) { 55 | console.log({ 56 | severity: 'error', 57 | requestId: res.requestId, 58 | service: 'lambda', 59 | message: 'input missing GitHub issue for Slack callback_id' 60 | }); 61 | return callback(lambdaFailure); 62 | } 63 | 64 | const response = payload.actions[0].name; 65 | 66 | let responseText = payload.actions[0].value; 67 | let responseObject; 68 | let responseError; 69 | 70 | // RESPONSE YES 71 | if (response === 'yes') { 72 | let options = { 73 | token: gitHubToken, 74 | number: res.github, 75 | owner: gitHubOwner, 76 | repo: gitHubRepo 77 | }; 78 | 79 | let attachment = { 80 | attachment_type: 'default', 81 | fallback: `Could not load Slack response, ${res.requestId}: closed GitHub issue ${gitHubRepo}/${res.github}`, 82 | text: responseText, 83 | color: '#008E00', 84 | footer: 'Dispatch alert acknowledged', 85 | ts: Math.floor((new Date).getTime()/1000), 86 | replace_original: false 87 | }; 88 | 89 | github.closeIssue(options, gitHubToken) 90 | .then(value => { // eslint-disable-line no-unused-vars 91 | // log success, return responseObject to Slack via callback 92 | console.log({ 93 | severity: 'info', 94 | requestId: res.requestId, 95 | service: 'github', 96 | message: `GitHub issue ${gitHubRepo}/${res.github} was successfully closed` 97 | }); 98 | 99 | responseObject = { attachments: [ attachment ] }; 100 | 101 | return callback(null, responseObject); 102 | }) 103 | .catch(error => { 104 | // log error, return responseError to Slack via callback 105 | console.log({ 106 | severity: 'error', 107 | requestId: res.requestId, 108 | service: 'github', 109 | message: `failed to close GitHub issue ${gitHubRepo}/${res.github}, ${error}` 110 | }); 111 | 112 | responseError = `Error: dispatch ${res.requestId} failed to close GitHub issue ${gitHubRepo}/${res.github}, ${error}`; 113 | 114 | return callback(null, responseError); 115 | }); 116 | } 117 | 118 | // RESPONSE NO 119 | else if (response === 'no') { 120 | const pagerDutyTitle = `dispatch ${res.requestId}: user ${payload.user.name} responded '${response}' for self-service issue ${gitHubRepo}/${res.github}`; 121 | const pagerDutyBody = `${pagerDutyTitle}\n\n https://github.com/${gitHubOwner}/${gitHubRepo}/issues/${res.github}`; 122 | 123 | let options = { 124 | accessToken: pagerDutyApiKey, 125 | title: pagerDutyTitle, 126 | serviceId: (res.pagerDutyServiceId ? res.pagerDutyServiceId : pagerDutyServiceId), 127 | incidentKey: res.requestId, 128 | from: pagerDutyFromAddress, 129 | body: pagerDutyBody 130 | }; 131 | 132 | let attachment = { 133 | attachment_type: 'default', 134 | fallback: `Could not load Slack response, ${res.requestId}: Created PagerDuty incident successfully`, 135 | text: responseText, 136 | color: '#CC0000', 137 | footer: 'Dispatch alert escalated', 138 | ts: Math.floor((new Date).getTime()/1000), 139 | replace_original: false 140 | }; 141 | 142 | const incident = pagerduty.createIncident(options); 143 | 144 | incident 145 | .then(value => { 146 | // log success, return responseObject to Slack via callback 147 | console.log({ 148 | severity: 'info', 149 | requestId: res.requestId, 150 | service: 'pagerduty', 151 | message: `created PagerDuty incident ${value.body.incident.incident_key} successfully` 152 | }); 153 | 154 | responseObject = { attachments: [ attachment ] }; 155 | 156 | return callback(null, responseObject); 157 | }) 158 | .catch(error => { 159 | // check for existing PagerDuty incident 160 | if (error.errorMessage && /matching dedup key already exists/.test(error.errorMessage)) { 161 | console.log({ 162 | severity: 'notice', 163 | requestId: res.requestId, 164 | service: 'pagerduty', 165 | message: `found existing PagerDuty incident, will not create duplicate: ${JSON.stringify(error)}` 166 | }); 167 | 168 | responseError = responseText ? responseText : `dispatch ${res.requestId} - found existing PagerDuty incident ${value.incident.incident_key}, will not create duplicate`; 169 | } else { 170 | console.log({ 171 | severity: 'error', 172 | requestId: res.requestId, 173 | service: 'pagerduty', 174 | message: `failed to create PagerDuty incident: ${JSON.stringify(error)}` 175 | }); 176 | 177 | responseError = `Error: dispatch ${res.requestId} failed to create PagerDuty incident`; 178 | } 179 | 180 | return callback(null, responseError); 181 | }); 182 | } 183 | 184 | else { 185 | console.log({ 186 | severity: 'error', 187 | requestId: res.requestId, 188 | service: 'lambda', 189 | message: 'unhandled payload response' 190 | }); 191 | 192 | return callback(`Error: dispatch ${res.requestId} unhandled payload response`); 193 | } 194 | }); 195 | }); 196 | }); 197 | }; 198 | 199 | /** 200 | * Ingest and validate SNS event object 201 | * 202 | * @param {object} event - SNS event object, contains message 203 | * @param {string} slackVerificationToken 204 | * @param {function} callback 205 | */ 206 | triage.checkEvent = function(event, slackVerificationToken, callback) { 207 | try { 208 | let payload = JSON.parse(querystring.parse(event.postBody).payload); 209 | 210 | if (payload.token !== slackVerificationToken) return callback('incorrect Slack verification token'); 211 | if (payload.actions.length > 1) return callback(`found ${payload.actions.length} actions in payload, expected 1`); 212 | 213 | return callback(null, payload); 214 | } catch (err) { 215 | return callback('failed to parse dispatch triage event payload'); 216 | } 217 | }; 218 | 219 | module.exports = triage; 220 | -------------------------------------------------------------------------------- /triage/function.template.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const lambdaCfn = require('@mapbox/lambda-cfn'); 4 | 5 | const lambdaTemplate = lambdaCfn.build({ 6 | name: 'triage', 7 | handler: 'triage/function.fn', 8 | memorySize: '1536', 9 | parameters: { 10 | PagerDutyApiKey: { 11 | Type: 'String', 12 | Description: '[secure] PagerDuty API key' 13 | }, 14 | PagerDutyServiceId: { 15 | Type: 'String', 16 | Description: 'PagerDuty service ID' 17 | }, 18 | PagerDutyFromAddress: { 19 | Type: 'String', 20 | Description: 'Email address of a valid PagerDuty user' 21 | }, 22 | GitHubOwner: { 23 | Type: 'String', 24 | Description: 'Owner of Github repo' 25 | }, 26 | GitHubRepo: { 27 | Type: 'String', 28 | Description: 'Github repository' 29 | }, 30 | GitHubToken: { 31 | Type: 'String', 32 | Description: '[secure] GitHub OAuth Token' 33 | }, 34 | SlackVerificationToken: { 35 | Type: 'String', 36 | Description: '[secure] Slack verification token for Dispatch Slack app' 37 | }, 38 | KmsKey: { 39 | Type: 'String', 40 | Description: 'Cloudformation-kms stack name or KMS key ARN' 41 | } 42 | }, 43 | statements: [ 44 | { 45 | Effect: 'Allow', 46 | Action: [ 47 | 'kms:Decrypt' 48 | ], 49 | Resource: { 50 | 'Fn::ImportValue': { 51 | 'Ref': 'KmsKey' 52 | } 53 | } 54 | } 55 | ], 56 | eventSources: { 57 | webhook: { 58 | method: 'POST', 59 | apiKey: false, 60 | integration: { 61 | PassthroughBehavior: 'WHEN_NO_TEMPLATES', 62 | RequestTemplates: { 63 | 'application/x-www-form-urlencoded': '{ "postBody" : $input.json("$")}' 64 | } 65 | } 66 | } 67 | } 68 | }); 69 | 70 | module.exports = lambdaTemplate; 71 | --------------------------------------------------------------------------------