roust 1.3.0 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/roust.rb CHANGED
@@ -1,12 +1,16 @@
1
1
  require 'httparty'
2
2
  require 'mail'
3
3
  require 'active_support/core_ext/hash'
4
-
5
- class Unauthenticated < Exception ; end
4
+ require 'roust/ticket'
5
+ require 'roust/queue'
6
+ require 'roust/user'
7
+ require 'roust/exceptions'
6
8
 
7
9
  class Roust
8
10
  include HTTParty
9
- #debug_output
11
+ include Roust::Ticket
12
+ include Roust::Queue
13
+ include Roust::User
10
14
 
11
15
  def initialize(credentials)
12
16
  server = credentials[:server]
@@ -43,358 +47,48 @@ class Roust
43
47
  authenticated?
44
48
  end
45
49
 
46
- def show(id)
47
- response = self.class.get("/ticket/#{id}/show")
48
-
49
- body, status = explode_response(response)
50
-
51
- if match = body.match(/^# (Ticket (\d+) does not exist\.)/)
52
- return { 'error' => match[1] }
53
- end
54
-
55
- # Replace CF spaces with underscores
56
- while body.match(/CF\.\{[\w_ ]*[ ]+[\w ]*\}/)
57
- body.gsub!(/CF\.\{([\w_ ]*)([ ]+)([\w ]*)\}/, 'CF.{\1_\3}')
58
- end
59
-
60
- # Sometimes the API returns requestors formatted like this:
61
- #
62
- # Requestors: foo@example.org,
63
- # bar@example.org, baz@example.org
64
- # qux@example.org, quux@example.org,
65
- # corge@example.org
66
- #
67
- # Turn it into this:
68
- #
69
- # Requestors: foo@example.org, bar@example.org, baz@example.org, ...
70
- #
71
- body.gsub!(/\n\n/, "\n")
72
-
73
- %w(Requestors Cc AdminCc).each do |field|
74
- body.gsub!(/^#{field}:(.+)^\n/m) do |match|
75
- match.strip.split(/,\s+/).join(', ').strip
76
- end
77
- end
78
-
79
- message = Mail.new(body)
80
-
81
- hash = Hash[message.header.fields.map {|header|
82
- key = header.name.to_s
83
- value = header.value.to_s
84
- [ key, value ]
85
- }]
86
-
87
- %w(Requestors Cc AdminCc).each do |field|
88
- hash[field] = hash[field].split(', ') if hash[field]
89
- end
90
-
91
- hash["id"] = hash["id"].split('/').last
92
-
93
- hash
94
- end
95
-
96
- def create(attrs)
97
- default_attrs = {
98
- 'id' => 'ticket/new'
99
- }
100
- attrs = default_attrs.merge(attrs).stringify_keys!
101
-
102
- if error = create_invalid?(attrs)
103
- return {'error' => error }
104
- end
105
-
106
- attrs['Text'].gsub!(/\n/,"\n ") if attrs['Text'] # insert a space on continuation lines.
107
-
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
125
- end
126
-
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 = explode_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)
150
- else
151
- # We should never hit this, but if we do, just pass it through and
152
- # surprise the user (!!!).
153
- body
154
- end
155
- end
156
-
157
- def update(id, attrs)
158
- content = compose_content('ticket', id, attrs)
159
-
160
- response = self.class.post(
161
- "/ticket/#{id}/edit",
162
- :body => {
163
- :content => content
164
- },
165
- )
166
-
167
- body, status = explode_response(response)
168
-
169
- case body
170
- when /^# You are not allowed to modify ticket \d+/
171
- { 'error' => body.strip }
172
- when /^# Syntax error/
173
- { 'error' => body.strip }
174
- when /^# Ticket (\d+) updated/
175
- id = body[/^# Ticket (\d+) updated/, 1]
176
- show(id)
177
- else
178
- # We should never hit this, but if we do, just pass it through and
179
- # surprise the user (!!!).
180
- body
181
- end
182
- end
183
-
184
50
  def authenticated?
185
51
  return true if show('1')
186
52
  end
187
53
 
188
- def search(query)
189
- params = {
190
- :query => query,
191
- :format => 's',
192
- :orderby => '+id'
193
- }
194
- response = self.class.get("/search/ticket", :query => params)
195
- body = response.body
196
- body.gsub!(/RT\/\d+\.\d+\.\d+\s\d{3}\s.*\n\n/,"")
197
-
198
- body.split("\n").map do |t|
199
- id, subject = t.split(': ', 2)
200
- {'id' => id, 'Subject' => subject}
201
- end
202
- end
203
-
204
- def history(id, opts={})
205
- options = {
206
- :format => 'short',
207
- :comments => false
208
- }.merge(opts)
209
-
210
- format = options[:format]
211
- comments = options[:comments]
212
- params = {
213
- :format => format[0]
214
- }
215
-
216
- response = self.class.get("/ticket/#{id}/history", :query => params)
217
-
218
- body, status = explode_response(response)
219
-
220
- case format
221
- when 'short'
222
- parse_short_history(body, :comments => comments)
223
- when 'long'
224
- parse_long_history(body, :comments => comments)
225
- end
226
- end
227
-
228
- # id can be numeric (e.g. 28) or textual (e.g. sales)
229
- def queue(id)
230
- response = self.class.get("/queue/#{id}")
231
-
232
- body, status = explode_response(response)
233
- case body
234
- when /No queue named/
235
- nil
236
- else
237
- body.gsub!(/\n\s*\n/,"\n") # remove blank lines for Mail
238
- message = Mail.new(body)
239
- Hash[message.header.fields.map {|header|
240
- key = header.name.to_s.downcase
241
- value = header.value.to_s
242
- [ key, value ]
243
- }]
244
- end
245
- end
246
-
247
- # id can be numeric (e.g. 28) or textual (e.g. john)
248
- def user_show(id)
249
- response = self.class.get("/user/#{id}")
250
-
251
- body, status = explode_response(response)
252
- case body
253
- when /No user named/
254
- nil
255
- else
256
- body.gsub!(/\n\s*\n/,"\n") # remove blank lines for Mail
257
- message = Mail.new(body)
258
- Hash[message.header.fields.map {|header|
259
- key = header.name.to_s.downcase
260
- value = header.value.to_s
261
- [ key, value ]
262
- }]
263
- end
264
- end
265
-
266
- alias :user :user_show
267
-
268
- def user_update(id, attrs)
269
- content = compose_content('user', id, attrs)
270
-
271
- response = self.class.post(
272
- "/user/#{id}/edit",
273
- :body => {
274
- :content => content
275
- },
276
- )
277
-
278
- body, status = explode_response(response)
279
-
280
- case body
281
- when /^# You are not allowed to modify user \d+/
282
- { 'error' => body.strip }
283
- when /^# Syntax error/
284
- { 'error' => body.strip }
285
- when /^# User (.+) updated/
286
- id = body[/^# User (.+) updated/, 1]
287
- user_show(id)
288
- else
289
- # We should never hit this, but if we do, just pass it through and
290
- # surprise the user (!!!).
291
- body
292
- end
293
- end
294
-
295
54
  private
55
+
296
56
  def compose_content(type, id, attrs)
297
57
  default_attrs = {
298
58
  'id' => [ type, id ].join('/')
299
59
  }
300
60
  attrs = default_attrs.merge(attrs).stringify_keys!
301
61
 
302
- content = attrs.map { |k,v|
62
+ content = attrs.map do |k, v|
303
63
  # Don't lowercase strings if they're already camel cased.
304
64
  k = case
305
- when k.is_a?(Symbol)
306
- k.to_s
307
- when k == 'id'
308
- k
309
- when k =~ /^[a-z]/
310
- k.capitalize
311
- else
312
- k
313
- end
65
+ when k.is_a?(Symbol)
66
+ k.to_s
67
+ when k == 'id'
68
+ k
69
+ when k =~ /^[a-z]/
70
+ k.capitalize
71
+ else
72
+ k
73
+ end
314
74
 
315
75
  v = v.join(', ') if v.respond_to?(:join)
316
76
 
317
77
  "#{k}: #{v}"
318
- }.join("\n")
78
+ end
79
+
80
+ content.join("\n")
319
81
  end
320
82
 
321
83
  def explode_response(response)
322
84
  body = response.body
323
85
  status = body[/RT\/\d+\.\d+\.\d+\s(\d{3}\s.*)\n/, 1]
324
86
 
325
- body.gsub!(/RT\/\d+\.\d+\.\d+\s\d{3}\s.*\n/,"")
87
+ body.gsub!(/RT\/\d+\.\d+\.\d+\s\d{3}\s.*\n/, '')
326
88
  body = body.empty? ? nil : body.lstrip
327
89
 
328
- raise Unauthenticated, "Invalid username or password" if status =~ /401 Credentials required/
90
+ raise Unauthenticated, 'Invalid username or password' if status =~ /401 Credentials required/
329
91
 
330
92
  return body, status
331
93
  end
332
-
333
- def create_invalid?(attrs)
334
- missing = %w(id Subject Queue).find_all {|k| !attrs.include?(k) }
335
-
336
- if missing.empty?
337
- return false
338
- else
339
- "Needs attributes: #{missing.join(', ')}"
340
- end
341
- end
342
-
343
- def parse_short_history(body, opts={})
344
- comments = opts[:comments]
345
- regex = comments ? '^\d+:' : '^\d+: [^Comments]'
346
- history = body.split("\n").select { |l| l =~ /#{regex}/ }
347
- history.map { |l| l.split(": ", 2) }
348
- end
349
-
350
- def parse_long_history(body, opts={})
351
- comments = opts[:comments]
352
- items = body.split("\n--\n")
353
- list = []
354
- items.each do |item|
355
- # Yes, this messes with the "content:" field but that's the one that's upsetting Mail.new
356
- item.gsub!(/\n\s*\n/,"\n") # remove blank lines for Mail
357
- history = Mail.new(item)
358
- next if not comments and history['type'].to_s =~ /Comment/ # skip comments
359
- reply = {}
360
-
361
- history.header.fields.each_with_index do |header, index|
362
- next if index == 0
363
-
364
- key = header.name.to_s.downcase
365
- value = header.value.to_s
366
-
367
- attachments = []
368
- case key
369
- when "attachments"
370
- temp = item.match(/Attachments:\s*(.*)/m)
371
- if temp.class != NilClass
372
- atarr = temp[1].split("\n")
373
- atarr.map { |a| a.gsub!(/^\s*/,"") }
374
- atarr.each do |a|
375
- i = a.match(/(\d+):\s*(.*)/)
376
- s = {
377
- :id => i[1].to_s,
378
- :name => i[2].to_s
379
- }
380
- sz = i[2].match(/(.*?)\s*\((.*?)\)/)
381
- if sz.class == MatchData
382
- s[:name] = sz[1].to_s
383
- s[:size] = sz[2].to_s
384
- end
385
- attachments << s
386
- end
387
- reply["attachments"] = attachments
388
- end
389
- when "content"
390
- reply["content"] = value
391
- else
392
- reply["#{key}"] = value
393
- end
394
- end
395
- list << reply
396
- end
397
-
398
- return list
399
- end
400
94
  end
data/roust.gemspec CHANGED
@@ -1,25 +1,25 @@
1
1
  # -*- encoding: utf-8 -*-
2
- $:.push File.expand_path("../lib", __FILE__)
3
- require "roust/version"
2
+ $LOAD_PATH.push(File.expand_path('../lib', __FILE__))
3
+ require 'roust/version'
4
4
 
5
5
  Gem::Specification.new do |s|
6
- s.name = "roust"
6
+ s.name = 'roust'
7
7
  s.version = Roust::VERSION
8
- s.date = %q{2014-01-23}
8
+ s.date = Time.now.strftime('%Y-%m-%d')
9
9
 
10
- s.authors = [ "Lindsay Holmwood" ]
11
- s.email = [ "lindsay@holmwood.id.au" ]
12
- s.summary = %q{Ruby client for RT's REST API}
13
- s.description = %q{Roust is a Ruby API client that accesses the REST interface version 1.0 of a Request Tracker instance. See http://www.bestpractical.com/ for Request Tracker.}
14
- s.homepage = "http://github.com/bulletproofnetworks/roust"
15
- s.license = "Apache 2.0"
10
+ s.authors = ['Lindsay Holmwood']
11
+ s.email = ['lindsay@holmwood.id.au']
12
+ s.summary = "Ruby client for RT's REST API"
13
+ s.description = 'Roust is a Ruby API client that accesses the REST interface version 1.0 of a Request Tracker instance. See http://www.bestpractical.com/ for Request Tracker.'
14
+ s.homepage = 'http://github.com/bulletproofnetworks/roust'
15
+ s.license = 'Apache 2.0'
16
16
 
17
- s.required_ruby_version = ">= 1.9.2"
17
+ s.required_ruby_version = '>= 1.9.2'
18
18
 
19
19
  s.files = `git ls-files`.split("\n")
20
20
  s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
21
- s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
22
- s.require_paths = ["lib"]
21
+ s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
22
+ s.require_paths = %w(lib)
23
23
 
24
24
  s.add_runtime_dependency 'mail', '>= 2.5.4'
25
25
  s.add_runtime_dependency 'httparty', '>= 0.13.1'
@@ -0,0 +1 @@
1
+ RT/3.4.6 401 Credentials required
@@ -0,0 +1,33 @@
1
+ require 'spec_helper'
2
+ require 'roust'
3
+
4
+ describe Roust do
5
+ include_context 'credentials'
6
+
7
+ describe 'authentication' do
8
+ it 'authenticates on instantiation' do
9
+ @rt = Roust.new(credentials)
10
+ expect(@rt.authenticated?).to eq(true)
11
+ end
12
+
13
+ it 'errors when credentials are incorrect' do
14
+ mocks_path = Pathname.new(__FILE__).parent.parent.join('mocks')
15
+
16
+ stub_request(:post, 'http://rt.example.org/index.html').
17
+ with(:body => {
18
+ 'user'=>'admin',
19
+ 'pass'=>'incorrect',
20
+ }).
21
+ to_return(:status => 200, :body => '', :headers => {})
22
+
23
+ stub_request(:get, 'http://rt.example.org/REST/1.0/ticket/1/show').
24
+ to_return(:status => 200,
25
+ :body => mocks_path.join('ticket-1-show-unauthenticated.txt').read,
26
+ :headers => {})
27
+
28
+ credentials.merge!({:username => 'admin', :password => 'incorrect'})
29
+
30
+ expect { Roust.new(credentials) }.to raise_error(Unauthenticated)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,40 @@
1
+ require 'spec_helper'
2
+ require 'roust'
3
+
4
+ describe Roust do
5
+ include_context 'credentials'
6
+
7
+ before do
8
+ mocks_path = Pathname.new(__FILE__).parent.parent.join('mocks')
9
+
10
+ stub_request(:get, 'http://rt.example.org/REST/1.0/queue/13')
11
+ .to_return(:status => 200,
12
+ :body => mocks_path.join('queue-13.txt').read,
13
+ :headers => {})
14
+
15
+ stub_request(:get, 'http://rt.example.org/REST/1.0/queue/nil')
16
+ .to_return(:status => 200,
17
+ :body => mocks_path.join('queue-nil.txt').read,
18
+ :headers => {})
19
+
20
+ @rt = Roust.new(credentials)
21
+ expect(@rt.authenticated?).to eq(true)
22
+ end
23
+
24
+ describe 'queue' do
25
+ it 'can lookup queue details' do
26
+ attrs = %w(id name description correspondaddress commentaddress) +
27
+ %w(initialpriority finalpriority defaultduein)
28
+
29
+ queue = @rt.queue('13')
30
+ attrs.each do |attr|
31
+ expect(queue[attr]).to_not eq(nil), "#{attr} key doesn't exist"
32
+ end
33
+ end
34
+
35
+ it 'returns nil for unknown queues' do
36
+ queue = @rt.queue('nil')
37
+ expect(queue).to eq(nil)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,80 @@
1
+ require 'spec_helper'
2
+ require 'roust'
3
+
4
+ describe Roust do
5
+ include_context 'credentials'
6
+
7
+ before do
8
+ mocks_path = Pathname.new(__FILE__).parent.parent.join('mocks')
9
+
10
+ stub_request(:get, 'http://rt.example.org/REST/1.0/search/ticket?format=s&orderby=%2Bid&query%5Bquery%5D=id%20=%201%20or%20id%20=%202')
11
+ .to_return(:status => 200,
12
+ :body => mocks_path.join('ticket-search-1-or-2.txt').read,
13
+ :headers => {})
14
+
15
+ stub_request(:get, 'http://rt.example.org/REST/1.0/ticket/1/history?format=s')
16
+ .to_return(:status => 200,
17
+ :body => mocks_path.join('ticket-1-history-short.txt').read,
18
+ :headers => {})
19
+
20
+ stub_request(:get, 'http://rt.example.org/REST/1.0/ticket/1/history?format=l')
21
+ .to_return(:status => 200,
22
+ :body => mocks_path.join('ticket-1-history-long.txt').read,
23
+ :headers => {})
24
+
25
+ @rt = Roust.new(credentials)
26
+ expect(@rt.authenticated?).to eq(true)
27
+ end
28
+
29
+ describe 'tickets' do
30
+ it 'can list tickets matching a query' do
31
+ results = @rt.search(:query => 'id = 1 or id = 2')
32
+ expect(results.size).to eq(2)
33
+ results.each do |result|
34
+ expect(result.size).to eq(2)
35
+ end
36
+ end
37
+
38
+ it 'can fetch metadata on individual tickets' do
39
+ ticket = @rt.show('1')
40
+ expect(ticket).to_not eq(nil)
41
+
42
+ attrs = %w(id Subject Queue) +
43
+ %w(Requestors Cc AdminCc Owner Creator) +
44
+ %w(Resolved Status) +
45
+ %w(Starts Started TimeLeft Due TimeWorked TimeEstimated) +
46
+ %w(LastUpdated Created Told) +
47
+ %w(Priority FinalPriority InitialPriority)
48
+
49
+ attrs.each do |attr|
50
+ expect(ticket[attr]).to_not eq(nil), "#{attr} key doesn't exist"
51
+ end
52
+
53
+ %w(Requestors Cc AdminCc).each do |field|
54
+ expect(ticket[field].size).to be > 1
55
+ end
56
+ end
57
+
58
+ it 'can fetch transactions on individual tickets' do
59
+ short = @rt.history('1', :format => 'short')
60
+
61
+ expect(short.size).to be > 1
62
+ short.each do |txn|
63
+ expect(txn.size).to eq(2)
64
+ expect(txn.first).to match(/^\d+$/)
65
+ expect(txn.last).to match(/^\w.*\w$/)
66
+ end
67
+
68
+ attrs = %w(ticket data oldvalue timetaken) +
69
+ %w(id type field newvalue content description)
70
+
71
+ long = @rt.history('1', :format => 'long')
72
+ expect(long.size).to be > 0
73
+ long.each do |txn|
74
+ attrs.each do |attr|
75
+ expect(txn[attr]).to_not eq(nil), "#{attr} key doesn't exist"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,58 @@
1
+ require 'spec_helper'
2
+ require 'roust'
3
+
4
+ describe Roust do
5
+ include_context 'credentials'
6
+
7
+ before do
8
+ mocks_path = Pathname.new(__FILE__).parent.parent.join('mocks')
9
+
10
+ stub_request(:get, 'http://rt.example.org/REST/1.0/user/dan@us.example')
11
+ .to_return(:status => 200,
12
+ :body => mocks_path.join('user-dan@us.example.txt').read,
13
+ :headers => {})
14
+
15
+ stub_request(:get, 'http://rt.example.org/REST/1.0/user/nil')
16
+ .to_return(:status => 200,
17
+ :body => mocks_path.join('user-nil.txt').read,
18
+ :headers => {})
19
+
20
+ stub_request(:post, 'http://rt.example.org/REST/1.0/user/dan@us.example/edit')
21
+ .with(:body => 'content=id%3A%20user%2Fdan%40us.example%0ARealName%3A%20Daniel%20Smith')
22
+ .to_return(:status => 200,
23
+ :body => mocks_path.join('user-dan@us.example-edit.txt').read,
24
+ :headers => {})
25
+
26
+ @rt = Roust.new(credentials)
27
+ expect(@rt.authenticated?).to eq(true)
28
+ end
29
+
30
+ describe 'user' do
31
+ it 'can lookup user details' do
32
+ attrs = %w(name realname gecos nickname emailaddress id lang password)
33
+
34
+ user = @rt.user_show('dan@us.example')
35
+ attrs.each do |attr|
36
+ expect(user[attr]).to_not eq(nil), "#{attr} key doesn't exist"
37
+ end
38
+ end
39
+
40
+ it 'returns nil for unknown users' do
41
+ queue = @rt.user_show('nil')
42
+ expect(queue).to eq(nil)
43
+ end
44
+
45
+ it 'can modify an existing user' do
46
+ mocks_path = Pathname.new(__FILE__).parent.parent.join('mocks')
47
+ stub_request(:get, 'http://rt.example.org/REST/1.0/user/dan@us.example')
48
+ .to_return(:status => 200,
49
+ :body => mocks_path.join('user-dan@us.example-after-edit.txt').read,
50
+ :headers => {})
51
+
52
+ attrs = {'RealName' => 'Daniel Smith'}
53
+ user = @rt.user_update('dan@us.example', attrs)
54
+
55
+ expect(user['realname']).to eq('Daniel Smith')
56
+ end
57
+ end
58
+ end
data/spec/spec_helper.rb CHANGED
@@ -7,20 +7,39 @@
7
7
 
8
8
  require 'pathname'
9
9
  lib = Pathname.new(__FILE__).parent.parent.join('lib').to_s
10
- $: << lib
10
+ $LOAD_PATH << lib
11
11
  require 'webmock/rspec'
12
12
 
13
13
  RSpec.configure do |config|
14
- # Use color in STDOUT
15
- config.color_enabled = true
16
-
17
14
  # Use color not only in STDOUT but also in pagers and files
18
15
  config.tty = true
16
+ end
17
+
18
+ # Boilerplate for all tests.
19
+ #
20
+ # All tests need to authenticate before they can do anything, so mock it out.
21
+ RSpec.shared_context 'credentials' do
22
+ let :credentials do
23
+ {
24
+ :server => 'http://rt.example.org',
25
+ :username => 'admin',
26
+ :password => 'password'
27
+ }
28
+ end
19
29
 
20
- # Use the specified formatter
21
- config.formatter = :documentation # :progress, :html, :textmate
30
+ before(:each) do
31
+ mocks_path = Pathname.new(__FILE__).parent.join('mocks')
22
32
 
23
- # Rspec 3 forward compatibility
24
- config.treat_symbols_as_metadata_keys_with_true_values = true
25
- end
33
+ stub_request(:post, 'http://rt.example.org/index.html')
34
+ .with(:body => {
35
+ 'user' => 'admin',
36
+ 'pass' => 'password'
37
+ })
38
+ .to_return(:status => 200, :body => '', :headers => {})
26
39
 
40
+ stub_request(:get, 'http://rt.example.org/REST/1.0/ticket/1/show')
41
+ .to_return(:status => 200,
42
+ :body => mocks_path.join('ticket-1-show.txt').read,
43
+ :headers => {})
44
+ end
45
+ end