roust 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/roust.rb ADDED
@@ -0,0 +1,856 @@
1
+ #!/usr/bin/ruby
2
+
3
+ require 'rest_client'
4
+ require 'mail'
5
+ require 'mime/types' # requires both nokogiri and rcov. Yuck.
6
+ require 'date'
7
+ require 'tmpdir'
8
+
9
+ ##A ruby library API to Request Tracker's REST interface. Requires the
10
+ ##rubygems rest-client, tmail and mime-types to be installed. You can
11
+ ##create a file name .rtclientrc in the same directory as client.rb with a
12
+ ##default server/user/pass to connect to RT as, so that you don't have to
13
+ ##specify it/update it in lots of different scripts.
14
+ ##
15
+ ## Thanks to Brian McArdle for patch dealing with spaces in Custom Fields.
16
+ ## To reference custom fields in RT that have spaces with rt-client, use an
17
+ ## underscore in the rt-client code, e.g. "CF.{Has_Space}"
18
+ ##
19
+ #
20
+ # See each method for sample usage. To use this, "gem install roust" and
21
+ #
22
+ # require "roust"
23
+
24
+ class Roust
25
+
26
+ UA = "Mozilla/5.0 ruby RT Client Interface 0.4.0"
27
+ attr_reader :status, :site, :version, :cookies, :server, :user, :cookie
28
+
29
+ # Create a new RT_Client object. Load up our stored cookie and check it.
30
+ # Log into RT again if needed and store the new cookie. You can specify
31
+ # login and cookie storage directories in 3 different ways:
32
+ # 1. Explicity during object creation
33
+ # 2. From a .rtclientrc file in the working directory of your ruby program
34
+ # 3. From a .rtclientrc file in the same directory as the library itself
35
+ #
36
+ # These are listed in order of priority; if you have explicit parameters,
37
+ # they are always used, even if you have .rtclientrc files. If there
38
+ # is both an .rtclientrc in your program's working directory and
39
+ # in the library directory, the one from your program's working directory
40
+ # is used. If no parameters are specified either explicity or by use
41
+ # of a .rtclientrc, then the defaults of "rt_user", "rt_pass" are used
42
+ # with a default server of "http://localhost", and cookies are stored
43
+ # in the directory where the library resides.
44
+ #
45
+ # rt= RT_Client.new( :server => "https://tickets.ambulance.com/",
46
+ # :user => "rt_user",
47
+ # :pass => "rt_pass",
48
+ # :cookies => "/my/cookie/dir" )
49
+ #
50
+ # rt= RT_Client.new # use defaults from .rtclientrc
51
+ #
52
+ # .rtclientrc format:
53
+ # server=<RT server>
54
+ # user=<RT user>
55
+ # pass=<RT password>
56
+ # cookies=<directory>
57
+ def initialize(opts={})
58
+ @options = opts
59
+
60
+ @boundary = "----xYzZY#{rand(1000000).to_s}xYzZY"
61
+ @version = "0.4.0"
62
+ @status = "Not connected"
63
+ @cookies = Dir.mktmpdir
64
+ @username = @options[:username]
65
+ @password = @options[:password]
66
+ @server = @options[:server]
67
+ @resource = "#{@server}/REST/1.0/"
68
+ @rtname = URI.parse(@server).host
69
+ @headers = {}
70
+ @last_response = ""
71
+
72
+ authenticate!
73
+ end
74
+
75
+ def callback
76
+ Proc.new do |response, request, result|
77
+ # p [ 'callback', response, request, result ]
78
+ @last_response = response
79
+ end
80
+ end
81
+
82
+ def authenticate!
83
+ @site = RestClient::Resource.new(@resource, &callback)
84
+ login = {
85
+ :user => @username,
86
+ :pass => @password
87
+ }
88
+
89
+ response = @site.post(login)
90
+ if response =~ /401 Credentials required/
91
+ raise "Unauthenticated"
92
+ else
93
+ headers = {
94
+ :cookies => response.cookies
95
+ }
96
+ end
97
+
98
+ @site = RestClient::Resource.new(@resource, headers, &callback)
99
+
100
+ response = @site['ticket/1/show'].get
101
+ authenticated?
102
+ end
103
+
104
+ def authenticated?
105
+ return false if @last_response.empty?
106
+ !(@last_response =~ /401 Credentials required/)
107
+ end
108
+
109
+ # gets the detail for a single ticket/user. If its a ticket, its without
110
+ # history or attachments (to get those use the history method) . If no
111
+ # type is specified, ticket is assumed. takes a single parameter
112
+ # containing the ticket/user id, and returns a hash of RT Fields => values
113
+ #
114
+ # hash = rt.show(822)
115
+ # hash = rt.show("822")
116
+ # hash = rt.show("ticket/822")
117
+ # hash = rt.show("user/#{login}")
118
+ # email = rt.show("user/somebody")["emailaddress"]
119
+ #
120
+ def show(id)
121
+ type = "ticket"
122
+ if id =~ /(\w+)\/(.+)/
123
+ type = $~[1].downcase
124
+ id = $~[2]
125
+ end
126
+
127
+ url = "#{type}/#{id}"
128
+ url << '/show' unless type == 'user'
129
+
130
+ response = @site[url].get
131
+
132
+ # Toss the HTTP response. Removes a string like this from before the header:
133
+ #
134
+ # RT/3.4.6 200 Ok
135
+ #
136
+ response.gsub!(/RT\/\d+\.\d+\.\d+\s\d{3}\s.*\n\n/,"")
137
+
138
+ # Replace CF spaces with underscores
139
+ while response.match(/CF\.\{[\w_ ]*[ ]+[\w ]*\}/)
140
+ response.gsub!(/CF\.\{([\w_ ]*)([ ]+)([\w ]*)\}/, 'CF.{\1_\3}')
141
+ end
142
+
143
+ return {:error => response } if response =~ /does not exist./
144
+
145
+ # Sometimes the API returns requestors formatted like this:
146
+ #
147
+ # Requestors: foo@example.org,
148
+ # bar@example.org, baz@example.org
149
+ # qux@example.org, quux@example.org,
150
+ # corge@example.org
151
+ #
152
+ # Turn it into this:
153
+ #
154
+ # Requestors: foo@example.org, bar@example.org, baz@example.org, ...
155
+ #
156
+ response.gsub!(/\n\n/, "\n")
157
+
158
+ %w(Requestors Cc AdminCc).each do |field|
159
+ response.gsub!(/^#{field}:(.+)^\n/m) do |match|
160
+ match.strip.split(/,\s+/).join(', ').strip
161
+ end
162
+ end
163
+
164
+ message = Mail.new(response)
165
+
166
+ hash = Hash[message.header.fields.map {|header|
167
+ key = header.name.to_s.downcase
168
+ value = header.value.to_s
169
+ [ key, value ]
170
+ }]
171
+
172
+ %w(requestors cc admincc).each do |field|
173
+ hash[field] = hash[field].split(', ') if hash[field]
174
+ end
175
+
176
+ hash
177
+ end
178
+
179
+ # gets a list of ticket links for a ticket.
180
+ # takes a single parameter containing the ticket id,
181
+ # and returns a hash of RT Fields => values
182
+ #
183
+ # hash = rt.links(822)
184
+ # hash = rt.links("822")
185
+ # hash = rt.links("ticket/822")
186
+ # hash = rt.links(:id => 822)
187
+ # hash = rt.links(:id => "822")
188
+ # hash = rt.links(:id => "ticket/822")
189
+ def links(id)
190
+ id = id[:id] if id.class == Hash
191
+ id = id.to_s
192
+ type = "ticket"
193
+ sid = id
194
+ if id =~ /(\w+)\/(.+)/
195
+ type = $~[1]
196
+ sid = $~[2]
197
+ end
198
+ reply = {}
199
+ resp = @site["ticket/#{sid}/links/show"].get
200
+ resp.gsub!(/RT\/\d+\.\d+\.\d+\s\d{3}\s.*\n\n/,"") # toss the HTTP response
201
+ resp.gsub!(/\n\n/,"\n") # remove double spacing, TMail stops at a blank line
202
+ while resp.match(/CF\.\{[\w_ ]*[ ]+[\w ]*\}/) #replace CF spaces with underscores
203
+ resp.gsub!(/CF\.\{([\w_ ]*)([ ]+)([\w ]*)\}/, 'CF.{\1_\3}')
204
+ end
205
+ return {:error => resp, } if resp =~ /does not exist./
206
+ th = TMail::Mail.parse(resp)
207
+ th.each_header do |k,v|
208
+ reply["#{k}"] = v.to_s
209
+ end
210
+ reply
211
+ end
212
+
213
+ # Creates a new ticket. Requires a hash that contains RT form fields as
214
+ # the keys. Capitalization is important; use :Queue, not :queue. You
215
+ # will need at least :Queue to create a ticket. For a full list of fields
216
+ # you can use, try "/opt/rt3/bin/rt edit ticket/1". Returns the newly
217
+ # created ticket number, or a complete REST response.
218
+ #
219
+ # id = rt.create( :Queue => "Customer Service",
220
+ # :Cc => "somebody\@email.com",
221
+ # :Subject => "I've fallen and I can't get up",
222
+ # :Text => "I think my hip is broken.\nPlease help.",
223
+ # :"CF.{CustomField}" => "Urgent",
224
+ # :Attachment => "/tmp/broken_hip.jpg" )
225
+ def create(field_hash)
226
+ field_hash[:id] = "ticket/new"
227
+ payload = compose(field_hash)
228
+ puts "Payload for new ticket:"
229
+ puts payload
230
+ resp = @site['ticket/new/edit'].post payload
231
+ new_id = resp.match(/Ticket\s*(\d+)/)
232
+ if new_id.class == MatchData
233
+ new_ticket = new_id[1]
234
+ else
235
+ new_ticket = resp
236
+ end
237
+ new_ticket # return the ticket number, or the full REST response
238
+ end
239
+
240
+ # create a new user. Requires a hash of RT fields => values. Returns
241
+ # the newly created user ID, or the full REST response if there is an error.
242
+ # For a full list of possible parameters that you can specify, look at
243
+ # "/opt/rt/bin/rt edit user/1"
244
+ #
245
+ # new_id = rt.create_user(:Name => "Joe_Smith", :EmailAddress => "joes\@here.com")
246
+ def create_user(field_hash)
247
+ field_hash[:id] = "user/new"
248
+ payload = compose(field_hash)
249
+ resp = @site['user/new/edit'].post payload
250
+ new_id = resp.match(/User\s*(\d+)/)
251
+ if new_id.class == MatchData
252
+ new_user = new_id[1]
253
+ else
254
+ new_user = resp
255
+ end
256
+ new_user # return the new user id or the full REST response
257
+ end
258
+
259
+ # edit or create a user. If the user exists, edits the user as "edit" would.
260
+ # If the user doesn't exist, creates it as "create_user" would.
261
+ def edit_or_create_user(field_hash)
262
+ if field_hash.has_key? :id
263
+ id = field_hash[:id]
264
+ if id !~ /^user\//
265
+ id = "user/#{id}"
266
+ field_hash[:id] = id
267
+ end
268
+ else
269
+ raise "RT_Client.edit_or_create_user require a user id in the 'id' key."
270
+ end
271
+ resp1 = "not called"
272
+ resp2 = "not called"
273
+ resp1 = edit(field_hash)
274
+ resp2 = create_user(field_hash) if resp1 =~ /does not exist./
275
+ resp = "Edit: #{resp1}\nCreate:#{resp2}"
276
+ resp
277
+ end
278
+
279
+ # edit an existing ticket/user. Requires a hash containing RT
280
+ # form fields as keys. the key :id is required.
281
+ # returns the complete REST response, whatever it is. If the
282
+ # id supplied contains "user/", it edits a user, otherwise
283
+ # it edits a ticket. For a full list of fields you can edit,
284
+ # try "/opt/rt3/bin/rt edit ticket/1"
285
+ #
286
+ # resp = rt.edit(:id => 822, :Status => "resolved")
287
+ # resp = rt.edit(:id => ticket_id, :"CF.{CustomField}" => var)
288
+ # resp = rt.edit(:id => "user/someone", :EMailAddress => "something@here.com")
289
+ # resp = rt.edit(:id => "user/bossman", :Password => "mypass")
290
+ # resp = rt.edit(:id => "user/4306", :Disabled => "1")
291
+ def edit(field_hash)
292
+ if field_hash.has_key? :id
293
+ id = field_hash[:id]
294
+ else
295
+ raise "RT_Client.edit requires a user or ticket id in the 'id' key."
296
+ end
297
+ type = "ticket"
298
+ sid = id
299
+ if id =~ /(\w+)\/(.+)/
300
+ type = $~[1]
301
+ sid = $~[2]
302
+ end
303
+ payload = compose(field_hash)
304
+ resp = @site["#{type}/#{sid}/edit"].post payload
305
+ resp
306
+ end
307
+
308
+ # Comment on a ticket. Requires a hash, which must have an :id key
309
+ # containing the ticket number. Returns the REST response. For a list of
310
+ # fields you can use in a comment, try "/opt/rt3/bin/rt comment ticket/1"
311
+ #
312
+ # rt.comment( :id => id,
313
+ # :Text => "Oh dear, I wonder if the hip smells like almonds?",
314
+ # :Attachment => "/tmp/almonds.gif" )
315
+ def comment(field_hash)
316
+ if field_hash.has_key? :id
317
+ id = field_hash[:id]
318
+ else
319
+ raise "RT_Client.comment requires a Ticket number in the 'id' key."
320
+ end
321
+ field_hash[:Action] = "comment"
322
+ payload = compose(field_hash)
323
+ @site["ticket/#{id}/comment"].post payload
324
+ end
325
+
326
+ ## MICK
327
+ def add_link(field_hash)
328
+ if field_hash.has_key? :id
329
+ id = field_hash[:id]
330
+ else
331
+ raise "RT_Client.comment requires a Ticket number in the 'id' key."
332
+ end
333
+
334
+ field_hash = Hash[field_hash.map do |key, value|
335
+ if key == :id then
336
+ [ key, value ]
337
+ else
338
+ expanded_value = "fsck.com-rt://#{@rtname}/ticket/#{value}"
339
+ [ key, expanded_value ]
340
+ end
341
+ end]
342
+
343
+ payload = compose(field_hash)
344
+ @site["ticket/#{id}/links"].post payload
345
+ end
346
+ ## MICK
347
+
348
+ # Find RT user details from an email address
349
+ #
350
+ # rt.user(:EmailAddress => 'some@email.com')
351
+ # => {"name"=>"rtlogin", "realname"=>"John Smith", "address1"=>"123 Main", etc }
352
+ def user(email)
353
+ resp = @site["user/#{email}"].get
354
+ resp.gsub!(/RT\/\d+\.\d+\.\d+\s\d{3}\s.*\n\n/,"") # toss the HTTP response
355
+ resp.gsub!(/\n\n/, "\n")
356
+
357
+ if resp =~ /No user named/
358
+ return {}
359
+ else
360
+ message = Mail.new(resp)
361
+ hash = Hash[message.header.fields.map {|header|
362
+ key = header.name.to_s.downcase
363
+ value = header.value.to_s
364
+ [ key, value ]
365
+ }]
366
+ # FIXME: correctly handle signatures
367
+ hash
368
+ end
369
+ end
370
+
371
+ # correspond on a ticket. Requires a hash, which must have an :id key
372
+ # containing the ticket number. Returns the REST response. For a list of
373
+ # fields you can use in correspondence, try "/opt/rt3/bin/rt correspond
374
+ # ticket/1"
375
+ #
376
+ # rt.correspond( :id => id,
377
+ # :Text => "We're sending help right away.",
378
+ # :Attachment => "/tmp/admittance.doc" )
379
+ def correspond(field_hash)
380
+ if field_hash.has_key? :id
381
+ if field_hash[:id] =~ /ticket\/(\d+)/
382
+ id = $~[1]
383
+ else
384
+ id = field_hash[:id]
385
+ end
386
+ else
387
+ raise "RT_Client.correspond requires a Ticket number in the 'id' key."
388
+ end
389
+ field_hash[:Action] = "correspond"
390
+ payload = compose(field_hash)
391
+ @site["ticket/#{id}/comment"].post payload
392
+ end
393
+
394
+ # Get a list of tickets matching some criteria.
395
+ # Takes a string Ticket-SQL query and an optional "order by" parameter.
396
+ # The order by is an RT field, prefix it with + for ascending
397
+ # or - for descending.
398
+ # Returns a nested array of arrays containing [ticket number, subject]
399
+ # The outer array is in the order requested.
400
+ #
401
+ # hash = rt.list(:query => "Queue = 'Sales'")
402
+ # hash = rt.list("Queue='Sales'")
403
+ # hash = rt.list(:query => "Queue = 'Sales'", :order => "-Id")
404
+ # hash = rt.list("Queue='Sales'","-Id")
405
+ def list(*params)
406
+ query = params[0]
407
+ order = ""
408
+ if params.size > 1
409
+ order = params[1]
410
+ end
411
+ if params[0].class == Hash
412
+ params = params[0]
413
+ query = params[:query] if params.has_key? :query
414
+ order = params[:order] if params.has_key? :order
415
+ end
416
+ reply = []
417
+
418
+ url = "search/ticket/?query=#{URI.escape(query)}&orderby=#{order}&format=s"
419
+ resp = @site[url].get
420
+ raise "Unauthenticated" if resp =~ /401 Credentials required/
421
+ raise "Invalid query (#{query})" if resp =~ /Invalid query/
422
+ resp = resp.split("\n") # convert to array of lines
423
+ resp.each do |line|
424
+ f = line.match(/^(\d+):\s*(.*)/)
425
+ reply.push [f[1],f[2]] if f.class == MatchData
426
+ end
427
+ reply
428
+ end
429
+
430
+ # A more extensive(expensive) query then the list method. Takes the same
431
+ # parameters as the list method; a string Ticket-SQL query and optional
432
+ # order, but returns a lot more information. Instead of just the ID and
433
+ # subject, you get back an array of hashes, where each hash represents
434
+ # one ticket, indentical to what you get from the show method (which only
435
+ # acts on one ticket). Use with caution; this can take a long time to
436
+ # execute.
437
+ #
438
+ # array = rt.query("Queue='Sales'")
439
+ # array = rt.query(:query => "Queue='Sales'",:order => "+Id")
440
+ # array = rt.query("Queue='Sales'","+Id")
441
+ # => array[0] = { "id" => "123", "requestors" => "someone@..", etc etc }
442
+ # => array[1] = { "id" => "126", "requestors" => "someone@else..", etc etc }
443
+ # => array[0]["id"] = "123"
444
+ def query(*params)
445
+ query = params[0]
446
+ order = ""
447
+ if params.size > 1
448
+ order = params[1]
449
+ end
450
+ if params[0].class == Hash
451
+ params = params[0]
452
+ query = params[:query] if params.has_key? :query
453
+ order = params[:order] if params.has_key? :order
454
+ end
455
+ replies = []
456
+ resp = @site["search/ticket/?query=#{URI.escape(query)}&orderby=#{order}&format=l"].get
457
+ return replies if resp =~/No matching results./
458
+ raise "Invalid query (#{query})" if resp =~ /Invalid query/
459
+ resp.gsub!(/RT\/\d+\.\d+\.\d+\s\d{3}\s.*\n\n/,"") # strip HTTP response
460
+ tickets = resp.split("\n--\n") # -- occurs between each ticket
461
+ tickets.each do |ticket|
462
+ ticket.gsub!(/^\n/,"") # strip leading blank lines
463
+ ticket.gsub!(/\n\n/,"\n") # remove blank lines for TMail
464
+ while ticket.match(/CF\.\{[\w_ ]*[ ]+[\w ]*\}/) #replace CF spaces with underscores
465
+ ticket.gsub!(/CF\.\{([\w_ ]*)([ ]+)([\w ]*)\}/, 'CF.{\1_\3}')
466
+ end
467
+ th = TMail::Mail.parse(ticket)
468
+ reply = {}
469
+ th.each_header do |k,v|
470
+ case k
471
+ when 'created','due','told','lastupdated','started'
472
+ begin
473
+ vv = DateTime.parse(v.to_s)
474
+ reply["#{k}"] = vv.strftime("%Y-%m-%d %H:%M:%S")
475
+ rescue ArgumentError
476
+ reply["#{k}"] = v.to_s
477
+ end
478
+ else
479
+ reply["#{k}"] = v.to_s
480
+ end
481
+ end
482
+ replies.push reply
483
+ end
484
+ replies
485
+ end
486
+
487
+ # Get a list of history transactions for a ticket. Takes a ticket ID and
488
+ # an optional format parameter. If the format is ommitted, the short
489
+ # format is assumed. If the short format is requested, it returns an
490
+ # array of 2 element arrays, where each 2-element array is [ticket_id,
491
+ # description]. If the long format is requested, it returns an array of
492
+ # hashes, where each hash contains the keys:
493
+ #
494
+ # id:: (history-id)
495
+ # Ticket:: (Ticket this history item belongs to)
496
+ # TimeTaken:: (time entered by the actor that generated this item)
497
+ # Type:: (what type of history item this is)
498
+ # Field:: (what field is affected by this item, if any)
499
+ # OldValue:: (the old value of the Field)
500
+ # NewValue:: (the new value of the Field)
501
+ # Data:: (Additional data about the item)
502
+ # Description:: (Description of this item; same as short format)
503
+ # Content:: (The content of this item)
504
+ # Creator:: (the RT user that created this item)
505
+ # Created:: (Date/time this item was created)
506
+ # Attachments:: (a hash describing attachments to this item)
507
+ #
508
+ # history = rt.history(881)
509
+ # => [["10501"," Ticket created by blah"],["10510"," Comments added by userX"]]
510
+ # history = rt.history(:id => 881, :format => "long")
511
+ # => [{"id" => "6171", "ticket" => "881" ....}, {"id" => "6180", ...} ]
512
+ def history(id, opts={})
513
+ options = {
514
+ :format => 'short',
515
+ :comments => false
516
+ }.merge(opts)
517
+
518
+ format = options[:format]
519
+ fmt = format[0]
520
+
521
+ comments = options[:comments]
522
+
523
+ resp = @site["ticket/#{id}/history?format=#{fmt}"].get
524
+
525
+ if fmt == "s"
526
+ regex = comments ? '^\d+:' : '^\d+: [^Comments]'
527
+ h = resp.split("\n").select{ |l| l =~ /#{regex}/ }
528
+ list = h.map { |l| l.split(": ", 2) }
529
+ else
530
+ resp.gsub!(/RT\/\d+\.\d+\.\d+\s\d{3}\s.*\n\n/,"") # toss the HTTP response
531
+ resp.gsub!(/^#.*?\n\n/,"") # toss the 'total" line
532
+ resp.gsub!(/^\n/m,"") # toss blank lines
533
+ while resp.match(/CF\.\{[\w_ ]*[ ]+[\w ]*\}/) #replace CF spaces with underscores
534
+ resp.gsub!(/CF\.\{([\w_ ]*)([ ]+)([\w ]*)\}/, 'CF.{\1_\3}')
535
+ end
536
+ items = resp.split("\n--\n")
537
+
538
+ list = []
539
+ items.each do |item|
540
+ th = Mail.new(item)
541
+ next if not comments and th["type"].to_s =~ /Comment/ # skip comments
542
+ reply = {}
543
+ th.header.fields.each do |header|
544
+ k = header.name.to_s.downcase
545
+ v = header.value.to_s
546
+
547
+ attachments = []
548
+ case k
549
+ when "attachments"
550
+ temp = item.match(/Attachments:\s*(.*)/m)
551
+ if temp.class != NilClass
552
+ atarr = temp[1].split("\n")
553
+ atarr.map { |a| a.gsub!(/^\s*/,"") }
554
+ atarr.each do |a|
555
+ i = a.match(/(\d+):\s*(.*)/)
556
+ s={}
557
+ s[:id] = i[1].to_s
558
+ s[:name] = i[2].to_s
559
+ sz = i[2].match(/(.*?)\s*\((.*?)\)/)
560
+ if sz.class == MatchData
561
+ s[:name] = sz[1].to_s
562
+ s[:size] = sz[2].to_s
563
+ end
564
+ attachments.push s
565
+ end
566
+ reply["attachments"] = attachments
567
+ end
568
+ when "content"
569
+ reply["content"] = v.to_s
570
+ #temp = item.match(/^Content: (.*?)^\w+:/m) # TMail strips line breaks
571
+ #reply["content"] = temp[1] if temp.class != NilClass
572
+ else
573
+ reply["#{k}"] = v.to_s
574
+ end
575
+ end
576
+ list.push(reply)
577
+ end
578
+ end
579
+ list
580
+ end
581
+
582
+ # Get the detail for a single history item. Needs a ticket ID and a
583
+ # history item ID, returns a hash of RT Fields => values. The hash
584
+ # also contains a special key named "attachments", whose value is
585
+ # an array of hashes, where each hash represents an attachment. The hash
586
+ # keys are :id, :name, and :size.
587
+ #
588
+ # x = rt.history_item(21, 6692)
589
+ # x = rt.history_item(:id => 21, :history => 6692)
590
+ # => x = {"ticket" => "21", "creator" => "somebody", "description" =>
591
+ # => "something happened", "attachments" => [{:name=>"file.txt",
592
+ # => :id=>"3289", size=>"651b"}, {:name=>"another.doc"... }]}
593
+ def history_item(*params)
594
+ id = params[0]
595
+ history = params[1]
596
+ if params[0].class == Hash
597
+ params = params[0]
598
+ id = params[:id] if params.has_key? :id
599
+ history = params[:history] if params.has_key? :history
600
+ end
601
+ reply = {}
602
+ resp = @site["ticket/#{id}/history/id/#{history}"].get
603
+ return reply if resp =~ /not related/ # history id must be related to the ticket id
604
+ resp.gsub!(/RT\/\d+\.\d+\.\d+\s\d{3}\s.*\n\n/,"") # toss the HTTP response
605
+ resp.gsub!(/^#.*?\n\n/,"") # toss the 'total" line
606
+ resp.gsub!(/^\n/m,"") # toss blank lines
607
+ while resp.match(/CF\.\{[\w_ ]*[ ]+[\w ]*\}/) #replace CF spaces with underscores
608
+ resp.gsub!(/CF\.\{([\w_ ]*)([ ]+)([\w ]*)\}/, 'CF.{\1_\3}')
609
+ end
610
+ th = TMail::Mail.parse(resp)
611
+ attachments = []
612
+ th.each_header do |k,v|
613
+ case k
614
+ when "attachments"
615
+ temp = resp.match(/Attachments:\s*(.*)[^\w|$]/m)
616
+ if temp.class != NilClass
617
+ atarr = temp[1].split("\n")
618
+ atarr.map { |a| a.gsub!(/^\s*/,"") }
619
+ atarr.each do |a|
620
+ i = a.match(/(\d+):\s*(.*)/)
621
+ s={}
622
+ s[:id] = i[1]
623
+ s[:name] = i[2]
624
+ sz = i[2].match(/(.*?)\s*\((.*?)\)/)
625
+ if sz.class == MatchData
626
+ s[:name] = sz[1]
627
+ s[:size] = sz[2]
628
+ end
629
+ attachments.push s
630
+ end
631
+ reply["#{k}"] = attachments
632
+ end
633
+ when "content"
634
+ reply["content"] = v.to_s
635
+ temp = resp.match(/^Content: (.*?)^\w+:/m) # TMail strips line breaks
636
+ reply["content"] = temp[1] if temp.class != NilClass
637
+ else
638
+ reply["#{k}"] = v.to_s
639
+ end
640
+ end
641
+ reply
642
+ end
643
+
644
+ # Get a list of attachments related to a ticket.
645
+ # Requires a ticket id, returns an array of hashes where each hash
646
+ # represents one attachment. Hash keys are :id, :name, :type, :size.
647
+ # You can optionally request that unnamed attachments be included,
648
+ # the default is to not include them.
649
+ def attachments(*params)
650
+ id = params[0]
651
+ unnamed = params[1]
652
+ if params[0].class == Hash
653
+ params = params[0]
654
+ id = params[:id] if params.has_key? :id
655
+ unnamed = params[:unnamed] if params.has_key? :unnamed
656
+ end
657
+ unnamed = false if unnamed.to_s == "0"
658
+ id = $~[1] if id =~ /ticket\/(\d+)/
659
+ resp = @site["ticket/#{id}/attachments"].get
660
+ resp.gsub!(/RT\/\d+\.\d+\.\d+\s\d{3}\s.*\n\n/,"") # toss the HTTP response
661
+ resp.gsub!(/^\n/m,"") # toss blank lines
662
+ while resp.match(/CF\.\{[\w_ ]*[ ]+[\w ]*\}/) #replace CF spaces with underscores
663
+ resp.gsub!(/CF\.\{([\w_ ]*)([ ]+)([\w ]*)\}/, 'CF.{\1_\3}')
664
+ end
665
+ th = TMail::Mail.parse(resp)
666
+ list = []
667
+ pattern = /(\d+:\s.*?\)),/
668
+ match = pattern.match(th['attachments'].to_s)
669
+ while match != nil
670
+ list.push match[0]
671
+ s = match.post_match
672
+ match = pattern.match(s)
673
+ end
674
+ attachments = []
675
+ list.each do |v|
676
+ attachment = {}
677
+ m=v.match(/(\d+):\s+(.*?)\s+\((.*?)\s+\/\s+(.*?)\)/)
678
+ if m.class == MatchData
679
+ next if m[2] == "(Unnamed)" and !unnamed
680
+ attachment[:id] = m[1]
681
+ attachment[:name] = m[2]
682
+ attachment[:type] = m[3]
683
+ attachment[:size] = m[4]
684
+ attachments.push attachment
685
+ end
686
+ end
687
+ attachments
688
+ end
689
+
690
+ # Get attachment content for single attachment. Requires a ticket ID
691
+ # and an attachment ID, which must be related. If a directory parameter
692
+ # is supplied, the attachment is written to that directory. If not,
693
+ # the attachment content is returned in the hash returned by the
694
+ # function as the key 'content', along with some other keys you always get:
695
+ #
696
+ # transaction:: the transaction id
697
+ # creator:: the user id number who attached it
698
+ # id:: the attachment id
699
+ # filename:: the name of the file
700
+ # contenttype:: MIME content type of the attachment
701
+ # created:: date of the attachment
702
+ # parent:: an attachment id if this was an embedded MIME attachment
703
+ #
704
+ # x = get_attachment(21,3879)
705
+ # x = get_attachment(:ticket => 21, :attachment => 3879)
706
+ # x = get_attachment(:ticket => 21, :attachment => 3879, :dir = "/some/dir")
707
+ def get_attachment(*params)
708
+ tid = params[0]
709
+ aid = params[1]
710
+ dir = nil
711
+ dir = params[2] if params.size > 2
712
+ if params[0].class == Hash
713
+ params = params[0]
714
+ tid = params[:ticket] if params.has_key? :ticket
715
+ aid = params[:attachment] if params.has_key? :attachment
716
+ dir = params[:dir] if params.has_key? :dir
717
+ end
718
+ tid = $~[1] if tid =~ /ticket\/(\d+)/
719
+ resp = @site["ticket/#{tid}/attachments/#{aid}"].get
720
+ resp.gsub!(/RT\/\d+\.\d+\.\d+\s\d{3}\s.*\n\n/,"") # toss HTTP response
721
+ while resp.match(/CF\.\{[\w_ ]*[ ]+[\w ]*\}/) #replace CF spaces with underscores
722
+ resp.gsub!(/CF\.\{([\w_ ]*)([ ]+)([\w ]*)\}/, 'CF.{\1_\3}')
723
+ end
724
+ headers = TMail::Mail.parse(resp)
725
+ reply = {}
726
+ headers.each_header do |k,v|
727
+ reply["#{k}"] = v.to_s
728
+ end
729
+ content = resp.match(/Content:\s+(.*)/m)[1]
730
+ content.gsub!(/\n\s{9}/,"\n") # strip leading spaces on each line
731
+ content.chomp!
732
+ content.chomp!
733
+ content.chomp! # 3 carriage returns at the end
734
+
735
+ binary = content.encode("UTF-8", :invalid => :replace, :undef => :replace, :replace => "?")
736
+ if dir
737
+ fh = File.new("#{dir}/#{headers['Filename'].to_s}","wb")
738
+ fh.write binary
739
+ fh.close
740
+ else
741
+ reply["content"] = binary
742
+ end
743
+ reply
744
+ end
745
+
746
+ # Add a watcher to a ticket, but only if not already a watcher. Takes a
747
+ # ticket ID, an email address (or array of email addresses), and an
748
+ # optional watcher type. If no watcher type is specified, its assumed to
749
+ # be "Cc". Possible watcher types are 'Requestors', 'Cc', and 'AdminCc'.
750
+ #
751
+ # rt.add_watcher(123,"someone@here.com")
752
+ # rt.add_watcher(123,["someone@here.com","another@there.com"])
753
+ # rt.add_watcher(123,"someone@here.com","Requestors")
754
+ # rt.add_watcher(:id => 123, :addr => "someone@here.com")
755
+ # rt.add_watcher(:id => 123, :addr => ["someone@here.com","another@there.com"])
756
+ # rt.add_watcher(:id => 123, :addr => "someone@here.com", :type => "AdminCc")
757
+ def add_watcher(*params)
758
+ tid = params[0]
759
+ addr = []
760
+ type = "cc"
761
+ addr = params[1] if params.size > 1
762
+ type = params[2] if params.size > 2
763
+ if params[0].class == Hash
764
+ params = params[0]
765
+ tid = params[:id] if params.has_key? :id
766
+ addr = params[:addr] if params.has_key? :addr
767
+ type = params[:type] if params.has_key? :type
768
+ end
769
+ addr = addr.to_a.uniq # make it array if its just a string, and remove dups
770
+ type.downcase!
771
+ tobj = show(tid) # get current watchers
772
+ ccs = tobj["cc"].split(", ")
773
+ accs = tobj["admincc"].split(", ")
774
+ reqs = tobj["requestors"].split(", ")
775
+ watchers = ccs | accs | reqs # union of all watchers
776
+ addr.each do |e|
777
+ case type
778
+ when "cc"
779
+ ccs.push(e) if not watchers.include?(e)
780
+ when "admincc"
781
+ accs.push(e) if not watchers.include?(e)
782
+ when "requestors"
783
+ reqs.push(e) if not watchers.include?(e)
784
+ end
785
+ end
786
+ case type
787
+ when "cc"
788
+ edit(:id => tid, :Cc => ccs.join(","))
789
+ when "admincc"
790
+ edit(:id => tid, :AdminCc => accs.join(","))
791
+ when "requestors"
792
+ edit(:id => tid, :Requestors => reqs.join(","))
793
+ end
794
+ end
795
+
796
+ # don't give up the password when the object is inspected
797
+ def inspect # :nodoc:
798
+ mystr = super()
799
+ mystr.gsub!(/(.)pass=.*?([,\}])/,"\\1pass=<hidden>\\2")
800
+ mystr
801
+ end
802
+
803
+ private
804
+
805
+ # Private helper for composing RT's "forms". Requires a hash where the
806
+ # keys are field names for an RT form. If there's a :Text key, the value
807
+ # is modified to insert whitespace on continuation lines. If there's an
808
+ # :Attachment key, the value is assumed to be a comma-separated list of
809
+ # filenames to attach. It returns a multipart MIME body complete
810
+ # with boundaries and headers, suitable for an HTTP POST.
811
+ def compose(fields) # :doc:
812
+ body = ""
813
+ if fields.class != Hash
814
+ raise "RT_Client.compose requires parameters as a hash."
815
+ end
816
+
817
+ # fixup Text field for RFC822 compliance
818
+ if fields.has_key? :Text
819
+ fields[:Text].gsub!(/\n/,"\n ") # insert a space on continuation lines.
820
+ end
821
+
822
+ # attachments
823
+ if fields.has_key? :Attachments
824
+ fields[:Attachment] = fields[:Attachments]
825
+ fields.delete :Attachments
826
+ end
827
+ if fields.has_key? :Attachment
828
+ filenames = fields[:Attachment].split(',')
829
+ i = 0
830
+ filenames.each do |v|
831
+ filename = File.basename(v)
832
+ mime_type = MIME::Types.type_for(v)[0]
833
+ i += 1
834
+ param_name = "attachment_#{i.to_s}"
835
+ body << "--#{@boundary}\r\n"
836
+ body << "Content-Disposition: form-data; "
837
+ body << "name=\"#{URI.escape(param_name.to_s)}\"; "
838
+ body << "filename=\"#{URI.escape(filename)}\"\r\n"
839
+ body << "Content-Type: #{mime_type.simplified}\r\n\r\n"
840
+ body << File.read(v) # oh dear, lets hope you have lots of RAM
841
+ end
842
+ # strip paths from filenames
843
+ fields[:Attachment] = filenames.map {|f| File.basename(f)}.join(',')
844
+ end
845
+ field_array = fields.map { |k,v| "#{k}: #{v}" }
846
+ content = field_array.join("\n") # our form
847
+ pp content
848
+ # add the form to the end of any attachments
849
+ body << "--#{@boundary}\r\n"
850
+ body << "Content-Disposition: form-data; "
851
+ body << "name=\"content\";\r\n\r\n"
852
+ body << content << "\r\n"
853
+ body << "--#{@boundary}--\r\n"
854
+ body
855
+ end
856
+ end