jira-ruby 0.0.2 → 0.0.3
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/README.rdoc +259 -0
- data/Rakefile +9 -0
- data/example.rb +47 -4
- data/jira-ruby.gemspec +5 -3
- data/lib/jira.rb +21 -7
- data/lib/jira/base.rb +466 -0
- data/lib/jira/base_factory.rb +49 -0
- data/lib/jira/client.rb +79 -8
- data/lib/jira/has_many_proxy.rb +43 -0
- data/lib/jira/http_error.rb +16 -0
- data/lib/jira/resource/attachment.rb +12 -0
- data/lib/jira/resource/comment.rb +14 -0
- data/lib/jira/resource/component.rb +4 -9
- data/lib/jira/resource/issue.rb +49 -5
- data/lib/jira/resource/issuetype.rb +10 -0
- data/lib/jira/resource/priority.rb +10 -0
- data/lib/jira/resource/project.rb +24 -3
- data/lib/jira/resource/status.rb +10 -0
- data/lib/jira/resource/user.rb +14 -0
- data/lib/jira/resource/version.rb +10 -0
- data/lib/jira/resource/worklog.rb +16 -0
- data/lib/jira/version.rb +2 -2
- data/spec/integration/attachment_spec.rb +26 -0
- data/spec/integration/comment_spec.rb +55 -0
- data/spec/integration/component_spec.rb +25 -52
- data/spec/integration/issue_spec.rb +50 -47
- data/spec/integration/issuetype_spec.rb +27 -0
- data/spec/integration/priority_spec.rb +27 -0
- data/spec/integration/project_spec.rb +32 -24
- data/spec/integration/status_spec.rb +27 -0
- data/spec/integration/user_spec.rb +25 -0
- data/spec/integration/version_spec.rb +43 -0
- data/spec/integration/worklog_spec.rb +55 -0
- data/spec/jira/base_factory_spec.rb +46 -0
- data/spec/jira/base_spec.rb +555 -0
- data/spec/jira/client_spec.rb +12 -12
- data/spec/jira/has_many_proxy_spec.rb +45 -0
- data/spec/jira/{resource/http_error_spec.rb → http_error_spec.rb} +1 -1
- data/spec/jira/resource/attachment_spec.rb +20 -0
- data/spec/jira/resource/issue_spec.rb +83 -0
- data/spec/jira/resource/project_factory_spec.rb +3 -3
- data/spec/jira/resource/project_spec.rb +28 -0
- data/spec/jira/resource/worklog_spec.rb +24 -0
- data/spec/mock_responses/attachment/10000.json +20 -0
- data/spec/mock_responses/component/10000.invalid.put.json +5 -0
- data/spec/mock_responses/issue.json +1108 -0
- data/spec/mock_responses/issue/10002.invalid.put.json +6 -0
- data/spec/mock_responses/issue/10002.json +13 -1
- data/spec/mock_responses/issue/10002.put.missing_field_update.json +6 -0
- data/spec/mock_responses/issue/10002/comment.json +65 -0
- data/spec/mock_responses/issue/10002/comment.post.json +29 -0
- data/spec/mock_responses/issue/10002/comment/10000.json +29 -0
- data/spec/mock_responses/issue/10002/comment/10000.put.json +29 -0
- data/spec/mock_responses/issue/10002/worklog.json +98 -0
- data/spec/mock_responses/issue/10002/worklog.post.json +30 -0
- data/spec/mock_responses/issue/10002/worklog/10000.json +31 -0
- data/spec/mock_responses/issue/10002/worklog/10000.put.json +30 -0
- data/spec/mock_responses/issuetype.json +42 -0
- data/spec/mock_responses/issuetype/5.json +8 -0
- data/spec/mock_responses/priority.json +42 -0
- data/spec/mock_responses/priority/1.json +8 -0
- data/spec/mock_responses/project/SAMPLEPROJECT.issues.json +1108 -0
- data/spec/mock_responses/project/SAMPLEPROJECT.json +15 -1
- data/spec/mock_responses/status.json +37 -0
- data/spec/mock_responses/status/1.json +7 -0
- data/spec/mock_responses/user?username=admin.json +17 -0
- data/spec/mock_responses/version.post.json +7 -0
- data/spec/mock_responses/version/10000.invalid.put.json +5 -0
- data/spec/mock_responses/version/10000.json +11 -0
- data/spec/mock_responses/version/10000.put.json +7 -0
- data/spec/spec_helper.rb +7 -12
- data/spec/support/matchers/have_attributes.rb +11 -0
- data/spec/support/matchers/have_many.rb +9 -0
- data/spec/support/matchers/have_one.rb +5 -0
- data/spec/support/shared_examples/integration.rb +174 -0
- metadata +139 -24
- data/README.markdown +0 -81
- data/lib/jira/resource/base.rb +0 -148
- data/lib/jira/resource/base_factory.rb +0 -44
- data/lib/jira/resource/http_error.rb +0 -17
- data/spec/jira/resource/base_factory_spec.rb +0 -36
- data/spec/jira/resource/base_spec.rb +0 -292
data/README.rdoc
ADDED
@@ -0,0 +1,259 @@
|
|
1
|
+
= JIRA 5 API Gem
|
2
|
+
|
3
|
+
This gem provides access to the Atlassian JIRA version 5 REST API (a.k.a REST
|
4
|
+
API version 2).
|
5
|
+
|
6
|
+
== Example usage
|
7
|
+
|
8
|
+
client = JIRA::Client.new(CONSUMER_KEY, CONSUMER_SECRET)
|
9
|
+
|
10
|
+
project = client.Project.find('SAMPLEPROJECT')
|
11
|
+
|
12
|
+
project.issues.each do |issue|
|
13
|
+
puts "#{issue.id} - #{issue.summary}"
|
14
|
+
end
|
15
|
+
|
16
|
+
issue.comments.each {|comment| ... }
|
17
|
+
|
18
|
+
comment = issue.comments.build({'body':'My new comment'})
|
19
|
+
comment.save
|
20
|
+
comment.delete
|
21
|
+
|
22
|
+
== Links to JIRA REST API documentation
|
23
|
+
|
24
|
+
* {Overview}[https://developer.atlassian.com/display/JIRADEV/JIRA+REST+APIs]
|
25
|
+
* {Reference}[http://docs.atlassian.com/jira/REST/5.0-rc1/]
|
26
|
+
|
27
|
+
|
28
|
+
== Setting up the JIRA SDK
|
29
|
+
|
30
|
+
On Mac OS,
|
31
|
+
|
32
|
+
brew install atlassian-plugin-sdk
|
33
|
+
|
34
|
+
Otherwise:
|
35
|
+
|
36
|
+
* Download the SDK from https://developer.atlassian.com/ (You will need
|
37
|
+
an Atlassian login for this)
|
38
|
+
* Unpack the dowloaded archive
|
39
|
+
* From within the archive directory, run:
|
40
|
+
|
41
|
+
./bin/atlas-run-standalone --product jira --version 5.0-rc2
|
42
|
+
|
43
|
+
Once this is running, you should be able to connect to
|
44
|
+
http://localhost:2990/ and login to the JIRA admin system using `admin:admin`
|
45
|
+
|
46
|
+
You'll need to create a dummy project and probably some issues to test using
|
47
|
+
this library.
|
48
|
+
|
49
|
+
== Configuring JIRA to use OAuth
|
50
|
+
|
51
|
+
From the JIRA API tutorial
|
52
|
+
|
53
|
+
The first step is to register a new consumer in JIRA. This is done through
|
54
|
+
the Application Links administration screens in JIRA. Create a new
|
55
|
+
Application Link.
|
56
|
+
{Administration/Plugins/Application Links}[http://localhost:2990/jira/plugins/servlet/applinks/listApplicationLinks]
|
57
|
+
|
58
|
+
When creating the Application Link use a placeholder URL or the correct URL
|
59
|
+
to your client (e.g. `http://localhost:3000`), if your client can be reached
|
60
|
+
via HTTP and choose the Generic Application type. After this Application Link
|
61
|
+
has been created, edit the configuration and go to the incoming
|
62
|
+
authentication configuration screen and select OAuth. Enter in this the
|
63
|
+
public key and the consumer key which your client will use when making
|
64
|
+
requests to JIRA.
|
65
|
+
|
66
|
+
This public key and consumer key will need to be generated by the Gem user, using OpenSSL
|
67
|
+
or similar to generate the public key and the provided rake task to generate the consumer
|
68
|
+
key.
|
69
|
+
|
70
|
+
After you have entered all the information click OK and ensure OAuth authentication is
|
71
|
+
enabled.
|
72
|
+
|
73
|
+
== Using the API Gem in your Rails application
|
74
|
+
|
75
|
+
The gem requires the consumer key and public certificate file (which
|
76
|
+
are generated in their respective rake tasks) to initialize an access token for
|
77
|
+
using the JIRA API.
|
78
|
+
|
79
|
+
Below is an example for setting up a rails application for OAuth authorization.
|
80
|
+
|
81
|
+
# app/controllers/application_controller.rb
|
82
|
+
class ApplicationController < ActionController::Base
|
83
|
+
protect_from_forgery
|
84
|
+
|
85
|
+
rescue_from JIRA::Client::UninitializedAccessTokenError do
|
86
|
+
redirect_to new_jira_session_url
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def get_jira_client
|
92
|
+
options = {
|
93
|
+
:private_key_file => "rsakey.pem"
|
94
|
+
}
|
95
|
+
@jira_client = JIRA::Client.new('test', '', options)
|
96
|
+
|
97
|
+
# Add AccessToken if authorised previously.
|
98
|
+
if session[:jira_auth]
|
99
|
+
@jira_client.set_access_token(
|
100
|
+
session[:jira_auth][:access_token],
|
101
|
+
session[:jira_auth][:access_key]
|
102
|
+
)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
Create a controller for handling the OAuth conversation.
|
108
|
+
|
109
|
+
# app/controllers/jira_sessions_controller.rb
|
110
|
+
require 'jira'
|
111
|
+
|
112
|
+
class JiraSessionsController < ApplicationController
|
113
|
+
|
114
|
+
before_filter :get_jira_client
|
115
|
+
|
116
|
+
def new
|
117
|
+
request_token = @jira_client.request_token
|
118
|
+
session[:request_token] = request_token.token
|
119
|
+
session[:request_secret] = request_token.secret
|
120
|
+
|
121
|
+
redirect_to request_token.authorize_url
|
122
|
+
end
|
123
|
+
|
124
|
+
def authorize
|
125
|
+
request_token = @jira_client.set_request_token(
|
126
|
+
session[:request_token], session[:request_secret]
|
127
|
+
)
|
128
|
+
access_token = @jira_client.init_access_token(
|
129
|
+
:oauth_verifier => params[:oauth_verifier]
|
130
|
+
)
|
131
|
+
|
132
|
+
session[:jira_auth] = {
|
133
|
+
:access_token => access_token.token,
|
134
|
+
:access_key => access_token.secret
|
135
|
+
}
|
136
|
+
|
137
|
+
session.delete(:request_token)
|
138
|
+
session.delete(:request_secret)
|
139
|
+
|
140
|
+
redirect_to projects_path
|
141
|
+
end
|
142
|
+
|
143
|
+
def destroy
|
144
|
+
session.data.delete(:jira_auth)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
Create your own controllers for the JIRA resources you wish to access.
|
149
|
+
|
150
|
+
# app/controllers/issues_controller.rb
|
151
|
+
class IssuesController < ApplicationController
|
152
|
+
before_filter :get_jira_client
|
153
|
+
def index
|
154
|
+
@issues = @jira_client.Issue.all
|
155
|
+
end
|
156
|
+
|
157
|
+
def show
|
158
|
+
@issue = @jira_client.Issue.find(params[:id])
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
== Using the API Gem in your Sinatra application
|
163
|
+
|
164
|
+
Here's the same example as a Sinatra application:
|
165
|
+
|
166
|
+
require 'jira'
|
167
|
+
class App < Sinatra::Base
|
168
|
+
enable :sessions
|
169
|
+
|
170
|
+
# This section gets called before every request. Here, we set up the
|
171
|
+
# OAuth consumer details including the consumer key, private key,
|
172
|
+
# site uri, and the request token, access token, and authorize paths
|
173
|
+
before do
|
174
|
+
options = {
|
175
|
+
:site => 'http://localhost:2990',
|
176
|
+
:signature_method => 'RSA-SHA1',
|
177
|
+
:request_token_path => "/jira/plugins/servlet/oauth/request-token",
|
178
|
+
:authorize_path => "/jira/plugins/servlet/oauth/authorize",
|
179
|
+
:access_token_path => "/jira/plugins/servlet/oauth/access-token",
|
180
|
+
:private_key_file => "rsakey.pem",
|
181
|
+
:rest_base_path => "/jira/rest/api/2"
|
182
|
+
}
|
183
|
+
|
184
|
+
@jira_client = JIRA::Client.new('jira-ruby-example', '', options)
|
185
|
+
@jira_client.consumer.http.set_debug_output($stderr)
|
186
|
+
|
187
|
+
# Add AccessToken if authorised previously.
|
188
|
+
if session[:jira_auth]
|
189
|
+
@jira_client.set_access_token(
|
190
|
+
session[:jira_auth][:access_token],
|
191
|
+
session[:jira_auth][:access_key]
|
192
|
+
)
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
|
197
|
+
# Starting point: http://<yourserver>/
|
198
|
+
# This will serve up a login link if you're not logged in. If you are, it'll show some user info and a
|
199
|
+
# signout link
|
200
|
+
get '/' do
|
201
|
+
if !session[:jira_auth]
|
202
|
+
# not logged in
|
203
|
+
<<-eos
|
204
|
+
<h1>jira-ruby (JIRA 5 Ruby Gem) demo </h1>You're not signed in. Why don't you
|
205
|
+
<a href=/signin>sign in</a> first.
|
206
|
+
eos
|
207
|
+
else
|
208
|
+
#logged in
|
209
|
+
@issues = @jira_client.Issue.all
|
210
|
+
|
211
|
+
# HTTP response inlined with bind data below...
|
212
|
+
<<-eos
|
213
|
+
You're now signed in. There #{@issues.count == 1 ? "is" : "are"} #{@issues.count}
|
214
|
+
issue#{@issues.count == 1 ? "" : "s"} in this JIRA instance. <a href='/signout'>Signout</a>
|
215
|
+
eos
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
# http://<yourserver>/signin
|
220
|
+
# Initiates the OAuth dance by first requesting a token then redirecting to
|
221
|
+
# http://<yourserver>/auth to get the @access_token
|
222
|
+
get '/signin' do
|
223
|
+
request_token = @jira_client.request_token
|
224
|
+
session[:request_token] = request_token.token
|
225
|
+
session[:request_secret] = request_token.secret
|
226
|
+
|
227
|
+
redirect request_token.authorize_url
|
228
|
+
end
|
229
|
+
|
230
|
+
# http://<yourserver>/callback
|
231
|
+
# Retrieves the @access_token then stores it inside a session cookie. In a real app,
|
232
|
+
# you'll want to persist the token in a datastore associated with the user.
|
233
|
+
get "/callback/" do
|
234
|
+
request_token = @jira_client.set_request_token(
|
235
|
+
session[:request_token], session[:request_secret]
|
236
|
+
)
|
237
|
+
access_token = @jira_client.init_access_token(
|
238
|
+
:oauth_verifier => params[:oauth_verifier]
|
239
|
+
)
|
240
|
+
|
241
|
+
session[:jira_auth] = {
|
242
|
+
:access_token => access_token.token,
|
243
|
+
:access_key => access_token.secret
|
244
|
+
}
|
245
|
+
|
246
|
+
session.delete(:request_token)
|
247
|
+
session.delete(:request_secret)
|
248
|
+
|
249
|
+
redirect "/"
|
250
|
+
end
|
251
|
+
|
252
|
+
# http://<yourserver>/signout
|
253
|
+
# Expires the session
|
254
|
+
get "/signout" do
|
255
|
+
session.delete(:jira_auth)
|
256
|
+
redirect "/"
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
data/Rakefile
CHANGED
@@ -2,8 +2,17 @@ require "bundler/gem_tasks"
|
|
2
2
|
|
3
3
|
require 'rubygems'
|
4
4
|
require 'rspec/core/rake_task'
|
5
|
+
require 'rake/rdoctask'
|
6
|
+
|
7
|
+
Dir.glob('lib/tasks/*.rake').each { |r| import r }
|
5
8
|
|
6
9
|
task :default => [:spec]
|
7
10
|
|
8
11
|
desc "Run RSpec tests"
|
9
12
|
RSpec::Core::RakeTask.new(:spec)
|
13
|
+
|
14
|
+
Rake::RDocTask.new(:doc) do |rd|
|
15
|
+
rd.main = 'README.rdoc'
|
16
|
+
rd.rdoc_dir = 'doc'
|
17
|
+
rd.rdoc_files.include('README.rdoc', 'lib/**/*.rb')
|
18
|
+
end
|
data/example.rb
CHANGED
@@ -7,7 +7,7 @@ options = {
|
|
7
7
|
|
8
8
|
CONSUMER_KEY = 'test'
|
9
9
|
|
10
|
-
client =
|
10
|
+
client = JIRA::Client.new(CONSUMER_KEY, '', options)
|
11
11
|
|
12
12
|
if ARGV.length == 0
|
13
13
|
# If not passed any command line arguments, open a browser and prompt the
|
@@ -42,6 +42,15 @@ pp issue
|
|
42
42
|
# # ------------------------------
|
43
43
|
# project = client.Project.find('SAMPLEPROJECT')
|
44
44
|
# pp project
|
45
|
+
# project.issues.each do |issue|
|
46
|
+
# puts "#{issue.id} - #{issue.fields['summary']}"
|
47
|
+
# end
|
48
|
+
#
|
49
|
+
# # List all Issues
|
50
|
+
# # ---------------
|
51
|
+
# client.Issue.all.each do |issue|
|
52
|
+
# puts "#{issue.id} - #{issue.fields['summary']}"
|
53
|
+
# end
|
45
54
|
#
|
46
55
|
# # Delete an issue
|
47
56
|
# # ---------------
|
@@ -58,9 +67,43 @@ pp issue
|
|
58
67
|
# issue.save({"fields"=>{"summary"=>"blarg from in example.rb","project"=>{"id"=>"10001"},"issuetype"=>{"id"=>"3"}}})
|
59
68
|
# issue.fetch
|
60
69
|
# pp issue
|
61
|
-
#
|
62
|
-
# Update an issue
|
63
|
-
# ---------------
|
70
|
+
#
|
71
|
+
# # Update an issue
|
72
|
+
# # ---------------
|
64
73
|
# issue = client.Issue.find("10002")
|
65
74
|
# issue.save({"fields"=>{"summary"=>"EVEN MOOOOOOARRR NINJAAAA!"}})
|
66
75
|
# pp issue
|
76
|
+
#
|
77
|
+
# # Find a user
|
78
|
+
# # -----------
|
79
|
+
# user = client.User.find('admin')
|
80
|
+
# pp user
|
81
|
+
#
|
82
|
+
# # Get all issue types
|
83
|
+
# # -------------------
|
84
|
+
# issuetypes = client.Issuetype.all
|
85
|
+
# pp issuetypes
|
86
|
+
#
|
87
|
+
# # Get a single issue type
|
88
|
+
# # -----------------------
|
89
|
+
# issuetype = client.Issuetype.find('5')
|
90
|
+
# pp issuetype
|
91
|
+
#
|
92
|
+
# # Get all comments for an issue
|
93
|
+
# # -----------------------------
|
94
|
+
# issue.comments.each do |comment|
|
95
|
+
# pp comment
|
96
|
+
# end
|
97
|
+
#
|
98
|
+
# # Build and Save a comment
|
99
|
+
# # ------------------------
|
100
|
+
# comment = issue.comments.build
|
101
|
+
# comment.save!(:body => "New comment from example script")
|
102
|
+
#
|
103
|
+
# # Delete a comment from the collection
|
104
|
+
# # ------------------------------------
|
105
|
+
# issue.comments.last.delete
|
106
|
+
#
|
107
|
+
# # Update an existing comment
|
108
|
+
# # --------------------------
|
109
|
+
# issue.comments.first.save({"body" => "an updated comment frome example.rb"})
|
data/jira-ruby.gemspec
CHANGED
@@ -4,11 +4,11 @@ require "jira/version"
|
|
4
4
|
|
5
5
|
Gem::Specification.new do |s|
|
6
6
|
s.name = "jira-ruby"
|
7
|
-
s.version =
|
7
|
+
s.version = JIRA::VERSION
|
8
8
|
s.authors = ["Trineo Ltd"]
|
9
9
|
s.homepage = "http://trineo.co.nz"
|
10
|
-
s.summary = %q{Ruby Gem for use with the Atlassian
|
11
|
-
s.description = %q{API for
|
10
|
+
s.summary = %q{Ruby Gem for use with the Atlassian JIRA 5 REST API}
|
11
|
+
s.description = %q{API for JIRA 5}
|
12
12
|
|
13
13
|
s.rubyforge_project = "jira-ruby"
|
14
14
|
|
@@ -24,5 +24,7 @@ Gem::Specification.new do |s|
|
|
24
24
|
s.add_development_dependency "oauth"
|
25
25
|
s.add_runtime_dependency "railties"
|
26
26
|
s.add_development_dependency "railties"
|
27
|
+
s.add_runtime_dependency "activesupport"
|
28
|
+
s.add_development_dependency "activesupport"
|
27
29
|
s.add_development_dependency "webmock"
|
28
30
|
end
|
data/lib/jira.rb
CHANGED
@@ -1,12 +1,26 @@
|
|
1
|
-
Dir[File.join(File.dirname(__FILE__),'tasks/*.rake')].each { |f| load f } if defined?(Rake)
|
2
|
-
|
3
1
|
$: << File.expand_path(File.dirname(__FILE__))
|
4
|
-
require 'jira/resource/base'
|
5
|
-
require 'jira/resource/base_factory'
|
6
|
-
require 'jira/resource/http_error'
|
7
2
|
|
8
|
-
require '
|
9
|
-
|
3
|
+
require 'active_support/inflector'
|
4
|
+
ActiveSupport::Inflector.inflections do |inflector|
|
5
|
+
inflector.singular 'status', 'status'
|
6
|
+
end
|
7
|
+
|
8
|
+
require 'jira/base'
|
9
|
+
require 'jira/base_factory'
|
10
|
+
require 'jira/has_many_proxy'
|
11
|
+
require 'jira/http_error'
|
12
|
+
|
13
|
+
require 'jira/resource/user'
|
14
|
+
require 'jira/resource/attachment'
|
10
15
|
require 'jira/resource/component'
|
16
|
+
require 'jira/resource/issuetype'
|
17
|
+
require 'jira/resource/version'
|
18
|
+
require 'jira/resource/project'
|
19
|
+
require 'jira/resource/priority'
|
20
|
+
require 'jira/resource/status'
|
21
|
+
require 'jira/resource/comment'
|
22
|
+
require 'jira/resource/worklog'
|
23
|
+
require 'jira/resource/issue'
|
11
24
|
|
12
25
|
require 'jira/client'
|
26
|
+
|
data/lib/jira/base.rb
ADDED
@@ -0,0 +1,466 @@
|
|
1
|
+
require 'active_support/core_ext/string'
|
2
|
+
require 'active_support/inflector'
|
3
|
+
|
4
|
+
module JIRA
|
5
|
+
|
6
|
+
# This class provides the basic object <-> REST mapping for all JIRA::Resource subclasses,
|
7
|
+
# i.e. the Create, Retrieve, Update, Delete lifecycle methods.
|
8
|
+
#
|
9
|
+
# == Lifecycle methods
|
10
|
+
#
|
11
|
+
# Note that not all lifecycle
|
12
|
+
# methods are available for all resources, for example some resources cannot be updated
|
13
|
+
# or deleted.
|
14
|
+
#
|
15
|
+
# === Retrieving all resources
|
16
|
+
#
|
17
|
+
# client.Resource.all
|
18
|
+
#
|
19
|
+
# === Retrieving a single resource
|
20
|
+
#
|
21
|
+
# client.Resource.find(id)
|
22
|
+
#
|
23
|
+
# === Creating a resource
|
24
|
+
#
|
25
|
+
# resource = client.Resource.build({'name' => '')
|
26
|
+
# resource.save
|
27
|
+
#
|
28
|
+
# === Updating a resource
|
29
|
+
#
|
30
|
+
# resource = client.Resource.find(id)
|
31
|
+
# resource.save('updated_attribute' => 'new value')
|
32
|
+
#
|
33
|
+
# === Deleting a resource
|
34
|
+
#
|
35
|
+
# resource = client.Resource.find(id)
|
36
|
+
# resource.delete
|
37
|
+
#
|
38
|
+
# == Nested resources
|
39
|
+
#
|
40
|
+
# Some resources are not defined in the top level of the URL namespace
|
41
|
+
# within the JIRA API, but are always nested under the context of another
|
42
|
+
# resource. For example, a JIRA::Resource::Comment always belongs to a
|
43
|
+
# JIRA::Resource::Issue.
|
44
|
+
#
|
45
|
+
# These resources must be indexed and built from an instance of the class
|
46
|
+
# they are nested under:
|
47
|
+
#
|
48
|
+
# issue = client.Issue.find(id)
|
49
|
+
# comments = issue.comments
|
50
|
+
# new_comment = issue.comments.build
|
51
|
+
#
|
52
|
+
class Base
|
53
|
+
|
54
|
+
# A reference to the JIRA::Client used to initialize this resource.
|
55
|
+
attr_reader :client
|
56
|
+
|
57
|
+
# Returns true if this instance has been fetched from the server
|
58
|
+
attr_accessor :expanded
|
59
|
+
|
60
|
+
# Returns true if this instance has been deleted from the server
|
61
|
+
attr_accessor :deleted
|
62
|
+
|
63
|
+
# The hash of attributes belonging to this instance. An exact
|
64
|
+
# representation of the JSON returned from the JIRA API
|
65
|
+
attr_accessor :attrs
|
66
|
+
|
67
|
+
alias :expanded? :expanded
|
68
|
+
alias :deleted? :deleted
|
69
|
+
|
70
|
+
def initialize(client, options = {})
|
71
|
+
@client = client
|
72
|
+
@attrs = options[:attrs] || {}
|
73
|
+
@expanded = options[:expanded] || false
|
74
|
+
@deleted = false
|
75
|
+
|
76
|
+
# If this class has any belongs_to relationships, a value for
|
77
|
+
# each of them must be passed in to the initializer.
|
78
|
+
self.class.belongs_to_relationships.each do |relation|
|
79
|
+
if options[relation]
|
80
|
+
instance_variable_set("@#{relation.to_s}", options[relation])
|
81
|
+
instance_variable_set("@#{relation.to_s}_id", options[relation].key_value)
|
82
|
+
elsif options["#{relation}_id".to_sym]
|
83
|
+
instance_variable_set("@#{relation.to_s}_id", options["#{relation}_id".to_sym])
|
84
|
+
else
|
85
|
+
raise ArgumentError.new("Required option #{relation.inspect} missing") unless options[relation]
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# The class methods are never called directly, they are always
|
91
|
+
# invoked from a BaseFactory subclass instance.
|
92
|
+
def self.all(client, options = {})
|
93
|
+
response = client.get(collection_path(client))
|
94
|
+
json = parse_json(response.body)
|
95
|
+
puts collection_attributes_are_nested
|
96
|
+
if collection_attributes_are_nested
|
97
|
+
json = json[endpoint_name.pluralize]
|
98
|
+
end
|
99
|
+
json.map do |attrs|
|
100
|
+
self.new(client, {:attrs => attrs}.merge(options))
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# Finds and retrieves a resource with the given ID.
|
105
|
+
def self.find(client, key, options = {})
|
106
|
+
instance = self.new(client, options)
|
107
|
+
instance.attrs[key_attribute.to_s] = key
|
108
|
+
instance.fetch
|
109
|
+
instance
|
110
|
+
end
|
111
|
+
|
112
|
+
# Builds a new instance of the resource with the given attributes.
|
113
|
+
# These attributes will be posted to the JIRA Api if save is called.
|
114
|
+
def self.build(client, attrs)
|
115
|
+
self.new(client, :attrs => attrs)
|
116
|
+
end
|
117
|
+
|
118
|
+
# Returns the name of this resource for use in URL components.
|
119
|
+
# E.g.
|
120
|
+
# JIRA::Resource::Issue.endpoint_name
|
121
|
+
# # => issue
|
122
|
+
def self.endpoint_name
|
123
|
+
self.name.split('::').last.downcase
|
124
|
+
end
|
125
|
+
|
126
|
+
# Returns the full path for a collection of this resource.
|
127
|
+
# E.g.
|
128
|
+
# JIRA::Resource::Issue.collection_path
|
129
|
+
# # => /jira/rest/api/2/issue
|
130
|
+
def self.collection_path(client, prefix = '/')
|
131
|
+
client.options[:rest_base_path] + prefix + self.endpoint_name
|
132
|
+
end
|
133
|
+
|
134
|
+
# Returns the singular path for the resource with the given key.
|
135
|
+
# E.g.
|
136
|
+
# JIRA::Resource::Issue.singular_path('123')
|
137
|
+
# # => /jira/rest/api/2/issue/123
|
138
|
+
#
|
139
|
+
# If a prefix parameter is provided it will be injected between the base
|
140
|
+
# path and the endpoint.
|
141
|
+
# E.g.
|
142
|
+
# JIRA::Resource::Comment.singular_path('456','/issue/123/')
|
143
|
+
# # => /jira/rest/api/2/issue/123/comment/456
|
144
|
+
def self.singular_path(client, key, prefix = '/')
|
145
|
+
collection_path(client, prefix) + '/' + key
|
146
|
+
end
|
147
|
+
|
148
|
+
# Returns the attribute name of the attribute used for find.
|
149
|
+
# Defaults to :id unless overridden.
|
150
|
+
def self.key_attribute
|
151
|
+
:id
|
152
|
+
end
|
153
|
+
|
154
|
+
def self.parse_json(string) # :nodoc:
|
155
|
+
JSON.parse(string)
|
156
|
+
end
|
157
|
+
|
158
|
+
# Declares that this class contains a singular instance of another resource
|
159
|
+
# within the JSON returned from the JIRA API.
|
160
|
+
#
|
161
|
+
# class Example < JIRA::Base
|
162
|
+
# has_one :child
|
163
|
+
# end
|
164
|
+
#
|
165
|
+
# example = client.Example.find(1)
|
166
|
+
# example.child # Returns a JIRA::Resource::Child
|
167
|
+
#
|
168
|
+
# The following options can be used to override the default behaviour of the
|
169
|
+
# relationship:
|
170
|
+
#
|
171
|
+
# [:attribute_key] The relationship will by default reference a JSON key on the
|
172
|
+
# object with the same name as the relationship.
|
173
|
+
#
|
174
|
+
# has_one :child # => {"id":"123",{"child":{"id":"456"}}}
|
175
|
+
#
|
176
|
+
# Use this option if the key in the JSON is named differently.
|
177
|
+
#
|
178
|
+
# # Respond to resource.child, but return the value of resource.attrs['kid']
|
179
|
+
# has_one :child, :attribute_key => 'kid' # => {"id":"123",{"kid":{"id":"456"}}}
|
180
|
+
#
|
181
|
+
# [:class] The class of the child instance will be inferred from the name of the
|
182
|
+
# relationship. E.g. <tt>has_one :child</tt> will return a <tt>JIRA::Resource::Child</tt>.
|
183
|
+
# Use this option to override the inferred class.
|
184
|
+
#
|
185
|
+
# has_one :child, :class => JIRA::Resource::Kid
|
186
|
+
# [:nested_under] In some cases, the JSON return from JIRA is nested deeply for particular
|
187
|
+
# relationships. This option allows the nesting to be specified.
|
188
|
+
#
|
189
|
+
# # Specify a single depth of nesting.
|
190
|
+
# has_one :child, :nested_under => 'foo'
|
191
|
+
# # => Looks for {"foo":{"child":{}}}
|
192
|
+
# # Specify deeply nested JSON
|
193
|
+
# has_one :child, :nested_under => ['foo', 'bar', 'baz']
|
194
|
+
# # => Looks for {"foo":{"bar":{"baz":{"child":{}}}}}
|
195
|
+
def self.has_one(resource, options = {})
|
196
|
+
attribute_key = options[:attribute_key] || resource.to_s
|
197
|
+
child_class = options[:class] || ('JIRA::Resource::' + resource.to_s.classify).constantize
|
198
|
+
define_method(resource) do
|
199
|
+
attribute = maybe_nested_attribute(attribute_key, options[:nested_under])
|
200
|
+
return nil unless attribute
|
201
|
+
child_class.new(client, :attrs => attribute)
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
# Declares that this class contains a collection of another resource
|
206
|
+
# within the JSON returned from the JIRA API.
|
207
|
+
#
|
208
|
+
# class Example < JIRA::Base
|
209
|
+
# has_many :children
|
210
|
+
# end
|
211
|
+
#
|
212
|
+
# example = client.Example.find(1)
|
213
|
+
# example.children # Returns an instance of Jira::Resource::HasManyProxy,
|
214
|
+
# # which behaves exactly like an array of
|
215
|
+
# # Jira::Resource::Child
|
216
|
+
#
|
217
|
+
# The following options can be used to override the default behaviour of the
|
218
|
+
# relationship:
|
219
|
+
#
|
220
|
+
# [:attribute_key] The relationship will by default reference a JSON key on the
|
221
|
+
# object with the same name as the relationship.
|
222
|
+
#
|
223
|
+
# has_many :children # => {"id":"123",{"children":[{"id":"456"},{"id":"789"}]}}
|
224
|
+
#
|
225
|
+
# Use this option if the key in the JSON is named differently.
|
226
|
+
#
|
227
|
+
# # Respond to resource.children, but return the value of resource.attrs['kids']
|
228
|
+
# has_many :children, :attribute_key => 'kids' # => {"id":"123",{"kids":[{"id":"456"},{"id":"789"}]}}
|
229
|
+
#
|
230
|
+
# [:class] The class of the child instance will be inferred from the name of the
|
231
|
+
# relationship. E.g. <tt>has_many :children</tt> will return an instance
|
232
|
+
# of <tt>JIRA::Resource::HasManyProxy</tt> containing the collection of
|
233
|
+
# <tt>JIRA::Resource::Child</tt>.
|
234
|
+
# Use this option to override the inferred class.
|
235
|
+
#
|
236
|
+
# has_many :children, :class => JIRA::Resource::Kid
|
237
|
+
# [:nested_under] In some cases, the JSON return from JIRA is nested deeply for particular
|
238
|
+
# relationships. This option allows the nesting to be specified.
|
239
|
+
#
|
240
|
+
# # Specify a single depth of nesting.
|
241
|
+
# has_many :children, :nested_under => 'foo'
|
242
|
+
# # => Looks for {"foo":{"children":{}}}
|
243
|
+
# # Specify deeply nested JSON
|
244
|
+
# has_many :children, :nested_under => ['foo', 'bar', 'baz']
|
245
|
+
# # => Looks for {"foo":{"bar":{"baz":{"children":{}}}}}
|
246
|
+
def self.has_many(collection, options = {})
|
247
|
+
attribute_key = options[:attribute_key] || collection.to_s
|
248
|
+
child_class = options[:class] || ('JIRA::Resource::' + collection.to_s.classify).constantize
|
249
|
+
self_class_basename = self.name.split('::').last.downcase.to_sym
|
250
|
+
define_method(collection) do
|
251
|
+
child_class_options = {self_class_basename => self}
|
252
|
+
attribute = maybe_nested_attribute(attribute_key, options[:nested_under]) || []
|
253
|
+
collection = attribute.map do |child_attributes|
|
254
|
+
child_class.new(client, child_class_options.merge(:attrs => child_attributes))
|
255
|
+
end
|
256
|
+
HasManyProxy.new(self, child_class, collection)
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
def self.belongs_to_relationships
|
261
|
+
@belongs_to_relationships ||= []
|
262
|
+
end
|
263
|
+
|
264
|
+
def self.belongs_to(resource)
|
265
|
+
belongs_to_relationships.push(resource)
|
266
|
+
attr_reader resource
|
267
|
+
attr_reader "#{resource}_id"
|
268
|
+
end
|
269
|
+
|
270
|
+
def self.collection_attributes_are_nested
|
271
|
+
@collection_attributes_are_nested ||= false
|
272
|
+
end
|
273
|
+
|
274
|
+
def self.nested_collections(value)
|
275
|
+
@collection_attributes_are_nested = value
|
276
|
+
end
|
277
|
+
|
278
|
+
# Returns a symbol for the given instance, for example
|
279
|
+
# JIRA::Resource::Issue returns :issue
|
280
|
+
def to_sym
|
281
|
+
self.class.endpoint_name.to_sym
|
282
|
+
end
|
283
|
+
|
284
|
+
# Checks if method_name is set in the attributes hash
|
285
|
+
# and returns true when found, otherwise proxies the
|
286
|
+
# call to the superclass.
|
287
|
+
def respond_to?(method_name)
|
288
|
+
if attrs.keys.include? method_name.to_s
|
289
|
+
true
|
290
|
+
else
|
291
|
+
super(method_name)
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
# Overrides method_missing to check the attribute hash
|
296
|
+
# for resources matching method_name and proxies the call
|
297
|
+
# to the superclass if no match is found.
|
298
|
+
def method_missing(method_name, *args, &block)
|
299
|
+
if attrs.keys.include? method_name.to_s
|
300
|
+
attrs[method_name.to_s]
|
301
|
+
else
|
302
|
+
super(method_name)
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
# Each resource has a unique key attribute, this method returns the value
|
307
|
+
# of that key for this instance.
|
308
|
+
def key_value
|
309
|
+
@attrs[self.class.key_attribute.to_s]
|
310
|
+
end
|
311
|
+
|
312
|
+
def collection_path(prefix = "/")
|
313
|
+
# Just proxy this to the class method
|
314
|
+
self.class.collection_path(client, prefix)
|
315
|
+
end
|
316
|
+
|
317
|
+
# This returns the URL path component that is specific to this instance,
|
318
|
+
# for example for Issue id 123 it returns '/issue/123'. For an unsaved
|
319
|
+
# issue it returns '/issue'
|
320
|
+
def path_component
|
321
|
+
path_component = "/#{self.class.endpoint_name}"
|
322
|
+
if key_value
|
323
|
+
path_component += '/' + key_value
|
324
|
+
end
|
325
|
+
path_component
|
326
|
+
end
|
327
|
+
|
328
|
+
# Fetches the attributes for the specified resource from JIRA unless
|
329
|
+
# the resource is already expanded and the optional force reload flag
|
330
|
+
# is not set
|
331
|
+
def fetch(reload = false)
|
332
|
+
return if expanded? && !reload
|
333
|
+
response = client.get(url)
|
334
|
+
set_attrs_from_response(response)
|
335
|
+
@expanded = true
|
336
|
+
end
|
337
|
+
|
338
|
+
# Saves the specified resource attributes by sending either a POST or PUT
|
339
|
+
# request to JIRA, depending on resource.new_record?
|
340
|
+
#
|
341
|
+
# Accepts an attributes hash of the values to be saved. Will throw a
|
342
|
+
# JIRA::HTTPError if the request fails (response is not HTTP 2xx).
|
343
|
+
def save!(attrs)
|
344
|
+
http_method = new_record? ? :post : :put
|
345
|
+
response = client.send(http_method, url, attrs.to_json)
|
346
|
+
set_attrs(attrs, false)
|
347
|
+
set_attrs_from_response(response)
|
348
|
+
@expanded = false
|
349
|
+
true
|
350
|
+
end
|
351
|
+
|
352
|
+
# Saves the specified resource attributes by sending either a POST or PUT
|
353
|
+
# request to JIRA, depending on resource.new_record?
|
354
|
+
#
|
355
|
+
# Accepts an attributes hash of the values to be saved. Will return false
|
356
|
+
# if the request fails.
|
357
|
+
def save(attrs)
|
358
|
+
begin
|
359
|
+
save_status = save!(attrs)
|
360
|
+
rescue JIRA::HTTPError => exception
|
361
|
+
set_attrs_from_response(exception.response) rescue JSON::ParserError # Merge error status generated by JIRA REST API
|
362
|
+
save_status = false
|
363
|
+
end
|
364
|
+
save_status
|
365
|
+
end
|
366
|
+
|
367
|
+
# Sets the attributes hash from a HTTPResponse object from JIRA if it is
|
368
|
+
# not nil or is not a json response.
|
369
|
+
def set_attrs_from_response(response)
|
370
|
+
unless response.body.nil? or response.body.length < 2
|
371
|
+
json = self.class.parse_json(response.body)
|
372
|
+
set_attrs(json)
|
373
|
+
end
|
374
|
+
end
|
375
|
+
|
376
|
+
# Set the current attributes from a hash. If clobber is true, any existing
|
377
|
+
# hash values will be clobbered by the new hash, otherwise the hash will
|
378
|
+
# be deeply merged into attrs. The target paramater is for internal use only
|
379
|
+
# and should not be used.
|
380
|
+
def set_attrs(hash, clobber=true, target = nil)
|
381
|
+
target ||= @attrs
|
382
|
+
if clobber
|
383
|
+
target.merge!(hash)
|
384
|
+
hash
|
385
|
+
else
|
386
|
+
hash.each do |k, v|
|
387
|
+
if v.is_a?(Hash)
|
388
|
+
set_attrs(v, clobber, target[k])
|
389
|
+
else
|
390
|
+
target[k] = v
|
391
|
+
end
|
392
|
+
end
|
393
|
+
end
|
394
|
+
end
|
395
|
+
|
396
|
+
# Sends a delete request to the JIRA Api and sets the deleted instance
|
397
|
+
# variable on the object to true.
|
398
|
+
def delete
|
399
|
+
client.delete(url)
|
400
|
+
@deleted = true
|
401
|
+
end
|
402
|
+
|
403
|
+
def has_errors?
|
404
|
+
respond_to?('errors')
|
405
|
+
end
|
406
|
+
|
407
|
+
def url
|
408
|
+
prefix = '/'
|
409
|
+
unless self.class.belongs_to_relationships.empty?
|
410
|
+
prefix = self.class.belongs_to_relationships.inject(prefix) do |prefix_so_far, relationship|
|
411
|
+
prefix_so_far + relationship.to_s + "/" + self.send("#{relationship.to_s}_id") + '/'
|
412
|
+
end
|
413
|
+
end
|
414
|
+
if @attrs['self']
|
415
|
+
@attrs['self']
|
416
|
+
elsif key_value
|
417
|
+
self.class.singular_path(client, key_value.to_s, prefix)
|
418
|
+
else
|
419
|
+
self.class.collection_path(client, prefix)
|
420
|
+
end
|
421
|
+
end
|
422
|
+
|
423
|
+
def to_s
|
424
|
+
"#<#{self.class.name}:#{object_id} @attrs=#{@attrs.inspect}>"
|
425
|
+
end
|
426
|
+
|
427
|
+
# Returns a JSON representation of the current attributes hash.
|
428
|
+
def to_json
|
429
|
+
attrs.to_json
|
430
|
+
end
|
431
|
+
|
432
|
+
# Determines if the resource is newly created by checking whether its
|
433
|
+
# key_value is set. If it is nil, the record is new and the method
|
434
|
+
# will return true.
|
435
|
+
def new_record?
|
436
|
+
key_value.nil?
|
437
|
+
end
|
438
|
+
|
439
|
+
protected
|
440
|
+
|
441
|
+
# This allows conditional lookup of possibly nested attributes. Example usage:
|
442
|
+
#
|
443
|
+
# maybe_nested_attribute('foo') # => @attrs['foo']
|
444
|
+
# maybe_nested_attribute('foo', 'bar') # => @attrs['bar']['foo']
|
445
|
+
# maybe_nested_attribute('foo', ['bar', 'baz']) # => @attrs['bar']['baz']['foo']
|
446
|
+
#
|
447
|
+
def maybe_nested_attribute(attribute_name, nested_under = nil)
|
448
|
+
self.class.maybe_nested_attribute(@attrs, attribute_name, nested_under)
|
449
|
+
end
|
450
|
+
|
451
|
+
def self.maybe_nested_attribute(attributes, attribute_name, nested_under = nil)
|
452
|
+
return attributes[attribute_name] if nested_under.nil?
|
453
|
+
if nested_under.instance_of? Array
|
454
|
+
final = nested_under.inject(attributes) do |parent, key|
|
455
|
+
break if parent.nil?
|
456
|
+
parent[key]
|
457
|
+
end
|
458
|
+
return nil if final.nil?
|
459
|
+
final[attribute_name]
|
460
|
+
else
|
461
|
+
return attributes[nested_under][attribute_name]
|
462
|
+
end
|
463
|
+
end
|
464
|
+
|
465
|
+
end
|
466
|
+
end
|