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.
- data/.gitignore +9 -0
- data/.rspec +3 -0
- data/.travis.yml +4 -0
- data/.yardopts +8 -0
- data/Gemfile +7 -0
- data/README.md +132 -0
- data/Rakefile +5 -0
- data/docs/development.md +20 -0
- data/docs/history.md +5 -0
- data/docs/ideas.md +54 -0
- data/docs/index.md +11 -0
- data/docs/usage.md +64 -0
- data/jiraby.gemspec +31 -0
- data/lib/jiraby.rb +8 -0
- data/lib/jiraby/entity.rb +21 -0
- data/lib/jiraby/exceptions.rb +8 -0
- data/lib/jiraby/issue.rb +109 -0
- data/lib/jiraby/jira.rb +319 -0
- data/lib/jiraby/json_resource.rb +136 -0
- data/lib/jiraby/project.rb +19 -0
- data/spec/data/field.json +32 -0
- data/spec/data/issue_10002.json +187 -0
- data/spec/data/issue_createmeta.json +35 -0
- data/spec/data/jira_issues.rb +265 -0
- data/spec/data/jira_projects.rb +117 -0
- data/spec/data/project_TST.json +97 -0
- data/spec/data/search_results.json +26 -0
- data/spec/entity_spec.rb +20 -0
- data/spec/issue_spec.rb +289 -0
- data/spec/jira_spec.rb +314 -0
- data/spec/json_resource_spec.rb +222 -0
- data/spec/mockapp/config.ru +6 -0
- data/spec/mockapp/index.html +10 -0
- data/spec/mockapp/jira.rb +61 -0
- data/spec/mockapp/views/auth/login_failed.erb +1 -0
- data/spec/mockapp/views/auth/login_success.erb +7 -0
- data/spec/mockapp/views/error.erb +3 -0
- data/spec/mockapp/views/field.erb +32 -0
- data/spec/mockapp/views/issue/TST-1.erb +186 -0
- data/spec/mockapp/views/issue/createmeta.erb +35 -0
- data/spec/mockapp/views/issue/err_nonexistent.erb +1 -0
- data/spec/mockapp/views/project/TST.erb +97 -0
- data/spec/mockapp/views/project/err_nonexistent.erb +4 -0
- data/spec/mockapp/views/search.erb +26 -0
- data/spec/project_spec.rb +20 -0
- data/spec/spec_helper.rb +26 -0
- data/tasks/mockjira.rake +10 -0
- data/tasks/pry.rake +28 -0
- data/tasks/spec.rake +9 -0
- data/tasks/test.rake +8 -0
- metadata +288 -0
data/lib/jiraby/jira.rb
ADDED
@@ -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
|
+
|