jiraby 0.0.1

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.
Files changed (51) hide show
  1. data/.gitignore +9 -0
  2. data/.rspec +3 -0
  3. data/.travis.yml +4 -0
  4. data/.yardopts +8 -0
  5. data/Gemfile +7 -0
  6. data/README.md +132 -0
  7. data/Rakefile +5 -0
  8. data/docs/development.md +20 -0
  9. data/docs/history.md +5 -0
  10. data/docs/ideas.md +54 -0
  11. data/docs/index.md +11 -0
  12. data/docs/usage.md +64 -0
  13. data/jiraby.gemspec +31 -0
  14. data/lib/jiraby.rb +8 -0
  15. data/lib/jiraby/entity.rb +21 -0
  16. data/lib/jiraby/exceptions.rb +8 -0
  17. data/lib/jiraby/issue.rb +109 -0
  18. data/lib/jiraby/jira.rb +319 -0
  19. data/lib/jiraby/json_resource.rb +136 -0
  20. data/lib/jiraby/project.rb +19 -0
  21. data/spec/data/field.json +32 -0
  22. data/spec/data/issue_10002.json +187 -0
  23. data/spec/data/issue_createmeta.json +35 -0
  24. data/spec/data/jira_issues.rb +265 -0
  25. data/spec/data/jira_projects.rb +117 -0
  26. data/spec/data/project_TST.json +97 -0
  27. data/spec/data/search_results.json +26 -0
  28. data/spec/entity_spec.rb +20 -0
  29. data/spec/issue_spec.rb +289 -0
  30. data/spec/jira_spec.rb +314 -0
  31. data/spec/json_resource_spec.rb +222 -0
  32. data/spec/mockapp/config.ru +6 -0
  33. data/spec/mockapp/index.html +10 -0
  34. data/spec/mockapp/jira.rb +61 -0
  35. data/spec/mockapp/views/auth/login_failed.erb +1 -0
  36. data/spec/mockapp/views/auth/login_success.erb +7 -0
  37. data/spec/mockapp/views/error.erb +3 -0
  38. data/spec/mockapp/views/field.erb +32 -0
  39. data/spec/mockapp/views/issue/TST-1.erb +186 -0
  40. data/spec/mockapp/views/issue/createmeta.erb +35 -0
  41. data/spec/mockapp/views/issue/err_nonexistent.erb +1 -0
  42. data/spec/mockapp/views/project/TST.erb +97 -0
  43. data/spec/mockapp/views/project/err_nonexistent.erb +4 -0
  44. data/spec/mockapp/views/search.erb +26 -0
  45. data/spec/project_spec.rb +20 -0
  46. data/spec/spec_helper.rb +26 -0
  47. data/tasks/mockjira.rake +10 -0
  48. data/tasks/pry.rake +28 -0
  49. data/tasks/spec.rake +9 -0
  50. data/tasks/test.rake +8 -0
  51. metadata +288 -0
