ruby-jira 0.1.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.
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jira
4
+ class Request
5
+ # Implements Jira Cloud rate-limit retry policy.
6
+ #
7
+ # Follows the official Atlassian guidance:
8
+ # https://developer.atlassian.com/cloud/jira/platform/rate-limiting/
9
+ #
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)
17
+ class RetryPolicy
18
+ IDEMPOTENT_HTTP_METHODS = %w[get head put delete options].freeze
19
+
20
+ # Unix timestamp threshold: values above this are epoch seconds, not second-counts.
21
+ UNIX_TIMESTAMP_THRESHOLD = 1_000_000_000
22
+
23
+ # Jitter range recommended by Atlassian docs: multiply backoff by factor in [0.7, 1.3].
24
+ JITTER_RANGE = (0.7..1.3)
25
+
26
+ def initialize(request:, rand_proc: method(:rand))
27
+ @request = request
28
+ @rand_proc = rand_proc
29
+ end
30
+
31
+ def sleep_before_retry(response:, retries_left:)
32
+ sleep(wait_seconds(response: response, retries_left: retries_left))
33
+ end
34
+
35
+ def retryable?(error:, method:, response:, retries_left:)
36
+ return false if retries_left <= 1
37
+ return false unless IDEMPOTENT_HTTP_METHODS.include?(method.to_s)
38
+ return true if error.is_a?(Jira::Error::TooManyRequests)
39
+
40
+ response_has_rate_limit_hint?(response)
41
+ end
42
+
43
+ def wait_seconds(response:, retries_left:)
44
+ retry_after = parse_retry_after(response)
45
+ return retry_after if retry_after
46
+
47
+ reset_delay = parse_rate_limit_reset(response)
48
+ return reset_delay if reset_delay
49
+
50
+ exponential_backoff_wait(retries_left)
51
+ end
52
+
53
+ private
54
+
55
+ # Retry-After: integer seconds (e.g. "5").
56
+ def parse_retry_after(response)
57
+ value = response&.headers&.[]("Retry-After")
58
+ return nil unless value
59
+
60
+ parse_seconds_or_timestamp(value)
61
+ end
62
+
63
+ # X-RateLimit-Reset: ISO 8601 timestamp (e.g. "2024-01-15T10:30:00.000Z").
64
+ # Also accepts RateLimit-Reset as fallback.
65
+ def parse_rate_limit_reset(response)
66
+ value = response&.headers&.[]("X-RateLimit-Reset") || response&.headers&.[]("RateLimit-Reset")
67
+ return nil unless value
68
+
69
+ seconds = parse_seconds_or_timestamp(value)
70
+ return nil if seconds.nil?
71
+
72
+ [seconds, 0.0].max
73
+ end
74
+
75
+ # Parses a header value that can be:
76
+ # - integer seconds: "5"
77
+ # - Unix epoch timestamp: "1705314600"
78
+ # - ISO 8601 datetime: "2024-01-15T10:30:00.000Z" (Jira Cloud standard)
79
+ # - HTTP date: "Mon, 15 Jan 2024 10:30:00 GMT"
80
+ def parse_seconds_or_timestamp(value)
81
+ numeric = Float(value)
82
+ return numeric if numeric < UNIX_TIMESTAMP_THRESHOLD
83
+
84
+ [numeric - Time.now.to_f, 0.0].max
85
+ rescue ArgumentError, TypeError
86
+ parse_datetime_string(value.to_s)
87
+ end
88
+
89
+ def parse_datetime_string(value)
90
+ [Time.iso8601(value).to_f - Time.now.to_f, 0.0].max
91
+ rescue ArgumentError
92
+ begin
93
+ [Time.httpdate(value).to_f - Time.now.to_f, 0.0].max
94
+ rescue ArgumentError
95
+ nil
96
+ end
97
+ end
98
+
99
+ # Exponential backoff with proportional jitter, per Atlassian recommendations:
100
+ # base_delay * 2^attempt, capped at max_delay, multiplied by rand(0.7..1.3).
101
+ def exponential_backoff_wait(retries_left)
102
+ retry_attempt = ratelimit_retries - retries_left
103
+ backoff = [ratelimit_base_delay * (2**retry_attempt), ratelimit_max_delay].min
104
+ jitter_factor = @rand_proc.call(JITTER_RANGE)
105
+ [backoff * jitter_factor, ratelimit_max_delay].min
106
+ end
107
+
108
+ def response_has_rate_limit_hint?(response)
109
+ headers = response&.headers || {}
110
+ headers.key?("Retry-After") || headers.key?("X-RateLimit-Reset") || headers.key?("RateLimit-Reset")
111
+ end
112
+
113
+ def ratelimit_retries
114
+ @request.ratelimit_retries || Configuration::DEFAULT_RATELIMIT_RETRIES
115
+ end
116
+
117
+ def ratelimit_base_delay
118
+ @request.ratelimit_base_delay || Configuration::DEFAULT_RATELIMIT_BASE_DELAY
119
+ end
120
+
121
+ def ratelimit_max_delay
122
+ @request.ratelimit_max_delay || Configuration::DEFAULT_RATELIMIT_MAX_DELAY
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jira
4
+ class Request
5
+ class UrlBuilder
6
+ OAUTH_API_BASE = "https://api.atlassian.com/ex/jira"
7
+ PLATFORM_API_PATH = "/rest/api/3"
8
+
9
+ def initialize(request:, authenticator:)
10
+ @request = request
11
+ @authenticator = authenticator
12
+ end
13
+
14
+ def build(path)
15
+ "#{api_base_url}#{normalize_path(path)}"
16
+ end
17
+
18
+ def api_request_path
19
+ URI.parse(api_base_url).request_uri
20
+ end
21
+
22
+ private
23
+
24
+ def api_base_url
25
+ case @authenticator.auth_type
26
+ when :basic
27
+ "#{normalized_endpoint}#{PLATFORM_API_PATH}"
28
+ when :oauth2
29
+ "#{OAUTH_API_BASE}/#{@request.cloud_id}#{PLATFORM_API_PATH}"
30
+ end
31
+ end
32
+
33
+ def normalized_endpoint
34
+ @request.endpoint.to_s.delete_suffix("/")
35
+ end
36
+
37
+ def normalize_path(path)
38
+ string_path = path.to_s
39
+ string_path.start_with?("/") ? string_path : "/#{string_path}"
40
+ end
41
+ end
42
+
43
+ class ParamsBuilder
44
+ def initialize(request:, authenticator:)
45
+ @request = request
46
+ @authenticator = authenticator
47
+ end
48
+
49
+ def build(options)
50
+ params = options.dup
51
+ merge_httparty_config!(params)
52
+ add_authorization_header!(params) unless params[:unauthenticated]
53
+ serialize_json_body!(params)
54
+ params
55
+ end
56
+
57
+ private
58
+
59
+ def merge_httparty_config!(params)
60
+ params.merge!(@request.httparty) if @request.httparty
61
+ end
62
+
63
+ def add_authorization_header!(params)
64
+ params[:headers] ||= {}
65
+ params[:headers].merge!(@authenticator.authorization_header)
66
+ end
67
+
68
+ def serialize_json_body!(params)
69
+ return unless params[:body].is_a?(Hash)
70
+ return if params[:multipart] == true
71
+
72
+ params[:headers] ||= {}
73
+ params[:headers]["Content-Type"] ||= "application/json"
74
+ params[:body] = params[:body].to_json
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jira
4
+ class Request
5
+ class ResponseParser
6
+ class << self
7
+ def parse(body) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
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
15
+
16
+ raise Error::Parsing, "Couldn't parse a response body"
17
+ end
18
+
19
+ def decode(response)
20
+ return {} if response.nil? || response.empty?
21
+
22
+ JSON.parse(response, symbolize_names: true)
23
+ rescue JSON::ParserError
24
+ raise Error::Parsing, "The response is not a valid JSON '#{response}'"
25
+ end
26
+
27
+ private
28
+
29
+ # Offset-based pagination: GET /project/search, GET /workflow/search, etc.
30
+ # Requires :values and at least one offset-pagination hint.
31
+ def offset_paginated?(body)
32
+ body.is_a?(Hash) &&
33
+ body.key?(:values) &&
34
+ (body.key?(:isLast) || body.key?(:nextPage) || body.key?(:startAt))
35
+ end
36
+
37
+ # Cursor-based pagination: POST /search/jql, etc.
38
+ # The token drives the next request; items live under a variable key.
39
+ def cursor_paginated?(body)
40
+ body.is_a?(Hash) && body.key?(:nextPageToken)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "httparty"
5
+ require "json"
6
+ require "time"
7
+ require "uri"
8
+
9
+ require_relative "request/authentication"
10
+ require_relative "request/rate_limiting"
11
+ require_relative "request/request_building"
12
+ require_relative "request/response_parsing"
13
+
14
+ module Jira
15
+ # @private
16
+ class Request
17
+ include HTTParty
18
+
19
+ OAUTH_MISSING_CREDENTIALS_MESSAGE = Authenticator::OAUTH_MISSING_CREDENTIALS_MESSAGE
20
+
21
+ format :json
22
+ maintain_method_across_redirects true
23
+ headers "Accept" => "application/json", "Content-Type" => "application/json"
24
+ parser(proc { |body, _| parse(body) })
25
+
26
+ attr_accessor :endpoint,
27
+ :auth_type,
28
+ :email,
29
+ :api_token,
30
+ :oauth_access_token,
31
+ :oauth_client_id,
32
+ :oauth_client_secret,
33
+ :oauth_refresh_token,
34
+ :oauth_grant_type,
35
+ :oauth_token_endpoint,
36
+ :oauth_access_token_expires_at,
37
+ :cloud_id,
38
+ :httparty,
39
+ :ratelimit_retries,
40
+ :ratelimit_base_delay,
41
+ :ratelimit_max_delay
42
+
43
+ class << self
44
+ def parse(body)
45
+ ResponseParser.parse(body)
46
+ end
47
+
48
+ def decode(response)
49
+ ResponseParser.decode(response)
50
+ end
51
+ end
52
+
53
+ %w[get post put patch delete].each do |method|
54
+ define_method(method) do |path, options = {}|
55
+ execute_request(method, path, options)
56
+ end
57
+ end
58
+
59
+ def validate(response)
60
+ error_klass = Error.klass(response)
61
+ raise error_klass, response if error_klass
62
+
63
+ parsed = response.parsed_response
64
+ parsed.client = self if parsed.respond_to?(:client=)
65
+ parsed
66
+ end
67
+
68
+ def request_defaults
69
+ validate_endpoint!
70
+ authenticator.validate!
71
+ end
72
+
73
+ def api_request_path
74
+ url_builder.api_request_path
75
+ end
76
+
77
+ private
78
+
79
+ def execute_request(method, path, options)
80
+ params = params_builder.build(options)
81
+ retries_left = retries_left_for(params)
82
+ result = perform_request_with_retry(method, path, params, retries_left)
83
+ setup_cursor_fetcher!(result, method, path, options) if result.is_a?(CursorPaginatedResponse)
84
+ result
85
+ end
86
+
87
+ def perform_request_with_retry(method, path, params, retries_left)
88
+ response = perform_request(method, path, params)
89
+ validate(response)
90
+ rescue Jira::Error::TooManyRequests, Jira::Error::ServiceUnavailable => e
91
+ raise e unless should_retry?(e, method, response, retries_left)
92
+
93
+ retry_policy.sleep_before_retry(response: response, retries_left: retries_left - 1)
94
+ retries_left -= 1
95
+ retry
96
+ end
97
+
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
107
+ send(method, path, merged)
108
+ end
109
+ end
110
+
111
+ def perform_request(method, path, params)
112
+ self.class.send(method, build_url(path), params)
113
+ end
114
+
115
+ def retries_left_for(params)
116
+ params.delete(:ratelimit_retries) || ratelimit_retries || Configuration::DEFAULT_RATELIMIT_RETRIES
117
+ end
118
+
119
+ def build_url(path)
120
+ url_builder.build(path)
121
+ end
122
+
123
+ def authorization_header
124
+ authenticator.authorization_header
125
+ end
126
+
127
+ def should_retry?(error, method, response, retries_left)
128
+ retry_policy.retryable?(error: error, method: method, response: response, retries_left: retries_left)
129
+ end
130
+
131
+ def validate_endpoint!
132
+ return unless endpoint.to_s.strip.empty?
133
+
134
+ raise Error::MissingCredentials, "Please set an endpoint to API"
135
+ end
136
+
137
+ def authenticator
138
+ @authenticator ||= Authenticator.new(request: self)
139
+ end
140
+
141
+ def retry_policy
142
+ @retry_policy ||= RetryPolicy.new(request: self)
143
+ end
144
+
145
+ def params_builder
146
+ @params_builder ||= ParamsBuilder.new(request: self, authenticator: authenticator)
147
+ end
148
+
149
+ def url_builder
150
+ @url_builder ||= UrlBuilder.new(request: self, authenticator: authenticator)
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jira
4
+ VERSION = "0.1.0"
5
+ end
data/lib/jira.rb ADDED
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "jira/version"
4
+ require_relative "jira/configuration"
5
+ require_relative "jira/error"
6
+ require_relative "jira/objectified_hash"
7
+ require_relative "jira/pagination/paginated_response"
8
+ require_relative "jira/pagination/cursor_paginated_response"
9
+ require_relative "jira/request"
10
+ require_relative "jira/api"
11
+ require_relative "jira/client"
12
+
13
+ module Jira
14
+ extend Configuration
15
+
16
+ # Alias for Jira::Client.new
17
+ #
18
+ # @return [Jira::Client]
19
+ def self.client(options = {})
20
+ Jira::Client.new(options)
21
+ end
22
+
23
+ def self.method_missing(method, ...)
24
+ return super unless client.respond_to?(method)
25
+
26
+ client.send(method, ...)
27
+ end
28
+
29
+ def self.respond_to_missing?(method_name, include_private = false)
30
+ client.respond_to?(method_name) || super
31
+ end
32
+
33
+ # Delegate to HTTParty.http_proxy
34
+ def self.http_proxy(address = nil, port = nil, username = nil, password = nil) # rubocop:disable Metrics/ParameterLists
35
+ Jira::Request.http_proxy(address, port, username, password)
36
+ end
37
+
38
+ # Returns an unsorted array of available client methods.
39
+ #
40
+ # @return [Array<Symbol>]
41
+ def self.actions # rubocop:disable Metrics/MethodLength
42
+ hidden = Regexp.union(
43
+ /endpoint/,
44
+ /auth_type/,
45
+ /email/,
46
+ /api_token/,
47
+ /oauth_access_token/,
48
+ /oauth_client_id/,
49
+ /oauth_client_secret/,
50
+ /oauth_refresh_token/,
51
+ /oauth_grant_type/,
52
+ /oauth_token_endpoint/,
53
+ /cloud_id/,
54
+ /user_agent/,
55
+ /get/,
56
+ /post/,
57
+ /put/,
58
+ /patch/,
59
+ /\Adelete\z/,
60
+ /validate\z/,
61
+ /httparty/
62
+ )
63
+ (Jira::Client.instance_methods - Object.methods).reject { |method_name| method_name[hidden] }
64
+ end
65
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby-jira
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Maciej Kozak
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: httparty
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.24'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.24'
26
+ description: A Ruby wrapper for Jira Cloud API
27
+ email:
28
+ - maciej.kozak@gmail.com
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - CHANGELOG.md
34
+ - LICENSE.txt
35
+ - README.md
36
+ - lib/jira.rb
37
+ - lib/jira/api.rb
38
+ - lib/jira/client.rb
39
+ - lib/jira/client/issues.rb
40
+ - lib/jira/client/project_permission_schemes.rb
41
+ - lib/jira/client/projects.rb
42
+ - lib/jira/configuration.rb
43
+ - lib/jira/error.rb
44
+ - lib/jira/objectified_hash.rb
45
+ - lib/jira/pagination/cursor_paginated_response.rb
46
+ - lib/jira/pagination/paginated_response.rb
47
+ - lib/jira/request.rb
48
+ - lib/jira/request/authentication.rb
49
+ - lib/jira/request/paginated_response.rb
50
+ - lib/jira/request/rate_limiting.rb
51
+ - lib/jira/request/request_building.rb
52
+ - lib/jira/request/response_parsing.rb
53
+ - lib/jira/version.rb
54
+ homepage: https://github.com/macio/ruby-jira
55
+ licenses:
56
+ - BSD-2-Clause
57
+ metadata:
58
+ allowed_push_host: https://rubygems.org
59
+ homepage_uri: https://github.com/macio/ruby-jira
60
+ source_code_uri: https://github.com/macio/ruby-jira/tree/main
61
+ changelog_uri: https://github.com/macio/ruby-jira/blob/main/CHANGELOG.md
62
+ rubygems_mfa_required: 'true'
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: 3.2.0
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 3.6.9
78
+ specification_version: 4
79
+ summary: Ruby client and CLI for Jira Cloud API
80
+ test_files: []