jira-ruby 2.3.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.md +20 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  4. data/.github/dependabot.yml +6 -0
  5. data/.github/workflows/CI.yml +30 -0
  6. data/.github/workflows/codeql.yml +96 -0
  7. data/.github/workflows/rubocop.yml +18 -0
  8. data/.gitignore +3 -1
  9. data/.rubocop.yml +123 -0
  10. data/.yardopts +4 -0
  11. data/Gemfile +11 -3
  12. data/Guardfile +2 -0
  13. data/README.md +94 -19
  14. data/Rakefile +3 -4
  15. data/jira-ruby.gemspec +12 -17
  16. data/lib/jira/atlassian/jwt.rb +76 -0
  17. data/lib/jira/base.rb +37 -36
  18. data/lib/jira/base_factory.rb +4 -1
  19. data/lib/jira/client.rb +132 -51
  20. data/lib/jira/has_many_proxy.rb +32 -28
  21. data/lib/jira/http_client.rb +80 -13
  22. data/lib/jira/http_error.rb +4 -0
  23. data/lib/jira/jwt_client.rb +19 -43
  24. data/lib/jira/oauth_client.rb +68 -3
  25. data/lib/jira/railtie.rb +2 -0
  26. data/lib/jira/request_client.rb +31 -2
  27. data/lib/jira/resource/agile.rb +7 -9
  28. data/lib/jira/resource/applinks.rb +5 -3
  29. data/lib/jira/resource/attachment.rb +144 -4
  30. data/lib/jira/resource/board.rb +5 -3
  31. data/lib/jira/resource/board_configuration.rb +2 -0
  32. data/lib/jira/resource/comment.rb +14 -0
  33. data/lib/jira/resource/component.rb +2 -0
  34. data/lib/jira/resource/createmeta.rb +7 -5
  35. data/lib/jira/resource/field.rb +13 -12
  36. data/lib/jira/resource/filter.rb +2 -0
  37. data/lib/jira/resource/issue.rb +148 -54
  38. data/lib/jira/resource/issue_picker_suggestions.rb +4 -1
  39. data/lib/jira/resource/issue_picker_suggestions_issue.rb +2 -0
  40. data/lib/jira/resource/issuelink.rb +6 -3
  41. data/lib/jira/resource/issuelinktype.rb +2 -0
  42. data/lib/jira/resource/issuetype.rb +2 -0
  43. data/lib/jira/resource/priority.rb +2 -0
  44. data/lib/jira/resource/project.rb +4 -2
  45. data/lib/jira/resource/properties.rb +56 -0
  46. data/lib/jira/resource/rapidview.rb +5 -3
  47. data/lib/jira/resource/remotelink.rb +2 -0
  48. data/lib/jira/resource/resolution.rb +2 -0
  49. data/lib/jira/resource/serverinfo.rb +2 -0
  50. data/lib/jira/resource/sprint.rb +14 -23
  51. data/lib/jira/resource/status.rb +7 -1
  52. data/lib/jira/resource/status_category.rb +10 -0
  53. data/lib/jira/resource/suggested_issue.rb +2 -0
  54. data/lib/jira/resource/transition.rb +2 -0
  55. data/lib/jira/resource/user.rb +3 -1
  56. data/lib/jira/resource/version.rb +2 -0
  57. data/lib/jira/resource/watcher.rb +3 -2
  58. data/lib/jira/resource/webhook.rb +9 -3
  59. data/lib/jira/resource/worklog.rb +15 -2
  60. data/lib/jira/version.rb +3 -1
  61. data/lib/jira-ruby.rb +6 -3
  62. data/lib/tasks/generate.rake +3 -1
  63. data/spec/data/files/jwt-signed-urls.json +317 -0
  64. data/spec/data/files/short.txt +1 -0
  65. data/spec/integration/attachment_spec.rb +3 -3
  66. data/spec/integration/comment_spec.rb +8 -8
  67. data/spec/integration/component_spec.rb +7 -7
  68. data/spec/integration/field_spec.rb +3 -3
  69. data/spec/integration/issue_spec.rb +21 -18
  70. data/spec/integration/issuelinktype_spec.rb +3 -3
  71. data/spec/integration/issuetype_spec.rb +3 -3
  72. data/spec/integration/priority_spec.rb +3 -3
  73. data/spec/integration/project_spec.rb +8 -8
  74. data/spec/integration/properties_spec.rb +45 -0
  75. data/spec/integration/rapidview_spec.rb +10 -10
  76. data/spec/integration/resolution_spec.rb +3 -3
  77. data/spec/integration/status_category_spec.rb +20 -0
  78. data/spec/integration/status_spec.rb +4 -8
  79. data/spec/integration/transition_spec.rb +5 -4
  80. data/spec/integration/user_spec.rb +34 -11
  81. data/spec/integration/version_spec.rb +7 -7
  82. data/spec/integration/watcher_spec.rb +21 -18
  83. data/spec/integration/webhook_spec.rb +35 -0
  84. data/spec/integration/worklog_spec.rb +8 -8
  85. data/spec/jira/atlassian/jwt_spec.rb +60 -0
  86. data/spec/jira/base_factory_spec.rb +13 -3
  87. data/spec/jira/base_spec.rb +135 -98
  88. data/spec/jira/client_spec.rb +72 -56
  89. data/spec/jira/has_many_proxy_spec.rb +3 -3
  90. data/spec/jira/http_client_spec.rb +94 -27
  91. data/spec/jira/http_error_spec.rb +2 -2
  92. data/spec/jira/oauth_client_spec.rb +14 -8
  93. data/spec/jira/request_client_spec.rb +4 -4
  94. data/spec/jira/resource/agile_spec.rb +32 -32
  95. data/spec/jira/resource/attachment_spec.rb +170 -57
  96. data/spec/jira/resource/board_spec.rb +24 -23
  97. data/spec/jira/resource/createmeta_spec.rb +48 -48
  98. data/spec/jira/resource/field_spec.rb +44 -27
  99. data/spec/jira/resource/filter_spec.rb +7 -6
  100. data/spec/jira/resource/issue_picker_suggestions_spec.rb +17 -17
  101. data/spec/jira/resource/issue_spec.rb +159 -93
  102. data/spec/jira/resource/jira_picker_suggestions_issue_spec.rb +3 -3
  103. data/spec/jira/resource/project_factory_spec.rb +3 -2
  104. data/spec/jira/resource/project_spec.rb +16 -16
  105. data/spec/jira/resource/sprint_spec.rb +88 -9
  106. data/spec/jira/resource/status_spec.rb +21 -0
  107. data/spec/jira/resource/user_factory_spec.rb +5 -5
  108. data/spec/jira/resource/worklog_spec.rb +4 -4
  109. data/spec/mock_responses/board/1_issues.json +2 -1
  110. data/spec/mock_responses/issue/10002/properties/foo.json +4 -0
  111. data/spec/mock_responses/issue/10002/properties/xyz.json +4 -0
  112. data/spec/mock_responses/issue/10002/properties/xyz.put.json +4 -0
  113. data/spec/mock_responses/issue/10002/properties.json +12 -0
  114. data/spec/mock_responses/issue.json +1 -0
  115. data/spec/mock_responses/rapidview/SAMPLEPROJECT.issues.full.json +2 -1
  116. data/spec/mock_responses/rapidview/SAMPLEPROJECT.issues.json +2 -1
  117. data/spec/mock_responses/sprint/1.json +13 -0
  118. data/spec/mock_responses/status/1.json +8 -1
  119. data/spec/mock_responses/status.json +40 -5
  120. data/spec/mock_responses/statuscategory/1.json +7 -0
  121. data/spec/mock_responses/statuscategory.json +30 -0
  122. data/spec/mock_responses/{user_username=admin.json → user_accountId=1234567890abcdef01234567.json} +2 -1
  123. data/spec/spec_helper.rb +1 -0
  124. data/spec/support/clients_helper.rb +5 -7
  125. data/spec/support/mock_client.rb +9 -0
  126. data/spec/support/mock_response.rb +8 -0
  127. data/spec/support/shared_examples/integration.rb +51 -58
  128. metadata +45 -257
  129. data/.travis.yml +0 -9
  130. data/example.rb +0 -232
  131. data/http-basic-example.rb +0 -113
  132. data/lib/jira/resource/sprint_report.rb +0 -8
  133. data/lib/jira/tasks.rb +0 -0
  134. data/spec/integration/webhook.rb +0 -25
  135. data/spec/jira/jwt_uri_builder_spec.rb +0 -59
  136. data/spec/mock_responses/jira/rest/webhooks/1.0/webhook.json +0 -11
  137. data/spec/mock_responses/webhook/webhook.json +0 -11
  138. /data/spec/mock_responses/{jira/rest/webhooks/1.0/webhook → webhook}/2.json +0 -0
