├── .gitignore ├── .travis.yml ├── script ├── test └── bootstrap ├── src ├── adapters │ ├── index.coffee │ ├── generic.coffee │ └── slack.coffee ├── github │ ├── index.coffee │ ├── pullrequests.coffee │ └── pullrequest.coffee ├── jira │ ├── index.coffee │ ├── query.coffee │ ├── labels.coffee │ ├── user.coffee │ ├── rank.coffee │ ├── comment.coffee │ ├── watch.coffee │ ├── search.coffee │ ├── transition.coffee │ ├── clone.coffee │ ├── assign.coffee │ ├── ticket.coffee │ ├── create.coffee │ └── webhook.coffee ├── help.coffee ├── utils.coffee ├── config.coffee └── index.coffee ├── index.coffee ├── LICENSE ├── package.json ├── yarn.lock └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.11" 4 | - "0.10" 5 | notifications: 6 | email: false 7 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # bootstrap environment 4 | source script/bootstrap 5 | 6 | mocha --compilers coffee:coffee-script 7 | -------------------------------------------------------------------------------- /src/adapters/index.coffee: -------------------------------------------------------------------------------- 1 | Slack = require "./slack" 2 | Generic = require "./generic" 3 | 4 | module.exports = { 5 | Slack 6 | Generic 7 | } 8 | -------------------------------------------------------------------------------- /src/github/index.coffee: -------------------------------------------------------------------------------- 1 | PullRequests = require "./pullrequests" 2 | PullRequest = require "./pullrequest" 3 | 4 | module.exports = { 5 | PullRequests 6 | PullRequest 7 | } 8 | -------------------------------------------------------------------------------- /index.coffee: -------------------------------------------------------------------------------- 1 | fs = require 'fs' 2 | path = require 'path' 3 | 4 | module.exports = (robot, scripts) -> 5 | scriptsPath = path.resolve(__dirname, 'src') 6 | fs.exists scriptsPath, (exists) -> 7 | robot.loadFile(scriptsPath, 'index.coffee') if exists 8 | 9 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Make sure everything is development forever 4 | export NODE_ENV=development 5 | 6 | # Load environment specific environment variables 7 | if [ -f .env ]; then 8 | source .env 9 | fi 10 | 11 | if [ -f .env.${NODE_ENV} ]; then 12 | source .env.${NODE_ENV} 13 | fi 14 | 15 | npm install 16 | 17 | # Make sure coffee and mocha are on the path 18 | export PATH="node_modules/.bin:$PATH" 19 | -------------------------------------------------------------------------------- /src/jira/index.coffee: -------------------------------------------------------------------------------- 1 | Assign = require "./assign" 2 | Clone = require "./clone" 3 | Comment = require "./comment" 4 | Create = require "./create" 5 | Labels = require "./labels" 6 | Rank = require "./rank" 7 | Search = require "./search" 8 | Query = require "./query" 9 | Ticket = require "./ticket" 10 | Transition = require "./transition" 11 | User = require "./user" 12 | Watch = require "./watch" 13 | Webhook = require "./webhook" 14 | 15 | module.exports = { 16 | Assign 17 | Clone 18 | Comment 19 | Create 20 | Labels 21 | Rank 22 | Search 23 | Query 24 | Ticket 25 | Transition 26 | User 27 | Watch 28 | Webhook 29 | } 30 | -------------------------------------------------------------------------------- /src/github/pullrequests.coffee: -------------------------------------------------------------------------------- 1 | _ = require "underscore" 2 | moment = require "moment" 3 | Octokat = require "octokat" 4 | 5 | Config = require "../config" 6 | PullRequest = require "./pullrequest" 7 | Utils = require "../utils" 8 | 9 | octo = new Octokat token: Config.github.token 10 | 11 | class PullRequests 12 | constructor: (prs) -> 13 | @prs = (new PullRequest p for p in prs) 14 | 15 | @fromKey: (key) -> 16 | octo.search.issues.fetch 17 | q: "#{key} @#{Config.github.organization} state:open type:pr" 18 | .then (json) -> 19 | return Promise.all json.items.map (issue) -> 20 | octo.fromUrl(issue.pullRequest.url).fetch() if issue.pullRequest?.url 21 | .then (issues) -> 22 | new PullRequests _(issues).compact() 23 | 24 | toAttachment: -> 25 | attachments = (pr.toAttachment() for pr in @prs) 26 | Promise.all attachments 27 | 28 | module.exports = PullRequests 29 | -------------------------------------------------------------------------------- /src/jira/query.coffee: -------------------------------------------------------------------------------- 1 | Config = require "../config" 2 | Ticket = require "./ticket" 3 | Utils = require "../utils" 4 | 5 | class Query 6 | 7 | @withQuery: (jql, max=50) -> 8 | noResults = "No results for #{jql}" 9 | found = "Found <#{Config.jira.url}/secure/IssueNavigator.jspa?jqlQuery=__JQL__&runQuery=true|__xx__ issues>" 10 | found.replace "__JQL__", encodeURIComponent jql 11 | 12 | Utils.fetch "#{Config.jira.url}/rest/api/2/search", 13 | method: "POST" 14 | body: JSON.stringify 15 | jql: jql 16 | startAt: 0 17 | maxResults: max 18 | fields: Config.jira.fields 19 | .then (json) -> 20 | if json.issues.length > 0 21 | text = found.replace("__xx__", json.total).replace "__JQL__", encodeURIComponent jql 22 | else 23 | text = noResults 24 | 25 | text: text 26 | tickets: (new Ticket issue for issue in json.issues) 27 | 28 | module.exports = Query 29 | -------------------------------------------------------------------------------- /src/jira/labels.coffee: -------------------------------------------------------------------------------- 1 | _ = require "underscore" 2 | 3 | Config = require "../config" 4 | Utils = require "../utils" 5 | 6 | class Labels 7 | 8 | @forTicketWith: (ticket, labels, context, includeAttachment=no, emit=yes) -> 9 | Utils.fetch "#{Config.jira.url}/rest/api/2/issue/#{ticket.key}", 10 | method: "PUT" 11 | body: JSON.stringify 12 | fields: labels: labels 13 | .then -> 14 | context.robot.emit "JiraTicketLabelled", ticket, context, includeAttachment if emit 15 | .catch (error) -> 16 | context.robot.emit "JiraTicketLabelFailed", error, context if emit 17 | Promise.reject error 18 | 19 | @forTicketKeyWith: (key, labels, context, includeAttachment=no, emit=yes) -> 20 | Create = require "./create" 21 | Create.fromKey(key) 22 | .then (ticket) -> 23 | labels = _(labels).union ticket.fields.labels 24 | Labels.forTicketWith ticket, labels, context, includeAttachment, emit 25 | 26 | module.exports = Labels 27 | -------------------------------------------------------------------------------- /src/jira/user.coffee: -------------------------------------------------------------------------------- 1 | _ = require "underscore" 2 | 3 | Config = require "../config" 4 | Utils = require "../utils" 5 | 6 | class User 7 | 8 | @withEmail: (email) -> 9 | Utils.fetch("#{Config.jira.url}/rest/api/2/user/search?username=#{email}") 10 | .then (users) -> 11 | jiraUser = _(users).findWhere emailAddress: email if users and users.length > 0 12 | throw "Cannot find jira user with #{email}, trying myself" unless jiraUser 13 | jiraUser 14 | .catch (error) -> 15 | Utils.robot.logger.error error 16 | Utils.Stats.increment "jirabot.user.lookup.email.failed" 17 | Utils.fetch("#{Config.jira.url}/rest/api/2/myself") 18 | 19 | @withUsername: (username) -> 20 | Utils.fetch("#{Config.jira.url}/rest/api/2/user?username=#{username}") 21 | .catch (error) -> 22 | Utils.Stats.increment "jirabot.user.lookup.username.failed" 23 | Utils.robot.logger.error "Cannot find jira user with #{username}, trying myself" 24 | Utils.fetch("#{Config.jira.url}/rest/api/2/myself") 25 | 26 | module.exports = User 27 | -------------------------------------------------------------------------------- /src/jira/rank.coffee: -------------------------------------------------------------------------------- 1 | Config = require "../config" 2 | Utils = require "../utils" 3 | 4 | class Rank 5 | 6 | @forTicketForDirection: (ticket, direction, context, includeAttachment=no, emit=yes) -> 7 | direction = direction.toLowerCase() 8 | 9 | switch direction 10 | when "up", "top" then direction = "Top" 11 | when "down", "bottom" then direction = "Bottom" 12 | else 13 | error = "`#{direction}` is not a valid rank direction" 14 | context.robot.emit "JiraTicketRankFailed", error, context if emit 15 | return Promise.reject error 16 | 17 | Utils.fetch("#{Config.jira.url}/secure/Rank#{direction}.jspa?issueId=#{ticket.id}") 18 | context.robot.emit "JiraTicketRanked", ticket, direction, context, includeAttachment if emit 19 | 20 | @forTicketKeyByDirection: (key, direction, context, includeAttachment=no, emit=yes) -> 21 | Create = require "./create" 22 | Create.fromKey(key) 23 | .then (ticket) -> 24 | Rank.forTicketForDirection ticket, direction, context, includeAttachment, emit 25 | 26 | module.exports = Rank 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 hubot-scripts 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hubot-jira-bot", 3 | "description": "A hubot script for all things JIRA, see README.md for details", 4 | "version": "7.4.0", 5 | "authors": [ 6 | "Nino D'Aversa " 7 | ], 8 | "license": "MIT", 9 | "keywords": [ 10 | "hubot", 11 | "hubot-scripts", 12 | "slack", 13 | "jira", 14 | "task", 15 | "bug" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "git@github.com:ndaversa/hubot-jira-bot.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/ndaversa/hubot-jira-bot/issues" 23 | }, 24 | "dependencies": { 25 | "coffee-script": "^1.10.0", 26 | "fuse.js": "^2.2.0", 27 | "memory-cache": "^0.1.5", 28 | "moment": "^2.12.0", 29 | "node-dogstatsd": "^0.0.6", 30 | "node-fetch": "^1.5.1", 31 | "octokat": "^0.5.0-beta.0", 32 | "underscore": "^1.8.3" 33 | }, 34 | "main": "index.coffee", 35 | "directories": { 36 | "test": "test" 37 | }, 38 | "scripts": { 39 | "test": "echo \"Error: no test specified\" && exit 1" 40 | }, 41 | "homepage": "https://github.com/ndaversa/hubot-jira-bot", 42 | "devDependencies": {}, 43 | "author": "Nino D'Aversa (http://ndaversa.com)" 44 | } 45 | -------------------------------------------------------------------------------- /src/jira/comment.coffee: -------------------------------------------------------------------------------- 1 | Config = require "../config" 2 | Utils = require "../utils" 3 | 4 | class Comment 5 | 6 | @forTicketWith: (ticket, comment, context, includeAttachment=no, emit=yes) -> 7 | room = Utils.JiraBot.adapter.getRoomName context 8 | Utils.fetch "#{Config.jira.url}/rest/api/2/issue/#{ticket.key}/comment", 9 | method: "POST" 10 | body: JSON.stringify 11 | body:""" 12 | #{comment} 13 | 14 | Comment left by #{context.message.user.name} in ##{room} on #{context.robot.adapterName} 15 | #{Utils.JiraBot.adapter.getPermalink context} 16 | """ 17 | .then -> 18 | Utils.robot.logger.debug "#{ticket.key}:Comment", context.message.user.email_address 19 | Utils.cache.put "#{ticket.key}:Comment", context.message.user.email_address 20 | context.robot.emit "JiraTicketCommented", ticket, context, includeAttachment if emit 21 | .catch (error) -> 22 | context.robot.emit "JiraTicketCommentFailed", error, context if emit 23 | Promise.reject error 24 | 25 | @forTicketKeyWith: (key, comment, context, includeAttachment=no, emit=yes) -> 26 | Create = require "./create" 27 | Create.fromKey(key) 28 | .then (ticket) -> 29 | Comment.forTicketWith ticket, comment, context, includeAttachment, emit 30 | 31 | module.exports = Comment 32 | -------------------------------------------------------------------------------- /src/github/pullrequest.coffee: -------------------------------------------------------------------------------- 1 | Octokat = require "octokat" 2 | moment = require "moment" 3 | 4 | Config = require "../config" 5 | Utils = require "../utils" 6 | 7 | octo = new Octokat token: Config.github.token 8 | 9 | class PullRequest 10 | constructor: (json) -> 11 | @[k] = v for k,v of json 12 | 13 | toAttachment: -> 14 | github = octo.fromUrl(@assignee.url) if @assignee?.url 15 | Utils.lookupUserWithGithub(github).then (assignee) => 16 | color: "#ff9933" 17 | author_name: @user.login 18 | author_icon: @user.avatarUrl 19 | author_link: @user.htmlUrl 20 | title: @title 21 | title_link: @htmlUrl 22 | fields: [ 23 | title: "Updated" 24 | value: moment(@updatedAt).fromNow() 25 | short: yes 26 | , 27 | title: "Status" 28 | value: if @mergeable then "Mergeable" else "Unresolved Conflicts" 29 | short: yes 30 | , 31 | title: "Assignee" 32 | value: if assignee then "<@#{assignee.id}>" else "Unassigned" 33 | short: yes 34 | , 35 | title: "Lines" 36 | value: "+#{@additions} -#{@deletions}" 37 | short: yes 38 | ] 39 | fallback: """ 40 | *#{@title}* +#{@additions} -#{@deletions} 41 | Updated: *#{moment(@updatedAt).fromNow()}* 42 | Status: #{if @mergeable then "Mergeable" else "Unresolved Conflicts"} 43 | Author: #{@user.login} 44 | Assignee: #{if assignee then "#{assignee.name}" else "Unassigned"} 45 | """ 46 | 47 | module.exports = PullRequest 48 | -------------------------------------------------------------------------------- /src/jira/watch.coffee: -------------------------------------------------------------------------------- 1 | Config = require "../config" 2 | User = require "./user" 3 | Utils = require "../utils" 4 | 5 | class Watch 6 | @forTicketKeyForPerson: (key, person, context, includeAttachment=no, remove=no, emit=yes) -> 7 | person = if person is "me" or not person then context.message.user.name else person 8 | 9 | key = key.toUpperCase() 10 | chatUser = Utils.lookupChatUser person 11 | 12 | if chatUser?.profile?.email? 13 | User.withEmail(chatUser.profile.email) 14 | .then (user) -> 15 | Utils.fetch "#{Config.jira.url}/rest/api/2/issue/#{key}/watchers#{if remove then "?username=#{user.name}" else ""}", 16 | method: if remove then "DELETE" else "POST" 17 | body: JSON.stringify user.name unless remove 18 | .then -> 19 | Create = require "./create" 20 | Create.fromKey key 21 | .then (ticket) -> 22 | if remove 23 | context.robot.emit "JiraTicketUnwatched", ticket, chatUser, context, includeAttachment if emit 24 | else 25 | context.robot.emit "JiraTicketWatched", ticket, chatUser, context, includeAttachment if emit 26 | .catch (error) -> 27 | context.robot.emit "JiraTicketWatchFailed", error, context if emit 28 | Promise.reject error 29 | else 30 | error = "Cannot find chat user `#{person}`" 31 | context.robot.emit "JiraTicketWatchFailed", error, context if emit 32 | Promise.reject error 33 | 34 | @forTicketKeyRemovePerson: (key, person, context, includeAttachment=no, emit) -> 35 | Watch.forTicketKeyForPerson key, person, context, includeAttachment, yes, emit 36 | 37 | module.exports = Watch 38 | -------------------------------------------------------------------------------- /src/jira/search.coffee: -------------------------------------------------------------------------------- 1 | Config = require "../config" 2 | Ticket = require "./ticket" 3 | Utils = require "../utils" 4 | 5 | class Search 6 | 7 | @withQueryForProject: (query, project, context, max=5) -> 8 | labels = [] 9 | if Config.labels.regex.test query 10 | labels = (query.match(Config.labels.regex).map((label) -> label.replace('#', '').trim())).concat(labels) 11 | query = query.replace Config.labels.regex, "" 12 | 13 | jql = if query.length > 0 then "text ~ '#{query}'" else "" 14 | noResults = "No results for #{query}" 15 | found = "Found <#{Config.jira.url}/secure/IssueNavigator.jspa?jqlQuery=__JQL__&runQuery=true|__xx__ issues> containing `#{query}`" 16 | 17 | if project 18 | jql += " and " if jql.length > 0 19 | jql += "project = '#{project}'" 20 | noResults += " in project `#{project}`" 21 | found += " in project `#{project}`" 22 | 23 | if labels.length > 0 24 | jql += " and " if jql.length > 0 25 | jql += "labels = '#{label}'" for label in labels 26 | noResults += " with labels `#{labels.join ', '}`" 27 | found += " with labels `#{labels.join ', '}`" 28 | 29 | found.replace "__JQL__", encodeURIComponent jql 30 | Utils.fetch "#{Config.jira.url}/rest/api/2/search", 31 | method: "POST" 32 | body: JSON.stringify 33 | jql: jql 34 | startAt: 0 35 | maxResults: max 36 | fields: Config.jira.fields 37 | .then (json) -> 38 | if json.issues.length > 0 39 | text = found.replace("__xx__", json.total).replace "__JQL__", encodeURIComponent jql 40 | else 41 | text = noResults 42 | 43 | text: text 44 | tickets: (new Ticket issue for issue in json.issues) 45 | 46 | module.exports = Search 47 | -------------------------------------------------------------------------------- /src/jira/transition.coffee: -------------------------------------------------------------------------------- 1 | _ = require "underscore" 2 | 3 | Config = require "../config" 4 | Utils = require "../utils" 5 | 6 | class Transition 7 | 8 | @forTicketToState: (ticket, toState, context, includeAttachment=no, emit=yes) -> 9 | type = _(Config.maps.transitions).find (type) -> type.name is toState 10 | transition = ticket.transitions.find (state) -> state.to.name.toLowerCase() is type.jira.toLowerCase() 11 | if transition 12 | Utils.fetch "#{Config.jira.url}/rest/api/2/issue/#{ticket.key}/transitions", 13 | method: "POST" 14 | body: JSON.stringify 15 | transition: 16 | id: transition.id 17 | .then -> 18 | Create = require "./create" 19 | Create.fromKey ticket.key 20 | .then (ticket) -> 21 | context.robot.emit "JiraTicketTransitioned", ticket, transition, context, includeAttachment if emit 22 | text: "<@#{context.message.user.id}> transitioned this ticket to #{transition.to.name}" 23 | fallback: "@#{context.message.user.name} transitioned this ticket to #{transition.to.name}" 24 | .catch (error) -> 25 | context.robot.emit "JiraTicketTransitionFailed", error, context if emit 26 | Promise.reject error 27 | else 28 | error = "<#{Config.jira.url}/browse/#{ticket.key}|#{ticket.key}> is a `#{ticket.fields.issuetype.name}` and does not support transitioning from `#{ticket.fields.status.name}` to `#{type.jira}`" 29 | context.robot.emit "JiraTicketTransitionFailed", error, context if emit 30 | Promise.reject error 31 | 32 | @forTicketKeyToState: (key, toState, context, includeAttachment=no, emit=yes) -> 33 | Create = require "./create" 34 | Create.fromKey(key) 35 | .then (ticket) -> 36 | Transition.forTicketToState ticket, toState, context, includeAttachment, emit 37 | 38 | module.exports = Transition 39 | -------------------------------------------------------------------------------- /src/jira/clone.coffee: -------------------------------------------------------------------------------- 1 | _ = require "underscore" 2 | 3 | Config = require "../config" 4 | Create = require "./create" 5 | Utils = require "../utils" 6 | 7 | class Clone 8 | @fromTicketKeyToProject: (key, project, channel, context, emit=yes) -> 9 | original = null 10 | cloned = null 11 | Create.fromKey(key) 12 | .then (issue) -> 13 | original = issue 14 | Utils.fetch("#{Config.jira.url}/rest/api/2/project/#{project}") 15 | .then (json) -> 16 | issueTypes = _(json.issueTypes).reject (it) -> it.name is "Sub-task" 17 | issueType = Utils.fuzzyFind issue.fields.issuetype.name, issueTypes, ['name'] 18 | issueType = issueTypes[0] unless issueType 19 | Promise.reject "Unable to find a suitable issue type in #{project} that matches with #{original.fields.issuetype.name}" unless issueType 20 | 21 | Create.fromJSON 22 | fields: 23 | project: 24 | key: project 25 | summary: original.fields.summary 26 | labels: original.fields.labels 27 | description: """ 28 | #{original.fields.description} 29 | 30 | Cloned from #{key} 31 | """ 32 | issuetype: name: issueType.name 33 | .then (json) -> 34 | Create.fromKey(json.key) 35 | .then (ticket) -> 36 | cloned = ticket 37 | room = Utils.JiraBot.adapter.getRoomName context 38 | Utils.fetch "#{Config.jira.url}/rest/api/2/issueLink", 39 | method: "POST" 40 | body: JSON.stringify 41 | type: 42 | name: "Cloners" 43 | inwardIssue: 44 | key: original.key 45 | outwardIssue: 46 | key: cloned.key 47 | comment: 48 | body: """ 49 | Cloned by #{context.message.user.name} in ##{room} on #{context.robot.adapterName} 50 | #{Utils.JiraBot.adapter.getPermalink context} 51 | """ 52 | .then -> 53 | if emit 54 | Utils.robot.emit "JiraTicketCreated", context, ticket: cloned 55 | Utils.robot.emit "JiraTicketCloned", cloned, channel, key, context if context.message.room isnt channel 56 | .catch (error) -> 57 | Utils.robot.emit "JiraTicketCloneFailed", error, key, context if emit 58 | 59 | module.exports = Clone 60 | -------------------------------------------------------------------------------- /src/jira/assign.coffee: -------------------------------------------------------------------------------- 1 | Config = require "../config" 2 | User = require "./user" 3 | Utils = require "../utils" 4 | 5 | class Assign 6 | 7 | @forTicketToPerson: (ticket, person, context, includeAttachment=no, emit=yes) -> 8 | person = if person is "me" then context.message.user.name else person 9 | chatUser = Utils.lookupChatUser person 10 | 11 | if chatUser?.profile?.email? 12 | User.withEmail(chatUser.profile.email) 13 | .then (user) -> 14 | Utils.fetch "#{Config.jira.url}/rest/api/2/issue/#{ticket.key}", 15 | method: "PUT" 16 | body: JSON.stringify 17 | fields: 18 | assignee: 19 | name: user.name 20 | .then -> 21 | Create = require "./create" 22 | Create.fromKey ticket.key 23 | .then (ticket) -> 24 | Utils.robot.logger.debug "#{ticket.key}:Assigned", context.message.user.email_address 25 | Utils.cache.put "#{ticket.key}:Assigned", context.message.user.email_address 26 | context.robot.emit "JiraTicketAssigned", ticket, chatUser, context, includeAttachment if emit 27 | text: "<@#{chatUser.id}> is now assigned to this ticket" 28 | fallback: "@#{chatUser.name} is now assigned to this ticket" 29 | .catch (error) -> 30 | context.robot.emit "JiraTicketAssignmentFailed", error, context if emit 31 | Promise.reject error 32 | else 33 | error = "Cannot find chat user `#{person}`" 34 | context.robot.emit "JiraTicketAssignmentFailed", error, context if emit 35 | Promise.reject error 36 | 37 | @forTicketKeyToPerson: (key, person, context, includeAttachment=no, emit=yes) -> 38 | Create = require "./create" 39 | Create.fromKey(key) 40 | .then (ticket) -> 41 | Assign.forTicketToPerson ticket, person, context, includeAttachment, emit 42 | 43 | @forTicketKeyToUnassigned: (key, context, includeAttachment=no, emit=yes) -> 44 | Utils.fetch "#{Config.jira.url}/rest/api/2/issue/#{key}", 45 | method: "PUT" 46 | body: JSON.stringify 47 | fields: 48 | assignee: 49 | name: null 50 | .then -> 51 | Create = require "./create" 52 | Create.fromKey(key) 53 | .then (ticket) -> 54 | context.robot.emit "JiraTicketUnassigned", ticket, context, no if emit 55 | 56 | module.exports = Assign 57 | -------------------------------------------------------------------------------- /src/jira/ticket.coffee: -------------------------------------------------------------------------------- 1 | Config = require "../config" 2 | Utils = require "../utils" 3 | 4 | class Ticket 5 | constructor: (json) -> 6 | @[k] = v for k,v of json 7 | 8 | toAttachment: (includeFields=yes) -> 9 | colors = [ 10 | keywords: "story feature improvement epic" 11 | color: "#14892c" 12 | , 13 | keywords: "bug" 14 | color: "#d04437" 15 | , 16 | keywords: "experiment exploratory task" 17 | color: "#f6c342" 18 | ] 19 | result = Utils.fuzzyFind @fields.issuetype.name, colors, ['keywords'] 20 | result = color: "#003366" unless result 21 | 22 | fields = [] 23 | fieldsFallback = "" 24 | if includeFields 25 | if @watchers?.length > 0 26 | watchers = [] 27 | fallbackWatchers = [] 28 | for watcher in @watchers 29 | watchers.push Utils.lookupUserWithJira watcher 30 | fallbackWatchers.push Utils.lookupUserWithJira watcher, yes 31 | fields = [ 32 | title: "Status" 33 | value: @fields.status.name 34 | short: yes 35 | , 36 | title: "Assignee" 37 | value: Utils.lookupUserWithJira @fields.assignee 38 | short: yes 39 | , 40 | title: "Reporter" 41 | value: Utils.lookupUserWithJira @fields.reporter 42 | short: yes 43 | , 44 | title: "Watchers" 45 | value: if watchers then watchers.join ', ' else "None" 46 | short: yes 47 | ] 48 | fieldsFallback = """ 49 | Status: #{@fields.status.name} 50 | Assignee: #{Utils.lookupUserWithJira @fields.assignee, yes} 51 | Reporter: #{Utils.lookupUserWithJira @fields.reporter, yes} 52 | Watchers: #{if fallbackWatchers then fallbackWatchers.join ', ' else "None"} 53 | """ 54 | 55 | color: result.color 56 | type: "JiraTicketAttachment" 57 | author_name: @key 58 | author_link: "#{Config.jira.url}/browse/#{@key}" 59 | author_icon: if @fields.assignee? then @fields.assignee.avatarUrls["32x32"] else "https://slack.global.ssl.fastly.net/12d4/img/services/jira_128.png" 60 | title: @fields.summary.trim() 61 | title_link: "#{Config.jira.url}/browse/#{@key}" 62 | fields: fields 63 | fallback: """ 64 | *#{@key} - #{@fields.summary.trim()}* 65 | #{Config.jira.url}/browse/#{@key} 66 | #{fieldsFallback} 67 | """ 68 | module.exports = Ticket 69 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | coffee-script@^1.10.0: 4 | version "1.11.1" 5 | resolved "https://registry.yarnpkg.com/coffee-script/-/coffee-script-1.11.1.tgz#bf1c47ad64443a0d95d12df2b147cc0a4daad6e9" 6 | 7 | encoding@^0.1.11: 8 | version "0.1.12" 9 | resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" 10 | dependencies: 11 | iconv-lite "~0.4.13" 12 | 13 | es6-promise@3.0.2: 14 | version "3.0.2" 15 | resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.0.2.tgz#010d5858423a5f118979665f46486a95c6ee2bb6" 16 | 17 | fuse.js@^2.2.0: 18 | version "2.5.0" 19 | resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-2.5.0.tgz#98295c2ac1684edbba22250d7049cb6f033e95ce" 20 | 21 | iconv-lite@~0.4.13: 22 | version "0.4.13" 23 | resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.13.tgz#1f88aba4ab0b1508e8312acc39345f36e992e2f2" 24 | 25 | is-stream@^1.0.1: 26 | version "1.1.0" 27 | resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" 28 | 29 | lodash@^3.10.1: 30 | version "3.10.1" 31 | resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6" 32 | 33 | memory-cache@^0.1.5: 34 | version "0.1.6" 35 | resolved "https://registry.yarnpkg.com/memory-cache/-/memory-cache-0.1.6.tgz#2ed9933ed7a8c718249be7366f7ca8749acf8a24" 36 | 37 | moment@^2.12.0: 38 | version "2.15.2" 39 | resolved "https://registry.yarnpkg.com/moment/-/moment-2.15.2.tgz#1bfdedf6a6e345f322fe956d5df5bd08a8ce84dc" 40 | 41 | node-dogstatsd: 42 | version "0.0.6" 43 | resolved "https://registry.yarnpkg.com/node-dogstatsd/-/node-dogstatsd-0.0.6.tgz#d697e4d1903a7ff0c16479cd5d1cde043737e047" 44 | 45 | node-fetch@^1.5.1: 46 | version "1.6.3" 47 | resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.6.3.tgz#dc234edd6489982d58e8f0db4f695029abcd8c04" 48 | dependencies: 49 | encoding "^0.1.11" 50 | is-stream "^1.0.1" 51 | 52 | octokat@^0.5.0-beta.0: 53 | version "0.5.0-beta.0" 54 | resolved "https://registry.yarnpkg.com/octokat/-/octokat-0.5.0-beta.0.tgz#92ca758b959b2edb363a3defdbc043c27694404b" 55 | dependencies: 56 | es6-promise "3.0.2" 57 | lodash "^3.10.1" 58 | xmlhttprequest "~1.8.0" 59 | 60 | underscore@^1.8.3: 61 | version "1.8.3" 62 | resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022" 63 | 64 | xmlhttprequest@~1.8.0: 65 | version "1.8.0" 66 | resolved "https://registry.yarnpkg.com/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz#67fe075c5c24fef39f9d65f5f7b7fe75171968fc" 67 | 68 | -------------------------------------------------------------------------------- /src/adapters/generic.coffee: -------------------------------------------------------------------------------- 1 | _ = require "underscore" 2 | Utils = require "../utils" 3 | 4 | class GenericAdapter 5 | @JIRA_NOTIFICATIONS_DISABLED: "jira-notifications-disabled" 6 | @JIRA_DM_COUNTS: "jira-dm-counts" 7 | 8 | constructor: (@robot) -> 9 | @disabledUsers = null 10 | @dmCounts = null 11 | 12 | @robot.brain.once "loaded", => 13 | @disabledUsers = @robot.brain.get(GenericAdapter.JIRA_NOTIFICATIONS_DISABLED) or [] 14 | @dmCounts = @robot.brain.get(GenericAdapter.JIRA_DM_COUNTS) or {} 15 | 16 | disableNotificationsFor: (user) -> 17 | @robot.logger.info "Disabling JIRA notifications for #{user.name}" 18 | @disabledUsers.push user.id 19 | @robot.brain.set GenericAdapter.JIRA_NOTIFICATIONS_DISABLED, _(@disabledUsers).unique() 20 | @robot.brain.save() 21 | 22 | enableNotificationsFor: (user) -> 23 | @robot.logger.info "Enabling JIRA notifications for #{user.name}" 24 | @disabledUsers = _(@disabledUsers).without user.id 25 | @robot.brain.set GenericAdapter.JIRA_NOTIFICATIONS_DISABLED, @disabledUsers 26 | @robot.brain.save() 27 | 28 | incrementDMCountFor: (user) -> 29 | return unless @dmCounts? 30 | return unless user?.id? 31 | 32 | @dmCounts[user.id] ||= 0 33 | @dmCounts[user.id]++ 34 | @robot.brain.set GenericAdapter.JIRA_DM_COUNTS, @dmCounts 35 | @robot.brain.save() 36 | 37 | getDMCountFor: (user) -> 38 | return 0 unless @dmCounts? 39 | return 0 unless user?.id? 40 | @dmCounts[user.id] ||= 0 41 | return @dmCounts[user.id] 42 | 43 | send: (context, message) -> 44 | room = @getRoom context 45 | return unless room 46 | 47 | if _(message).isString() 48 | payload = message 49 | else if message.text or message.attachments 50 | payload = "" 51 | 52 | if message.text 53 | payload += "#{message.text}\n" 54 | 55 | if message.attachments 56 | for attachment in message.attachments 57 | payload += "#{attachment.fallback}\n" 58 | else 59 | Utils.Stats.increment "jirabot.message.empty" 60 | return @robot.logger.error "Unable to find a message to send", message 61 | 62 | @robot.send room: room.id, payload 63 | 64 | dm: (users, message) -> 65 | users = [ users ] unless _(users).isArray() 66 | for user in users when user 67 | if _(@disabledUsers).contains user.id 68 | Utils.Stats.increment "jirabot.surpress.notification" 69 | @robot.logger.debug "JIRA Notification surpressed for #{user.name}" 70 | else 71 | if message.author? and user.profile?.email is message.author.emailAddress 72 | @robot.logger.debug "JIRA Notification surpressed for #{user.name} because it would be a self-notification" 73 | continue 74 | message.text += "\n#{message.footer}" if message.text and message.footer and @getDMCountFor(user) < 3 75 | @send message: room: user.id, _(message).pick "attachments", "text" 76 | @incrementDMCountFor user 77 | 78 | getPermalink: (context) -> "" 79 | 80 | normalizeContext: (context) -> 81 | if _(context).isString() 82 | normalized = message: room: context 83 | else if context?.room 84 | normalized = message: context 85 | else if context?.message?.room 86 | normalized = context 87 | normalized 88 | 89 | getRoom: (context) -> 90 | context = @normalizeContext context 91 | id: context.message.room 92 | name: context.message.room 93 | 94 | getRoomName: (context) -> 95 | room = @getRoom context 96 | room.name 97 | 98 | getUsers: -> 99 | @robot.brain.users() 100 | 101 | module.exports = GenericAdapter 102 | -------------------------------------------------------------------------------- /src/jira/create.coffee: -------------------------------------------------------------------------------- 1 | _ = require "underscore" 2 | 3 | Assign = require "./assign" 4 | Config = require "../config" 5 | Ticket = require "./ticket" 6 | Transition = require "./transition" 7 | User = require "./user" 8 | Utils = require "../utils" 9 | 10 | class Create 11 | @fromJSON: (json) -> 12 | Utils.fetch "#{Config.jira.url}/rest/api/2/issue", 13 | method: "POST" 14 | body: JSON.stringify json 15 | 16 | @with: (project, type, summary, context, fields, emit=yes) -> 17 | toState = null 18 | assignee = null 19 | room = Utils.JiraBot.adapter.getRoomName context 20 | 21 | if context.message.user.email_address 22 | user = User.withEmail(context.message.user.email_address) 23 | else 24 | user = Promise.resolve() 25 | 26 | user.then (reporter) -> 27 | { summary, description, toState, assignee, labels, priority } = Utils.extract.all summary 28 | labels.unshift room 29 | 30 | issue = 31 | fields: 32 | project: key: project 33 | summary: summary 34 | description: "" 35 | issuetype: name: type 36 | 37 | _(issue.fields).extend fields if fields 38 | issue.fields.labels = _(issue.fields.labels).union labels 39 | issue.fields.description += """ 40 | #{(if description then description + "\n\n" else "")} 41 | Reported by #{context.message.user.name} in ##{room} on #{context.robot.adapterName} 42 | #{Utils.JiraBot.adapter.getPermalink context} 43 | """ 44 | issue.fields.reporter = reporter if reporter 45 | issue.fields.priority = id: priority.id if priority 46 | Create.fromJSON issue 47 | .then (json) -> 48 | Create.fromKey(json.key) 49 | .then (ticket) -> 50 | Promise.all([ 51 | Transition.forTicketToState ticket, toState, context, no, no if toState 52 | Assign.forTicketToPerson ticket, assignee, context, no, no if assignee 53 | ticket 54 | ]) 55 | .catch (error) -> 56 | Utils.robot.logger.error error 57 | [ undefined, text:error, ticket] 58 | .then (results) -> 59 | [ transition, assignee, ticket ] = results 60 | roomProject = Config.maps.projects[room] 61 | if emit 62 | Utils.robot.emit "JiraTicketCreated", context, 63 | ticket: ticket 64 | transition: transition 65 | assignee: assignee 66 | unless emit and roomProject is project 67 | Utils.robot.emit "JiraTicketCreatedElsewhere", context, 68 | ticket: ticket 69 | transition: transition 70 | assignee: assignee 71 | ticket 72 | .catch (error) -> 73 | context.robot.logger.error error.stack 74 | .catch (error) -> 75 | Utils.robot.logger.error error.stack 76 | Utils.robot.emit "JiraTicketCreationFailed", error, context if emit 77 | Promise.reject error 78 | 79 | @fromKey: (key) -> 80 | key = key.trim().toUpperCase() 81 | params = 82 | expand: Config.jira.expand 83 | fields: Config.jira.fields 84 | 85 | Utils.fetch("#{Config.jira.url}/rest/api/2/issue/#{key}#{Utils.buildQueryString params}") 86 | .then (json) -> 87 | Promise.all [ 88 | json, 89 | Utils.fetch "#{Config.jira.url}/rest/api/2/issue/#{json.key}/watchers" 90 | ] 91 | .then (jsons) -> 92 | new Ticket _(jsons[0]).extend _(jsons[1]).pick("watchers") 93 | 94 | @subtaskFromKeyWith: (key, summary, context, emit=yes) -> 95 | Create.fromKey(key) 96 | .then (parent) -> 97 | Create.with parent.fields.project.key, "Sub-task", summary, context, 98 | parent: key: parent.key 99 | labels: parent.fields.labels or [] 100 | description: "Sub-task of #{key}\n\n" 101 | , emit 102 | 103 | module.exports = Create 104 | -------------------------------------------------------------------------------- /src/jira/webhook.coffee: -------------------------------------------------------------------------------- 1 | _ = require "underscore" 2 | 3 | Config = require "../config" 4 | Utils = require "../utils" 5 | User = require "./user" 6 | 7 | class Webhook 8 | constructor: (@robot) -> 9 | @robot.router.post "/hubot/jira-events", (req, res) => 10 | return unless req.body? 11 | event = req.body 12 | if event.changelog? 13 | @onChangelog event 14 | else if event.comment? 15 | @onComment event 16 | else if event.webhookEvent is "jira:issue_created" 17 | @onCreate event 18 | 19 | res.send 'OK' 20 | 21 | onChangelog: (event) -> 22 | return unless event.changelog.items?.length > 0 23 | for item in event.changelog.items 24 | switch item.field 25 | when "status" 26 | @onStatusChange event, item 27 | when "description" 28 | @onDescriptionChange event, item 29 | when "assignee" 30 | @onAssigneeChange event, item 31 | 32 | onComment: (event) -> 33 | if Config.mention.regex.test event.comment.body 34 | @onAtHandleMention event, event.comment.body.match(Config.mention.regex)[1], event.comment.body 35 | if Config.jira.mentionRegex.test event.comment.body 36 | @onJiraMention event, event.comment.body.match(Config.jira.mentionRegex)[1], event.comment.body 37 | Create = require "./create" 38 | regex = /rest\/api\/2\/issue\/(\d*)\// 39 | [ __, key] = event.comment.self.match regex 40 | Create.fromKey(key) 41 | .then (ticket) => 42 | if author = Utils.cache.get "#{ticket.key}:Comment" 43 | User.withEmail(author) 44 | .then (user) -> 45 | event.comment.author = user 46 | ticket 47 | else 48 | ticket 49 | .then (ticket) => 50 | @robot.emit "JiraWebhookTicketComment", ticket, event.comment 51 | 52 | onCreate: (event) -> 53 | @onDescriptionChange event, 54 | toString: event.issue.fields.description or "" 55 | fromString: "" 56 | 57 | if event.issue.fields.assignee 58 | @onAssigneeChange event, 59 | field: "assignee" 60 | fieldtype: "jira" 61 | to: event.issue.fields.assignee.name 62 | 63 | onJiraMention: (event, username, context) -> 64 | chatUser = null 65 | User.withUsername(username) 66 | .then (jiraUser) -> 67 | chatUser = Utils.lookupChatUserWithJira jiraUser 68 | Promise.reject() unless chatUser 69 | Create = require "./create" 70 | Create.fromKey(event.issue.key) 71 | .then (ticket) => 72 | @robot.emit "JiraWebhookTicketMention", ticket, chatUser, event, context 73 | 74 | onAtHandleMention: (event, handle, context) -> 75 | chatUser = Utils.lookupChatUser handle 76 | return unless chatUser 77 | Create = require "./create" 78 | Create.fromKey(event.issue.key) 79 | .then (ticket) => 80 | @robot.emit "JiraWebhookTicketMention", ticket, chatUser, event, context 81 | 82 | onDescriptionChange: (event, item) -> 83 | if Config.mention.regex.test item.toString 84 | previousMentions = item.fromString.match Config.mention.regexGlobal 85 | latestMentions = item.toString.match Config.mention.regexGlobal 86 | newMentions = _(latestMentions).difference previousMentions 87 | for mention in newMentions 88 | handle = mention.match(Config.mention.regex)[1] 89 | @onAtHandleMention event, handle, item.toString 90 | 91 | if Config.jira.mentionRegex.test item.toString 92 | previousMentions = item.fromString.match Config.jira.mentionRegexGlobal 93 | latestMentions = item.toString.match Config.jira.mentionRegexGlobal 94 | newMentions = _(latestMentions).difference previousMentions 95 | for mention in newMentions 96 | username = mention.match(Config.jira.mentionRegex)[1] 97 | @onJiraMention event, username, item.toString 98 | 99 | onAssigneeChange: (event, item) -> 100 | return unless item.to 101 | 102 | chatUser = null 103 | User.withUsername(item.to) 104 | .then (jiraUser) -> 105 | chatUser = Utils.lookupChatUserWithJira jiraUser 106 | Promise.reject() unless chatUser 107 | Create = require "./create" 108 | Create.fromKey(event.issue.key) 109 | .then (ticket) => 110 | if author = Utils.cache.get "#{ticket.key}:Assigned" 111 | User.withEmail(author) 112 | .then (user) -> 113 | event.user = user 114 | ticket 115 | else 116 | ticket 117 | .then (ticket) => 118 | @robot.emit "JiraWebhookTicketAssigned", ticket, chatUser, event 119 | 120 | onStatusChange: (event, item) -> 121 | states = [ 122 | keywords: "done completed resolved fixed merged" 123 | name: "JiraWebhookTicketDone" 124 | , 125 | keywords: "progress" 126 | name: "JiraWebhookTicketInProgress" 127 | , 128 | keywords: "review reviewed" 129 | name: "JiraWebhookTicketInReview" 130 | ] 131 | status = Utils.fuzzyFind item.toString.toLowerCase(), states, ['keywords'] 132 | return @robot.logger.debug "#{event.issue.key}: Ignoring transition to '#{item.toString}'" unless status 133 | 134 | Create = require "./create" 135 | Create.fromKey(event.issue.key) 136 | .then (ticket) => 137 | @robot.logger.debug "#{event.issue.key}: Emitting #{status.name} because of the transition to '#{item.toString}'" 138 | @robot.emit status.name, ticket, event 139 | 140 | module.exports = Webhook 141 | -------------------------------------------------------------------------------- /src/help.coffee: -------------------------------------------------------------------------------- 1 | _ = require "underscore" 2 | 3 | Config = require "./config" 4 | 5 | class Help 6 | 7 | @forTopic: (topic, robot) -> 8 | overview = """ 9 | *The Definitive #{robot.name.toUpperCase()} JIRA Manual* 10 | @#{robot.name} can help you *search* for JIRA tickets, *open* 11 | them, *transition* them thru different states, *comment* on them, *rank* 12 | them _up_ or _down_, start or stop *watching* them or change who is 13 | *assigned* to a ticket 14 | """ 15 | 16 | opening = """ 17 | *Opening Tickets* 18 | > #{robot.name} [``] `` `` [`<description>`] 19 | 20 | You can omit `<project>` when using the command in the desired projects channel 21 | Otherwise you can specify one of the following for `<project>`: #{(_(Config.maps.projects).keys().map (c) -> "`##{c}`").join ', '} 22 | `<type>` is one of the following: #{(_(Config.maps.types).keys().map (t) -> "`#{t}`").join ', '} 23 | `<description>` is optional and is surrounded with single or triple backticks 24 | and can be used to provide a more detailed description for the ticket. 25 | `<title>` is a short summary of the ticket 26 | *Optional `<title>` Attributes* 27 | _Labels_: include one or many hashtags that will become labels on the jira ticket 28 | `#quick #techdebt` 29 | 30 | _Assignment_: include a handle that will be used to assign the ticket after creation 31 | `@username` 32 | 33 | _Transitions_: include a transition to make after the ticket is created 34 | #{(Config.maps.transitions.map (t) -> "`>#{t.name}`").join ', '} 35 | 36 | _Priority_: include the ticket priority to be assigned upon ticket creation 37 | #{(_(Config.maps.priorities).map (p) -> "`!#{p.name.toLowerCase()}`").join ', '} 38 | """ 39 | 40 | subtask = """ 41 | *Creating Sub-tasks* 42 | > #{robot.name} subtask `<ticket>` `<summary>` 43 | 44 | Where `<ticket>` is the parent JIRA ticket number 45 | and `<summary>` is a short summary of the task 46 | """ 47 | 48 | 49 | clone = """ 50 | *Cloning Tickets* 51 | > `<ticket>` clone to `<channel>` 52 | > `<ticket>` > `<channel>` 53 | 54 | Where `<ticket>` is the JIRA ticket number 55 | and `<channel>` is one of the following: #{(_(Config.maps.projects).keys().map (c) -> "`##{c}`").join ', '} 56 | """ 57 | 58 | rank = """ 59 | *Ranking Tickets* 60 | > `<ticket>` rank top 61 | > `<ticket>` rank bottom 62 | 63 | Where `<ticket>` is the JIRA ticket number. Note this will rank it the top 64 | of column for the current state 65 | """ 66 | 67 | labels = """ 68 | *Adding labels to a Ticket* 69 | > `<ticket>` < `#label1 #label2 #label3` 70 | 71 | Where `<ticket>` is the JIRA ticket number 72 | """ 73 | 74 | 75 | comment = """ 76 | *Commenting on a Ticket* 77 | > `<ticket>` < `<comment>` 78 | 79 | Where `<ticket>` is the JIRA ticket number 80 | and `<comment>` is the comment you wish to leave on the ticket 81 | """ 82 | 83 | assignment = """ 84 | *Assigning Tickets* 85 | > `<ticket>` assign `@username` 86 | 87 | Where `<ticket>` is the JIRA ticket number 88 | and `@username` is a user handle 89 | """ 90 | 91 | transition = """ 92 | *Transitioning Tickets* 93 | > `<ticket>` to `<state>` 94 | > `<ticket>` >`<state>` 95 | 96 | Where `<ticket>` is the JIRA ticket number 97 | and `<state>` is one of the following: #{(Config.maps.transitions.map (t) -> "`#{t.name}`").join ', '} 98 | """ 99 | 100 | watch = """ 101 | *Watching Tickets* 102 | > `<ticket>` watch [`@username]`] 103 | 104 | Where `<ticket>` is the JIRA ticket number 105 | `@username` is optional, if specified the corresponding JIRA user will become 106 | the watcher on the ticket, if omitted the message author will become the watcher 107 | """ 108 | 109 | notifications = """ 110 | *Ticket Notifications* 111 | 112 | Whenever you begin watching a JIRA ticket you will be notified (via a direct 113 | message from @#{robot.name}) whenever any of the following events occur: 114 | - a comment is left on the ticket 115 | - the ticket is in progress 116 | - the ticket is resolved 117 | 118 | You will also be notified if: 119 | - you are mentioned on the ticket 120 | - you are assigned to the ticket 121 | 122 | If you are assigned to a ticket, you will be notified when: 123 | - a comment is left on the ticket 124 | 125 | To enable or disable this feature you can send the following directly to #{robot.name}: 126 | 127 | > jira disable notifications 128 | 129 | or if you wish to re-enable 130 | 131 | > jira enable notifications 132 | """ 133 | 134 | search = """ 135 | *Searching Tickets* 136 | > #{robot.name} jira search `<term>` 137 | *Optional `<term>` Attributes* 138 | _Labels_: include one or many hashtags that will become labels included in the search 139 | `#quick #techdebt` 140 | 141 | Where `<term>` is some text contained in the ticket you are looking for 142 | """ 143 | 144 | query = """ 145 | *Querying Tickets using JQL* 146 | > #{robot.name} jira query `<jql>` 147 | 148 | Where `<jql>` is a valid JQL query 149 | """ 150 | 151 | if _(["report", "open", "file", "subtask", _(Config.maps.types).keys()]).chain().flatten().contains(topic).value() 152 | responses = [ opening, subtask ] 153 | else if _(["clone", "duplicate", "copy"]).contains topic 154 | responses = [ clone ] 155 | else if _(["rank", "ranking"]).contains topic 156 | responses = [ rank ] 157 | else if _(["comment", "comments"]).contains topic 158 | responses = [ comment ] 159 | else if _(["labels", "label"]).contains topic 160 | responses = [ labels ] 161 | else if _(["assign", "assignment"]).contains topic 162 | responses = [ assignment ] 163 | else if _(["transition", "transitions", "state", "move"]).contains topic 164 | responses = [ transition ] 165 | else if _(["search", "searching"]).contains topic 166 | responses = [ search ] 167 | else if _(["query", "querying"]).contains topic 168 | responses = [ query ] 169 | else if _(["watch", "watching", "notifications", "notify"]).contains topic 170 | responses = [ watch, notifications ] 171 | else 172 | responses = [ overview, opening, subtask, clone, rank, comment, labels, assignment, transition, watch, notifications, search, query ] 173 | 174 | return "\n#{responses.join '\n\n\n'}" 175 | 176 | module.exports = Help 177 | -------------------------------------------------------------------------------- /src/utils.coffee: -------------------------------------------------------------------------------- 1 | _ = require "underscore" 2 | Fuse = require "fuse.js" 3 | fetch = require "node-fetch" 4 | cache = require "memory-cache" 5 | 6 | Config = require "./config" 7 | StatsD = require('node-dogstatsd').StatsD 8 | 9 | if Config.stats.host and Config.stats.port and Config.stats.prefix 10 | c = new StatsD Config.stats.host, Config.stats.port 11 | 12 | class Utils 13 | @robot: null 14 | 15 | @fetch: (url, opts) -> 16 | options = 17 | headers: 18 | "X-Atlassian-Token": "no-check" 19 | "Content-Type": "application/json" 20 | "Authorization": 'Basic ' + new Buffer("#{Config.jira.username}:#{Config.jira.password}").toString('base64') 21 | options = _(options).extend opts 22 | 23 | Utils.robot.logger.debug "Fetching: #{url}" 24 | fetch(url,options).then (response) -> 25 | if response.status >= 200 and response.status < 300 26 | return response 27 | else 28 | error = new Error "#{response.statusText}: #{response.url.split("?")[0]}" 29 | error.response = response 30 | throw error 31 | .then (response) -> 32 | response.text() 33 | .then (text) -> 34 | JSON.parse(text) if text 35 | .catch (error) -> 36 | Utils.robot.logger.error error 37 | Utils.robot.logger.error error.stack 38 | try 39 | error.response.json().then (json) -> 40 | Utils.robot.logger.error JSON.stringify json 41 | message = "\n`#{error}`" 42 | message += "\n`#{v}`" for k,v of json.errors 43 | throw message 44 | catch e 45 | throw error 46 | 47 | @lookupRoomsForProject: (project) -> 48 | results = _(Config.maps.projects).pick (p) -> p is project 49 | _(results).keys() 50 | 51 | @lookupChatUser: (username) -> 52 | users = Utils.JiraBot.adapter.getUsers() 53 | return users[username] if users[username] #Note: if get the user id instead of username 54 | 55 | result = (users[user] for user of users when users[user].name is username) 56 | if result?.length is 1 57 | return result[0] 58 | return null 59 | 60 | @lookupUserWithJira: (jira, fallback=no) -> 61 | users = Utils.JiraBot.adapter.getUsers() 62 | result = (users[user] for user of users when users[user].profile.email is jira.emailAddress) if jira 63 | if result?.length is 1 64 | return if fallback then result[0].name else "<@#{result[0].id}>" 65 | else if jira 66 | return jira.displayName 67 | else 68 | return "Unassigned" 69 | 70 | @lookupChatUsersWithJira: (jiraUsers, message) -> 71 | jiraUsers = [ jiraUsers ] unless _(jiraUsers).isArray() 72 | chatUsers = [] 73 | for jiraUser in jiraUsers when jiraUser 74 | user = Utils.lookupChatUserWithJira jiraUser 75 | chatUsers.push user if user 76 | return chatUsers 77 | 78 | @lookupChatUserWithJira: (jira) -> 79 | users = Utils.JiraBot.adapter.getUsers() 80 | result = (users[user] for user of users when users[user].profile.email is jira.emailAddress) if jira 81 | return result[0] if result?.length is 1 82 | return null 83 | 84 | @detectPossibleDuplicate: (summary, tickets) -> 85 | t = new Fuse tickets, 86 | keys: ['fields.summary'] 87 | shouldSort: yes 88 | verbose: no 89 | threshold: 0.6 90 | 91 | return _(t.search summary).first() 92 | 93 | @lookupUserWithGithub: (github) -> 94 | return Promise.resolve() unless github 95 | 96 | findMatch = (user) -> 97 | name = user.name or user.login 98 | return unless name 99 | users = Utils.JiraBot.adapter.getUsers() 100 | users = _(users).keys().map (id) -> 101 | u = users[id] 102 | id: u.id 103 | name: u.name 104 | real_name: u.real_name 105 | 106 | f = new Fuse users, 107 | keys: ['real_name'] 108 | shouldSort: yes 109 | verbose: no 110 | threshold: 0.55 111 | 112 | results = f.search name 113 | result = if results? and results.length >=1 then results[0] else undefined 114 | return Promise.resolve result 115 | 116 | if github.fetch? 117 | github.fetch().then findMatch 118 | else 119 | findMatch github 120 | 121 | @buildQueryString: (params) -> 122 | "?#{("#{encodeURIComponent k}=#{encodeURIComponent v}" for k,v of params when v).join "&"}" 123 | 124 | @fuzzyFind: (term, arr, keys, opts) -> 125 | f = new Fuse arr, _(keys: keys, shouldSort: yes, threshold: 0.3).extend opts 126 | results = f.search term 127 | result = if results? and results.length >=1 then results[0] 128 | 129 | @cache: 130 | put: (key, value, time=Config.cache.default.expiry) -> cache.put key, value, time 131 | get: cache.get 132 | 133 | @Stats: 134 | increment: (label, tags) -> 135 | try 136 | label = label 137 | .replace( /[\/\(\)-]/g, '.' ) #Convert slashes, brackets and dashes to dots 138 | .replace( /[:\?]/g, '' ) #Remove any colon or question mark 139 | .replace( /\.+/g, '.' ) #Compress multiple periods into one 140 | .replace( /\.$/, '' ) #Remove any trailing period 141 | 142 | console.log "#{Config.stats.prefix}.#{label}", tags if Config.debug 143 | c.increment "#{Config.stats.prefix}.#{label}", tags if c 144 | catch e 145 | console.error e 146 | 147 | @extract: 148 | all: (summary) -> 149 | [summary, description] = Utils.extract.description summary 150 | [summary, toState] = Utils.extract.transition summary 151 | [summary, assignee] = Utils.extract.mention summary 152 | [summary, labels] = Utils.extract.labels summary 153 | [summary, priority] = Utils.extract.priority summary 154 | summary = summary.trim() 155 | { summary, description, toState, assignee, labels, priority } 156 | 157 | description: (summary) -> 158 | description = summary.match(Config.quote.regex)[1] if Config.quote.regex.test(summary) 159 | summary = summary.replace(Config.quote.regex, "") if description 160 | return [summary, description] 161 | 162 | transition: (summary) -> 163 | if Config.maps.transitions 164 | if Config.transitions.shouldRegex.test(summary) 165 | [ __, toState] = summary.match Config.transitions.shouldRegex 166 | summary = summary.replace(Config.transitions.shouldRegex, "") if toState 167 | return [summary, toState] 168 | 169 | mention: (summary) -> 170 | if Config.mention.regex.test summary 171 | assignee = summary.match(Config.mention.regex)[1] 172 | summary = summary.replace Config.mention.regex, "" 173 | return [summary, assignee] 174 | 175 | labels: (summary) -> 176 | labels = [] 177 | if Config.labels.regex.test summary 178 | labels = (summary.match(Config.labels.regex).map((label) -> label.replace('#', '').trim())).concat(labels) 179 | summary = summary.replace Config.labels.regex, "" 180 | if Config.labels.slackChannelRegexGlobal.test summary 181 | labels = (summary.match(Config.labels.slackChannelRegexGlobal).map((label) -> label.replace(Config.labels.slackChannelRegex, "$1"))).concat(labels) 182 | summary = summary.replace Config.labels.slackChannelRegexGlobal, "" 183 | return [summary, labels] 184 | 185 | priority: (summary) -> 186 | if Config.maps.priorities and Config.priority.regex.test summary 187 | priority = summary.match(Config.priority.regex)[1] 188 | priority = Config.maps.priorities.find (p) -> p.name.toLowerCase() is priority.toLowerCase() 189 | summary = summary.replace Config.priority.regex, "" 190 | [summary, priority] 191 | 192 | module.exports = Utils 193 | -------------------------------------------------------------------------------- /src/config.coffee: -------------------------------------------------------------------------------- 1 | # Configuration: 2 | # HUBOT_GITHUB_ORG - Github Organization or Github User 3 | # HUBOT_GITHUB_TOKEN - Github Application Token 4 | # HUBOT_JIRA_DUPLICATE_DETECTION - Set to true if wish to detect duplicates when creating new issues 5 | # HUBOT_JIRA_GITHUB_DISABLED - Set to true if you wish to disable github integration 6 | # HUBOT_JIRA_MENTIONS_DISABLED - Set to true if you wish to disable posting tickets in response to mentions in normal messages 7 | # HUBOT_JIRA_PASSWORD 8 | # HUBOT_JIRA_PRIORITIES_MAP [{"name":"Blocker","id":"1"},{"name":"Critical","id":"2"},{"name":"Major","id":"3"},{"name":"Minor","id":"4"},{"name":"Trivial","id":"5"}] 9 | # HUBOT_JIRA_PROJECTS_MAP {"web":"WEB","android":"AN","ios":"IOS","platform":"PLAT"} 10 | # HUBOT_JIRA_TRANSITIONS_MAP [{"name":"triage","jira":"Triage"},{"name":"icebox","jira":"Icebox"},{"name":"backlog","jira":"Backlog"},{"name":"devready","jira":"Selected for Development"},{"name":"inprogress","jira":"In Progress"},{"name":"design","jira":"Design Triage"}] 11 | # HUBOT_JIRA_TYPES_MAP {"story":"Story / Feature","bug":"Bug","task":"Task"} 12 | # HUBOT_JIRA_URL (format: "https://jira-domain.com:9090") 13 | # HUBOT_JIRA_USERNAME 14 | # HUBOT_JIRA_FIELDS - customize the jira fields returned by api, defaults to: ["issuetype", "status", "assignee", "reporter", "summary", "description", "labels", "project"] 15 | # HUBOT_SLACK_BUTTONS {"watch":{"name":"watch","text":"Watch","type":"button","value":"watch","style":"primary"},"assign":{"name":"assign","text":"Assign to me","type":"button","value":"assign"},"devready":{"name":"devready","text":"Dev Ready","type":"button","value":"selected"},"inprogress":{"name":"inprogress","text":"In Progress","type":"button","value":"progress"},"rank":{"name":"rank","text":"Rank Top","type":"button","value":"top"},"running":{"name":"running","text":"Running","type":"button","value":"running"},"review":{"name":"review","text":"Review","type":"button","value":"review"},"resolved":{"name":"resolved","text":"Resolved","type":"button","style":"primary","value":"resolved"},"done":{"name":"done","text":"Done","type":"button","style":"primary","value":"done"}} 16 | # HUBOT_SLACK_PROJECT_BUTTON_STATE_MAP {"PLAT":{"inprogress":["review","running","resolved"],"review":["running","resolved"],"running":["resolved"],"resolved":["devready","inprogress"],"mention":["watch","assign","devready","inprogress","rank"]},"HAL":{"inprogress":["review","running","resolved"],"review":["running","resolved"],"running":["resolved"],"resolved":["devready","inprogress"],"mention":["watch","assign","devready","inprogress","rank"]},"default":{"inprogress":["review","done"],"review":["done"],"done":["devready, inprogress"],"mention":["watch","assign","devready","inprogress","rank"]}} 17 | # HUBOT_SLACK_VERIFICATION_TOKEN - The slack verification token for your application 18 | # STATSD_HOST - The host of a StatsD server, if you wish to send stats on JiraBot usage 19 | # STATSD_PORT - The port of a StatsD server 20 | # STATS_PREFIX - The prefix to use when sending stats 21 | 22 | class Config 23 | @cache: 24 | default: expiry: 60*1000 # 1 minute 25 | mention: expiry: 5*60*1000 # 5 minutes 26 | 27 | @stats: 28 | host: process.env.STATSD_HOST 29 | port: process.env.STATSD_PORT 30 | prefix: process.env.STATS_PREFIX 31 | 32 | @duplicates: 33 | detection: !!process.env.HUBOT_JIRA_DUPLICATE_DETECTION and process.env.HUBOT_SLACK_BUTTONS 34 | timeout: 30*1000 # 30 seconds 35 | 36 | @maps: 37 | projects: JSON.parse process.env.HUBOT_JIRA_PROJECTS_MAP 38 | types: JSON.parse process.env.HUBOT_JIRA_TYPES_MAP 39 | priorities: 40 | if process.env.HUBOT_JIRA_PRIORITIES_MAP 41 | JSON.parse process.env.HUBOT_JIRA_PRIORITIES_MAP 42 | transitions: 43 | if process.env.HUBOT_JIRA_TRANSITIONS_MAP 44 | JSON.parse process.env.HUBOT_JIRA_TRANSITIONS_MAP 45 | 46 | @projects: 47 | prefixes: (key for team, key of Config.maps.projects).reduce (x,y) -> x + "-|" + y 48 | channels: (team for team, key of Config.maps.projects).reduce (x,y) -> x + "|" + y 49 | 50 | @types: 51 | commands: (command for command, type of Config.maps.types).reduce (x,y) -> x + "|" + y 52 | 53 | @jira: 54 | url: process.env.HUBOT_JIRA_URL 55 | username: process.env.HUBOT_JIRA_USERNAME 56 | password: process.env.HUBOT_JIRA_PASSWORD 57 | mentionsDisabled: !!process.env.HUBOT_JIRA_MENTIONS_DISABLED 58 | expand: "transitions" 59 | fields: ["issuetype", "status", "assignee", "reporter", "summary", "description", "labels", "project"] 60 | mentionRegex: /(?:\[~([\w._-]*)\])/i 61 | mentionRegexGlobal: /(?:\[~([\w._-]*)\])/gi 62 | @jira.urlRegexBase = "#{Config.jira.url}/browse/".replace /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' 63 | @jira.urlRegex = new RegExp "(?:#{Config.jira.urlRegexBase})((?:#{Config.projects.prefixes}-)\\d+)\\s*", "i" 64 | @jira.urlRegexGlobal = new RegExp "(?:#{Config.jira.urlRegexBase})((?:#{Config.projects.prefixes}-)\\d+)\\s*", "gi" 65 | if process.env.HUBOT_JIRA_FIELDS 66 | @jira.fields = JSON.parse process.env.HUBOT_JIRA_FIELDS 67 | 68 | @github: 69 | disabled: !!process.env.HUBOT_JIRA_GITHUB_DISABLED 70 | organization: process.env.HUBOT_GITHUB_ORG 71 | token: process.env.HUBOT_GITHUB_TOKEN 72 | 73 | @slack: 74 | buttons: 75 | if process.env.HUBOT_SLACK_BUTTONS 76 | JSON.parse process.env.HUBOT_SLACK_BUTTONS 77 | project: button: state: map: 78 | if process.env.HUBOT_SLACK_PROJECT_BUTTON_STATE_MAP 79 | JSON.parse process.env.HUBOT_SLACK_PROJECT_BUTTON_STATE_MAP 80 | verification: token: process.env.HUBOT_SLACK_VERIFICATION_TOKEN 81 | token: process.env.HUBOT_SLACK_TOKEN 82 | api: token: process.env.HUBOT_SLACK_API_TOKEN or process.env.HUBOT_SLACK_TOKEN 83 | 84 | @ticket: 85 | regex: new RegExp "(^|\\s)(" + Config.projects.prefixes + "-)(\\d+)\\b", "i" 86 | regexGlobal: new RegExp "(^|\\s)(" + Config.projects.prefixes + "-)(\\d+)\\b", "gi" 87 | 88 | @commands: 89 | regex: new RegExp "(?:#?(#{Config.projects.channels})\\s+)?(#{Config.types.commands}) ([^]+)", "i" 90 | 91 | @transitions: 92 | if Config.maps.transitions 93 | regex: new RegExp "^((?:#{Config.projects.prefixes}-)(?:\\d+))\\s+(?:to\\s+|>\\s?|>\\s?)(#{(Config.maps.transitions.map (t) -> t.name).join "|"})", "i" 94 | shouldRegex: new RegExp "\\s+(?:>|>)\\s?(#{(Config.maps.transitions.map (t) -> t.name).join "|"})", "i" 95 | 96 | @priority: 97 | if Config.maps.priorities 98 | regex: new RegExp "\\s+!(#{(Config.maps.priorities.map (priority) -> priority.name).join '|'})\\b", "i" 99 | 100 | @quote: 101 | regex: /`{1,3}([^]*?)`{1,3}/ 102 | 103 | @mention: 104 | regex: /(?:(?:^|\s+)<?@([\w._-]*)>?)/i 105 | regexGlobal: /(?:(?:^|\s+)<?@([\w._-]*)>?)/gi 106 | 107 | @rank: 108 | regex: new RegExp "(?:^|\\s)((?:#{Config.projects.prefixes}-)(?:\\d+)) rank (.*)", "i" 109 | 110 | @watch: 111 | notificationsRegex: /jira (allow|start|enable|disallow|disable|stop)( notifications)?/i 112 | regex: new RegExp "^((?:#{Config.projects.prefixes}-)(?:\\d+)) (un)?watch(?: @?([\\w._-]*))?", "i" 113 | 114 | @subtask: 115 | regex: new RegExp "subtask\\s+((?:#{Config.projects.prefixes}-)(?:\\d+)) ([^]+)", "i" 116 | 117 | @assign: 118 | regex: new RegExp "^((?:#{Config.projects.prefixes}-)(?:\\d+))(?: (un)?assign)? @?([\\w._-]*)\\s*$", "i" 119 | 120 | @clone: 121 | regex: new RegExp "^((?:#{Config.projects.prefixes}-)(?:\\d+))\\s*(?:(?:>|clone(?:s)?(?:\\s+to)?)\\s*)(?:#)?(#{Config.projects.channels})", "i" 122 | 123 | @comment: 124 | regex: new RegExp "^((?:#{Config.projects.prefixes}-)(?:\\d+))\\s?(?:<\\s?)([^]+)", "i" 125 | 126 | @labels: 127 | addRegex: new RegExp "^((?:#{Config.projects.prefixes}-)(?:\\d+))\\s?<(\\s*#\\S+)+$", "i" 128 | slackChannelRegexGlobal: /(?:\s+|^)<#[A-Z0-9]*\|\S+>/g 129 | slackChannelRegex: /(?:\s+|^)<#[A-Z0-9]*\|(\S+)>/ 130 | regex: /(?:\s+|^)#\S+/g 131 | 132 | @search: 133 | regex: /(?:j|jira) (?:s|search|find) (.+)/ 134 | 135 | @query: 136 | regex: /(?:j|jira) (?:q|query) (.+)/ 137 | 138 | @help: 139 | regex: /(?:help jira|jira help)(?: (.*))?/ 140 | 141 | module.exports = Config 142 | -------------------------------------------------------------------------------- /src/adapters/slack.coffee: -------------------------------------------------------------------------------- 1 | _ = require "underscore" 2 | 3 | Config = require "../config" 4 | Jira = require "../jira" 5 | Utils = require "../utils" 6 | GenericAdapter = require "./generic" 7 | 8 | class Slack extends GenericAdapter 9 | constructor: (@robot) -> 10 | super @robot 11 | @queue = {} 12 | 13 | @robot.router.post "/hubot/slack-events", (req, res) => 14 | try 15 | payload = JSON.parse req.body.payload 16 | return unless payload.token is Config.slack.verification.token 17 | return @robot.emit "SlackEvents", payload, res unless @shouldJiraBotHandle payload 18 | catch e 19 | @robot.logger.debug e 20 | Utils.Stats.increment "jirabot.webhook.failed" 21 | return 22 | 23 | @onButtonActions(payload).then -> 24 | res.json payload.original_message 25 | .catch (error) -> 26 | @robot.logger.error error 27 | 28 | getRoom: (context) -> 29 | context = @normalizeContext context 30 | room = @robot.adapter.client.rtm.dataStore.getChannelOrGroupByName context.message.room 31 | room = @robot.adapter.client.rtm.dataStore.getChannelGroupOrDMById context.message.room unless room 32 | room = @robot.adapter.client.rtm.dataStore.getDMByUserId context.message.room unless room 33 | room = @robot.adapter.client.rtm.dataStore.getDMByName context.message.room unless room 34 | room 35 | 36 | getUsers: -> 37 | @robot.adapter.client.rtm.dataStore.users 38 | 39 | send: (context, message) -> 40 | payload = text: "" 41 | room = @getRoom context 42 | return unless room 43 | 44 | if _(message).isString() 45 | payload.text = message 46 | else 47 | payload = _(payload).chain().extend(message).pick("text", "attachments").value() 48 | 49 | if Config.slack.verification.token and payload.attachments?.length > 0 50 | attachments = [] 51 | for a in payload.attachments 52 | attachments.push a 53 | attachments.push @buttonAttachmentsForState "mention", a if a and a.type is "JiraTicketAttachment" 54 | payload.attachments = attachments 55 | 56 | payload.text = " " if payload.attachments?.length > 0 and payload.text.length is 0 57 | if payload.text.length > 0 58 | @robot.adapter.send 59 | room: room.id 60 | message: thread_ts: context.message.thread_ts 61 | , payload 62 | 63 | shouldJiraBotHandle: (context) -> 64 | id = context.callback_id 65 | matches = id.match Config.ticket.regexGlobal 66 | 67 | if matches and matches[0] 68 | return yes 69 | else if ~id.indexOf "JiraBotDuplicate" 70 | return yes 71 | else 72 | return no 73 | 74 | onButtonActions: (payload) -> 75 | Promise.all payload.actions.map (action) => @handleButtonAction payload, action 76 | 77 | handleDuplicateReponse: (payload, action) -> 78 | id = payload.callback_id.split(":")[1] 79 | msg = payload.original_message 80 | msg.attachments.pop() 81 | if item = @queue[id] 82 | clearTimeout item.timer 83 | delete @queue[id] 84 | 85 | if action.name is "create" and action.value is "yes" 86 | msg.attachments.push text: "Creating ticket..." 87 | item.action() 88 | 89 | if action.name is "create" and action.value is "no" 90 | msg.attachments.push text: "Ticket creation has been cancelled" 91 | Utils.Stats.increment "jirabot.slack.button.duplicate.#{action.name}.#{action.value}" 92 | 93 | return Promise.resolve() 94 | 95 | handleButtonAction: (payload, action) -> 96 | key = payload.callback_id 97 | return @handleDuplicateReponse payload, action if ~key.indexOf "JiraBotDuplicate" 98 | 99 | return new Promise (resolve, reject) => 100 | key = payload.callback_id 101 | user = payload.user 102 | msg = payload.original_message 103 | envelope = message: user: user 104 | 105 | switch action.name 106 | when "rank" 107 | Jira.Rank.forTicketKeyByDirection key, "up", envelope, no, no 108 | msg.attachments.push 109 | text: "<@#{user.id}> ranked this ticket to the top" 110 | resolve() 111 | when "watch" 112 | Jira.Create.fromKey(key) 113 | .then (ticket) => 114 | watchers = Utils.lookupChatUsersWithJira ticket.watchers 115 | if _(watchers).findWhere(id: user.id) 116 | msg.attachments.push 117 | text: "<@#{user.id}> has stopped watching this ticket" 118 | Jira.Watch.forTicketKeyRemovePerson key, null, envelope, no, no 119 | else 120 | msg.attachments.push 121 | text: "<@#{user.id}> is now watching this ticket" 122 | Jira.Watch.forTicketKeyForPerson key, user.name, envelope, no, no, no 123 | resolve() 124 | when "assign" 125 | Jira.Create.fromKey(key) 126 | .then (ticket) => 127 | assignee = Utils.lookupChatUserWithJira ticket.fields.assignee 128 | if assignee and assignee.id is user.id 129 | Jira.Assign.forTicketKeyToUnassigned key, envelope, no, no 130 | msg.attachments.push 131 | text: "<@#{user.id}> has unassigned themself" 132 | else 133 | Jira.Assign.forTicketKeyToPerson key, user.name, envelope, no, no 134 | msg.attachments.push 135 | text: "<@#{user.id}> is now assigned to this ticket" 136 | resolve() 137 | else 138 | result = Utils.fuzzyFind action.value, Config.maps.transitions, ['jira'] 139 | if result 140 | msg.attachments.push @buttonAttachmentsForState action.name, 141 | key: key 142 | text: "<@#{user.id}> transitioned this ticket to #{result.jira}" 143 | Jira.Transition.forTicketKeyToState key, result.name, envelope, no, no 144 | else 145 | msg.attachments.push 146 | text: "Unable to to process #{action.name}" 147 | resolve() 148 | Utils.Stats.increment "jirabot.slack.button.#{action.name}" 149 | 150 | getPermalink: (context) -> 151 | team = _(context.robot.adapter.client.rtm.dataStore.teams).pairs() 152 | if domain = team[0]?[1]?.domain 153 | "https://#{domain}.slack.com/archives/#{context.message.room}/p#{context.message.id.replace '.', ''}" 154 | else 155 | "" 156 | 157 | buttonAttachmentsForState: (state="mention", details) -> 158 | key = details.author_name or details.key 159 | return {} unless key and key.length > 0 160 | project = key.split("-")[0] 161 | return {} unless project 162 | buttons = Config.slack.buttons 163 | return {} unless buttons 164 | map = Config.slack.project.button.state.map[project] or Config.slack.project.button.state.map.default 165 | return {} unless map 166 | actions = [] 167 | actions.push buttons[button] for button in map[state] if map[state] 168 | 169 | fallback: "Unable to display quick action buttons" 170 | attachment_type: "default" 171 | callback_id: key 172 | color: details.color 173 | actions: actions 174 | text: details.text 175 | 176 | detectForDuplicates: (project, type, summary, context) -> 177 | original = summary 178 | create = -> Jira.Create.with project, type, original, context 179 | { summary } = Utils.extract.all summary 180 | 181 | Jira.Search.withQueryForProject(summary, project, context, 20) 182 | .then (results) => 183 | if duplicate = Utils.detectPossibleDuplicate summary, results.tickets 184 | now = Date.now() 185 | @queue[now] = 186 | timer: setTimeout => 187 | create() 188 | delete @queue[now] 189 | , Config.duplicates.timeout 190 | action: create 191 | 192 | attachments = [ duplicate.toAttachment no ] 193 | attachments.push 194 | fallback: "Unable to display quick action buttons" 195 | attachment_type: "default" 196 | callback_id: "JiraBotDuplicate:#{now}" 197 | text: """ 198 | There are potential duplicates of this issue. 199 | If you do not respond, the ticket will be created in #{Config.duplicates.timeout/1000} seconds 200 | 201 | What would you like to do? 202 | """ 203 | actions: [ 204 | name: "create" 205 | text: "Create anyways" 206 | style: "primary" 207 | type: "button" 208 | value: "yes" 209 | , 210 | name: "create" 211 | text: "Do not create" 212 | style: "danger" 213 | type: "button" 214 | value: "no" 215 | ] 216 | 217 | @send context, 218 | text: results.text 219 | attachments: attachments 220 | , no 221 | else 222 | create() 223 | .catch -> 224 | create() 225 | 226 | module.exports = Slack 227 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hubot Jira Bot 2 | Lets you search for JIRA tickets, open 3 | them, transition them thru different states, comment on them, rank 4 | them up or down, start or stop watching them or change who is 5 | assigned to a ticket. Also, notifications for assignments, mentions and watched tickets. 6 | 7 | ### Dependencies: 8 | - moment 9 | - octokat 10 | - node-fetch 11 | - underscore 12 | - fuse.js 13 | 14 | ### Configuration: 15 | - `HUBOT_GITHUB_ORG` - Github Organization or Github User 16 | - `HUBOT_GITHUB_TOKEN` - Github Application Token 17 | - `HUBOT_JIRA_GITHUB_DISABLED` - Set to true if you wish to disable github integration 18 | - `HUBOT_JIRA_MENTIONS_DISABLED` - Set to true if you wish to disable posting tickets in response to mentions in normal messages 19 | - `HUBOT_JIRA_PASSWORD` 20 | - `HUBOT_JIRA_PRIORITIES_MAP` `[{"name":"Blocker","id":"1"},{"name":"Critical","id":"2"},{"name":"Major","id":"3"},{"name":"Minor","id":"4"},{"name":"Trivial","id":"5"}]` 21 | - `HUBOT_JIRA_PROJECTS_MAP` `{"web":"WEB","android":"AN","ios":"IOS","platform":"PLAT"}` 22 | - `HUBOT_JIRA_TRANSITIONS_MAP` `[{"name":"triage","jira":"Triage"},{"name":"icebox","jira":"Icebox"},{"name":"backlog","jira":"Backlog"},{"name":"devready","jira":"Selected for Development"},{"name":"inprogress","jira":"In Progress"},{"name":"design","jira":"Design Triage"}]` 23 | - `HUBOT_JIRA_TYPES_MAP` `{"story":"Story / Feature","bug":"Bug","task":"Task"}` 24 | - `HUBOT_JIRA_URL` `https://jira-domain.com:9090` 25 | - `HUBOT_JIRA_USERNAME` 26 | - `HUBOT_JIRA_FIELDS` `customize the jira fields returned by api, defaults to: ["issuetype", "status", "assignee", "reporter", "summary", "description", "labels", "project"]` 27 | - `HUBOT_SLACK_BUTTONS` `{"watch":{"name":"watch","text":"Watch","type":"button","value":"watch","style":"primary"},"assign":{"name":"assign","text":"Assign to me","type":"button","value":"assign"},"devready":{"name":"devready","text":"Dev Ready","type":"button","value":"selected"},"inprogress":{"name":"inprogress","text":"In Progress","type":"button","value":"progress"},"rank":{"name":"rank","text":"Rank Top","type":"button","value":"top"},"running":{"name":"running","text":"Running","type":"button","value":"running"},"review":{"name":"review","text":"Review","type":"button","value":"review"},"resolved":{"name":"resolved","text":"Resolved","type":"button","style":"primary","value":"resolved"},"done":{"name":"done","text":"Done","type":"button","style":"primary","value":"done"}}` 28 | - `HUBOT_SLACK_PROJECT_BUTTON_STATE_MAP` `{"PLAT":{"inprogress":["review","running","resolved"],"review":["running","resolved"],"running":["resolved"],"resolved":["devready","inprogress"],"mention":["watch","assign","devready","inprogress","rank"]},"HAL":{"inprogress":["review","running","resolved"],"review":["running","resolved"],"running":["resolved"],"resolved":["devready","inprogress"],"mention":["watch","assign","devready","inprogress","rank"]},"default":{"inprogress":["review","done"],"review":["done"],"done":["devready, inprogress"],"mention":["watch","assign","devready","inprogress","rank"]}}` 29 | - `HUBOT_SLACK_VERIFICATION_TOKEN` - The slack verification token for your application 30 | 31 | Note that `HUBOT_JIRA_USERNAME` should be the JIRA username, this is 32 | not necessarily the username used if you log in via the web. To 33 | determine a user's username, log in as that user via the web, and check 34 | the user profile. Frequently, users may log in using an email address such 35 | as 'bob@somewhere.com' or a stem, such as 'bob'; these may or may not match 36 | the username in JIRA. 37 | 38 | #### A note about chat:jira user lookup 39 | In order for direct messages (notifications) and a few other 40 | username based commands to work JiraBot attempts to match JIRA users with chat 41 | users by email address. This has been tested primarily on the [Hubot 42 | Slack adapter](https://github.com/slackhq/hubot-slack) and may not work without modification on others. 43 | The take away is that you must have the same e-mail address on both 44 | services for this to work as expected. 45 | 46 | #### Notifications via Webhooks 47 | In order to receive JIRA notifications you will need to setup a webhook. 48 | You can find instructions to do so on [Atlassian's 49 | website](https://developer.atlassian.com/jiradev/jira-apis/webhooks). 50 | You will need your hubot to be reachable from the outside world for this 51 | to work. JiraBot is listening on `/hubot/jira-events`. Currently 52 | the following notifications are available: 53 | 54 | * You are mentioned in a ticket in either the description or in a 55 | comment 56 | * You are assigned to a ticket 57 | * The following notifications apply if you are assigned to the ticket: 58 | * A new comment is left on the ticket 59 | * The following notifications apply if you are watching the ticket in 60 | question: 61 | * Work begins on the ticket (enters the In Progress state or similar) 62 | * The ticket is closed 63 | * A new comment is left on the ticket 64 | 65 | ### The Definitive hubot JIRA Manual 66 | @hubot can help you *search* for JIRA tickets, *open* 67 | them, *transition* them thru different states, *comment* on them, *rank* 68 | them _up_ or _down_, start or stop *watching* them or change who is 69 | *assigned* to a ticket 70 | 71 | 72 | #### Opening Tickets 73 | > hubot [`<project>`] `<type>` `<title>` [`<description>`] 74 | 75 | You can omit `<project>` when using the command in the desired projects channel 76 | Otherwise you can specify one of the following for `<project>`: `# web`, `# android`, `# ios`, `# platform` 77 | `<type>` is one of the following: `story`, `bug`, `task` 78 | `<description>` is optional and is surrounded with single or triple backticks 79 | and can be used to provide a more detailed description for the ticket. 80 | `<title>` is a short summary of the ticket 81 | 82 | ##### Optional `<title>` Attributes 83 | 84 | _Labels_: include one or many hashtags that will become labels on the jira ticket 85 | `# quick # techdebt` 86 | 87 | _Assignment_: include a handle that will be used to assign the ticket after creation 88 | `@username` 89 | 90 | _Transitions_: include a transition to make after the ticket is created 91 | `>triage`, `>icebox`, `>backlog`, `>devready`, `>inprogress`, `>design` 92 | 93 | _Priority_: include the ticket priority to be assigned upon ticket creation 94 | `!blocker`, `!critical`, `!major`, `!minor`, `!trivial` 95 | 96 | 97 | #### Creating Sub-tasks 98 | > hubot subtask `<ticket>` `<summary>` 99 | 100 | Where `<ticket>` is the parent JIRA ticket number 101 | and `<summary>` is a short summary of the task 102 | 103 | 104 | #### Cloning Tickets 105 | >`<ticket>` clone to `<channel>` 106 | > `<ticket>` > `<channel>` 107 | 108 | Where `<ticket>` is the JIRA ticket number 109 | and `<channel>` is one of the following: `# web`, `# android`, `# ios`, `# platform` 110 | 111 | 112 | #### Ranking Tickets 113 | >`<ticket>` rank top 114 | > `<ticket>` rank bottom 115 | 116 | Where `<ticket>` is the JIRA ticket number. Note this will rank it the top 117 | of column for the current state 118 | 119 | 120 | #### Commenting on a Ticket 121 | >`<ticket>` < `<comment>` 122 | 123 | Where `<ticket>` is the JIRA ticket number 124 | and `<comment>` is the comment you wish to leave on the ticket 125 | 126 | 127 | #### Adding labels to a Ticket 128 | >`<ticket>` < `# label1 # label2 # label3` 129 | 130 | Where `<ticket>` is the JIRA ticket number 131 | 132 | 133 | #### Assigning Tickets 134 | >`<ticket>` assign `@username` 135 | 136 | Where `<ticket>` is the JIRA ticket number 137 | and `@username` is a user handle 138 | 139 | 140 | #### Transitioning Tickets 141 | >`<ticket>` to `<state>` 142 | > `<ticket>` >`<state>` 143 | 144 | Where `<ticket>` is the JIRA ticket number 145 | and `<state>` is one of the following: `triage`, `icebox`, `backlog`, `devready`, `inprogress`, `design` 146 | 147 | 148 | #### Watching Tickets 149 | >`<ticket>` watch [`@username]`] 150 | 151 | Where `<ticket>` is the JIRA ticket number 152 | `@username` is optional, if specified the corresponding JIRA user will become 153 | the watcher on the ticket, if omitted the message author will become the watcher 154 | 155 | 156 | #### Ticket Notifications 157 | 158 | Whenever you begin watching a JIRA ticket you will be notified (via a direct 159 | message from @hubot) whenever any of the following events occur: 160 | - a comment is left on the ticket 161 | - the ticket is in progress 162 | - the ticket is resolved 163 | 164 | You will also be notified if: 165 | - you are mentioned on the ticket 166 | - you are assigned to the ticket 167 | 168 | If you are assigned to a ticket, you will be notified when: 169 | - a comment is left on the ticket 170 | 171 | To enable or disable this feature you can send the following directly to hubot: 172 | 173 | > jira disable notifications 174 | 175 | or if you wish to re-enable 176 | 177 | > jira enable notifications 178 | 179 | 180 | #### Searching Tickets 181 | > hubot jira search `<term>` 182 | 183 | ##### Optional `<term>` Attributes 184 | _Labels_: include one or many hashtags that will become labels included in the search 185 | `# quick # techdebt` 186 | 187 | Where `<term>` is some text contained in the ticket you are looking for## Documentation with Configuration examples from above 188 | 189 | 190 | #### Quering Tickets with JQL 191 | > hubot jira query `<jql>` 192 | 193 | Where `<jql>` is a valid JQL query 194 | """ 195 | -------------------------------------------------------------------------------- /src/index.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Lets you search for JIRA tickets, open 3 | # them, transition them thru different states, comment on them, rank 4 | # them up or down, start or stop watching them or change who is 5 | # assigned to a ticket. Also, notifications for mentions, assignments and watched tickets. 6 | # 7 | # Dependencies: 8 | # - moment 9 | # - octokat 10 | # - node-fetch 11 | # - underscore 12 | # - fuse.js 13 | # 14 | # Author: 15 | # ndaversa 16 | # 17 | # Contributions: 18 | # sjakubowski 19 | 20 | _ = require "underscore" 21 | moment = require "moment" 22 | 23 | Config = require "./config" 24 | Github = require "./github" 25 | Help = require "./help" 26 | Jira = require "./jira" 27 | Adapters = require "./adapters" 28 | Utils = require "./utils" 29 | 30 | class JiraBot 31 | 32 | constructor: (@robot) -> 33 | return new JiraBot @robot unless @ instanceof JiraBot 34 | Utils.robot = @robot 35 | Utils.JiraBot = @ 36 | 37 | @webhook = new Jira.Webhook @robot 38 | switch @robot.adapterName 39 | when "slack" 40 | @adapter = new Adapters.Slack @robot 41 | else 42 | @adapter = new Adapters.Generic @robot 43 | 44 | @registerWebhookListeners() 45 | @registerEventListeners() 46 | @registerRobotResponses() 47 | 48 | send: (context, message, filter=yes) -> 49 | context = @adapter.normalizeContext context 50 | message = @filterAttachmentsForPreviousMentions context, message if filter 51 | @adapter.send context, message 52 | 53 | filterAttachmentsForPreviousMentions: (context, message) -> 54 | return message if _(message).isString() 55 | return message unless message.attachments?.length > 0 56 | room = context.message.room 57 | 58 | removals = [] 59 | for attachment in message.attachments when attachment and attachment.type is "JiraTicketAttachment" 60 | ticket = attachment.author_name?.trim().toUpperCase() 61 | continue unless Config.ticket.regex.test ticket 62 | 63 | key = "#{room}:#{ticket}" 64 | if Utils.cache.get key 65 | removals.push attachment 66 | Utils.Stats.increment "jirabot.surpress.attachment" 67 | @robot.logger.debug "Supressing ticket attachment for #{ticket} in #{@adapter.getRoomName context}" 68 | else 69 | Utils.cache.put key, true, Config.cache.mention.expiry 70 | 71 | message.attachments = _(message.attachments).difference removals 72 | return message 73 | 74 | matchJiraTicket: (context) -> 75 | if context.match? 76 | matches = context.match Config.ticket.regexGlobal 77 | unless matches and matches[0] 78 | urlMatch = context.match Config.jira.urlRegex 79 | if urlMatch and urlMatch[1] 80 | matches = [ urlMatch[1] ] 81 | 82 | if matches and matches[0] 83 | return matches 84 | else if context.message?.attachments? 85 | attachments = context.message.attachments 86 | for attachment in attachments when attachment.text? 87 | matches = attachment.text.match Config.ticket.regexGlobal 88 | if matches and matches[0] 89 | return matches 90 | return false 91 | 92 | prepareResponseForJiraTickets: (context) -> 93 | Promise.all(context.match.map (key) => 94 | _attachments = [] 95 | Jira.Create.fromKey(key).then (ticket) -> 96 | _attachments.push ticket.toAttachment() 97 | ticket 98 | .then (ticket) -> 99 | Github.PullRequests.fromKey ticket.key unless Config.github.disabled 100 | .then (prs) -> 101 | prs?.toAttachment() 102 | .then (attachments) -> 103 | _attachments.push a for a in attachments if attachments 104 | _attachments 105 | ).then (attachments) => 106 | @send context, attachments: _(attachments).flatten() 107 | .catch (error) => 108 | @send context, "#{error}" 109 | @robot.logger.error error.stack 110 | 111 | registerWebhookListeners: -> 112 | # Watchers 113 | disableDisclaimer = """ 114 | If you wish to stop receiving notifications for the tickets you are watching, reply with: 115 | > jira disable notifications 116 | """ 117 | @robot.on "JiraWebhookTicketInProgress", (ticket, event) => 118 | assignee = Utils.lookupUserWithJira ticket.fields.assignee 119 | assigneeText = "." 120 | assigneeText = " by #{assignee}" if assignee isnt "Unassigned" 121 | 122 | @adapter.dm Utils.lookupChatUsersWithJira(ticket.watchers), 123 | text: """ 124 | A ticket you are watching is now being worked on#{assigneeText} 125 | """ 126 | author: event.user 127 | footer: disableDisclaimer 128 | attachments: [ ticket.toAttachment no ] 129 | Utils.Stats.increment "jirabot.webhook.ticket.inprogress" 130 | 131 | @robot.on "JiraWebhookTicketInReview", (ticket, event) => 132 | assignee = Utils.lookupUserWithJira ticket.fields.assignee 133 | assigneeText = "" 134 | assigneeText = "Please message #{assignee} if you wish to provide feedback." if assignee isnt "Unassigned" 135 | 136 | @adapter.dm Utils.lookupChatUsersWithJira(ticket.watchers), 137 | text: """ 138 | A ticket you are watching is now ready for review. 139 | #{assigneeText} 140 | """ 141 | author: event.user 142 | footer: disableDisclaimer 143 | attachments: [ ticket.toAttachment no ] 144 | Utils.Stats.increment "jirabot.webhook.ticket.inreview" 145 | 146 | @robot.on "JiraWebhookTicketDone", (ticket, event) => 147 | @adapter.dm Utils.lookupChatUsersWithJira(ticket.watchers), 148 | text: """ 149 | A ticket you are watching has been marked `Done`. 150 | """ 151 | author: event.user 152 | footer: disableDisclaimer 153 | attachments: [ ticket.toAttachment no ] 154 | Utils.Stats.increment "jirabot.webhook.ticket.done" 155 | 156 | # Comment notifications for watchers 157 | @robot.on "JiraWebhookTicketComment", (ticket, comment) => 158 | @adapter.dm Utils.lookupChatUsersWithJira(ticket.watchers), 159 | text: """ 160 | A ticket you are watching has a new comment from #{comment.author.displayName}: 161 | ``` 162 | #{comment.body} 163 | ``` 164 | """ 165 | author: comment.author 166 | footer: disableDisclaimer 167 | attachments: [ ticket.toAttachment no ] 168 | Utils.Stats.increment "jirabot.webhook.ticket.comment" 169 | 170 | # Comment notifications for assignee 171 | @robot.on "JiraWebhookTicketComment", (ticket, comment) => 172 | return unless ticket.fields.assignee 173 | return if ticket.watchers.length > 0 and _(ticket.watchers).findWhere name: ticket.fields.assignee.name 174 | 175 | @adapter.dm Utils.lookupChatUsersWithJira(ticket.fields.assignee), 176 | text: """ 177 | A ticket you are assigned to has a new comment from #{comment.author.displayName}: 178 | ``` 179 | #{comment.body} 180 | ``` 181 | """ 182 | author: comment.author 183 | footer: disableDisclaimer 184 | attachments: [ ticket.toAttachment no ] 185 | Utils.Stats.increment "jirabot.webhook.ticket.comment" 186 | 187 | # Mentions 188 | @robot.on "JiraWebhookTicketMention", (ticket, user, event, context) => 189 | @adapter.dm user, 190 | text: """ 191 | You were mentioned in a ticket by #{event.user.displayName}: 192 | ``` 193 | #{context} 194 | ``` 195 | """ 196 | author: event.user 197 | footer: disableDisclaimer 198 | attachments: [ ticket.toAttachment no ] 199 | Utils.Stats.increment "jirabot.webhook.ticket.mention" 200 | 201 | # Assigned 202 | @robot.on "JiraWebhookTicketAssigned", (ticket, user, event) => 203 | @adapter.dm user, 204 | text: """ 205 | You were assigned to a ticket by #{event.user.displayName}: 206 | """ 207 | author: event.user 208 | footer: disableDisclaimer 209 | attachments: [ ticket.toAttachment no ] 210 | Utils.Stats.increment "jirabot.webhook.ticket.assigned" 211 | 212 | registerEventListeners: -> 213 | 214 | #Find Matches (for cross-script usage) 215 | @robot.on "JiraFindTicketMatches", (context, cb) => 216 | cb @matchJiraTicket context 217 | 218 | #Prepare Responses For Tickets (for cross-script usage) 219 | @robot.on "JiraPrepareResponseForTickets", (context) => 220 | @prepareResponseForJiraTickets context 221 | 222 | #Create 223 | @robot.on "JiraTicketCreated", (context, details) => 224 | @send context, 225 | text: "Ticket created" 226 | attachments: [ 227 | details.ticket.toAttachment no 228 | details.assignee 229 | details.transition 230 | ] 231 | Utils.Stats.increment "jirabot.ticket.create.success" 232 | 233 | @robot.on "JiraTicketCreationFailed", (error, context) => 234 | robot.logger.error error.stack 235 | @send context, "Unable to create ticket #{error}" 236 | Utils.Stats.increment "jirabot.ticket.create.failed" 237 | 238 | #Created in another room 239 | @robot.on "JiraTicketCreatedElsewhere", (context, details) => 240 | room = @adapter.getRoom context 241 | for r in Utils.lookupRoomsForProject details.ticket.fields.project.key 242 | @send r, 243 | text: "Ticket created in <##{room.id}|#{room.name}> by <@#{context.message.user.id}>" 244 | attachments: [ 245 | details.ticket.toAttachment no 246 | details.assignee 247 | details.transition 248 | ] 249 | Utils.Stats.increment "jirabot.ticket.create.elsewhere" 250 | 251 | #Clone 252 | @robot.on "JiraTicketCloned", (ticket, channel, clone, context) => 253 | room = @adapter.getRoom context 254 | @send channel, 255 | text: "Ticket created: Cloned from #{clone} in <##{room.id}|#{room.name}> by <@#{context.message.user.id}>" 256 | attachments: [ ticket.toAttachment no ] 257 | Utils.Stats.increment "jirabot.ticket.clone.success" 258 | 259 | @robot.on "JiraTicketCloneFailed", (error, ticket, context) => 260 | @robot.logger.error error.stack 261 | room = @adapter.getRoom context 262 | @send context, "Unable to clone `#{ticket}` to the <\##{room.id}|#{room.name}> project :sadpanda:\n```#{error}```" 263 | Utils.Stats.increment "jirabot.ticket.clone.failed" 264 | 265 | #Transition 266 | @robot.on "JiraTicketTransitioned", (ticket, transition, context, includeAttachment=no) => 267 | @send context, 268 | text: "Transitioned #{ticket.key} to `#{transition.to.name}`" 269 | attachments: [ ticket.toAttachment no ] if includeAttachment 270 | Utils.Stats.increment "jirabot.ticket.transition.success" 271 | 272 | @robot.on "JiraTicketTransitionFailed", (error, context) => 273 | @robot.logger.error error.stack 274 | @send context, "#{error}" 275 | Utils.Stats.increment "jirabot.ticket.transition.failed" 276 | 277 | #Assign 278 | @robot.on "JiraTicketAssigned", (ticket, user, context, includeAttachment=no) => 279 | @send context, 280 | text: "Assigned <@#{user.id}> to #{ticket.key}" 281 | attachments: [ ticket.toAttachment no ] if includeAttachment 282 | Utils.Stats.increment "jirabot.ticket.assign.success" 283 | 284 | @robot.on "JiraTicketUnassigned", (ticket, context, includeAttachment=no) => 285 | @send context, 286 | text: "#{ticket.key} is now unassigned" 287 | attachments: [ ticket.toAttachment no ] if includeAttachment 288 | Utils.Stats.increment "jirabot.ticket.unassign.success" 289 | 290 | @robot.on "JiraTicketAssignmentFailed", (error, context) => 291 | @robot.logger.error error.stack 292 | @send context, "#{error}" 293 | Utils.Stats.increment "jirabot.ticket.assign.failed" 294 | 295 | #Watch 296 | @robot.on "JiraTicketWatched", (ticket, user, context, includeAttachment=no) => 297 | @send context, 298 | text: "Added <@#{user.id}> as a watcher on #{ticket.key}" 299 | attachments: [ ticket.toAttachment no ] if includeAttachment 300 | Utils.Stats.increment "jirabot.ticket.watch.success" 301 | 302 | @robot.on "JiraTicketUnwatched", (ticket, user, context, includeAttachment=no) => 303 | @send context, 304 | text: "Removed <@#{user.id}> as a watcher on #{ticket.key}" 305 | attachments: [ ticket.toAttachment no ] if includeAttachment 306 | Utils.Stats.increment "jirabot.ticket.unwatch.success" 307 | 308 | @robot.on "JiraTicketWatchFailed", (error, context) => 309 | @robot.logger.error error.stack 310 | @send context, "#{error}" 311 | Utils.Stats.increment "jirabot.ticket.watch.failed" 312 | 313 | #Rank 314 | @robot.on "JiraTicketRanked", (ticket, direction, context, includeAttachment=no) => 315 | @send context, 316 | text: "Ranked #{ticket.key} to `#{direction}`" 317 | attachments: [ ticket.toAttachment no ] if includeAttachment 318 | Utils.Stats.increment "jirabot.ticket.rank.success" 319 | 320 | @robot.on "JiraTicketRankFailed", (error, context) => 321 | @robot.logger.error error.stack 322 | @send context, "#{error}" 323 | Utils.Stats.increment "jirabot.ticket.rank.failed" 324 | 325 | #Labels 326 | @robot.on "JiraTicketLabelled", (ticket, context, includeAttachment=no) => 327 | @send context, 328 | text: "Added labels to #{ticket.key}" 329 | attachments: [ ticket.toAttachment no ] if includeAttachment 330 | Utils.Stats.increment "jirabot.ticket.label.success" 331 | 332 | @robot.on "JiraTicketLabelFailed", (error, context) => 333 | @robot.logger.error error.stack 334 | @send context, "#{error}" 335 | Utils.Stats.increment "jirabot.ticket.label.failed" 336 | 337 | #Comments 338 | @robot.on "JiraTicketCommented", (ticket, context, includeAttachment=no) => 339 | @send context, 340 | text: "Added comment to #{ticket.key}" 341 | attachments: [ ticket.toAttachment no ] if includeAttachment 342 | Utils.Stats.increment "jirabot.ticket.comment.success" 343 | 344 | @robot.on "JiraTicketCommentFailed", (error, context) => 345 | @robot.logger.error error.stack 346 | @send context, "#{error}" 347 | Utils.Stats.increment "jirabot.ticket.comment.failed" 348 | 349 | registerRobotResponses: -> 350 | #Help 351 | @robot.respond Config.help.regex, (context) => 352 | context.finish() 353 | [ __, topic] = context.match 354 | @send context, Help.forTopic topic, @robot 355 | Utils.Stats.increment "command.jirabot.help" 356 | 357 | #Enable/Disable Watch Notifications 358 | @robot.respond Config.watch.notificationsRegex, (context) => 359 | context.finish() 360 | [ __, state ] = context.match 361 | switch state 362 | when "allow", "start", "enable" 363 | @adapter.enableNotificationsFor context.message.user 364 | @send context, """ 365 | JIRA Watch notifications have been *enabled* 366 | 367 | You will start receiving notifications for JIRA tickets you are watching 368 | 369 | If you wish to _disable_ them just send me this message: 370 | > jira disable notifications 371 | """ 372 | when "disallow", "stop", "disable" 373 | @adapter.disableNotificationsFor context.message.user 374 | @send context, """ 375 | JIRA Watch notifications have been *disabled* 376 | 377 | You will no longer receive notifications for JIRA tickets you are watching 378 | 379 | If you wish to _enable_ them again just send me this message: 380 | > jira enable notifications 381 | """ 382 | Utils.Stats.increment "command.jirabot.toggleNotifications" 383 | 384 | #Search 385 | @robot.respond Config.search.regex, (context) => 386 | context.finish() 387 | [__, query] = context.match 388 | room = context.message.room 389 | project = Config.maps.projects[room] 390 | Jira.Search.withQueryForProject(query, project, context) 391 | .then (results) => 392 | attachments = (ticket.toAttachment() for ticket in results.tickets) 393 | @send context, 394 | text: results.text 395 | attachments: attachments 396 | , no 397 | .catch (error) => 398 | @send context, "Unable to search for `#{query}` :sadpanda:" 399 | @robot.logger.error error.stack 400 | Utils.Stats.increment "command.jirabot.search" 401 | 402 | #Query 403 | @robot.respond Config.query.regex, (context) => 404 | context.finish() 405 | [__, query] = context.match 406 | Jira.Query.withQuery(query) 407 | .then (results) => 408 | attachments = (ticket.toAttachment(no) for ticket in results.tickets) 409 | @send context, 410 | text: results.text 411 | attachments: attachments 412 | , no 413 | .catch (error) => 414 | @send context, "Unable to execute query `#{query}` :sadpanda:" 415 | @robot.logger.error error.stack 416 | Utils.Stats.increment "command.jirabot.query" 417 | 418 | #Transition 419 | if Config.maps.transitions 420 | @robot.hear Config.transitions.regex, (context) => 421 | context.finish() 422 | [ __, key, toState ] = context.match 423 | Jira.Transition.forTicketKeyToState key, toState, context, no 424 | Utils.Stats.increment "command.jirabot.transition" 425 | 426 | #Clone 427 | @robot.hear Config.clone.regex, (context) => 428 | context.finish() 429 | [ __, ticket, channel ] = context.match 430 | project = Config.maps.projects[channel] 431 | Jira.Clone.fromTicketKeyToProject ticket, project, channel, context 432 | Utils.Stats.increment "command.jirabot.clone" 433 | 434 | #Watch 435 | @robot.hear Config.watch.regex, (context) => 436 | context.finish() 437 | [ __, key, remove, person ] = context.match 438 | 439 | if remove 440 | Jira.Watch.forTicketKeyRemovePerson key, person, context, no 441 | else 442 | Jira.Watch.forTicketKeyForPerson key, person, context, no 443 | Utils.Stats.increment "command.jirabot.watch" 444 | 445 | #Rank 446 | @robot.hear Config.rank.regex, (context) => 447 | context.finish() 448 | [ __, key, direction ] = context.match 449 | Jira.Rank.forTicketKeyByDirection key, direction, context, no 450 | Utils.Stats.increment "command.jirabot.rank" 451 | 452 | #Labels 453 | @robot.hear Config.labels.addRegex, (context) => 454 | context.finish() 455 | [ __, key ] = context.match 456 | {input: input} = context.match 457 | labels = [] 458 | labels = (input.match(Config.labels.regex).map((label) -> label.replace('#', '').trim())).concat(labels) 459 | 460 | Jira.Labels.forTicketKeyWith key, labels, context, no 461 | Utils.Stats.increment "command.jirabot.label" 462 | 463 | #Comment 464 | @robot.hear Config.comment.regex, (context) => 465 | context.finish() 466 | [ __, key, comment ] = context.match 467 | 468 | Jira.Comment.forTicketKeyWith key, comment, context, no 469 | Utils.Stats.increment "command.jirabot.comment" 470 | 471 | #Subtask 472 | @robot.respond Config.subtask.regex, (context) => 473 | context.finish() 474 | [ __, key, summary ] = context.match 475 | Jira.Create.subtaskFromKeyWith key, summary, context 476 | Utils.Stats.increment "command.jirabot.subtask" 477 | 478 | #Assign 479 | @robot.hear Config.assign.regex, (context) => 480 | context.finish() 481 | [ __, key, remove, person ] = context.match 482 | 483 | if remove 484 | Jira.Assign.forTicketKeyToUnassigned key, context, no 485 | else 486 | Jira.Assign.forTicketKeyToPerson key, person, context, no 487 | 488 | Utils.Stats.increment "command.jirabot.assign" 489 | 490 | #Create 491 | @robot.respond Config.commands.regex, (context) => 492 | [ __, project, command, summary ] = context.match 493 | room = project or @adapter.getRoomName context 494 | project = Config.maps.projects[room.toLowerCase()] 495 | type = Config.maps.types[command.toLowerCase()] 496 | 497 | unless project 498 | channels = [] 499 | for team, key of Config.maps.projects 500 | room = @adapter.getRoom team 501 | channels.push " <\##{room.id}|#{room.name}>" if room 502 | return context.reply "#{type} must be submitted in one of the following project channels: #{channels}" 503 | 504 | if Config.duplicates.detection and @adapter.detectForDuplicates? 505 | @adapter.detectForDuplicates project, type, summary, context 506 | else 507 | Jira.Create.with project, type, summary, context 508 | 509 | Utils.Stats.increment "command.jirabot.create" 510 | 511 | unless Config.jira.mentionsDisabled 512 | #Mention ticket by url or key 513 | @robot.listen @matchJiraTicket, (context) => 514 | @prepareResponseForJiraTickets context 515 | Utils.Stats.increment "command.jirabot.mention.ticket" 516 | 517 | module.exports = JiraBot 518 | --------------------------------------------------------------------------------