ruby-jira 0.1.0 → 0.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.
@@ -3,64 +3,41 @@
3
3
  require "uri"
4
4
 
5
5
  module Jira
6
- # Wrapper for Jira offset-paginated responses (values/isLast style).
6
+ # Wrapper for Jira offset-paginated responses.
7
7
  #
8
- # Endpoints like GET /project/search and GET /workflow/search return a body of:
9
- # { values: [...], isLast: bool, total: int, nextPage: url, startAt: int, maxResults: int }
8
+ # Supports two formats:
9
+ # - Classic: { values: [...], isLast: bool, nextPage: url, startAt: int, maxResults: int, total: int }
10
+ # (GET /project/search, GET /issue/{key}/changelog, POST /comment/list)
11
+ # - Legacy: { <items_key>: [...], startAt: int, maxResults: int, total: int }
12
+ # (GET /issue/{key}/comment, GET /issue/{key}/worklog)
13
+ #
14
+ # For legacy format, items key is auto-detected (first non-metadata Array value).
15
+ # When nextPage URL is absent, a next_page_fetcher proc set by the Request layer
16
+ # drives pagination by incrementing startAt.
10
17
  class PaginatedResponse
11
- attr_accessor :client
18
+ include Logging
19
+ include Pagination::CollectionBehavior
20
+
21
+ METADATA_KEYS = %i[
22
+ isLast maxResults nextPage self startAt total pageSize nextPageToken expand warningMessages
23
+ ].freeze
24
+
25
+ attr_accessor :client, :next_page_fetcher
12
26
  attr_reader :total, :max_results, :start_at, :self_url
13
27
 
14
- def initialize(body)
28
+ def initialize(body) # rubocop:disable Metrics/AbcSize
15
29
  @body = body
16
- @array = wrap_items(body.fetch(:values, []))
17
- @is_last = body.fetch(:isLast, false)
18
- @max_results = body.fetch(:maxResults, 0).to_i
30
+ items_key, items = detect_items(body)
31
+ log "PaginatedResponse: items_key=#{items_key.inspect} count=#{items.length}"
32
+ @array = wrap_items(items)
33
+ @is_last = body.key?(:isLast) ? body[:isLast] : (@array.length + body.fetch(:startAt, 0) >= body.fetch(:total, 0))
34
+ @max_results = (body[:maxResults] || body[:pageSize] || items.length).to_i
19
35
  @next_page = body.fetch(:nextPage, "")
20
36
  @self_url = body.fetch(:self, "")
21
37
  @start_at = body.fetch(:startAt, 0).to_i
22
38
  @total = body.fetch(:total, 0).to_i
23
39
  end
24
40
 
25
- def inspect
26
- @array.inspect
27
- end
28
-
29
- def method_missing(name, *, &)
30
- return @array.send(name, *, &) if @array.respond_to?(name)
31
-
32
- super
33
- end
34
-
35
- def respond_to_missing?(method_name, include_private = false)
36
- super || @array.respond_to?(method_name, include_private)
37
- end
38
-
39
- def each_page
40
- current = self
41
- yield current
42
- while current.has_next_page?
43
- current = current.next_page
44
- yield current
45
- end
46
- end
47
-
48
- def lazy_paginate
49
- to_enum(:each_page).lazy.flat_map(&:to_ary)
50
- end
51
-
52
- def auto_paginate(&block)
53
- return lazy_paginate.to_a unless block
54
-
55
- lazy_paginate.each(&block)
56
- end
57
-
58
- def paginate_with_limit(limit, &block)
59
- return lazy_paginate.take(limit).to_a unless block
60
-
61
- lazy_paginate.take(limit).each(&block)
62
- end
63
-
64
41
  def last_page?
65
42
  @is_last == true
66
43
  end
@@ -72,14 +49,20 @@ module Jira
72
49
  alias has_first_page? first_page?
73
50
 
74
51
  def next_page?
75
- @is_last == false && !@next_page.to_s.empty?
52
+ !last_page? && (!@next_page.to_s.empty? || !@next_page_fetcher.nil?)
76
53
  end
77
54
  alias has_next_page? next_page?
78
55
 
79
56
  def next_page
80
- return nil unless has_next_page?
57
+ return nil unless next_page?
58
+ return next_page_by_link unless @next_page.to_s.empty?
59
+ raise Error::MissingCredentials, "next_page_fetcher not set on PaginatedResponse" unless @next_page_fetcher
81
60
 
82
- @client.get(client_relative_path(@next_page))
61
+ @next_page_fetcher.call(@start_at + @max_results)
62
+ end
63
+
64
+ def pagination_progress_marker
65
+ [@start_at, @next_page.to_s]
83
66
  end