@@ -1,4 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'net/http/post/multipart'
4
+ require 'open-uri'
2
5
 
3
6
  module JIRA
4
7
  module Resource
@@ -6,27 +9,153 @@ module JIRA
6
9
  delegate_to_target_class :meta
7
10
  end
8
11
 
12
+ # This class provides the Attachment object <-> REST mapping for JIRA::Resource::Attachment derived class,
13
+ # i.e. the Create, Retrieve, Update, Delete lifecycle methods.
14
+ #
15
+ # == Lifecycle methods
16
+ #
17
+ # === Retrieving a single attachment by Issue and attachment id
18
+ #
19
+ # # Find attachment with id 30076 on issue SUP-3000.
20
+ # issue = JIRA::Resource::Issue.find(client, 'SUP-3000', { fields: 'attachment' } )
21
+ # attachment = issue.attachments.find do |attachment_curr|
22
+ # 30076 == attachment_curr.id.to_i
23
+ # end
24
+ #
25
+ # === Retrieving meta information for an attachments
26
+ #
27
+ # attachment.meta
28
+ #
29
+ # === Retrieving file contents of attachment
30
+ #
31
+ # content = URI.open(attachment.content).read
32
+ # content = attachment.download_contents
33
+ # content = attachment.download_file { |file| file.read }
34
+ #
35
+ # === Adding an attachment to an issue
36
+ #
37
+ # Dir.mktmpdir do |dir|
38
+ # path = File.join(dir, filename)
39
+ # IO.copy_stream(file.path, path)
40
+ #
41
+ # issue = JIRA::Resource::Issue.find(client, 'SUP-3000', { fields: 'attachment' } )
42
+ # attachment = issue.attachments.build
43
+ # attachment.save!( { file: path, mimeType: content_type } )
44
+ # end
45
+ #
46
+ # === Deleting an attachment
47
+ #
48
+ # attachment.delete
49
+ #
50
+ # @!method save(attrs, path = url)
51
+ # Uploads a file as an attachment to its issue.
52
+ #
53
+ # Filename used will be the basename of the given file.
54
+ #
55
+ # @param [Hash] attrs the options to create a message with.
56
+ # @option attrs [IO,String] :file The file to upload, either a file object or a file path to find the file.
57
+ # @option attrs [String] :mimeType The MIME type of the file.
58
+ # @return [Boolean] True if successful, false if failed.
59
+ #
60
+ # @!attribute [r] self
61
+ # @return [String] URL to JSON of this attachment
62
+ # @!attribute [r] filename
63
+ # @return [String] the filename
64
+ # @!attribute [r] author
65
+ # @return [JIRA::Resource::User] the user who created the attachment
66
+ # @!attribute [r] created
67
+ # @return [String] timestamp when the attachment was created
68
+ # @!attribute [r] size
69
+ # @return [Integer] the file size
70
+ # @!attribute [r] mimeType
71
+ # @return [String] MIME of the content type
72
+ # @!attribute [r] content
73
+ # @return [String] URL (not the contents) to download the contents of the attachment
74
+ # @!attribute [r] thumbnail
75
+ # @return [String] URL to download the thumbnail of the attachment
76
+ #
9
77
  class Attachment < JIRA::Base
