roust 1.0.1 → 1.1.0

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