roust 1.0.1 → 1.1.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 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