Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Fetching contributors…

Cannot retrieve contributors at this time

418 lines (334 sloc) 13.01 kb
# Description:
# Showing of redmine issue via the REST API
# It also listens for the #nnnn format and provides issue data and link
# Eg. "Hey guys check out #273"
#
# Dependencies:
# None
#
# Configuration:
# HUBOT_REDMINE_SSL
# HUBOT_REDMINE_BASE_URL
# HUBOT_REDMINE_TOKEN
# HUBOT_REDMINE_IGNORED_USERS
#
# Commands:
# hubot (redmine|show) me <issue-id> - Show the issue status
# hubot show (my|user's) issues - Show your issues or another user's issues
# hubot assign <issue-id> to <user-first-name> ["notes"] - Assign the issue to the user (searches login or firstname)
# hubot update <issue-id> with "<note>" - Adds a note to the issue
# hubot add <hours> hours to <issue-id> ["comments"] - Adds hours to the issue with the optional comments
# hubot link me <issue-id> - Returns a link to the redmine issue
# hubot set <issue-id> to <int>% ["comments"] - Updates an issue and sets the percent done
#
# Notes:
# <issue-id> can be formatted in the following ways: 1234, #1234,
# issue 1234, issue #1234
#
# Author:
# robhurring
if process.env.HUBOT_REDMINE_SSL?
HTTP = require('https')
else
HTTP = require('http')
URL = require('url')
QUERY = require('querystring')
module.exports = (robot) ->
redmine = new Redmine process.env.HUBOT_REDMINE_BASE_URL, process.env.HUBOT_REDMINE_TOKEN
# Robot link me <issue>
robot.respond /link me (?:issue )?(?:#)?(\d+)/i, (msg) ->
id = msg.match[1]
msg.reply "#{redmine.url}/issues/#{id}"
# Robot set <issue> to <percent>% ["comments"]
robot.respond /set (?:issue )?(?:#)?(\d+) to (\d{1,3})%?(?: "?([^"]+)"?)?/i, (msg) ->
[id, percent, notes] = msg.match[1..3]
percent = parseInt percent
if notes?
notes = "#{msg.message.user.name}: #{userComments}"
else
notes = "Ratio set by: #{msg.message.user.name}"
attributes =
"notes": notes
"done_ratio": percent
redmine.Issue(id).update attributes, (err, data, status) ->
if status == 200
msg.reply "Set ##{id} to #{percent}%"
else
msg.reply "Update failed! (#{err})"
# Robot add <hours> hours to <issue_id> ["comments for the time tracking"]
robot.respond /add (\d{1,2}) hours? to (?:issue )?(?:#)?(\d+)(?: "?([^"]+)"?)?/i, (msg) ->
[hours, id, userComments] = msg.match[1..3]
hours = parseInt hours
if userComments?
comments = "#{msg.message.user.name}: #{userComments}"
else
comments = "Time logged by: #{msg.message.user.name}"
attributes =
"issue_id": id
"hours": hours
"comments": comments
redmine.TimeEntry(null).create attributes, (error, data, status) ->
if status == 201
msg.reply "Your time was logged"
else
msg.reply "Nothing could be logged. Make sure RedMine has a default activity set for time tracking. (Settings -> Enumerations -> Activities)"
# Robot show <my|user's> [redmine] issues
robot.respond /show (?:my|(\w+\'s)) (?:redmine )?issues/i, (msg) ->
userMode = true
firstName =
if msg.match[1]?
userMode = false
msg.match[1].replace(/\'.+/, '')
else
msg.message.user.name.split(/\s/)[0]
redmine.Users name:firstName, (err,data) ->
unless data.total_count > 0
msg.reply "Couldn't find any users with the name \"#{firstName}\""
return false
user = resolveUsers(firstName, data.users)[0]
params =
"assigned_to_id": user.id
"limit": 25,
"status_id": "open"
"sort": "priority:desc",
redmine.Issues params, (err, data) ->
if err?
msg.reply "Couldn't get a list of issues for you!"
else
_ = []
if userMode
_.push "You have #{data.total_count} issue(s)."
else
_.push "#{user.firstname} has #{data.total_count} issue(s)."
for issue in data.issues
do (issue) ->
_.push "\n[#{issue.tracker.name} - #{issue.priority.name} - #{issue.status.name}] ##{issue.id}: #{issue.subject}"
msg.reply _.join "\n"
# Robot update <issue> with "<note>"
robot.respond /update (?:issue )?(?:#)?(\d+)(?:\s*with\s*)?(?:[-:,])? (?:"?([^"]+)"?)/i, (msg) ->
[id, note] = msg.match[1..2]
attributes =
"notes": "#{msg.message.user.name}: #{note}"
redmine.Issue(id).update attributes, (err, data, status) ->
unless data?
if status == 404
msg.reply "Issue ##{id} doesn't exist."
else
msg.reply "Couldn't update this issue, sorry :("
else
msg.reply "Done! Updated ##{id} with \"#{note}\""
# Robot add issue to "<project>" [traker <id>] with "<subject>"
robot.respond /add (?:issue )?(?:\s*to\s*)?(?:"?([^" ]+)"? )(?:tracker\s)?(\d+)?(?:\s*with\s*)("?([^"]+)"?)/i, (msg) ->
[project_id, tracker_id, subject] = msg.match[1..3]
attributes =
"project_id": "#{project_id}"
"subject": "#{subject}"
if tracker_id?
attributes =
"project_id": "#{project_id}"
"subject": "#{subject}"
"tracker_id": "#{tracker_id}"
redmine.Issue().add attributes, (err, data, status) ->
unless data?
if status == 404
msg.reply "Couldn't update this issue, #{status} :("
else
msg.reply "Done! Added issue #{data.id} with \"#{subject}\""
# Robot assign <issue> to <user> ["note to add with the assignment]
robot.respond /assign (?:issue )?(?:#)?(\d+) to (\w+)(?: "?([^"]+)"?)?/i, (msg) ->
[id, userName, note] = msg.match[1..3]
redmine.Users name:userName, (err, data) ->
unless data.total_count > 0
msg.reply "Couldn't find any users with the name \"#{userName}\""
return false
# try to resolve the user using login/firstname -- take the first result (hacky)
user = resolveUsers(userName, data.users)[0]
attributes =
"assigned_to_id": user.id
# allow an optional note with the re-assign
attributes["notes"] = "#{msg.message.user.name}: #{note}" if note?
# get our issue
redmine.Issue(id).update attributes, (err, data, status) ->
unless data?
if status == 404
msg.reply "Issue ##{id} doesn't exist."
else
msg.reply "There was an error assigning this issue."
else
msg.reply "Assigned ##{id} to #{user.firstname}."
msg.send '/play trombone' if parseInt(id) == 3631
# Robot redmine me <issue>
robot.respond /(?:redmine|show)(?: me)? (?:issue )?(?:#)?(\d+)/i, (msg) ->
id = msg.match[1]
params =
"include": "journals"
redmine.Issue(id).show params, (err, data, status) ->
unless status == 200
msg.reply "Issue ##{id} doesn't exist."
return false
issue = data.issue
_ = []
_.push "\n[#{issue.project.name} - #{issue.priority.name}] #{issue.tracker.name} ##{issue.id} (#{issue.status.name})"
_.push "Assigned: #{issue.assigned_to?.name ? 'Nobody'} (opened by #{issue.author.name})"
if issue.status.name.toLowerCase() != 'new'
_.push "Progress: #{issue.done_ratio}% (#{issue.spent_hours} hours)"
_.push "Subject: #{issue.subject}"
_.push "\n#{issue.description}"
# journals
_.push "\n" + Array(10).join('-') + '8<' + Array(50).join('-') + "\n"
for journal in issue.journals
do (journal) ->
if journal.notes? and journal.notes != ""
date = formatDate journal.created_on, 'mm/dd/yyyy (hh:ii ap)'
_.push "#{journal.user.name} on #{date}:"
_.push " #{journal.notes}\n"
msg.reply _.join "\n"
# Listens to #NNNN and gives ticket info
robot.hear /.*(#(\d+)).*/, (msg) ->
id = msg.match[1].replace /#/, ""
ignoredUsers = process.env.HUBOT_REDMINE_IGNORED_USERS or ""
#Ignore cetain users, like Redmine plugins
if msg.message.user.name in ignoredUsers.split(',')
return
if isNaN(id)
return
params = []
redmine.Issue(id).show params, (err, data, status) ->
unless status == 200
# Issue not found, don't say anything
return false
issue = data.issue
url = "#{redmine.url}/issues/#{id}"
msg.send "#{issue.tracker.name} <a href=\"#{url}\">##{issue.id}</a> (#{issue.project.name}): #{issue.subject} (#{issue.status.name}) [#{issue.priority.name}]"
# simple ghetto fab date formatter this should definitely be replaced, but didn't want to
# introduce dependencies this early
#
# dateStamp - any string that can initialize a date
# fmt - format string that may use the following elements
# mm - month
# dd - day
# yyyy - full year
# hh - hours
# ii - minutes
# ss - seconds
# ap - am / pm
#
# returns the formatted date
formatDate = (dateStamp, fmt = 'mm/dd/yyyy at hh:ii ap') ->
d = new Date(dateStamp)
# split up the date
[m,d,y,h,i,s,ap] =
[d.getMonth() + 1, d.getDate(), d.getFullYear(), d.getHours(), d.getMinutes(), d.getSeconds(), 'AM']
# leadig 0s
i = "0#{i}" if i < 10
s = "0#{s}" if s < 10
# adjust hours
if h > 12
h = h - 12
ap = "PM"
# ghetto fab!
fmt
.replace(/mm/, m)
.replace(/dd/, d)
.replace(/yyyy/, y)
.replace(/hh/, h)
.replace(/ii/, i)
.replace(/ss/, s)
.replace(/ap/, ap)
# tries to resolve ambiguous users by matching login or firstname
# redmine's user search is pretty broad (using login/name/email/etc.) so
# we're trying to just pull it in a bit and get a single user
#
# name - this should be the name you're trying to match
# data - this is the array of users from redmine
#
# returns an array with a single user, or the original array if nothing matched
resolveUsers = (name, data) ->
name = name.toLowerCase();
# try matching login
found = data.filter (user) -> user.login.toLowerCase() == name
return found if found.length == 1
# try first name
found = data.filter (user) -> user.firstname.toLowerCase() == name
return found if found.length == 1
# give up
data
# Redmine API Mapping
# This isn't 100% complete, but its the basics for what we would need in campfire
class Redmine
constructor: (url, token) ->
@url = url
@token = token
Users: (params, callback) ->
@get "/users.json", params, callback
User: (id) ->
show: (callback) =>
@get "/users/#{id}.json", {}, callback
Projects: (params, callback) ->
@get "/projects.json", params, callback
Issues: (params, callback) ->
@get "/issues.json", params, callback
Issue: (id) ->
show: (params, callback) =>
@get "/issues/#{id}.json", params, callback
update: (attributes, callback) =>
@put "/issues/#{id}.json", {issue: attributes}, callback
add: (attributes, callback) =>
@post "/issues.json", {issue: attributes}, callback
TimeEntry: (id = null) ->
create: (attributes, callback) =>
@post "/time_entries.json", {time_entry: attributes}, callback
# Private: do a GET request against the API
get: (path, params, callback) ->
path = "#{path}?#{QUERY.stringify params}" if params?
@request "GET", path, null, callback
# Private: do a POST request against the API
post: (path, body, callback) ->
@request "POST", path, body, callback
# Private: do a PUT request against the API
put: (path, body, callback) ->
@request "PUT", path, body, callback
# Private: Perform a request against the redmine REST API
# from the campfire adapter :)
request: (method, path, body, callback) ->
headers =
"Content-Type": "application/json"
"X-Redmine-API-Key": @token
endpoint = URL.parse(@url)
pathname = endpoint.pathname.replace /^\/$/, ''
options =
"host" : endpoint.hostname
"port" : endpoint.port
"path" : "#{pathname}#{path}"
"method" : method
"headers": headers
if method in ["POST", "PUT"]
if typeof(body) isnt "string"
body = JSON.stringify body
options.headers["Content-Length"] = body.length
request = HTTP.request options, (response) ->
data = ""
response.on "data", (chunk) ->
data += chunk
response.on "end", ->
switch response.statusCode
when 200
try
callback null, JSON.parse(data), response.statusCode
catch err
callback null, (data or { }), response.statusCode
when 401
throw new Error "401: Authentication failed."
else
console.error "Code: #{response.statusCode}"
callback null, null, response.statusCode
response.on "error", (err) ->
console.error "Redmine response error: #{err}"
callback err, null, response.statusCode
if method in ["POST", "PUT"]
request.end(body, 'binary')
else
request.end()
request.on "error", (err) ->
console.error "Redmine request error: #{err}"
callback err, null, 0
Jump to Line
Something went wrong with that request. Please try again.