├── .gitignore ├── .travis.yml ├── .dockerignore ├── doc └── img │ └── example.png ├── test ├── logger.test.js ├── config.test.js └── bot.test.js ├── Dockerfile ├── lib ├── logger.js ├── config.js └── bot.js ├── config.default.js ├── LICENSE ├── app.js ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | config.js 4 | .DS_Store 5 | .nyc_output 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5" 4 | after_success: npm run coverage 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | .dockerignore 4 | Dockerfile 5 | npm-debug.log 6 | coverage/ 7 | -------------------------------------------------------------------------------- /doc/img/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaunburdick/slack-jirabot/HEAD/doc/img/example.png -------------------------------------------------------------------------------- /test/logger.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | 5 | test('Logger: create an instance of logger', (assert) => { 6 | const logger = require(`${process.env.PWD}/lib/logger`)(); 7 | assert.ok(logger); 8 | assert.end(); 9 | }); 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | 3 | MAINTAINER Shaun Burdick 4 | 5 | ENV NODE_ENV=production \ 6 | JIRA_PROTOCOL= \ 7 | JIRA_HOST= \ 8 | JIRA_PORT= \ 9 | JIRA_BASE= \ 10 | JIRA_USER= \ 11 | JIRA_PASS= \ 12 | JIRA_API_VERSION= \ 13 | JIRA_STRICT_SSL= \ 14 | JIRA_REGEX= \ 15 | JIRA_SPRINT_FIELD= \ 16 | JIRA_RESPONSE= \ 17 | SLACK_TOKEN= \ 18 | SLACK_AUTO_RECONNECT= 19 | 20 | ADD . /usr/src/myapp 21 | 22 | WORKDIR /usr/src/myapp 23 | 24 | RUN ["npm", "install"] 25 | 26 | CMD ["npm", "start"] 27 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const winston = require('winston'); 4 | 5 | /** 6 | * Returns a function that will generate a preconfigured instance of winston. 7 | * 8 | * @return {function} A preconfigured instance of winston 9 | */ 10 | module.exports = () => { 11 | const logger = new winston.Logger({ 12 | transports: [ 13 | new (winston.transports.Console)({ 14 | timestamp: true, 15 | prettyPrint: true, 16 | handleExceptions: true 17 | }) 18 | ] 19 | }); 20 | logger.cli(); 21 | 22 | return logger; 23 | }; 24 | -------------------------------------------------------------------------------- /config.default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const config = { 4 | jira: { 5 | protocol: 'https', 6 | host: 'jira.yourhost.domain', 7 | port: 443, 8 | base: '', 9 | user: 'username', 10 | pass: 'password', 11 | apiVersion: 'latest', 12 | strictSSL: false, 13 | regex: '([A-Z][A-Z0-9]+-[0-9]+)', 14 | sprintField: '', 15 | customFields: { 16 | 17 | }, 18 | response: 'full' // full or minimal 19 | }, 20 | slack: { 21 | token: 'xoxb-Your-Token', 22 | autoReconnect: true 23 | }, 24 | usermap: {} 25 | }; 26 | module.exports = config; 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | 3 | Copyright (c) 2016 Shaun Burdick 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const logger = require('./lib/logger')(); 4 | const redact = require('redact-object'); 5 | const Bot = require('./lib/bot'); 6 | const Config = require('./lib/config'); 7 | 8 | let config; 9 | 10 | /** 11 | * Load config 12 | */ 13 | const rawConfig = (() => { 14 | let retVal; 15 | try { 16 | retVal = require('./config'); 17 | } catch (exception) { 18 | retVal = require('./config.default'); 19 | } 20 | 21 | return retVal; 22 | })(); 23 | 24 | try { 25 | config = Config.parse(rawConfig); 26 | } catch (error) { 27 | logger.error('Could not parse config', error); 28 | process.exit(1); 29 | } 30 | 31 | logger.info('Using the following configuration:', redact(config, ['token', 'pass'])); 32 | 33 | const bot = new Bot(config); 34 | bot.start(); 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-jirabot", 3 | "version": "2.3.3", 4 | "description": "Slackbot for interacting with JIRA", 5 | "main": "app.js", 6 | "private": true, 7 | "scripts": { 8 | "start": "node app.js", 9 | "test": "npm run lint && npm run unit", 10 | "unit": "nyc --all tape ./test/*.test.js | faucet && nyc report", 11 | "lint": "semistandard --verbose | snazzy", 12 | "coverage": "nyc report --reporter=text-lcov | coveralls" 13 | }, 14 | "author": "Shaun Burdick ", 15 | "homepage": "http://github.com/shaunburdick/slack-jirabot", 16 | "repository": { 17 | "type": "git", 18 | "url": "http://github.com/shaunburdick/slack-jirabot.git" 19 | }, 20 | "license": "GPL-3.0", 21 | "engine": { 22 | "node": "^4.0.0" 23 | }, 24 | "dependencies": { 25 | "botkit": "^0.4.10", 26 | "jira-client": "^3.0.2", 27 | "jira2slack": "^1.0.0", 28 | "moment": "^2.10.3", 29 | "redact-object": "^1.0.1", 30 | "winston": "^2.1.1" 31 | }, 32 | "devDependencies": { 33 | "coveralls": "^2.11.9", 34 | "faucet": "0.0.1", 35 | "nyc": "^6.4.0", 36 | "semistandard": "^8.0.0", 37 | "snazzy": "^4.0.0", 38 | "tape": "^4.5.1" 39 | }, 40 | "semistandard": { 41 | "ignore": [ 42 | "coverage" 43 | ] 44 | }, 45 | "nyc": { 46 | "include": [ 47 | "lib/**/*.js" 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/config.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | const Config = require(`${process.env.PWD}/lib/config`); 5 | const rawConfig = require(`${process.env.PWD}/config.default`); 6 | 7 | test('Config: Config: parse the string \'true\' into a boolean true', (assert) => { 8 | assert.equal(Config.parseBool('true'), true); 9 | assert.equal(Config.parseBool('True'), true); 10 | assert.equal(Config.parseBool('TRUE'), true); 11 | assert.end(); 12 | }); 13 | 14 | test('Config: parse the string \'1\' into a boolean true', (assert) => { 15 | assert.equal(Config.parseBool('1'), true); 16 | assert.end(); 17 | }); 18 | 19 | test('Config: parse any other string into a boolean false', (assert) => { 20 | assert.equal(Config.parseBool('false'), false); 21 | assert.equal(Config.parseBool('lksjfljksdf'), false); 22 | assert.equal(Config.parseBool('nope'), false); 23 | assert.end(); 24 | }); 25 | 26 | test('Config: pass the original value if not a string', (assert) => { 27 | assert.equal(Config.parseBool(1), 1); 28 | assert.end(); 29 | }); 30 | 31 | test('Config: parse default config as is', (assert) => { 32 | assert.equal(Config.parse(rawConfig), rawConfig); 33 | assert.end(); 34 | }); 35 | 36 | test('Config: use env values over file values', (assert) => { 37 | process.env.JIRA_REGEX = 'foo'; 38 | const conf = Config.parse(rawConfig); 39 | 40 | assert.deepEqual(conf.jira.regex, 'foo'); 41 | assert.end(); 42 | }); 43 | 44 | test('Config: throw an error if config is not an object', (assert) => { 45 | assert.throws(Config.parse.bind(null, 'foo'), /Config is not an object/); 46 | assert.end(); 47 | }); 48 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Parse a boolean from a string 5 | * 6 | * @param {string} string A string to parse into a boolean 7 | * @return {mixed} Either a boolean or the original value 8 | */ 9 | function parseBool(string) { 10 | if (typeof string === 'string') { 11 | return /^(true|1)$/i.test(string); 12 | } 13 | 14 | return string; 15 | } 16 | 17 | /** 18 | * Parses and enhances config object 19 | * 20 | * @param {object} cfg the raw object from file 21 | * @return {object} the paresed config object 22 | * @throws Error if it cannot parse object 23 | */ 24 | function parse(cfg) { 25 | if (typeof cfg !== 'object') { 26 | throw new Error('Config is not an object'); 27 | } 28 | 29 | const config = cfg; 30 | 31 | /** 32 | * Pull config from ENV if set 33 | */ 34 | config.jira.protocol = process.env.JIRA_PROTOCOL || config.jira.protocol; 35 | config.jira.host = process.env.JIRA_HOST || config.jira.host; 36 | config.jira.port = parseInt(process.env.JIRA_PORT, 10) || config.jira.port; 37 | config.jira.base = process.env.JIRA_BASE || config.jira.base; 38 | config.jira.user = process.env.JIRA_USER || config.jira.user; 39 | config.jira.pass = process.env.JIRA_PASS || config.jira.pass; 40 | config.jira.apiVersion = process.env.JIRA_API_VERSION || config.jira.apiVersion; 41 | config.jira.strictSSL = parseBool(process.env.JIRA_STRICT_SSL) || config.jira.strictSSL; 42 | config.jira.regex = process.env.JIRA_REGEX || config.jira.regex; 43 | config.jira.sprintField = process.env.JIRA_SPRINT_FIELD || config.jira.sprintField; 44 | config.jira.response = process.env.JIRA_RESPONSE || config.jira.response; 45 | 46 | config.slack.token = process.env.SLACK_TOKEN || config.slack.token; 47 | config.slack.autoReconnect = parseBool(process.env.SLACK_AUTO_RECONNECT) || 48 | config.slack.autoReconnect; 49 | 50 | return config; 51 | } 52 | 53 | module.exports = { 54 | parse, 55 | parseBool, 56 | }; 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slack Bot for JIRA 2 | [![Build Status](https://travis-ci.org/shaunburdick/slack-jirabot.svg)](https://travis-ci.org/shaunburdick/slack-jirabot) [![Coverage Status](https://coveralls.io/repos/shaunburdick/slack-jirabot/badge.svg?branch=master&service=github)](https://coveralls.io/github/shaunburdick/slack-jirabot?branch=master) [![Docker Pulls](https://img.shields.io/docker/pulls/shaunburdick/slack-jirabot.svg?maxAge=2592000)](https://hub.docker.com/r/shaunburdick/slack-jirabot/) [![js-semistandard-style](https://img.shields.io/badge/code%20style-semistandard-brightgreen.svg)](https://github.com/Flet/semistandard) 3 | 4 | This slack bot will listen on any channel it's on for JIRA tickets. It will lookup the ticket and respond with some information about it. 5 | 6 | ## Example 7 | ![Example](https://github.com/shaunburdick/slack-jirabot/raw/master/doc/img/example.png) 8 | 9 | ## Install 10 | 1. Clone this [repository](https://github.com/shaunburdick/slack-jirabot.git) 11 | 2. `npm install` 12 | 3. Copy `./config.default.js` to `./config.js` and [fill it out](#configjs) 13 | 4. `npm start` 14 | 15 | ## Test 16 | 1. `npm install` (make sure your NODE_ENV != `production`) 17 | 2. `npm test` 18 | 19 | ## config.js 20 | The config file should be filled out as follows: 21 | - jira: 22 | - protocol: string, https or http 23 | - host: string, the host or fqdn for JIRA (jira.yourhost.domain) 24 | - port: integer, the port JIRA is on, usually 80 or 443 25 | - base: string, If JIRA doesn't sit at the root, put its base directory here 26 | - user: string, Username of JIRA user 27 | - pass: string, Password of JIRA user 28 | - apiVersion: string, API version slug, usually latest 29 | - strictSSL: boolean, set false for self-signed certificates 30 | - regex: string, a string that will be used as a RegExp to match tickets, defaults to '([A-Z][A-Z0-9]+\-[0-9]+)' 31 | - sprintField: string, If using greenhopper, set the custom field that holds sprint information (customfield_1xxxx) 32 | - response: string, If 'full'(default), it will display all fields in response. 'minimal' just shows title/description 33 | - customFields: 34 | - Add any custom fields you would like to display 35 | - customfield_1xxxx: "Custom Title" 36 | - Object notation is supported 37 | - "customfield_1xxxx.member": "Custom Title" 38 | - "customfield_1xxxx[0].member": "Custom Title" 39 | 40 | - slack: 41 | - token: string, Your slack token 42 | - autoReconnect: boolean, Reconnect on disconnect 43 | 44 | - usermap: 45 | - Map a JIRA username to a Slack username 46 | - "jira-username": "slack-username" 47 | 48 | ## Docker 49 | Build an image using `docker build -t your_image:tag` 50 | 51 | Official Image [shaunburdick/slack-jirabot](https://hub.docker.com/r/shaunburdick/slack-jirabot/) 52 | 53 | ### Configuration Environment Variables 54 | You can set the configuration of the bot by using environment variables. _ENVIRONMENT_VARIABLE_=Default Value 55 | - JIRA_PROTOCOL=https, https or http 56 | - JIRA_HOST=jira.yourdomain.com, hostname for JIRA 57 | - JIRA_PORT=443, Usually 80 or 443 58 | - JIRA_BASE= , If JIRA doesn't sit at the root, put its base directory here 59 | - JIRA_USER=username, Username of JIRA user 60 | - JIRA_PASS=password, Password of JIRA user 61 | - JIRA_API_VERSION=latest, API version slug 62 | - JIRA_VERBOSE=false, Verbose logging 63 | - JIRA_STRICT_SSL=false, Set to false for self-signed certificates 64 | - JIRA_REGEX=([A-Z0-9]+-[0-9]+), The regex to match JIRA tickets 65 | - JIRA_SPRINT_FIELD=, if using greenhopper, set the custom field that holds sprint information (customfield_xxxxx) 66 | - JIRA_RESPONSE=, If 'full' (default), it will display all fields in response. 'minimal' just shows title/description 67 | - SLACK_TOKEN=xoxb-foo, Your Slack Token 68 | - SLACK_AUTO_RECONNECT=true, Reconnect on disconnect 69 | 70 | Set them using the `-e` flag while running docker: 71 | 72 | ``` 73 | docker run -it \ 74 | -e JIRA_HOST=foo.bar.com \ 75 | -e JIRA_USER=someuser \ 76 | -e JIRA_PASS=12345 \ 77 | -e SLACK_TOKEN=xobo-blarty-blar-blar \ 78 | shaunburdick/slack-jirabot:latest 79 | ``` 80 | 81 | ## Contributing 82 | 1. Create a new branch, please don't work in master directly. 83 | 2. Add failing tests for the change you want to make (if appliciable). Run `npm test` to see the tests fail. 84 | 3. Fix stuff. 85 | 4. Run `npm test` to see if the tests pass. Repeat steps 2-4 until done. 86 | 5. Update the documentation to reflect any changes. 87 | 6. Push to your fork and submit a pull request. 88 | -------------------------------------------------------------------------------- /test/bot.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const test = require('tape'); 4 | const Bot = require(`${process.env.PWD}/lib/bot`); 5 | const configDist = require(`${process.env.PWD}/config.default.js`); 6 | 7 | test('Bot: instantiate and set config', (assert) => { 8 | const bot = new Bot(configDist); 9 | assert.equal(bot.config, configDist); 10 | assert.end(); 11 | }); 12 | 13 | test('Bot: build issue links', (assert) => { 14 | const bot = new Bot(configDist); 15 | const issueKey = 'Test-1'; 16 | const expectedLink = `https://jira.yourhost.domain:443/browse/${issueKey}`; 17 | 18 | assert.equal(bot.buildIssueLink(issueKey), expectedLink); 19 | assert.end(); 20 | }); 21 | 22 | test('Bot: build issue links correctly with base', (assert) => { 23 | configDist.jira.base = 'foo'; 24 | const bot = new Bot(configDist); 25 | const issueKey = 'TEST-1'; 26 | const expectedLink = `https://jira.yourhost.domain:443/foo/browse/${issueKey}`; 27 | assert.equal(bot.buildIssueLink(issueKey), expectedLink); 28 | assert.end(); 29 | }); 30 | 31 | test('Bot: parse a sprint name from greenhopper field', (assert) => { 32 | const bot = new Bot(configDist); 33 | const sprintName = 'TEST'; 34 | const exampleSprint = [ 35 | `derpry-derp-derp,name=${sprintName},foo` 36 | ]; 37 | 38 | assert.equal(bot.parseSprint(exampleSprint), sprintName); 39 | assert.notOk(bot.parseSprint(['busted'])); 40 | assert.end(); 41 | }); 42 | 43 | test('Bot: parse a sprint name from the last sprint in the greenhopper field', (assert) => { 44 | const bot = new Bot(configDist); 45 | const sprintName = 'TEST'; 46 | const exampleSprint = [ 47 | `derpry-derp-derp,name=${sprintName}1,foo`, 48 | `derpry-derp-derp,name=${sprintName}2,foo`, 49 | `derpry-derp-derp,name=${sprintName}3,foo` 50 | ]; 51 | 52 | assert.equal(bot.parseSprint(exampleSprint), `${sprintName}3`); 53 | assert.end(); 54 | }); 55 | 56 | test('Bot: translate a jira username to a slack username', (assert) => { 57 | configDist.usermap = { 58 | foo: 'bar', 59 | fizz: 'buzz', 60 | ping: 'pong' 61 | }; 62 | 63 | const bot = new Bot(configDist); 64 | 65 | assert.equal(bot.jira2Slack('foo'), '@bar'); 66 | assert.equal(bot.jira2Slack('ping'), '@pong'); 67 | assert.notOk(bot.jira2Slack('blap')); 68 | assert.end(); 69 | }); 70 | 71 | test('Bot: parse unique jira tickets from a message', (assert) => { 72 | const bot = new Bot(configDist); 73 | assert.deepEqual(bot.parseTickets('Chan', 'blarty blar TEST-1'), ['TEST-1']); 74 | assert.deepEqual(bot.parseTickets('Chan', 'blarty blar TEST-2 TEST-2'), ['TEST-2']); 75 | assert.deepEqual(bot.parseTickets('Chan', 'blarty blar TEST-3 TEST-4'), ['TEST-3', 'TEST-4']); 76 | assert.deepEqual(bot.parseTickets('Chan', 'blarty blar Test-1 Test-1'), []); 77 | assert.end(); 78 | }); 79 | 80 | test('Bot: handle empty message/channel', (assert) => { 81 | const bot = new Bot(configDist); 82 | assert.deepEqual(bot.parseTickets('Chan', null), []); 83 | assert.deepEqual(bot.parseTickets(null, 'Foo'), []); 84 | assert.end(); 85 | }); 86 | 87 | test('Bot: populate the ticket buffer', (assert) => { 88 | const bot = new Bot(configDist); 89 | const ticket = 'TEST-1'; 90 | const channel = 'Test'; 91 | const hash = bot.hashTicket(channel, ticket); 92 | 93 | assert.deepEqual(bot.parseTickets(channel, `fooå${ticket}`), [ticket]); 94 | assert.ok(bot.ticketBuffer.get(hash)); 95 | 96 | // Expect the ticket to not be repeated 97 | assert.deepEqual(bot.parseTickets(channel, `foo ${ticket}`), []); 98 | assert.end(); 99 | }); 100 | 101 | test('Bot: respond to the same ticket in different channels', (assert) => { 102 | const bot = new Bot(configDist); 103 | const ticket = 'TEST-1'; 104 | const channel1 = 'Test1'; 105 | const channel2 = 'Test2'; 106 | 107 | assert.deepEqual(bot.parseTickets(channel1, `foo ${ticket}`), [ticket]); 108 | assert.deepEqual(bot.parseTickets(channel2, `foo ${ticket}`), [ticket]); 109 | assert.end(); 110 | }); 111 | 112 | test('Bot: cleanup the ticket buffer', (assert) => { 113 | const bot = new Bot(configDist); 114 | const ticket = 'TEST-1'; 115 | const channel = 'Test'; 116 | const hash = bot.hashTicket(channel, ticket); 117 | 118 | assert.deepEqual(bot.parseTickets(channel, `foo ${ticket}`), [ticket]); 119 | assert.ok(bot.ticketBuffer.get(hash)); 120 | 121 | // set the Ticket Buffer Length low to trigger the cleanup 122 | bot.TICKET_BUFFER_LENGTH = -1; 123 | bot.cleanupTicketBuffer(); 124 | assert.notOk(bot.ticketBuffer.get(hash)); 125 | 126 | assert.end(); 127 | }); 128 | 129 | test('Bot: return a default description if empty', (assert) => { 130 | const bot = new Bot(configDist); 131 | assert.equal(bot.formatIssueDescription(''), 'Ticket does not contain a description'); 132 | assert.end(); 133 | }); 134 | 135 | test('Bot: replace quotes', (assert) => { 136 | const bot = new Bot(configDist); 137 | assert.equal(bot.formatIssueDescription('{quote}foo{quote}'), '```foo```'); 138 | assert.end(); 139 | }); 140 | 141 | test('Bot: replace code blocks', (assert) => { 142 | const bot = new Bot(configDist); 143 | assert.equal(bot.formatIssueDescription('{code}foo{code}'), '```foo```'); 144 | assert.end(); 145 | }); 146 | 147 | test('Bot: show custom fields', (assert) => { 148 | assert.plan(5); 149 | const issue = { 150 | key: 'TEST-1', 151 | fields: { 152 | created: '2015-05-01T00:00:00.000', 153 | updated: '2015-05-01T00:01:00.000', 154 | summary: 'Blarty', 155 | description: 'Foo foo foo foo foo foo', 156 | status: { 157 | name: 'Open' 158 | }, 159 | priority: { 160 | name: 'Low' 161 | }, 162 | reporter: { 163 | name: 'bob', 164 | displayName: 'Bob' 165 | }, 166 | assignee: { 167 | name: 'fred', 168 | displayName: 'Fred' 169 | }, 170 | customfield_10000: 'Fizz', 171 | customfield_10001: [ 172 | { value: 'Buzz' } 173 | ] 174 | } 175 | }; 176 | 177 | // Add some custom fields 178 | configDist.jira.customFields.customfield_10000 = 'CF1'; 179 | configDist.jira.customFields['customfield_10001[0].value'] = 'CF2'; 180 | configDist.jira.customFields['customfield_10003 && exit()'] = 'Nope1'; 181 | configDist.jira.customFields['customfield_10004; exit()'] = 'Nope2'; 182 | configDist.jira.customFields.customfield_10005 = 'Nope3'; 183 | 184 | const bot = new Bot(configDist); 185 | const response = bot.issueResponse(issue); 186 | 187 | Object.keys(response.fields).map((key) => { 188 | switch (response.fields[key].title) { 189 | case configDist.jira.customFields.customfield_10000: 190 | assert.equal(response.fields[key].value, issue.fields.customfield_10000); 191 | break; 192 | case configDist.jira.customFields['customfield_10001[0].value']: 193 | assert.equal(response.fields[key].value, issue.fields.customfield_10001[0].value); 194 | break; 195 | case configDist.jira.customFields['customfield_10003 && exit()']: 196 | assert.equal(response.fields[key].value, 197 | 'Invalid characters in customfield_10003 && exit()'); 198 | break; 199 | case configDist.jira.customFields['customfield_10004; exit()']: 200 | assert.equal(response.fields[key].value, 'Invalid characters in customfield_10004; exit()'); 201 | break; 202 | case configDist.jira.customFields.customfield_10005: 203 | assert.equal(response.fields[key].value, 'Unable to read customfield_10005'); 204 | break; 205 | default: 206 | // nothing to see here 207 | } 208 | 209 | return null; 210 | }); 211 | }); 212 | 213 | test('Bot: show minimal response', (assert) => { 214 | const issue = { 215 | key: 'TEST-1', 216 | fields: { 217 | created: '2015-05-01T00:00:00.000', 218 | updated: '2015-05-01T00:01:00.000', 219 | summary: 'Blarty', 220 | description: 'Foo foo foo foo foo foo', 221 | status: { 222 | name: 'Open' 223 | }, 224 | priority: { 225 | name: 'Low' 226 | }, 227 | reporter: { 228 | name: 'bob', 229 | displayName: 'Bob' 230 | }, 231 | assignee: { 232 | name: 'fred', 233 | displayName: 'Fred' 234 | }, 235 | customfield_10000: 'Fizz', 236 | customfield_10001: [ 237 | { value: 'Buzz' } 238 | ] 239 | } 240 | }; 241 | 242 | // Add some custom fields 243 | configDist.jira.customFields.customfield_10000 = 'CF1'; 244 | configDist.jira.customFields['customfield_10001[0].value'] = 'CF2'; 245 | configDist.jira.customFields['customfield_10003 && exit()'] = 'Nope1'; 246 | configDist.jira.customFields['customfield_10004; exit()'] = 'Nope2'; 247 | configDist.jira.customFields.customfield_10005 = 'Nope3'; 248 | configDist.jira.response = 'minimal'; 249 | 250 | const bot = new Bot(configDist); 251 | const response = bot.issueResponse(issue); 252 | 253 | assert.equal(response.fields.length, 0, 'No fields should be provided in minimal response'); 254 | assert.end(); 255 | }); 256 | 257 | test('Bot: Check formatting', (assert) => { 258 | const issue = { 259 | key: 'TEST-1', 260 | fields: { 261 | created: '2015-05-01T00:00:00.000', 262 | updated: '2015-05-01T00:01:00.000', 263 | summary: 'Blarty', 264 | description: 'h1. Heading\nFoo foo _foo_ foo foo foo\n' + 265 | '* Bulleted List\n** Indented more\n* Indented less\n\n' + 266 | '- Bulleted Dash List\n- Bulleted Dash List\n- Bulleted Dash List\n\n' + 267 | '# Numbered List\n' + 268 | '## Indented more\n' + 269 | '## Indented more\n' + 270 | '### Indented morer\n' + 271 | '### Indented morer\n' + 272 | '### Indented morer\n' + 273 | '## Indented more\n' + 274 | '# Indented less\n\n' + 275 | '||heading 1||heading 2||\n' + 276 | '|col A1|col B1|\n|col A2|col B2|\n\n' + 277 | 'Bold: *boldy*\n' + 278 | 'Bold (spaced): * boldy is spaced *\n' + 279 | 'Italic: _italicy_\n' + 280 | 'Italic (spaced): _italicy is poorly spaced _\n' + 281 | 'Monospace: {{$code}}\n' + 282 | 'Citations: ??citation??\n' + 283 | 'Subscript: ~subscript~\n' + 284 | 'Superscript: ^superscript^\n' + 285 | 'Strikethrough: -strikethrough-\n' + 286 | 'Not Strikethrough: i-use-dashes\n' + 287 | 'Strikethrough (spaced): - strikethrough is poorly spaced-\n' + 288 | 'Code: {code}some code{code}\n' + 289 | 'Quote: {quote}quoted text{quote}\n' + 290 | 'No Format: {noformat}pre text{noformat}\n' + 291 | 'Unnamed Link: [http://someurl.com]\n' + 292 | 'Named Link: [Someurl|http://someurl.com]\n' + 293 | 'Blockquote: \nbq. This is quoted\n' + 294 | 'Color: {color:white}This is white text{color}\n' + 295 | 'Panel: {panel:title=foo}Panel Contents{panel}\n', 296 | status: { 297 | name: 'Open' 298 | }, 299 | priority: { 300 | name: 'Low' 301 | }, 302 | reporter: { 303 | name: 'bob', 304 | displayName: 'Bob' 305 | }, 306 | assignee: { 307 | name: 'fred', 308 | displayName: 'Fred' 309 | }, 310 | customfield_10000: 'Fizz', 311 | customfield_10001: [ 312 | { value: 'Buzz' } 313 | ] 314 | } 315 | }; 316 | 317 | const expectedText = '\n *Heading*\n\nFoo foo _foo_ foo foo foo\n' + 318 | '• Bulleted List\n • Indented more\n• Indented less\n\n' + 319 | '• Bulleted Dash List\n• Bulleted Dash List\n• Bulleted Dash List\n\n' + 320 | '1. Numbered List\n' + 321 | ' 1. Indented more\n' + 322 | ' 2. Indented more\n' + 323 | ' 1. Indented morer\n' + 324 | ' 2. Indented morer\n' + 325 | ' 3. Indented morer\n' + 326 | ' 3. Indented more\n' + 327 | '2. Indented less\n\n' + 328 | '\n|heading 1|heading 2|\n' + 329 | '| --- | --- |\n|col A1|col B1|\n|col A2|col B2|\n\n' + 330 | 'Bold: *boldy*\n' + 331 | 'Bold (spaced): *boldy is spaced* \n' + 332 | 'Italic: _italicy_\n' + 333 | 'Italic (spaced): _italicy is poorly spaced_ \n' + 334 | 'Monospace: `$code`\n' + 335 | 'Citations: _-- citation_\n' + 336 | 'Subscript: _subscript\n' + 337 | 'Superscript: ^superscript\n' + 338 | 'Strikethrough: ~strikethrough~\n' + 339 | 'Not Strikethrough: i-use-dashes\n' + 340 | 'Strikethrough (spaced): ~strikethrough is poorly spaced~\n' + 341 | 'Code: ```some code```\n' + 342 | 'Quote: ```quoted text```\n' + 343 | 'No Format: ```pre text```\n' + 344 | 'Unnamed Link: \n' + 345 | 'Named Link: \n' + 346 | 'Blockquote: \n> This is quoted\n' + 347 | 'Color: This is white text\n' + 348 | 'Panel: \n| foo |\n| --- |\n| Panel Contents |\n'; 349 | 350 | const bot = new Bot(configDist); 351 | const response = bot.issueResponse(issue); 352 | 353 | assert.equal(response.text, expectedText, 'Atlassian Markup should be converted to Slack Markup'); 354 | assert.end(); 355 | }); 356 | -------------------------------------------------------------------------------- /lib/bot.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const JiraApi = require('jira-client'); 4 | const Botkit = require('botkit'); 5 | const moment = require('moment'); 6 | const J2S = require('jira2slack'); 7 | const logger = require('./logger')(); 8 | const PACKAGE = require('../package'); 9 | 10 | const RESPONSE_FULL = 'full'; 11 | 12 | /** 13 | * @module Bot 14 | */ 15 | class Bot { 16 | /** 17 | * Constructor. 18 | * 19 | * @constructor 20 | * @param {Config} config The final configuration for the bot 21 | */ 22 | constructor (config) { 23 | this.config = config; 24 | /* hold tickets and last time responded to */ 25 | this.ticketBuffer = new Map(); 26 | 27 | /* Length of buffer to prevent ticket from being responded to */ 28 | this.TICKET_BUFFER_LENGTH = 300000; 29 | 30 | this.controller = Botkit.slackbot({ 31 | stats_optout: true, 32 | logger 33 | }); 34 | 35 | this.ticketRegExp = new RegExp(config.jira.regex, 'g'); 36 | logger.info(`Ticket Matching Regexp: ${this.ticketRegExp}`); 37 | 38 | this.jira = new JiraApi({ 39 | protocol: config.jira.protocol, 40 | host: config.jira.host, 41 | port: config.jira.port, 42 | username: config.jira.user, 43 | password: config.jira.pass, 44 | apiVersion: config.jira.apiVersion, 45 | strictSSL: config.jira.strictSSL, 46 | base: config.jira.base 47 | }); 48 | } 49 | 50 | /** 51 | * Build a response string about an issue. 52 | * 53 | * @param {Issue} issue the issue object returned by JIRA 54 | * @param {string} usrFormat the format to respond with 55 | * @return {Attachment} The response attachment. 56 | */ 57 | issueResponse (issue, usrFormat) { 58 | const format = usrFormat || this.config.jira.response; 59 | const response = { 60 | fallback: `No summary found for ${issue.key}` 61 | }; 62 | const created = moment(issue.fields.created); 63 | const updated = moment(issue.fields.updated); 64 | 65 | response.text = this.formatIssueDescription(issue.fields.description); 66 | response.mrkdwn_in = ['text']; // Parse text as markdown 67 | response.fallback = issue.fields.summary; 68 | response.pretext = `Here is some information on ${issue.key}`; 69 | response.title = issue.fields.summary; 70 | response.title_link = this.buildIssueLink(issue.key); 71 | response.footer = `Slack Jira ${PACKAGE.version} - ${PACKAGE.homepage}`; 72 | response.fields = []; 73 | if (format === RESPONSE_FULL) { 74 | response.fields.push({ 75 | title: 'Created', 76 | value: created.calendar(), 77 | short: true 78 | }); 79 | response.fields.push({ 80 | title: 'Updated', 81 | value: updated.calendar(), 82 | short: true 83 | }); 84 | response.fields.push({ 85 | title: 'Status', 86 | value: issue.fields.status.name, 87 | short: true 88 | }); 89 | response.fields.push({ 90 | title: 'Priority', 91 | value: issue.fields.priority.name, 92 | short: true 93 | }); 94 | response.fields.push({ 95 | title: 'Reporter', 96 | value: (this.jira2Slack(issue.fields.reporter.name) || issue.fields.reporter.displayName), 97 | short: true 98 | }); 99 | let assignee = 'Unassigned'; 100 | if (issue.fields.assignee) { 101 | assignee = (this.jira2Slack(issue.fields.assignee.name) || 102 | issue.fields.assignee.displayName); 103 | } 104 | response.fields.push({ 105 | title: 'Assignee', 106 | value: assignee, 107 | short: true 108 | }); 109 | // Sprint fields 110 | if (this.config.jira.sprintField) { 111 | response.fields.push({ 112 | title: 'Sprint', 113 | value: (this.parseSprint(issue.fields[this.config.jira.sprintField]) || 'Not Assigned'), 114 | short: false 115 | }); 116 | } 117 | // Custom fields 118 | if (this.config.jira.customFields && Object.keys(this.config.jira.customFields).length) { 119 | Object.keys(this.config.jira.customFields).map((customField) => { 120 | let fieldVal = null; 121 | // Do some simple guarding before eval 122 | if (!/[;&\|\(\)]/.test(customField)) { 123 | try { 124 | /* eslint no-eval: 0*/ 125 | fieldVal = eval(`issue.fields.${customField}`); 126 | } catch (e) { 127 | fieldVal = `Error while reading ${customField}`; 128 | } 129 | } else { 130 | fieldVal = `Invalid characters in ${customField}`; 131 | } 132 | fieldVal = fieldVal || `Unable to read ${customField}`; 133 | return response.fields.push({ 134 | title: this.config.jira.customFields[customField], 135 | value: fieldVal, 136 | short: false 137 | }); 138 | }); 139 | } 140 | } 141 | 142 | return response; 143 | } 144 | 145 | /** 146 | * Format a ticket description for display. 147 | * * Truncate to 1000 characters 148 | * * Replace any {quote} with ``` 149 | * * If there is no description, add a default value 150 | * 151 | * @param {string} description The raw description 152 | * @return {string} the formatted description 153 | */ 154 | formatIssueDescription (description) { 155 | const desc = description || 'Ticket does not contain a description'; 156 | return J2S.toSlack(desc); 157 | } 158 | 159 | /** 160 | * Construct a link to an issue based on the issueKey and config 161 | * 162 | * @param {string} issueKey The issueKey for the issue 163 | * @return {string} The constructed link 164 | */ 165 | buildIssueLink (issueKey) { 166 | let base = '/browse/'; 167 | if (this.config.jira.base) { 168 | // Strip preceeding and trailing forward slash 169 | base = `/${this.config.jira.base.replace(/^\/|\/$/g, '')}${base}`; 170 | } 171 | return `${this.config.jira.protocol}://${this.config.jira.host}:${this.config.jira.port}${base}${issueKey}`; 172 | } 173 | 174 | /** 175 | * Parses the sprint name of a ticket. 176 | * If the ticket is in more than one sprint 177 | * A. Shame on you 178 | * B. This will take the last one 179 | * 180 | * @param {string[]} customField The contents of the greenhopper custom field 181 | * @return {string} The name of the sprint or '' 182 | */ 183 | parseSprint (customField) { 184 | let retVal = ''; 185 | if (customField && customField.length > 0) { 186 | const sprintString = customField.pop(); 187 | const matches = sprintString.match(/,name=([^,]+),/); 188 | if (matches && matches[1]) { 189 | retVal = matches[1]; 190 | } 191 | } 192 | return retVal; 193 | } 194 | 195 | /** 196 | * Lookup a JIRA username and return their Slack username 197 | * Meh... Trying to come up with a better system for this feature 198 | * 199 | * @param {string} username the JIRA username 200 | * @return {string} The slack username or '' 201 | */ 202 | jira2Slack (username) { 203 | let retVal = ''; 204 | if (this.config.usermap[username]) { 205 | retVal = `@${this.config.usermap[username]}`; 206 | } 207 | return retVal; 208 | } 209 | 210 | /** 211 | * Parse out JIRA tickets from a message. 212 | * This will return unique tickets that haven't been 213 | * responded with recently. 214 | * 215 | * @param {string} channel the channel the message came from 216 | * @param {string} message the message to search in 217 | * @return {string[]} an array of tickets, empty if none found 218 | */ 219 | parseTickets (channel, message) { 220 | const retVal = []; 221 | if (!channel || !message) { 222 | return retVal; 223 | } 224 | const uniques = {}; 225 | const found = message.match(this.ticketRegExp); 226 | const now = Date.now(); 227 | let ticketHash; 228 | if (found && found.length) { 229 | found.forEach((ticket) => { 230 | ticketHash = this.hashTicket(channel, ticket); 231 | if ( 232 | !uniques.hasOwnProperty(ticket) && 233 | (now - (this.ticketBuffer.get(ticketHash) || 0) > this.TICKET_BUFFER_LENGTH) 234 | ) { 235 | retVal.push(ticket); 236 | uniques[ticket] = 1; 237 | this.ticketBuffer.set(ticketHash, now); 238 | } 239 | }); 240 | } 241 | return retVal; 242 | } 243 | 244 | /** 245 | * Hashes the channel + ticket combo. 246 | * 247 | * @param {string} channel The name of the channel 248 | * @param {string} ticket The name of the ticket 249 | * @return {string} The unique hash 250 | */ 251 | hashTicket (channel, ticket) { 252 | return `${channel}-${ticket}`; 253 | } 254 | 255 | /** 256 | * Remove any tickets from the buffer if they are past the length 257 | * 258 | * @return {null} nada 259 | */ 260 | cleanupTicketBuffer () { 261 | const now = Date.now(); 262 | logger.debug('Cleaning Ticket Buffer'); 263 | this.ticketBuffer.forEach((time, key) => { 264 | if (now - time > this.TICKET_BUFFER_LENGTH) { 265 | logger.debug(`Deleting ${key}`); 266 | this.ticketBuffer.delete(key); 267 | } 268 | }); 269 | } 270 | 271 | /** 272 | * Function to be called on slack open 273 | * 274 | * @param {object} payload Connection payload 275 | * @return {Bot} returns itself 276 | */ 277 | slackOpen (payload) { 278 | const channels = []; 279 | const groups = []; 280 | const mpims = []; 281 | 282 | logger.info(`Welcome to Slack. You are @${payload.self.name} of ${payload.team.name}`); 283 | 284 | if (payload.channels) { 285 | payload.channels.forEach((channel) => { 286 | if (channel.is_member) { 287 | channels.push(`#${channel.name}`); 288 | } 289 | }); 290 | 291 | logger.info(`You are in: ${channels.join(', ')}`); 292 | } 293 | 294 | if (payload.groups) { 295 | payload.groups.forEach((group) => { 296 | groups.push(`${group.name}`); 297 | }); 298 | 299 | logger.info(`Groups: ${groups.join(', ')}`); 300 | } 301 | 302 | if (payload.mpims) { 303 | payload.mpims.forEach((mpim) => { 304 | mpims.push(`${mpim.name}`); 305 | }); 306 | 307 | logger.info(`Multi-person IMs: ${mpims.join(', ')}`); 308 | } 309 | 310 | return this; 311 | } 312 | 313 | /** 314 | * Handle an incoming message 315 | * @param {object} message The incoming message from Slack 316 | * @returns {null} nada 317 | */ 318 | handleMessage (message) { 319 | const response = { 320 | as_user: true, 321 | attachments: [] 322 | }; 323 | 324 | if (message.type === 'message' && message.text) { 325 | const found = this.parseTickets(message.channel, message.text); 326 | if (found && found.length) { 327 | logger.info(`Detected ${found.join(',')}`); 328 | found.forEach((issueId) => { 329 | this.jira.findIssue(issueId) 330 | .then((issue) => { 331 | // If direct mention, use full format 332 | const responseFormat = message.event === 'direct_mention' ? RESPONSE_FULL : null; 333 | response.attachments = [this.issueResponse(issue, responseFormat)]; 334 | this.bot.reply(message, response, (err) => { 335 | if (err) { 336 | logger.error('Unable to respond', err); 337 | } else { 338 | logger.info(`@${this.bot.identity.name} responded with`, response); 339 | } 340 | }); 341 | }) 342 | .catch((error) => { 343 | logger.error(`Got an error trying to find ${issueId}`, error); 344 | }); 345 | }); 346 | } else { 347 | // nothing to do 348 | } 349 | } else { 350 | logger.info(`@${this.bot.identity.name} could not respond.`); 351 | } 352 | } 353 | 354 | /** 355 | * Start the bot 356 | * 357 | * @return {Bot} returns itself 358 | */ 359 | start () { 360 | this.controller.on( 361 | 'direct_mention,mention,ambient,direct_message', 362 | (bot, message) => { 363 | this.handleMessage(message); 364 | } 365 | ); 366 | 367 | this.controller.on('rtm_close', () => { 368 | logger.info('The RTM api just closed'); 369 | 370 | if (this.config.slack.autoReconnect) { 371 | this.connect(); 372 | } 373 | }); 374 | 375 | this.connect(); 376 | 377 | setInterval(() => { 378 | this.cleanupTicketBuffer(); 379 | }, 60000); 380 | 381 | return this; 382 | } 383 | 384 | /** 385 | * Connect to the RTM 386 | * @return {Bot} this 387 | */ 388 | connect () { 389 | this.bot = this.controller.spawn({ 390 | token: this.config.slack.token, 391 | retry: this.config.slack.autoReconnect ? Infinity : 0 392 | }).startRTM((err, bot, payload) => { 393 | if (err) { 394 | logger.error('Error starting bot!', err); 395 | } 396 | 397 | this.slackOpen(payload); 398 | }); 399 | 400 | return this; 401 | } 402 | } 403 | 404 | module.exports = Bot; 405 | --------------------------------------------------------------------------------