10
78
  belongs_to :issue
11
79
  has_one :author, class: JIRA::Resource::User
12
80
 
81
+ # @private
13
82
  def self.endpoint_name
14
83
  'attachments'
15
84
  end
16
85
 
86
+ # Gets metadata about attachments on the server.
87
+ # @example Return metadata
88
+ # Attachment.meta(client)
89
+ # => { "enabled" => true, "uploadLimit" => 1000000 }
90
+ # @return [Hash] The metadata for attachments. (See example.)
17
91
  def self.meta(client)
18
- response = client.get(client.options[:rest_base_path] + '/attachment/meta')
92
+ response = client.get("#{client.options[:rest_base_path]}/attachment/meta")
19
93
  parse_json(response.body)
20
94
  end
21
95
 
96
+ # Opens a file streaming the download of the attachment.
97
+ # @example Write file contents to a file.
98
+ # File.open('some-filename', 'wb') do |output|
99
+ # download_file do |file|
100
+ # IO.copy_stream(file, output)
101
+ # end
102
+ # end
103
+ # @example Stream file contents for an HTTP response.
104
+ # response.headers[ "Content-Type" ] = "application/octet-stream"
105
+ # download_file do |file|
106
+ # chunk = file.read(8000)
107
+ # while chunk.present? do
108
+ # response.stream.write(chunk)
109
+ # chunk = file.read(8000)
110
+ # end
111
+ # end
112
+ # response.stream.close
113
+ # @param [Hash] headers Any additional headers to call Jira.
114
+ # @yield |file|
115
+ # @yieldparam [IO] file The IO object streaming the download.
116
+ def download_file(headers = {}, &)
117
+ default_headers = client.options[:default_headers]
118
+ URI.parse(content).open(default_headers.merge(headers), &)
119
+ end
120
+
121
+ # Downloads the file contents as a string object.
122
+ #
123
+ # Note that this reads the contents into a ruby string in memory.
124
+ # A file might be very large so it is recommended to avoid this unless you are certain about doing so.
125
+ # Use the download_file method instead and avoid calling the read method without a limit.
126
+ #
127
+ # @example Save file contents to a string.
128
+ # content = download_contents
129
+ # @param [Hash] headers Any additional headers to call Jira.
130
+ # @return [String,NilClass] The file contents.
131
+ def download_contents(headers = {})
132
+ download_file(headers, &:read)
133
+ end
134
+
135
+ # Uploads a file as an attachment to its issue.
136
+ #
137
+ # Filename used will be the basename of the given file.
138
+ #
139
+ # @example Save a file as an attachment
140
+ # issue = JIRA::Resource::Issue.find(client, 'SUP-3000', { fields: 'attachment' } )
141
+ # attachment = issue.attachments.build
142
+ # attachment.save!( { file: path, mimeType: 'text/plain' } )
143
+ # @param [Hash] attrs the options to create a message with.
144
+ # @option attrs [IO,String] :file The file to upload, either a file object or a file path to find the file.
145
+ # @option attrs [String] :mimeType The MIME type of the file.
146
+ # @raise [JIRA::HTTPError] if failed
22
147
  def save!(attrs, path = url)
