jira-ruby 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/CI.yml +1 -0
  3. data/.github/workflows/rubocop.yml +1 -1
  4. data/README.md +0 -1
  5. data/jira-ruby.gemspec +2 -1
  6. data/lib/jira/atlassian/jwt.rb +76 -0
  7. data/lib/jira/base_factory.rb +1 -1
  8. data/lib/jira/client.rb +10 -2
  9. data/lib/jira/jwt_client.rb +2 -2
  10. data/lib/jira/resource/attachment.rb +17 -2
  11. data/lib/jira/resource/comment.rb +12 -0
  12. data/lib/jira/resource/createmeta.rb +4 -4
  13. data/lib/jira/resource/issue.rb +52 -18
  14. data/lib/jira/resource/properties.rb +56 -0
  15. data/lib/jira/resource/worklog.rb +12 -0
  16. data/lib/jira/version.rb +1 -1
  17. data/lib/jira-ruby.rb +1 -0
  18. data/spec/data/files/jwt-signed-urls.json +317 -0
  19. data/spec/integration/issue_spec.rb +1 -2
  20. data/spec/integration/properties_spec.rb +45 -0
  21. data/spec/integration/transition_spec.rb +3 -2
  22. data/spec/integration/webhook_spec.rb +4 -2
  23. data/spec/jira/atlassian/jwt_spec.rb +60 -0
  24. data/spec/jira/resource/issue_spec.rb +39 -1
  25. data/spec/mock_responses/issue/10002/properties/foo.json +4 -0
  26. data/spec/mock_responses/issue/10002/properties/xyz.json +4 -0
  27. data/spec/mock_responses/issue/10002/properties/xyz.put.json +4 -0
  28. data/spec/mock_responses/issue/10002/properties.json +12 -0
  29. data/spec/support/clients_helper.rb +3 -3
  30. data/spec/support/shared_examples/integration.rb +34 -38
  31. metadata +27 -6
  32. data/spec/mock_responses/jira/rest/webhooks/1.0/webhook.json +0 -11
  33. data/spec/mock_responses/webhook/webhook.json +0 -11
  34. /data/spec/mock_responses/{jira/rest/webhooks/1.0/webhook → webhook}/2.json +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bbc33adf73b2ff3a7e4ba9d20246e3a7a760d6c94530aac292776e1cd03455d6
4
- data.tar.gz: 91be26b124667936bf93d4216210c5ce77d203b744bfce7b5d7a0e2bd313d339
3
+ metadata.gz: 4a043cafa57aad32acfda37495a8419b4511581786620049cb6264a05265aefb
4
+ data.tar.gz: 2c3b66dd69faaef351e043b10c62f3cfd7662e003905363db6bdbf458bfb10b1
5
5
  SHA512:
6
- metadata.gz: 4a7c2b8d43b3b8235a1ea51f1baf4d628a3726a7bb8dc39ecb62b8ef795ca085fb85ebb99941e8d699f5a20cb41ecceb3a3675781eb081481329ddc36f957701
7
- data.tar.gz: dbabaf3245895c038b4496230eb2a3fdf7e0590a9bf549ace6b87db25268a492714579758e4789f8edd9a5da173be0e7b5bf3c732b17a0c9caad7cc1556bed55
6
+ metadata.gz: 677a496c74235ea213ac92416b9dfb303e56bc04ea94b617134925ce744c25bd81583b2516a342c347a935959795c35813245499efcf2c058b075d308ef51454
7
+ data.tar.gz: ef42dd385a24ff73526e8e2d21fedb0af60832444f9d1e43c0c23a1cca58da61751a24901430abf0650cffacdd7b41ff28fa7620deb195ede7081af504eb4295
@@ -10,6 +10,7 @@ jobs:
10
10
  fail-fast: false
11
11
  matrix:
12
12
  ruby:
13
+ - '4.0'
13
14
  - '3.4'
14
15
  - '3.3'
15
16
  - '3.2'
@@ -11,7 +11,7 @@ jobs:
11
11
  - name: Set up Ruby
12
12
  uses: ruby/setup-ruby@v1
13
13
  with:
14
- ruby-version: '3.1'
14
+ ruby-version: '3.4'
15
15
  bundler-cache: true
16
16
  - name: Run rubocop
17
17
  run: |
data/README.md CHANGED
@@ -1,6 +1,5 @@
1
1
  # JIRA API Gem
2
2
 