84
67
 
85
68
  def client_relative_path(link)
@@ -89,6 +72,19 @@ module Jira
89
72
 
90
73
  private
91
74
 
75
+ def next_page_by_link
76
+ raise Error::MissingCredentials, "client not set on PaginatedResponse" unless @client
77
+
78
+ @client.get(client_relative_path(@next_page))
79
+ end
80
+
81
+ def detect_items(body)
82
+ return [:values, body[:values]] if body.key?(:values)
83
+
84
+ body.each { |k, v| return [k, v] if !METADATA_KEYS.include?(k) && v.is_a?(Array) }
85
+ [nil, []]
86
+ end
87
+
92
88
  def wrap_items(items)
93
89
  items.map { |item| item.is_a?(Hash) ? ObjectifiedHash.new(item) : item }
94
90
  end
@@ -8,12 +8,12 @@ module Jira
8
8
  # https://developer.atlassian.com/cloud/jira/platform/rate-limiting/
9
9
  #
10
10
  # Supported response headers (enforced by Jira Cloud):
11
- # Retry-After seconds to wait before retrying (429 and some 503)
12
- # X-RateLimit-Reset ISO 8601 timestamp when the window resets (429 only)
13
- # X-RateLimit-Limit max request rate for the current scope
14
- # X-RateLimit-Remaining remaining capacity in the current window
15
- # X-RateLimit-NearLimit "true" when < 20% capacity remains
16
- # RateLimit-Reason which limit was exceeded (burst/quota/per-issue)
11
+ # Retry-After - seconds to wait before retrying (429 and some 503)
12
+ # X-RateLimit-Reset - ISO 8601 timestamp when the window resets (429 only)
13
+ # X-RateLimit-Limit - max request rate for the current scope
14
+ # X-RateLimit-Remaining - remaining capacity in the current window
15
+ # X-RateLimit-NearLimit - "true" when < 20% capacity remains
16
+ # RateLimit-Reason - which limit was exceeded (burst/quota/per-issue)
17
17
  class RetryPolicy
18
18
  IDEMPOTENT_HTTP_METHODS = %w[get head put delete options].freeze
19
19
 
@@ -4,16 +4,12 @@ module Jira
4
4
  class Request
5
5
  class ResponseParser
6
6
  class << self
7
- def parse(body) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
7
+ def parse(body)
8
8
  decoded = decode(body)
9
- return PaginatedResponse.new(decoded) if offset_paginated?(decoded)
10
- return CursorPaginatedResponse.new(decoded) if cursor_paginated?(decoded)
11
- return ObjectifiedHash.new(decoded) if decoded.is_a?(Hash)
12
- return decoded.map { |item| item.is_a?(Hash) ? ObjectifiedHash.new(item) : item } if decoded.is_a?(Array)
13
- return true if decoded
14
- return false unless decoded
9
+ paginated = parse_paginated(decoded)
10
+ return paginated if paginated
15
11
 
16
- raise Error::Parsing, "Couldn't parse a response body"
12
+ parse_non_paginated(decoded)
17
13
  end
18
14
 
19
15
  def decode(response)
@@ -26,18 +22,88 @@ module Jira
26
22
 
27
23
  private
28
24
 
29
- # Offset-based pagination: GET /project/search, GET /workflow/search, etc.
30
- # Requires :values and at least one offset-pagination hint.
25
+ def parse_paginated(decoded)
26
+ case pagination_model(decoded)
27
+ when :cursor
28
+ CursorPaginatedResponse.new(decoded)
29
+ when :offset
30
+ PaginatedResponse.new(decoded)
31
+ end
32
+ end
33
+
34
+ def parse_non_paginated(decoded)
35
+ return ObjectifiedHash.new(decoded) if decoded.is_a?(Hash)
36
+ return decoded.map { |item| item.is_a?(Hash) ? ObjectifiedHash.new(item) : item } if decoded.is_a?(Array)
37
+ return true if decoded
38
+ return false unless decoded
39
+
40
+ raise Error::Parsing, "Couldn't parse a response body"
41
+ end
42
+
43
+ def pagination_model(body)
44
+ return nil unless body.is_a?(Hash)
45
+ return :cursor if cursor_paginated?(body)
46
+ return :offset if offset_paginated?(body)
47
+
48
+ nil
49
+ end
50
+
51
+ # Offset-based pagination:
52
+ # - PageBean responses: startAt/maxResults/total/isLast/nextPage + values
53
+ # - Legacy page responses: startAt/maxResults/total + endpoint-specific items key
31
54
  def offset_paginated?(body)