23
148
  file = attrs['file'] || attrs[:file] # Keep supporting 'file' parameter as a string for backward compatibility
24
- 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'
25
154
 
26
155
  headers = { 'X-Atlassian-Token' => 'nocheck' }
27
- data = { 'file' => UploadIO.new(file, mime_type, file) }
156
+ data = { 'file' => Multipart::Post::UploadIO.new(file, mime_type, fname) }
28
157
 
29
- response = client.post_multipart(path, data , headers)
158
+ response = client.post_multipart(path, data, headers)
30
159
 
31
160
  set_attributes(attrs, response)
32
161
 
@@ -34,6 +163,17 @@ module JIRA
34
163
  true
35
164
  end
36
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
+
37
177
  private
38
178
 
39
179
  def set_attributes(attributes, response)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'cgi'
2
4
 
3
5
  module JIRA
@@ -7,7 +9,7 @@ module JIRA
7
9
 
8
10
  class Board < JIRA::Base
9
11
  def self.all(client)
10
- path = path_base(client) + '/board'
12
+ path = "#{path_base(client)}/board"
11
13
  response = client.get(path)
12
14
  json = parse_json(response.body)
13
15
  results = json['values']
@@ -74,13 +76,13 @@ module JIRA
74
76
  end
75
77
 
76
78
  def add_issue_to_backlog(issue)