@@ -0,0 +1,319 @@
1
+ # Builtin
2
+ require 'enumerator'
3
+
4
+ # Gems
5
+ require 'rubygems'
6
+ require 'yajl'
7
+ require 'rest_client'
8
+
9
+ # Local
10
+ require 'jiraby/issue'
11
+ require 'jiraby/project'
12
+ require 'jiraby/exceptions'
13
+ require 'jiraby/json_resource'
14
+
15
+ module Jiraby
16
+ # Wrapper for Jira
17
+ class Jira
18
+ @@max_results = 50
19
+
20
+ # Initialize a Jira instance at the given URL.
21
+ #
22
+ # @param [String] url
23
+ # Full URL of the JIRA instance to connect to. If this does not begin
24
+ # with http: or https:, then http:// is assumed.
25
+ # @param [String] username
26
+ # Jira username
27
+ # @param [String] password
28
+ # Jira password
29
+ # @param [String] api_version
30
+ # The API version to use. For now, only '2' is supported.
31
+ #
32
+ # TODO: Handle the case where the wrong API version is used for a given
33
+ # Jira instance (should give 404s when resources are requested)
34
+ #
35
+ def initialize(url, username, password, api_version='2')
36
+ if !known_api_versions.include?(api_version)
37
+ raise ArgumentError.new("Unknown Jira API version: #{api_version}")
38
+ end
39
+
40
+ # Prepend http:// and remove trailing slashes
41
+ url = "http://#{url}" if url !~ /https:|http:/
42
+ url.gsub!(/\/+$/, '')
43
+ @url = url
44
+
45
+ @credentials = {:user => username, :password => password}
46
+ @api_version = api_version
47
+ @_field_mapping = nil
48
+
49
+ # All REST API access is done through the @rest attribute
50
+ @rest = Jiraby::JSONResource.new(base_url, @credentials)
51
+ end #initialize
52
+
53
+ attr_reader :url, :api_version, :rest
54
+
55
+ # Return a list of known Jira API versions.
56
+ #
57
+ def known_api_versions
58
+ return ['2']
59
+ end #known_api_versions
60
+
61
+
62
+ # Return the URL for authenticating to Jira.
63
+ #
64
+ def auth_url
65
+ "#{@url}/rest/auth/1/session"
66
+ end #auth_url
67
+
68
+ def base_url
69
+ "#{@url}/rest/api/#{@api_version}"
70
+ end
71
+
72
+ # Raise an exception if the current API version is one of those listed.
73
+ #
74
+ # @param [String] feature
75
+ # Name or short description of the feature in question
76
+ # @param [Array] api_versions
77
+ # One or more version strings for Jira APIs that do not support the
78
+ # feature in question
79
+ #
80
+ def not_implemented_in(feature, *api_versions)
81
+ if api_versions.include?(@api_version)
82
+ raise NotImplementedError,
83
+ "#{feature} not supported by version #{@api_version} of the Jira API"
84
+ end
85
+ end #not_implemented_in
86
+
87
+ # Return a URL query, suitable for use in a GET/DELETE/HEAD request
88
+ # that accepts queries like `?var1=value1&var2=value2`.
89
+ def _path_with_query(path, query={})
90
+ # TODO: Escape special chars
91
+ params = query.map {|k,v| "#{k}=#{v}"}.join("&")
92
+ if params.empty?
93
+ return path
94
+ else
95
+ return "#{path}?#{params}"
96
+ end
97
+ end
98
+
99
+ # REST wrapper methods returning Jiraby::Entity
100
+ def get(path, query={})
101
+ @rest[_path_with_query(path, query)].get
102
+ end
103
+
104
+ def delete(path, query={})
105
+ @rest[_path_with_query(path, query)].delete
106
+ end
107
+
108
+ def put(path, data)
109
+ @rest[path].put data
110
+ end
111
+
112
+ def post(path, data)
113
+ @rest[path].post data
114
+ end
115
+
116
+ # Find all issues matching the given JQL query, and return an
117
+ # `Enumerator` that yields each one as an Issue object.
118
+ # Each Issue is fetched from the REST API as needed.
119
+ #
120
+ # @param [String] jql_query
121
+ # JQL query for the issues you want to match
122
+ #
123
+ # @return [Enumerator]
124
+ #
125
+ def search(jql_query)
126
+ params = {
127
+ :jql => jql_query,
128
+ }
129
+ issues = self.enumerator(:post, 'search', params, 'issues')
130
+ return Enumerator.new do |e|
131
+ issues.each do |data|
132
+ e << Issue.new(self, data)
133
+ end
134
+ end
135
+ end
136
+
137
+ # Return an Enumerator yielding items returned by a REST method that
138
+ # accepts `startAt` and `maxResults` parameters. This allows you to
139
+ # iterate through large data sets
140
+ #
141
+ # For example, using the issue `search` method to look up all issues
142
+ # in project "FOO", then using `each` to iterate over them:
143
+ #
144
+ # query = 'project=FOO order by key'
145
+ # jira.enumerator(
146
+ # :post, 'search', {:jql => query}, 'issues'
147
+ # ).each do |issue|
148
+ # puts "#{issue.key}: #{issue.fields.summary}"
149
+ # end
150
+ #
151
+ # The output might be:
152
+ #
153
+ # FOO-1: First issue in Foo project
154
+ # FOO-2: Another issue
155
+ # (...)
156
+ # FOO-149: Penultimate issue
157
+ # FOO-150: Last issue
158
+ #
159
+ # Below is a complete list of Jira REST API methods that accept `startAt`
160
+ # and `maxResults`.
161
+ #
162
+ # Returning Entity:
163
+ # GET /dashboard => { 'dashboards' => [...], 'total' => N } (dashboards)
164
+ # GET /search => { 'issues' => [...], 'total' => N } (issues)
165
+ # POST /search => { 'issues' => [...], 'total' => N } (issues)
166
+ #
167
+ # Returning Array of Entity:
168
+ # GET /user/assignable/multiProjectSearch => [...] (users)
169
+ # GET /user/assignable/search => [...] (users)
170
+ # GET /user/permission/search => [...] (users)
171
+ # GET /user/search => [...] (users)
172
+ # GET /user/viewissue/search => [...] (users)
173
+ #
174
+ def enumerator(method, path, params={}, list_key=nil)
175
+ max_results = @@max_results
176
+ return Enumerator.new do |enum|
177
+ page = 0
178
+ more = true
179
+ while(more) do
180
+ paged_params = params.merge({
181
+ :startAt => page * max_results,
182
+ :maxResults => max_results
183
+ })
184
+ response = self.send(method, path, paged_params)
185
+
186
+ # Some methods (like 'search') return an Entity, with the list of
187
+ # items indexed by `list_key`.
188
+ if response.is_a?(Jiraby::Entity)
189
+ items = response[list_key]
190
+ # Others (like 'user/search') return an array of Entity.
191
+ elsif response.is_a?(Array)
192
+ items = response
193
+ else
194
+ raise RuntimeError.new("Unexpected data: #{response}")
195
+ end
196
+
197
+ items.to_a.each do |item|
198
+ enum << item
199
+ end
200
+
201
+ if items.to_a.count < max_results
202
+ more = false
203
+ else
204
+ page += 1
205
+ end
206
+ end # while(more)
207
+ end # Enumerator.new
208
+ end
209
+
210
+ # Return the Issue with the given key.
211
+ #
212
+ # @param [String] key
213
+ # The issue's unique identifier (usually like PROJ-NNN)
214
+ #
215
+ # @return [Issue]
216
+ # An Issue populated with data returned by the API
217
+ #
218
+ # @raise [IssueNotFound]
219
+ # If the issue was not found or fetching failed
220
+ #
221
+ def issue(key)
222
+ if key.nil? || key.to_s.strip.empty?
223
+ raise ArgumentError.new("Issue key is required")
224
+ end
225
+ json = self.get "issue/#{key}"
226
+ if json and (json.empty? or json['errorMessages'])
227
+ raise IssueNotFound.new("Issue '#{key}' not found in Jira")
228
+ else
229
+ return Issue.new(self, json)
230
+ end
231
+ end #issue
232
+
233
+
234
+ # Create a new issue
235
+ #
236
+ # @param [String] project_key
237
+ # Identifier for the project to create the issue under
238
+ # @param [String] issue_type
239
+ # The name of an issue type. May be any issue types accepted by the given
240
+ # project; typically "Bug", "Task", "Improvement", "New Feature", or
241
+ # "Sub-task"
242
+ #
243
+ # @return [Issue]
244
+ #
245
+ def create_issue(project_key, issue_type='Bug')
246
+ issue_data = self.post 'issue', {
247
+ "fields" => {"project" => {"key" => project_key} }
248
+ }
249
+ return Issue.new(self, issue_data) if issue_data
250
+ return nil
251
+ end #create_issue
252
+
253
+
254
+ # Return the Project with the given key.
255
+ #
256
+ # @param [String] key
257
+ # The project's unique identifier (usually like PROJ)
258
+ #
259
+ # @return [Project]
260
+ # A Project populated with data returned by the API, or
261
+ # nil if no such project is found.
262
+ #
263
+ def project(key)
264
+ json = self.get "project/#{key}"
265
+ if json and (json.empty? or json['errorMessages'])
266
+ raise ProjectNotFound.new("Project '#{key}' not found in Jira")
267
+ else
268
+ return Project.new(json)
269
+ end
270
+ end #project
271
+
272
+
273
+ # Return the 'createmeta' data for the given project key, or nil if
274
+ # the project is not found.
275
+ #
276
+ # TODO: Move this into the Project class?
277
+ #
278
+ def project_meta(project_key)
279
+ meta = self.get 'issue/createmeta?expand=projects.issuetypes.fields'
280
+ metadata = meta.projects.find {|proj| proj['key'] == project_key}
281
+ if metadata and !metadata.nil?
282
+ return metadata
283
+ else
284
+ raise ProjectNotFound.new("Project '#{project_key}' not found in Jira")
285
+ end
286
+ end #project_meta
287
+
288
+
289
+ # Return the total number of issues matching the given JQL query, or
290
+ # the count of all issues if no JQL query is given.
291
+ #
292
+ # @param [String] jql
293
+ # JQL query for the issues you want to match
294
+ #
295
+ # @return [Integer]
296
+ # Number of issues matching the query
297
+ #
298
+ def count(jql='')
299
+ result = self.post 'search', {
300
+ :jql => jql,
301
+ :startAt => 0,
302
+ :maxResults => 1,
303
+ :fields => [''],
304
+ }
305
+ return result.total
306
+ end #count
307
+
308
+
309
+ # Return a hash of 'field_id' => 'Field Name' for all fields
310
+ def field_mapping
311
+ if @_field_mapping.nil?
312
+ ids_and_names = self.get('field').collect { |f| [f.id, f.name] }
313
+ @_field_mapping = Hash[ids_and_names]
314
+ end
315
+ return @_field_mapping
316
+ end #field_mapping
317
+
318
+ end # class Jira
319
+ end # module Jiraby
@@ -0,0 +1,136 @@
1
+ require 'rest_client'
2
+ require 'jiraby/entity'
3
+
4
+ module Jiraby
5
+ class JSONParseError < RuntimeError
6
+ attr_reader :response
7
+
8
+ def initialize(message, response)
9
+ super(message)
10
+ @response = response
11
+ end
12
+ end
13
+
14
+ # A RestClient::Resource that expects JSON data from all requests, and
15
+ # wraps all JSON in native Ruby hashes so you don't have to parse it.
16
+ #
17
+ # Expectations:
18
+ #
19
+ # - All GET, HEAD, DELETE, PUT, POST, and PATCH requests to your REST API
20
+ # will return a JSON string. With JSONResource, the #get, #head, #delete,
21
+ # #put, #post, and #patch methods return a Hash (if the response is actually
22
+ # JSON and can be parsed), or raise a JSONParseError if parsing fails.
23
+ #
24
+ # - All payloads (first argument to #put, #post, and #patch) are expected to
25
+ # be JSON strings; you can provide the raw JSON string, or provide a Hash
26
+ # and it will be automatically encoded as a JSON string.
27
+ #
28
+ # - All error responses from the REST API are expected to be JSON strings.
29
+ # When an error occurs (such as 401, 404, 500 etc.), normally a
30
+ # RestClient::Exception is raised. With JSONResource, the error response
31
+ # is parsed as JSON and returned as a Hash; no exception is raised.
32
+ #
33
+ class JSONResource < RestClient::Resource
34
+ @@debug = false
35
+
36
+ def initialize(url, options={}, backwards_compatibility=nil, &block)
37
+ options[:headers] = {} if options[:headers].nil?
38
+ options[:headers].merge!(:content_type => :json, :accept => :json)
39
+ super(url, options, backwards_compatibility, &block)
40
+ end
41
+
42
+ # Aliases to RestClient::Resource methods
43
+ alias_method :_get, :get
44
+ alias_method :_delete, :delete
45
+ alias_method :_head, :head
46
+ alias_method :_post, :post
47
+ alias_method :_put, :put
48
+ alias_method :_patch, :patch
49
+
50
+ # Wrapped RestClient::Resource methods that accept and return Hash data
51
+ def get(additional_headers={}, &block)
52
+ wrap(:_get, additional_headers, &block)
53
+ end
54
+
55
+ def delete(additional_headers={}, &block)
56
+ wrap(:_delete, additional_headers, &block)
57
+ end
58
+
59
+ def head(additional_headers={}, &block)
60
+ wrap(:_head, additional_headers, &block)
61
+ end
62
+
63
+ def post(payload, additional_headers={}, &block)
64
+ wrap_with_payload(:_post, payload, additional_headers, &block)
65
+ end
66
+
67
+ def put(payload, additional_headers={}, &block)
68
+ wrap_with_payload(:_put, payload, additional_headers, &block)
69
+ end
70
+
71
+ def patch(payload, additional_headers={}, &block)
72
+ wrap_with_payload(:_patch, payload, additional_headers, &block)
73
+ end
74
+
75
+ # Wrap the given method to return a Hash response parsed from JSON
76
+ #
77
+ def wrap(method, additional_headers={}, &block)
78
+ puts "#{@url} wrap(#{method})" if @@debug
79
+ response = maybe_error_response do
80
+ send(method, additional_headers, &block)
81
+ end
82
+ return parsed_response(response)
83
+ end
84
+
85
+ # Wrap the given method to send a Hash payload, and return a Hash response
86
+ # parsed from JSON.
87
+ #
88
+ def wrap_with_payload(method, payload, additional_headers={}, &block)
89
+ puts "#{@url} wrap_with_payload(#{method}, #{payload})" if @@debug
90
+ if payload.is_a?(Hash)
91
+ payload = Yajl::Encoder.encode(payload)
92
+ end
93
+ response = maybe_error_response do
94
+ send(method, payload, additional_headers, &block)
95
+ end
96
+ return parsed_response(response)
97
+ end
98
+
99
+ # Parse `response` as JSON and return a Hash or array of Hashes.
100
+ # Raise `JSONParseError` if parsing fails.
101
+ #
102
+ def parsed_response(response)
103
+ begin
104
+ json = Yajl::Parser.parse(response)
105
+ rescue Yajl::ParseError => ex
106
+ # FIXME: Sometimes getting "input must be a string or IO" error here
107
+ raise JSONParseError.new(ex.message, response)
108
+ else
109
+ if json.is_a?(Hash)
110
+ return Entity.new(json)
111
+ elsif json.is_a?(Array)
112
+ return json.collect do |hash|
113
+ Entity.new(hash)
114
+ end
115
+ else
116
+ return nil
117
+ end
118
+ end
119
+ end
120
+
121
+ # Yield a response from the given block; if a `RestClient::Exception` is
122
+ # raised, return the exception's response instead.
123
+ #
124
+ def maybe_error_response(&block)
125
+ begin
126
+ yield
127
+ rescue RestClient::RequestTimeout => ex
128
+ raise ex
129
+ rescue RestClient::Exception => ex
130
+ ex.response
131
+ end
132
+ end
133
+
134
+ end # class JSONResource
135
+ end # module Jiraby
136
+