32
- body.is_a?(Hash) &&
33
- body.key?(:values) &&
34
- (body.key?(:isLast) || body.key?(:nextPage) || body.key?(:startAt))
55
+ return false unless body.is_a?(Hash)
56
+ return false if cursor_signature?(body)
57
+
58
+ offset_values_signature?(body) || offset_generic_signature?(body)
35
59
  end
36
60
 
37
- # Cursor-based pagination: POST /search/jql, etc.
38
- # The token drives the next request; items live under a variable key.
61
+ # Cursor-based pagination:
62
+ # - Token: nextPageToken (search/jql and similar)
63
+ # - Cursor: nextPageCursor/cursor (plans-like)
64
+ # - URL cursor: nextPage + lastPage without startAt (changed-worklogs style)
65
+ # - Last page marker in token-based APIs: isLast + array without startAt
39
66
  def cursor_paginated?(body)
40
- body.is_a?(Hash) && body.key?(:nextPageToken)
67
+ return false unless body.is_a?(Hash)
68
+
69
+ cursor_by_token?(body) ||
70
+ cursor_by_cursor?(body) ||
71
+ cursor_by_link?(body) ||
72
+ cursor_last_page_without_token?(body)
73
+ end
74
+
75
+ def cursor_by_token?(body)
76
+ body.key?(:nextPageToken)
77
+ end
78
+
79
+ def cursor_by_cursor?(body)
80
+ body.key?(:nextPageCursor) || body.key?(:cursor)
81
+ end
82
+
83
+ def cursor_by_link?(body)
84
+ body.key?(:nextPage) && body.key?(:lastPage) && !body.key?(:startAt)
85
+ end
86
+
87
+ def cursor_last_page_without_token?(body)
88
+ body.key?(:isLast) && !body.key?(:startAt) && !body.key?(:values) && body.values.any?(Array)
89
+ end
90
+
91
+ def cursor_signature?(body)
92
+ cursor_by_token?(body) || cursor_by_cursor?(body) || cursor_by_link?(body)
93
+ end
94
+
95
+ def offset_values_signature?(body)
96
+ body.key?(:values) && (body.key?(:isLast) || body.key?(:nextPage) || body.key?(:startAt))
97
+ end
98
+
99
+ def offset_generic_signature?(body)
100
+ markers_present = body.key?(:maxResults) ||
101
+ body.key?(:pageSize) ||
102
+ body.key?(:total) ||
103
+ body.key?(:isLast) ||
104
+ body.key?(:nextPage)
105
+
106
+ body.key?(:startAt) && markers_present && body.values.any?(Array)
41
107
  end
42
108
  end
43
109
  end
data/lib/jira/request.rb CHANGED
@@ -6,6 +6,7 @@ require "json"
6
6
  require "time"
7
7
  require "uri"
8
8
 
9
+ require_relative "logging"
9
10
  require_relative "request/authentication"
10
11
  require_relative "request/rate_limiting"
11
12
  require_relative "request/request_building"
@@ -15,6 +16,7 @@ module Jira
15
16
  # @private
16
17
  class Request
17
18
  include HTTParty
19
+ include Logging
18
20
 
19
21
  OAUTH_MISSING_CREDENTIALS_MESSAGE = Authenticator::OAUTH_MISSING_CREDENTIALS_MESSAGE
20
22
 
@@ -79,8 +81,10 @@ module Jira
79
81
  def execute_request(method, path, options)
80
82
  params = params_builder.build(options)
81
83
  retries_left = retries_left_for(params)
84
+ log "#{method.upcase} #{path} #{options.inspect}"
82
85
  result = perform_request_with_retry(method, path, params, retries_left)
83
- setup_cursor_fetcher!(result, method, path, options) if result.is_a?(CursorPaginatedResponse)
86
+ log "→ #{result.class}"
87
+ setup_pagination_fetcher!(result, method, path, options)
84
88
  result
85
89
  end
86
90
 
@@ -90,24 +94,52 @@ module Jira
90
94
  rescue Jira::Error::TooManyRequests, Jira::Error::ServiceUnavailable => e
91
95
  raise e unless should_retry?(e, method, response, retries_left)
92
96
 
97
+ wait = retry_policy.wait_seconds(response: response, retries_left: retries_left - 1)
98
+ log "rate limited (HTTP #{response.code}), retrying in #{wait.round(1)}s (#{retries_left - 1} retries left)"
93
99
  retry_policy.sleep_before_retry(response: response, retries_left: retries_left - 1)
94
100
  retries_left -= 1
95
101
  retry
96
102
  end
97
103
 