3
- [![Code Climate](https://codeclimate.com/github/sumoheavy/jira-ruby.svg)](https://codeclimate.com/github/sumoheavy/jira-ruby)
4
3
  [![Build Status](https://github.com/sumoheavy/jira-ruby/actions/workflows/CI.yml/badge.svg)](https://github.com/sumoheavy/jira-ruby/actions/workflows/CI.yml)
5
4
 
6
5
  This gem provides access to the Atlassian JIRA REST API.
data/jira-ruby.gemspec CHANGED
@@ -23,7 +23,8 @@ Gem::Specification.new do |s|
23
23
  s.require_paths = ['lib']
24
24
 
25
25
  s.add_dependency 'activesupport'
26
- s.add_dependency 'atlassian-jwt'
26
+ s.add_dependency 'cgi'
27
+ s.add_dependency 'jwt', '>= 2.1'
27
28
  s.add_dependency 'multipart-post'
28
29
  s.add_dependency 'oauth', '~> 1.0'
29
30
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2013 Atlassian Pty Ltd.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require 'jwt'
18
+ require 'uri'
19
+ require 'cgi'
20
+
21
+ module JIRA
22
+ module Atlassian
23
+ module Jwt
24
+ class << self
25
+ CANONICAL_QUERY_SEPARATOR = '&'
26
+ ESCAPED_CANONICAL_QUERY_SEPARATOR = '%26'
27
+
28
+ def create_canonical_request(uri, http_method, base_uri)
29
+ uri = URI.parse(uri) unless uri.is_a? URI
30
+ base_uri = URI.parse(base_uri) unless base_uri.is_a? URI
31
+
32
+ [
33
+ http_method.upcase,
34
+ canonicalize_uri(uri, base_uri),
35
+ canonicalize_query_string(uri.query)
36
+ ].join(CANONICAL_QUERY_SEPARATOR)
37
+ end
38
+
39
+ def build_claims(issuer, url, http_method, base_url = '', issued_at = nil, expires = nil, attributes = {}) # rubocop:disable Metrics/ParameterLists
40
+ issued_at ||= Time.now.to_i
41
+ expires ||= issued_at + 60
42
+ qsh = Digest::SHA256.hexdigest(create_canonical_request(url, http_method, base_url))
43
+
44
+ {
45
+ iss: issuer,
46
+ iat: issued_at,
47
+ exp: expires,
48
+ qsh: qsh
49
+ }.merge(attributes)
50
+ end
51
+
52
+ def canonicalize_uri(uri, base_uri)
53
+ path = uri.path.sub(/^#{base_uri.path}/, '')
54
+ path = '/' if path.nil? || path.empty?
55
+ path = "/#{path}" unless path.start_with? '/'
56
+ path.chomp!('/') if path.length > 1
57
+ path.gsub(CANONICAL_QUERY_SEPARATOR, ESCAPED_CANONICAL_QUERY_SEPARATOR)
58
+ end
59
+
60
+ def canonicalize_query_string(query)
61
+ return '' if query.nil? || query.empty?
62
+
63
+ query = CGI.parse(query)
64
+ query.delete('jwt')
65
+ query.each do |k, v|
66
+ query[k] = v.map { |a| CGI.escape a }.join(',') if v.is_a? Array
67
+ query[k].gsub!('+', '%20') # Use %20, not CGI.escape default of "+"
68
+ query[k].gsub!('%7E', '~') # Unescape "~" per JS tests
69
+ end
70
+ query = query.sort.to_h
71
+ query.map { |k, v| "#{CGI.escape k}=#{v}" }.join(CANONICAL_QUERY_SEPARATOR)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -38,7 +38,7 @@ module JIRA
38
38
  # The principle purpose of this class is to delegate methods to the corresponding
39
39
  # non-factory class and automatically prepend the client argument to the argument
40
40
  # list.
41
- delegate_to_target_class :all, :find, :collection_path, :singular_path, :jql, :get_backlog_issues,
41
+ delegate_to_target_class :all, :find, :collection_path, :singular_path, :jql, :jql_paged, :get_backlog_issues,
42
42
  :get_board_issues, :get_sprints, :get_sprint_issues, :get_projects, :get_projects_full
43
43
 
44
44
  # This method needs special handling as it has a default argument value
data/lib/jira/client.rb CHANGED
@@ -302,6 +302,10 @@ module JIRA
302
302
  JIRA::Resource::AgileFactory.new(self)
303
303
  end
304
304
 
305
+ def Properties
306
+ JIRA::Resource::PropertiesFactory.new(self)
307
+ end
308
+
305
309
  # HTTP methods without a body
306
310
 
307
311
  # Make an HTTP DELETE request
@@ -351,7 +355,9 @@ module JIRA
351
355
  # @raise [JIRA::HTTPError] If the response is not an HTTP success code
352
356
  def post_multipart(path, file, headers = {})
353
357
  puts "post multipart: #{path} - [#{file}]" if @http_debug
354
- @request_client.request_multipart(path, file, merge_default_headers(headers))
358
+ res = @request_client.request_multipart(path, file, merge_default_headers(headers))
359
+ puts "response: #{res}" if @http_debug
360
+ res
355
361
  end
356
362
 
357
363
  # Make an HTTP PUT request
@@ -375,7 +381,9 @@ module JIRA
375
381
  # @raise [JIRA::HTTPError] If the response is not an HTTP success code
376
382
  def request(http_method, path, body = '', headers = {})
377
383
  puts "#{http_method}: #{path} - [#{body}]" if @http_debug
378
- @request_client.request(http_method, path, body, headers)
384
+ res = @request_client.request(http_method, path, body, headers)
385
+ puts "response: #{res}" if @http_debug
386
+ res
379
387
  end
380
388
 
381
389
  # @private
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'atlassian/jwt'
3
+ require 'jira/atlassian/jwt'
4
4
 
5
5
  module JIRA
6
6
  class JwtClient < HttpClient
@@ -29,7 +29,7 @@ module JIRA
29
29
  end
30
30
 
31
31
  def build_jwt(url)
32
- claim = Atlassian::Jwt.build_claims \
32
+ claim = JIRA::Atlassian::Jwt.build_claims \
33
33
  @options[:issuer],
34
34
  url,
35
35
  http_method.to_s,
@@ -146,10 +146,14 @@ module JIRA
146
146
  # @raise [JIRA::HTTPError] if failed
147
147
  def save!(attrs, path = url)
148
148
  file = attrs['file'] || attrs[:file] # Keep supporting 'file' parameter as a string for backward compatibility
149
- mime_type = attrs[:mimeType] || 'application/binary'
149
+ # If :filename does not exist or is nil, that is fine as it will force
150
+ # Upload to determine the filename automatically from file.
151
+ # Breaking the filename out allows this to support any IO-based file parameter.
152
+ fname = attrs['filename'] || attrs[:filename]
153
+ mime_type = attrs['mimeType'] || attrs[:mimeType] || 'application/binary'
150
154
 
151
155
  headers = { 'X-Atlassian-Token' => 'nocheck' }
152
- data = { 'file' => Multipart::Post::UploadIO.new(file, mime_type, file) }
156
+ data = { 'file' => Multipart::Post::UploadIO.new(file, mime_type, fname) }
153
157
 
154
158
  response = client.post_multipart(path, data, headers)
155
159
 
@@ -159,6 +163,17 @@ module JIRA
159
163
  true
160
164
  end
161
165
 
166
+ def http_download
167
+ # Actually fetch the attachment
168
+ # Note: Jira handles attachment's weird!
169
+ # Typically, they respond with a redirect location that should not have the same authentication
170
+ client.get(attrs['content'])
171
+ rescue JIRA::HTTPError => e
172
+ raise e unless e.response.code =~ /\A3\d\d\z/ && e.response['location'].present?
173
+
174
+ Net::HTTP.get_response(URI(e.response['location']))
175
+ end
176
+
162
177
  private
163
178
 
164
179
  def set_attributes(attributes, response)
@@ -9,6 +9,18 @@ module JIRA
9
9
  belongs_to :issue
10
10
 
11
11
  nested_collections true
12
+
13
+ def self.all(client, options = {})
14
+ issue = options[:issue]
15
+ raise ArgumentError, 'parent issue is required' unless issue
16
+
17
+ response = client.get("#{issue.url}/#{endpoint_name}")
18
+ json = parse_json(response.body)
19
+ json = json[endpoint_name.pluralize]
20
+ json.map do |attrs|
21
+ new(client, { attrs: }.merge(options))
22
+ end
23
+ end
12
24
  end
13
25
  end
14
26
  end
@@ -12,22 +12,22 @@ module JIRA
12
12
 
13
13
  def self.all(client, params = {})
14
14
  if params.key?(:projectKeys)
15
- values = Array(params[:projectKeys]).map { |i| (i.is_a?(JIRA::Resource::Project) ? i.key : i) }
15
+ values = Array(params[:projectKeys]).map { |i| i.is_a?(JIRA::Resource::Project) ? i.key : i }
16
16
  params[:projectKeys] = values.join(',')
17
17
  end
18
18
 
19
19
  if params.key?(:projectIds)
20
- values = Array(params[:projectIds]).map { |i| (i.is_a?(JIRA::Resource::Project) ? i.id : i) }
20
+ values = Array(params[:projectIds]).map { |i| i.is_a?(JIRA::Resource::Project) ? i.id : i }
21
21
  params[:projectIds] = values.join(',')
22
22
  end
23
23
 
24
24
  if params.key?(:issuetypeNames)
25
- values = Array(params[:issuetypeNames]).map { |i| (i.is_a?(JIRA::Resource::Issuetype) ? i.name : i) }
25
+ values = Array(params[:issuetypeNames]).map { |i| i.is_a?(JIRA::Resource::Issuetype) ? i.name : i }
26
26
  params[:issuetypeNames] = values.join(',')
27
27
  end
28
28
 
29
29
  if params.key?(:issuetypeIds)
30
- values = Array(params[:issuetypeIds]).map { |i| (i.is_a?(JIRA::Resource::Issuetype) ? i.id : i) }
30
+ values = Array(params[:issuetypeIds]).map { |i| i.is_a?(JIRA::Resource::Issuetype) ? i.id : i }
31
31
  params[:issuetypeIds] = values.join(',')
32
32
  end
33
33
 
@@ -57,6 +57,7 @@ module JIRA
57
57
  has_many :issuelinks, nested_under: 'fields'
58
58
  has_many :remotelink, class: JIRA::Resource::Remotelink
59
59
  has_many :watchers, attribute_key: 'watches', nested_under: %w[fields watches]
60
+ has_many :properties, class: JIRA::Resource::Properties
60
61
 
61
62
  # Get collection of issues.
62
63
  # @param client [JIRA::Client]
@@ -80,7 +81,57 @@ module JIRA
80
81
  result
81
82
  end
82
83
 
84
+ # Get issues using JQL query.
85
+ # @param client [JIRA::Client]
86
+ # @param jql [String] the JQL query string to search with
87
+ # @param options [Hash] Jira API options for the search
88
+ # @return [Array<JIRA::Resource::Issue>] or [Integer] total count if max_results is 0
83
89
  def self.jql(client, jql, options = { fields: nil, max_results: nil, expand: nil, reconcile_issues: nil })
90
+ issues = []
91
+ total = nil
92
+ next_page_token = nil
93
+ is_last = false
94
+
95
+ until is_last
96
+ result = jql_paged(client, jql, options.merge(next_page_token:))
97
+
98
+ issues.concat(result[:issues])
99
+ total = result[:total]
100
+ next_page_token = result[:next_page_token]
101
+ is_last = next_page_token.nil?
102
+ end
103
+ options[:max_results]&.zero? ? total : issues
104
+ end
105
+
106
+ # Get paged issues using JQL query.
107
+ # @param jql [String] the JQL query string to search with
108
+ # @param options [Hash] Jira API options for the search, including next_page_token
109
+ # @return [Hash] with format { issues: [JIRA::Resource::Issue], next_page_token: [String], total: [Integer] }
110
+ def self.jql_paged(client, jql, options = { fields: nil, max_results: nil, expand: nil, reconcile_issues: nil, next_page_token: nil })
111
+ url = jql_url(client, jql, options)
112
+ next_page_token = options[:next_page_token]
113
+ max_results = options[:max_results]
114
+
115
+ issues = []
116
+
117
+ page_url = url.dup
118
+ page_url << "&nextPageToken=#{next_page_token}" if next_page_token
119
+
120
+ response = client.get(page_url)
121
+ json = parse_json(response.body)
122
+ total = json['total']
123
+
124
+ unless max_results&.zero?
125
+ next_page_token = json['nextPageToken']
126
+ json['issues'].map do |issue|
127
+ issues << client.Issue.build(issue)
128
+ end
129
+ end
130
+
131
+ { issues:, next_page_token:, total: }
132
+ end
133
+
134
+ def self.jql_url(client, jql, options)
84
135
  url = client.options[:rest_base_path] + "/search/jql?jql=#{CGI.escape(jql)}"
85
136
 
86
137
  if options[:fields]
@@ -95,24 +146,7 @@ module JIRA
95
146
  options[:expand] = [options[:expand]] if options[:expand].is_a?(String)
96
147
  url << "&expand=#{options[:expand].to_a.map { |value| CGI.escape(value.to_s) }.join(',')}"
97
148
  end
98
-
99
- issues = []
100
- next_page_token = nil
101
- json = {}
102
- while json['isLast'] != true
103
- page_url = url.dup
104
- page_url << "&nextPageToken=#{next_page_token}" if next_page_token
105
-
106
- response = client.get(page_url)
107
- json = parse_json(response.body)
108
- return json['total'] if options[:max_results]&.zero?
109
-
110
- next_page_token = json['nextPageToken']
111
- json['issues'].map do |issue|
112
- issues << client.Issue.build(issue)
113
- end
114
- end
115
- issues
149
+ url
116
150
  end
117
151
 
118
152
  # Fetches the attributes for the specified resource from JIRA unless
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/inflector'
4
+
5
+ module JIRA
6
+ module Resource
7
+ class PropertiesFactory < JIRA::BaseFactory # :nodoc:
8
+ end
9
+
10
+ class Properties < JIRA::Base
11
+ belongs_to :issue
12
+
13
+ def self.key_attribute
14
+ :key
15
+ end
16
+
17
+ def self.all(client, options = {})
18
+ issue = options[:issue]
19
+ raise ArgumentError, 'parent issue is required' unless issue
20
+
21
+ response = client.get("#{issue.url}/#{endpoint_name}")
22
+ json = parse_json(response.body)
23
+ json[key_attribute.to_s.pluralize].map do |attrs|
24
+ ## Net get the individual property
25
+ self_response = client.get(attrs['self'])
26
+ property = parse_json(self_response.body)
27
+ ## Make sure to build the new resource via the issue.properties in order to support the has_many proxy
28
+ issue.properties.build(property)
29
+ end
30
+ end
31
+
32
+ ## Override save so we can handle the required attrs (and default 'value' when appropriate)
33
+ def save!(attrs = {}, path = nil)
34
+ if attrs.is_a?(Hash) && attrs.key?(self.class.key_attribute.to_s)
35
+ raise ArgumentError, "Use of 'value' is required when '#{self.class.key_attribute}' is provided" \
36
+ unless attrs.key?('value')
37
+
38
+ set_attrs(self.class.key_attribute.to_s => attrs[self.class.key_attribute.to_s])
39
+ end
40
+
41
+ raise ArgumentError, "'key' is required on a new record" if new_record?
42
+
43
+ path ||= patched_url
44
+ ## We can take either the 'value' element from the hash, OR use the entire attrs as the value
45
+ value = attrs.is_a?(Hash) && attrs.key?('value') ? attrs['value'] : attrs
46
+ value = '' if value.nil?
47
+ ## Note: this API endpoint requires a non-empty JSON body for the value of the property
48
+ ## Note2: this API endpoint does not return a body, so no need to call set_attrs_from_response
49
+ client.send(:put, path, value.to_json)
50
+ set_attrs({ 'value' => value }, false)
51
+ @expanded = false
52
+ true
53
+ end
54
+ end
55
+ end
56
+ end
@@ -10,6 +10,18 @@ module JIRA
10
10
  has_one :update_author, class: JIRA::Resource::User, attribute_key: 'updateAuthor'
11
11
  belongs_to :issue
12
12
  nested_collections true
13
+
14
+ def self.all(client, options = {})
15
+ issue = options[:issue]
16
+ raise ArgumentError, 'parent issue is required' unless issue
17
+
18
+ response = client.get("#{issue.url}/#{endpoint_name}")
19
+ json = parse_json(response.body)
20
+ json = json[endpoint_name.pluralize]
21
+ json.map do |attrs|
22
+ new(client, { attrs: }.merge(options))
23
+ end
24
+ end
13
25
  end
14
26
  end
15
27
  end
data/lib/jira/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JIRA
4
- VERSION = '3.0.0'
4
+ VERSION = '3.2.0'
5
5
  end
data/lib/jira-ruby.rb CHANGED
@@ -23,6 +23,7 @@ require 'jira/resource/status'
23
23
  require 'jira/resource/status_category'
24
24
  require 'jira/resource/transition'
25
25
  require 'jira/resource/project'
26
+ require 'jira/resource/properties'
26
27
  require 'jira/resource/priority'
27
28
  require 'jira/resource/comment'
28
29
  require 'jira/resource/worklog'