77
- client.post(path_base(client) + '/backlog/issue', { issues: [issue.id] }.to_json)
79
+ client.post("#{path_base(client)}/backlog/issue", { issues: [issue.id] }.to_json)
78
80
  end
79
81
 
80
82
  private
81
83
 
82
84
  def self.path_base(client)
83
- client.options[:context_path] + '/rest/agile/1.0'
85
+ "#{client.options[:context_path]}/rest/agile/1.0"
84
86
  end
85
87
 
86
88
  def path_base(client)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JIRA
2
4
  module Resource
3
5
  class BoardConfigurationFactory < JIRA::BaseFactory # :nodoc:
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JIRA
2
4
  module Resource
3
5
  class CommentFactory < JIRA::BaseFactory # :nodoc:
@@ -7,6 +9,18 @@ module JIRA
7
9
  belongs_to :issue
8
10
 
9
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
10
24
  end
11
25
  end
12
26
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JIRA
2
4
  module Resource
3
5
  class ComponentFactory < JIRA::BaseFactory # :nodoc:
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JIRA
2
4
  module Resource
3
5
  class CreatemetaFactory < JIRA::BaseFactory # :nodoc:
@@ -10,22 +12,22 @@ module JIRA
10
12
 
11
13
  def self.all(client, params = {})
12
14
  if params.key?(:projectKeys)
13
- 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 }
14
16
  params[:projectKeys] = values.join(',')
15
17
  end
16
18
 
17
19
  if params.key?(:projectIds)
18
- 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 }
19
21
  params[:projectIds] = values.join(',')
20
22
  end
21
23
 
22
24
  if params.key?(:issuetypeNames)
23
- 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 }
24
26
  params[:issuetypeNames] = values.join(',')
25
27
  end
26
28
 
27
29
  if params.key?(:issuetypeIds)
28
- 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 }
29
31
  params[:issuetypeIds] = values.join(',')
30
32
  end
31
33
 
@@ -36,7 +38,7 @@ module JIRA
36
38
 
37
39
  json = parse_json(response.body)
38
40
  json['projects'].map do |attrs|
39
- new(client, attrs: attrs)
41
+ new(client, attrs:)
40
42
  end
41
43
  end
42
44
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JIRA
2
4
  module Resource
3
5
  class FieldFactory < JIRA::BaseFactory # :nodoc:
@@ -19,43 +21,42 @@ module JIRA
19
21
 
20
22
  def self.map_fields(client)
21
23
  field_map = {}
22
- field_map_reverse = {}
23
24
  fields = client.Field.all
24
25
 
25
26
  # two pass approach, so that a custom field with the same name
26
27
  # as a system field can't take precedence
27
28
  fields.each do |f|
28
29
  next if f.custom
30
+
29
31
  name = safe_name(f.name)
30
- field_map_reverse[f.id] = [f.name, name] # capture both the official name, and the mapped name
31
32
  field_map[name] = f.id
32
33
  end
33
34
 
34
- fields.each do |f|
35
+ fields.each do |f| # rubocop:disable Style/CombinableLoops
35
36
  next unless f.custom
37
+
36
38
  name = if field_map.key? f.name
37
39
  renamed = safer_name(f.name, f.id)
38
40
  warn "Duplicate Field name #{f.name} #{f.id} - renaming as #{renamed}"
39
41
  renamed
40
42
  else
41
43
  safe_name(f.name)
42
- end
43
- field_map_reverse[f.id] = [f.name, name] # capture both the official name, and the mapped name
44
+ end
44
45
  field_map[name] = f.id
45
46
  end
46
47
 
47
- client.cache.field_map_reverse = field_map_reverse # not sure where this will be used yet, but sure to be useful
48
- client.cache.field_map = field_map
48
+ client.field_map_cache = field_map
49
49
  end
50
50
 
51
51
  def self.field_map(client)
52
- client.cache.field_map
52
+ client.field_map_cache
53
53
  end
54
54
 
55
55
  def self.name_to_id(client, field_name)
56
56
  field_name = field_name.to_s