98
- def setup_cursor_fetcher!(result, method, path, options)
99
- result.next_page_fetcher = lambda do |token|
100
- merged = options.dup
101
- if method.to_s == "get"
102
- merged[:query] = (merged.fetch(:query, nil) || {}).merge(nextPageToken: token)
103
- else
104
- body = merged[:body].is_a?(Hash) ? merged[:body].dup : {}
105
- merged[:body] = body.merge(nextPageToken: token)
106
- end
104
+ def setup_pagination_fetcher!(result, method, path, options)
105
+ case result
106
+ when PaginatedResponse
107
+ setup_fetcher_for(result:, method:, path:, options:, key: :startAt)
108
+ when CursorPaginatedResponse
109
+ return unless result.fetcher_based_pagination?
110
+ return if result.cursor_parameter_key.nil?
111
+
112
+ setup_fetcher_for(result:, method:, path:, options:, key: result.cursor_parameter_key)
113
+ end
114
+ end
115
+
116
+ def setup_fetcher_for(result:, method:, path:, options:, key:)
117
+ result.next_page_fetcher = lambda do |value|
118
+ merged = duplicate_request_options(options)
119
+ inject_pagination_parameter!(options: merged, method:, key:, value:)
107
120
  send(method, path, merged)
108
121
  end
109
122
  end
110
123
 
124
+ def duplicate_request_options(options)
125
+ duplicated = options.dup
126
+ duplicated[:query] = options[:query].dup if options[:query].is_a?(Hash)
127
+ duplicated[:body] = options[:body].dup if options[:body].is_a?(Hash)
128
+ duplicated
129
+ end
130
+
131
+ def inject_pagination_parameter!(options:, method:, key:, value:)
132
+ target = pagination_parameter_target(method:, options:)
133
+ options[target] = (options[target] || {}).merge(key => value)
134
+ end
135
+
136
+ def pagination_parameter_target(method:, options:)
137
+ return :query if method.to_s == "get"
138
+ return :body if options[:body].is_a?(Hash)
139
+
140
+ :query
141
+ end
142
+
111
143
  def perform_request(method, path, params)
112
144
  self.class.send(method, build_url(path), params)
113
145
  end
@@ -116,13 +148,9 @@ module Jira
116
148
  params.delete(:ratelimit_retries) || ratelimit_retries || Configuration::DEFAULT_RATELIMIT_RETRIES
117
149
  end
118
150
 
119
- def build_url(path)
120
- url_builder.build(path)
121
- end
151
+ def build_url(path) = url_builder.build(path)
122
152
 
123
- def authorization_header
124
- authenticator.authorization_header
125
- end
153
+ def authorization_header = authenticator.authorization_header
126
154
 
127
155
  def should_retry?(error, method, response, retries_left)
128
156
  retry_policy.retryable?(error: error, method: method, response: response, retries_left: retries_left)
data/lib/jira/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Jira
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/jira.rb CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  require_relative "jira/version"
4
4
  require_relative "jira/configuration"
5
+ require_relative "jira/logging"
5
6
  require_relative "jira/error"
6
7
  require_relative "jira/objectified_hash"
8
+ require_relative "jira/pagination/collection_behavior"
7
9
  require_relative "jira/pagination/paginated_response"
8
10
  require_relative "jira/pagination/cursor_paginated_response"
9
11
  require_relative "jira/request"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-jira
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maciej Kozak
@@ -36,17 +36,24 @@ files:
36
36
  - lib/jira.rb
37
37
  - lib/jira/api.rb
38
38
  - lib/jira/client.rb
39
+ - lib/jira/client/issue_comments.rb
40
+ - lib/jira/client/issue_search.rb
41
+ - lib/jira/client/issue_worklogs.rb
39
42
  - lib/jira/client/issues.rb
43
+ - lib/jira/client/project_categories.rb
40
44
  - lib/jira/client/project_permission_schemes.rb
45
+ - lib/jira/client/project_properties.rb
41
46
  - lib/jira/client/projects.rb
47
+ - lib/jira/client/time_tracking.rb
42
48
  - lib/jira/configuration.rb
43
49
  - lib/jira/error.rb
50
+ - lib/jira/logging.rb
44
51
  - lib/jira/objectified_hash.rb
52
+ - lib/jira/pagination/collection_behavior.rb
45
53
  - lib/jira/pagination/cursor_paginated_response.rb
46
54
  - lib/jira/pagination/paginated_response.rb
47
55
  - lib/jira/request.rb
48
56
  - lib/jira/request/authentication.rb
49
- - lib/jira/request/paginated_response.rb
50
57
  - lib/jira/request/rate_limiting.rb
51
58
  - lib/jira/request/request_building.rb
52
59
  - lib/jira/request/response_parsing.rb
@@ -1,4 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # This file is intentionally empty.
4
- # PaginatedResponse was moved to Jira::PaginatedResponse (lib/jira/paginated_response.rb).