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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +53 -18
- data/lib/jira/client/issue_comments.rb +80 -0
- data/lib/jira/client/issue_search.rb +60 -0
- data/lib/jira/client/issue_worklogs.rb +123 -0
- data/lib/jira/client/issues.rb +191 -0
- data/lib/jira/client/project_categories.rb +60 -0
- data/lib/jira/client/project_permission_schemes.rb +18 -0
- data/lib/jira/client/project_properties.rb +54 -0
- data/lib/jira/client/projects.rb +60 -0
- data/lib/jira/client/time_tracking.rb +57 -0
- data/lib/jira/client.rb +6 -0
- data/lib/jira/configuration.rb +2 -0
- data/lib/jira/error.rb +3 -0
- data/lib/jira/logging.rb +15 -0
- data/lib/jira/pagination/collection_behavior.rb +72 -0
- data/lib/jira/pagination/cursor_paginated_response.rb +57 -41
- data/lib/jira/pagination/paginated_response.rb +46 -50
- data/lib/jira/request/rate_limiting.rb +6 -6
- data/lib/jira/request/response_parsing.rb +82 -16
- data/lib/jira/request.rb +44 -16
- data/lib/jira/version.rb +1 -1
- data/lib/jira.rb +2 -0
- metadata +9 -2
- data/lib/jira/request/paginated_response.rb +0 -4
|
@@ -3,64 +3,41 @@
|
|
|
3
3
|
require "uri"
|
|
4
4
|
|
|
5
5
|
module Jira
|
|
6
|
-
# Wrapper for Jira offset-paginated responses
|
|
6
|
+
# Wrapper for Jira offset-paginated responses.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
@
|
|
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
|
-
|
|
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
|
|
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
|
-
@
|
|
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
|
|
12
|
-
# X-RateLimit-Reset
|
|
13
|
-
# X-RateLimit-Limit
|
|
14
|
-
# X-RateLimit-Remaining
|
|
15
|
-
# X-RateLimit-NearLimit
|
|
16
|
-
# RateLimit-Reason
|
|
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)
|
|
7
|
+
def parse(body)
|
|
8
8
|
decoded = decode(body)
|
|
9
|
-
|
|
10
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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:
|
|
38
|
-
#
|
|
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)
|
|
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
|
-
|
|
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
|
|
99
|
-
result
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
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.
|
|
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
|