57
- return field_name unless client.cache.field_map && client.cache.field_map[field_name]
58
- client.cache.field_map[field_name]
57
+ return field_name unless client.field_map_cache && client.field_map_cache[field_name]
58
+
59
+ client.field_map_cache[field_name]
59
60
  end
60
61
 
61
62
  def respond_to?(method_name, _include_all = false)
@@ -66,7 +67,7 @@ module JIRA
66
67
  end
67
68
  end
68
69
 
69
- def method_missing(method_name, *args, &block)
70
+ def method_missing(method_name, *args, &)
70
71
  if attrs.key?(method_name.to_s)
71
72
  attrs[method_name.to_s]
72
73
  else
@@ -74,7 +75,7 @@ module JIRA
74
75
  if attrs.key?(official_name)
75
76
  attrs[official_name]
76
77
  else
77
- super(method_name, *args, &block)
78
+ super
78
79
  end
79
80
  end
80
81
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JIRA
2
4
  module Resource
3
5
  class FilterFactory < JIRA::BaseFactory # :nodoc:
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'cgi'
2
4
  require 'json'
3
5
 
@@ -6,100 +8,168 @@ module JIRA
6
8
  class IssueFactory < JIRA::BaseFactory # :nodoc:
7
9
  end
8
10
 
11
+ # This class provides the Issue object <-> REST mapping for JIRA::Resource::Issue derived class,
12
+ # i.e. the Create, Retrieve, Update, Delete lifecycle methods.
13
+ #
14
+ # == Lifecycle methods
15
+ #
16
+ # === Retrieving all issues
17
+ #
18
+ # client.Issue.all
19
+ #
20
+ # === Retrieving a single issue
21
+ #
22
+ # options = { expand: 'editmeta' }
23
+ # issue = client.Issue.find("SUP-3000", options)
24
+ #
25
+ # === Creating a new issue
26
+ #
27
+ # issue = client.Issue.build(fields: { summary: 'New issue', project: { key: 'SUP' }, issuetype: { name: 'Bug' } })
28
+ # issue.save
29
+ #
30
+ # === Updating an issue
31
+ #
32
+ # issue = client.Issue.find("SUP-3000")
33
+ # issue.save(fields: { summary: 'Updated issue' })
34
+ #
35
+ # === Deleting an issue
36
+ #
37
+ # issue = client.Issue.find("SUP-3000")
38
+ # issue.delete
39
+ #
9
40
  class Issue < JIRA::Base
10
- has_one :reporter, class: JIRA::Resource::User,
11
- nested_under: 'fields'
12
- has_one :assignee, class: JIRA::Resource::User,
13
- nested_under: 'fields'
14
- has_one :project, nested_under: 'fields'
15
-
41
+ has_one :reporter, class: JIRA::Resource::User, nested_under: 'fields'
42
+ has_one :assignee, class: JIRA::Resource::User, nested_under: 'fields'
43
+ has_one :project, nested_under: 'fields'
16
44
  has_one :issuetype, nested_under: 'fields'
17
-
18
- has_one :priority, nested_under: 'fields'
19
-
20
- has_one :status, nested_under: 'fields'
21
-
45
+ has_one :priority, nested_under: 'fields'
46
+ has_one :status, nested_under: 'fields'
47
+ has_one :resolution, nested_under: 'fields'
22
48
  has_many :transitions
23
-
24
49
  has_many :components, nested_under: 'fields'
25
-
26
50
  has_many :comments, nested_under: %w[fields comment]
27
-
28
- has_many :attachments, nested_under: 'fields',
29
- attribute_key: 'attachment'
30
-
31
- has_many :versions, nested_under: 'fields'
32
- has_many :fixVersions, class: JIRA::Resource::Version,
33
- nested_under: 'fields'
34
-
51
+ has_many :attachments, nested_under: 'fields', attribute_key: 'attachment'
52
+ has_many :versions, nested_under: 'fields'
53
+ has_many :fixVersions, class: JIRA::Resource::Version, nested_under: 'fields'
35
54
  has_many :worklogs, nested_under: %w[fields worklog]
