forked from github/hubot-scripts
-
Notifications
You must be signed in to change notification settings - Fork 0
/
redmine.coffee
417 lines (334 loc) · 12.7 KB
/
redmine.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
# 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