36
- has_one :sprint, class: JIRA::Resource::Sprint,
37
- nested_under: 'fields'
38
-
39
- has_many :closed_sprints, class: JIRA::Resource::Sprint,
40
- nested_under: 'fields', attribute_key: 'closedSprints'
41
-
55
+ has_one :sprint, class: JIRA::Resource::Sprint, nested_under: 'fields'
56
+ has_many :closed_sprints, class: JIRA::Resource::Sprint, nested_under: 'fields', attribute_key: 'closedSprints'
42
57
  has_many :issuelinks, nested_under: 'fields'
43
-
44
58
  has_many :remotelink, class: JIRA::Resource::Remotelink
59
+ has_many :watchers, attribute_key: 'watches', nested_under: %w[fields watches]
60
+ has_many :properties, class: JIRA::Resource::Properties
45
61
 
46
- has_many :watchers, attribute_key: 'watches',
47
- nested_under: %w[fields watches]
48
-
62
+ # Get collection of issues.
63
+ # @param client [JIRA::Client]
64
+ # @return [Array<JIRA::Resource::Issue>]
49
65
  def self.all(client)
50
66
  start_at = 0
51
67
  max_results = 1000
52
68
  result = []
53
69
  loop do
54
70
  url = client.options[:rest_base_path] +
55
- "/search?expand=transitions.fields&maxResults=#{max_results}&startAt=#{start_at}"
71
+ "/search/jql?expand=transitions.fields&maxResults=#{max_results}&startAt=#{start_at}"
56
72
  response = client.get(url)
57
73
  json = parse_json(response.body)
58
74
  json['issues'].map do |issue|
59
75
  result.push(client.Issue.build(issue))
60
76
  end
61
77
  break if json['issues'].empty?
78
+
62
79
  start_at += json['issues'].size
63
80
  end
64
81
  result
65
82
  end
66
83
 
67
- def self.jql(client, jql, options = { fields: nil, start_at: nil, max_results: nil, expand: nil, validate_query: true })
68
- url = client.options[:rest_base_path] + "/search?jql=#{CGI.escape(jql)}"
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
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 = []
69
116
 
70
- url << "&fields=#{options[:fields].map { |value| CGI.escape(client.Field.name_to_id(value)) }.join(',')}" if options[:fields]
71
- url << "&startAt=#{CGI.escape(options[:start_at].to_s)}" if options[:start_at]
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)
135
+ url = client.options[:rest_base_path] + "/search/jql?jql=#{CGI.escape(jql)}"
136
+
137
+ if options[:fields]
138
+ url << "&fields=#{options[:fields].map do |value|
139
+ CGI.escape(client.Field.name_to_id(value))
140
+ end.join(',')}"
141
+ end
72
142
  url << "&maxResults=#{CGI.escape(options[:max_results].to_s)}" if options[:max_results]
73
- url << '&validateQuery=false' if options[:validate_query] === false
143
+ url << "&reconcileIssues=#{CGI.escape(options[:reconcile_issues].to_s)}" if options[:reconcile_issues]
74
144
 
75
145
  if options[:expand]
76
146
  options[:expand] = [options[:expand]] if options[:expand].is_a?(String)
77
147
  url << "&expand=#{options[:expand].to_a.map { |value| CGI.escape(value.to_s) }.join(',')}"
78
148
  end
79
-
80
- response = client.get(url)
81
- json = parse_json(response.body)
82
- if options[:max_results] && (options[:max_results] == 0)
83
- return json['total']
84
- end
85
- json['issues'].map do |issue|
86
- client.Issue.build(issue)
87
- end
149
+ url
88
150
  end
89
151
 
90
152
  # Fetches the attributes for the specified resource from JIRA unless
91
153
  # the resource is already expanded and the optional force reload flag
92
154
  # is not set
155
+ # @param [Boolean] reload
156
+ # @param [Hash] query_params
157
+ # @option query_params [String] :fields
158
+ # @option query_params [String] :expand
159
+ # @option query_params [Integer] :startAt
160
+ # @option query_params [Integer] :maxResults
161
+ # @return [void]
93
162
  def fetch(reload = false, query_params = {})
94
163
  return if expanded? && !reload
164
+
95
165
  response = client.get(url_with_query_params(url, query_params))
96
166
  set_attrs_from_response(response)
97
- if @attrs && @attrs['fields'] && @attrs['fields']['worklog'] && (@attrs['fields']['worklog']['total'] > @attrs['fields']['worklog']['maxResults'])
167
+ if @attrs && @attrs['fields'] &&
168
+ @attrs['fields']['worklog'] &&
169
+ (@attrs['fields']['worklog']['total'] > @attrs['fields']['worklog']['maxResults'])
98
170
  worklog_url = client.options[:rest_base_path] + "/#{self.class.endpoint_name}/#{id}/worklog"
99
171
  response = client.get(worklog_url)
100
- unless response.body.nil? || (response.body.length < 2)
101
- set_attrs({ 'fields' => { 'worklog' => self.class.parse_json(response.body) } }, false)
102
- end
172
+ set_attrs({ 'fields' => { 'worklog' => self.class.parse_json(response.body) } }, false) unless response.body.nil? || (response.body.length < 2)
103
173
  end
104
174
  @expanded = true
105
175
  end
@@ -112,15 +182,19 @@ module JIRA
112
182
  json['fields']
113
183
  end
114
184
 
185
+ # @private
115
186
  def respond_to?(method_name, _include_all = false)
116
- if attrs.key?('fields') && [method_name.to_s, client.Field.name_to_id(method_name)].any? { |k| attrs['fields'].key?(k) }
187
+ if attrs.key?('fields') && [method_name.to_s, client.Field.name_to_id(method_name)].any? do |k|
188
+ attrs['fields'].key?(k)
189
+ end
117
190
  true
118
191
  else
119
192
  super(method_name)
120
193
  end
121
194
  end
122
195
 
123
- def method_missing(method_name, *args, &block)
196
+ # @private
197
+ def method_missing(method_name, *args, &)
124
198
  if attrs.key?('fields')
125
199
  if attrs['fields'].key?(method_name.to_s)
126
200
  attrs['fields'][method_name.to_s]
@@ -129,13 +203,33 @@ module JIRA
129
203
  if attrs['fields'].key?(official_name)
130
204
  attrs['fields'][official_name]
131
205
  else
132
- super(method_name, *args, &block)
206
+ super
133
207
  end
134
208
  end
135
209
  else
136
- super(method_name, *args, &block)
210
+ super
137
211
  end
138
212
  end
213
+
214
+ # @!method self.find(client, key, options = {})
215
+ # Gets/fetches an issue from JIRA.
216
+ #
217
+ # Note: attachments are not fetched by default.
218
+ #
219
+ # @param [JIRA::Client] client
220
+ # @param [String] key the key of the issue to find
221
+ # @param [Hash] options the options to find the issue with
222
+ # @option options [String] :fields the fields to include in the response
223
+ # @return [JIRA::Resource::Issue] the found issue
224
+ # @example Find an issue
225
+ # JIRA::Resource::Issue.find(client, "SUP-3000", { fields: %w[summary description attachment created ] } )
226
+ #
227
+ # @!method self.build(attrs = {})
228
+ # Constructs a new issue object.
229
+ # @param [Hash] attrs the attributes to initialize the issue with
230
+ # @return [JIRA::Resource::Issue] the new issue
231
+ #
232
+ # .
139
233
  end
140
234
  end
141
235
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JIRA
2
4
  module Resource
3
5
  class IssuePickerSuggestionsFactory < JIRA::BaseFactory # :nodoc:
@@ -6,7 +8,8 @@ module JIRA
6
8
  class IssuePickerSuggestions < JIRA::Base
7
9
  has_many :sections, class: JIRA::Resource::IssuePickerSuggestionsIssue
8
10
 
9
- def self.all(client, query = '', options = { current_jql: nil, current_issue_key: nil, current_project_id: nil, show_sub_tasks: nil, show_sub_tasks_parent: nil })
11
+ def self.all(client, query = '', options = { current_jql: nil, current_issue_key: nil, current_project_id: nil,
12
+ show_sub_tasks: nil, show_sub_tasks_parent: nil })
10
13
  url = client.options[:rest_base_path] + "/issue/picker?query=#{CGI.escape(query)}"
11
14
 
12
15
  url << "&currentJQL=#{CGI.escape(options[:current_jql])}" if options[:current_jql]