activeproject 0.3.0 → 0.5.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/README.md +201 -55
- data/lib/active_project/adapters/base.rb +154 -14
- data/lib/active_project/adapters/basecamp/comments.rb +34 -0
- data/lib/active_project/adapters/basecamp/connection.rb +6 -24
- data/lib/active_project/adapters/basecamp/issues.rb +6 -5
- data/lib/active_project/adapters/basecamp/webhooks.rb +7 -8
- data/lib/active_project/adapters/fizzy/columns.rb +116 -0
- data/lib/active_project/adapters/fizzy/comments.rb +129 -0
- data/lib/active_project/adapters/fizzy/connection.rb +41 -0
- data/lib/active_project/adapters/fizzy/issues.rb +221 -0
- data/lib/active_project/adapters/fizzy/projects.rb +105 -0
- data/lib/active_project/adapters/fizzy_adapter.rb +151 -0
- data/lib/active_project/adapters/github_project/comments.rb +91 -0
- data/lib/active_project/adapters/github_project/connection.rb +58 -0
- data/lib/active_project/adapters/github_project/helpers.rb +100 -0
- data/lib/active_project/adapters/github_project/issues.rb +287 -0
- data/lib/active_project/adapters/github_project/projects.rb +139 -0
- data/lib/active_project/adapters/github_project/webhooks.rb +168 -0
- data/lib/active_project/adapters/github_project.rb +8 -0
- data/lib/active_project/adapters/github_project_adapter.rb +65 -0
- data/lib/active_project/adapters/github_repo/connection.rb +62 -0
- data/lib/active_project/adapters/github_repo/issues.rb +242 -0
- data/lib/active_project/adapters/github_repo/projects.rb +116 -0
- data/lib/active_project/adapters/github_repo/webhooks.rb +354 -0
- data/lib/active_project/adapters/github_repo_adapter.rb +134 -0
- data/lib/active_project/adapters/jira/attribute_normalizer.rb +16 -0
- data/lib/active_project/adapters/jira/comments.rb +41 -0
- data/lib/active_project/adapters/jira/connection.rb +15 -15
- data/lib/active_project/adapters/jira/issues.rb +21 -7
- data/lib/active_project/adapters/jira/projects.rb +3 -1
- data/lib/active_project/adapters/jira/transitions.rb +2 -1
- data/lib/active_project/adapters/jira/webhooks.rb +5 -7
- data/lib/active_project/adapters/jira_adapter.rb +23 -3
- data/lib/active_project/adapters/trello/comments.rb +34 -0
- data/lib/active_project/adapters/trello/connection.rb +12 -9
- data/lib/active_project/adapters/trello/issues.rb +7 -5
- data/lib/active_project/adapters/trello/webhooks.rb +5 -7
- data/lib/active_project/adapters/trello_adapter.rb +5 -3
- data/lib/active_project/association_proxy.rb +3 -2
- data/lib/active_project/configuration.rb +6 -3
- data/lib/active_project/configurations/base_adapter_configuration.rb +102 -0
- data/lib/active_project/configurations/basecamp_configuration.rb +42 -0
- data/lib/active_project/configurations/fizzy_configuration.rb +47 -0
- data/lib/active_project/configurations/github_configuration.rb +57 -0
- data/lib/active_project/configurations/jira_configuration.rb +54 -0
- data/lib/active_project/configurations/trello_configuration.rb +24 -2
- data/lib/active_project/connections/base.rb +35 -0
- data/lib/active_project/connections/graph_ql.rb +83 -0
- data/lib/active_project/connections/http_client.rb +79 -0
- data/lib/active_project/connections/pagination.rb +44 -0
- data/lib/active_project/connections/rest.rb +33 -0
- data/lib/active_project/error_mapper.rb +38 -0
- data/lib/active_project/errors.rb +13 -0
- data/lib/active_project/railtie.rb +1 -3
- data/lib/active_project/resources/base_resource.rb +13 -14
- data/lib/active_project/resources/comment.rb +46 -2
- data/lib/active_project/resources/issue.rb +106 -18
- data/lib/active_project/resources/persistable_resource.rb +47 -0
- data/lib/active_project/resources/project.rb +1 -1
- data/lib/active_project/status_mapper.rb +145 -0
- data/lib/active_project/version.rb +1 -1
- data/lib/active_project/webhook_event.rb +34 -12
- data/lib/activeproject.rb +9 -6
- metadata +74 -16
- data/lib/active_project/adapters/http_client.rb +0 -71
- data/lib/active_project/adapters/pagination.rb +0 -68
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveProject
|
|
4
|
+
module Configurations
|
|
5
|
+
class GithubConfiguration < BaseAdapterConfiguration
|
|
6
|
+
# expected options:
|
|
7
|
+
# :access_token – PAT or GitHub App installation token
|
|
8
|
+
# :owner – user/org login the adapter should default to (optional for github_project)
|
|
9
|
+
# :repo – repository name (required for github_repo adapter)
|
|
10
|
+
# optional:
|
|
11
|
+
# :status_mappings – Maps GitHub project status names to normalized symbols
|
|
12
|
+
# Example: { "Todo" => :open, "In Progress" => :in_progress, "Blocked" => :blocked, "Done" => :closed }
|
|
13
|
+
# Supports: :open, :in_progress, :blocked, :on_hold, :closed
|
|
14
|
+
# :webhook_secret – For webhook signature verification
|
|
15
|
+
|
|
16
|
+
attr_accessor :status_mappings
|
|
17
|
+
|
|
18
|
+
def initialize(options = {})
|
|
19
|
+
# Set up default status mappings before calling super
|
|
20
|
+
@status_mappings = options.delete(:status_mappings) || {
|
|
21
|
+
"open" => :open,
|
|
22
|
+
"closed" => :closed
|
|
23
|
+
}
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def freeze
|
|
28
|
+
# Ensure nested hashes are also frozen
|
|
29
|
+
@status_mappings.freeze
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
protected
|
|
34
|
+
|
|
35
|
+
def validate_configuration!
|
|
36
|
+
require_options(:access_token)
|
|
37
|
+
validate_option_type(:access_token, String)
|
|
38
|
+
validate_option_type(:owner, String, allow_nil: true)
|
|
39
|
+
validate_option_type(:repo, String, allow_nil: true)
|
|
40
|
+
validate_option_type(:webhook_secret, String, allow_nil: true)
|
|
41
|
+
validate_option_type(:status_mappings, Hash, allow_nil: true)
|
|
42
|
+
|
|
43
|
+
# Skip format validation in test environment with dummy values
|
|
44
|
+
return if test_environment_with_dummy_values?
|
|
45
|
+
|
|
46
|
+
# Validate access_token format (GitHub tokens start with specific prefixes)
|
|
47
|
+
token = options[:access_token]
|
|
48
|
+
unless token.match?(/^(gh[pousr]_|github_pat_)/i) || token.length >= 20
|
|
49
|
+
raise ArgumentError,
|
|
50
|
+
"GitHub access token appears to be invalid. Expected a GitHub PAT " \
|
|
51
|
+
"(starting with 'ghp_', 'gho_', 'ghu_', 'ghs_', 'ghr_', or 'github_pat_') " \
|
|
52
|
+
"or a token at least 20 characters long."
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveProject
|
|
4
|
+
module Configurations
|
|
5
|
+
class JiraConfiguration < BaseAdapterConfiguration
|
|
6
|
+
# expected options:
|
|
7
|
+
# :site_url – Jira instance URL (e.g., "https://company.atlassian.net")
|
|
8
|
+
# :username – Jira username/email
|
|
9
|
+
# :api_token – Jira API token (not password)
|
|
10
|
+
# optional:
|
|
11
|
+
# :timeout – Request timeout in seconds (default: 30)
|
|
12
|
+
# :verify_ssl – Whether to verify SSL certificates (default: true)
|
|
13
|
+
|
|
14
|
+
protected
|
|
15
|
+
|
|
16
|
+
def validate_configuration!
|
|
17
|
+
super # Call parent validation for retry options
|
|
18
|
+
require_options(:site_url, :username, :api_token)
|
|
19
|
+
validate_option_type(:site_url, String)
|
|
20
|
+
validate_option_type(:username, String)
|
|
21
|
+
validate_option_type(:api_token, String)
|
|
22
|
+
validate_option_type(:timeout, Integer, allow_nil: true)
|
|
23
|
+
validate_option_type(:verify_ssl, [ TrueClass, FalseClass ], allow_nil: true)
|
|
24
|
+
|
|
25
|
+
# Skip format validation in test environment with dummy values
|
|
26
|
+
return if test_environment_with_dummy_values?
|
|
27
|
+
|
|
28
|
+
# Validate site_url format
|
|
29
|
+
site_url = options[:site_url]
|
|
30
|
+
unless site_url.match?(/^https?:\/\/.+/)
|
|
31
|
+
raise ArgumentError,
|
|
32
|
+
"Jira site_url must be a valid URL starting with http:// or https://, " \
|
|
33
|
+
"got: #{site_url.inspect}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Validate username format (email or username)
|
|
37
|
+
username = options[:username]
|
|
38
|
+
unless username.include?("@") || username.match?(/^[a-zA-Z0-9._-]+$/)
|
|
39
|
+
raise ArgumentError,
|
|
40
|
+
"Jira username should be an email address or valid username, " \
|
|
41
|
+
"got: #{username.inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Validate API token format (should be base64-ish)
|
|
45
|
+
api_token = options[:api_token]
|
|
46
|
+
if api_token.length < 10
|
|
47
|
+
raise ArgumentError,
|
|
48
|
+
"Jira API token appears to be too short. Expected an API token from " \
|
|
49
|
+
"Atlassian Account Settings, got token of length #{api_token.length}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -6,10 +6,16 @@ module ActiveProject
|
|
|
6
6
|
class TrelloConfiguration < BaseAdapterConfiguration
|
|
7
7
|
# @!attribute [rw] status_mappings
|
|
8
8
|
# @return [Hash] Mappings from Board IDs to List ID/Name to Status Symbol.
|
|
9
|
+
# Supports expanded status vocabulary: :open, :in_progress, :blocked, :on_hold, :closed
|
|
9
10
|
# @example
|
|
10
11
|
# {
|
|
11
|
-
# 'board_id_1' => {
|
|
12
|
-
#
|
|
12
|
+
# 'board_id_1' => {
|
|
13
|
+
# 'list_id_backlog' => :open,
|
|
14
|
+
# 'list_id_progress' => :in_progress,
|
|
15
|
+
# 'list_id_blocked' => :blocked,
|
|
16
|
+
# 'list_id_done' => :closed
|
|
17
|
+
# },
|
|
18
|
+
# 'board_id_2' => { 'Done List Name' => :closed } # Using names (less reliable)
|
|
13
19
|
# }
|
|
14
20
|
attr_accessor :status_mappings
|
|
15
21
|
|
|
@@ -18,6 +24,22 @@ module ActiveProject
|
|
|
18
24
|
@status_mappings = options.delete(:status_mappings) || {}
|
|
19
25
|
end
|
|
20
26
|
|
|
27
|
+
protected
|
|
28
|
+
|
|
29
|
+
def validate_configuration!
|
|
30
|
+
require_options(:key, :token)
|
|
31
|
+
validate_option_type(:key, String)
|
|
32
|
+
validate_option_type(:token, String)
|
|
33
|
+
validate_option_type(:status_mappings, Hash, allow_nil: true) if options[:status_mappings]
|
|
34
|
+
|
|
35
|
+
# Skip format validation in test environment with dummy values
|
|
36
|
+
nil if test_environment_with_dummy_values?
|
|
37
|
+
|
|
38
|
+
# Additional validation for actual tokens could go here
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
public
|
|
42
|
+
|
|
21
43
|
def freeze
|
|
22
44
|
# Ensure nested hashes are also frozen
|
|
23
45
|
@status_mappings.each_value(&:freeze)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveProject
|
|
4
|
+
module Connections
|
|
5
|
+
# Shared helpers used by both REST and GraphQL modules,
|
|
6
|
+
# because every cult needs a doctrine of "Don't Repeat Yourself."
|
|
7
|
+
module Base
|
|
8
|
+
# ------------------------------------------------------------------
|
|
9
|
+
# RFC 5988 Link header parsing
|
|
10
|
+
# ------------------------------------------------------------------
|
|
11
|
+
#
|
|
12
|
+
# Parses your classic overengineered pagination headers.
|
|
13
|
+
#
|
|
14
|
+
# <https://api.example.com/…?page=2>; rel="next",
|
|
15
|
+
# <https://api.example.com/…?page=5>; rel="last"
|
|
16
|
+
#
|
|
17
|
+
# REST’s way of pretending it’s not just guessing how many pages there are.
|
|
18
|
+
#
|
|
19
|
+
# @param header [String, nil]
|
|
20
|
+
# @return [Hash{String => String}] map of rel => absolute URL
|
|
21
|
+
def parse_link_header(header)
|
|
22
|
+
return {} unless header # Always a good first step: check if we’ve been given absolutely nothing.
|
|
23
|
+
|
|
24
|
+
header.split(",").each_with_object({}) do |part, acc|
|
|
25
|
+
url, rel = part.split(";", 2)
|
|
26
|
+
next unless url && rel
|
|
27
|
+
|
|
28
|
+
url = url[/<([^>]+)>/, 1] # Pull the sacred URL from its <> temple.
|
|
29
|
+
rel = rel[/rel="?([^";]+)"?/, 1] # Decode the rel tag, likely “next,” “prev,” or “you tried.”
|
|
30
|
+
acc[rel] = url if url && rel
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveProject
|
|
4
|
+
module Connections
|
|
5
|
+
# Supposedly "reusable" GraphQL connection logic.
|
|
6
|
+
# Because clearly REST wasn't performative enough, and now we need a whole query language
|
|
7
|
+
# to fetch a user's email address.
|
|
8
|
+
module GraphQl
|
|
9
|
+
include Base
|
|
10
|
+
include Pagination # Because apparently, every five-item list deserves its own saga.
|
|
11
|
+
|
|
12
|
+
# Initializes the GraphQL connection. Requires an endpoint, a token,
|
|
13
|
+
# an optional auth header, and—if it still doesn't work—maybe a goat sacrifice.
|
|
14
|
+
# Bonus points if you time it around Eid al-Adha or Yom Kippur.
|
|
15
|
+
# Nothing says "API design" like invoking Abrahamic tension.
|
|
16
|
+
def init_graphql(endpoint:, token:, auth_header: "Authorization", extra_headers: {})
|
|
17
|
+
default_headers = {
|
|
18
|
+
"Content-Type" => "application/json"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
build_connection(
|
|
22
|
+
base_url: endpoint,
|
|
23
|
+
auth_middleware: ->(c) { c.headers[auth_header] = "Bearer #{token}" },
|
|
24
|
+
extra_headers: default_headers.merge(extra_headers)
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Executes a GraphQL POST request. Because normal HTTP verbs had too much dignity.
|
|
29
|
+
#
|
|
30
|
+
# @return [Hash] The "data" part, which is always buried under a mountain of abstract misery.
|
|
31
|
+
def request_gql(query:, variables: {})
|
|
32
|
+
payload = { query: query, variables: variables }.to_json
|
|
33
|
+
res = request(:post, "", body: payload)
|
|
34
|
+
raise_graphql_errors!(res) # Make sure to decode the latest prophecy from the Error Oracle.
|
|
35
|
+
res["data"]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Handle GraphQL errors with configurable behavior for partial success.
|
|
41
|
+
# GitHub sometimes returns "partial success" (data + errors) when we query
|
|
42
|
+
# both `user` and `organization` for the same login.
|
|
43
|
+
#
|
|
44
|
+
# @param result [Hash] The GraphQL response containing "data" and/or "errors"
|
|
45
|
+
# @raise [ActiveProject::AuthenticationError] for auth-related errors
|
|
46
|
+
# @raise [ActiveProject::NotFoundError] for not-found errors
|
|
47
|
+
# @raise [ActiveProject::ValidationError] for other GraphQL errors
|
|
48
|
+
def raise_graphql_errors!(result)
|
|
49
|
+
errs = result["errors"]
|
|
50
|
+
return unless errs&.any? # no errors → nothing to do
|
|
51
|
+
|
|
52
|
+
data = result["data"]
|
|
53
|
+
|
|
54
|
+
has_useful_data =
|
|
55
|
+
case data
|
|
56
|
+
when Hash then data.values.compact.any?
|
|
57
|
+
when Array then data.compact.any?
|
|
58
|
+
else !data.nil?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Log partial errors for debugging even when we have useful data
|
|
62
|
+
if has_useful_data && defined?(ActiveProject.logger) && ActiveProject.logger
|
|
63
|
+
partial_msg = errs.map { |e| e["message"] }.join("; ")
|
|
64
|
+
ActiveProject.logger.warn("[ActiveProject::GraphQL] Partial errors ignored: #{partial_msg}")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
return if has_useful_data # partial success – continue with data
|
|
68
|
+
|
|
69
|
+
# ─── No useful data. Treat as fatal. Map message to our error hierarchy. ──
|
|
70
|
+
msg = errs.map { |e| e["message"] }.join("; ")
|
|
71
|
+
|
|
72
|
+
case msg
|
|
73
|
+
when /unauth/i
|
|
74
|
+
raise ActiveProject::AuthenticationError, msg
|
|
75
|
+
when /not\s+found|unknown id|resolve to a User/i
|
|
76
|
+
raise ActiveProject::NotFoundError, msg
|
|
77
|
+
else
|
|
78
|
+
raise ActiveProject::ValidationError.new(msg, errors: errs)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
require "async/http/faraday"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module ActiveProject
|
|
9
|
+
module Connections
|
|
10
|
+
module HttpClient
|
|
11
|
+
include Base
|
|
12
|
+
attr_reader :connection, :last_response
|
|
13
|
+
|
|
14
|
+
DEFAULT_HEADERS = {
|
|
15
|
+
"Content-Type" => "application/json",
|
|
16
|
+
"Accept" => "application/json",
|
|
17
|
+
"User-Agent" => -> { ActiveProject.user_agent }
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
# Default retry configuration - can be overridden in adapter configs
|
|
21
|
+
DEFAULT_RETRY_OPTS = {
|
|
22
|
+
max: 3, # Maximum number of retries
|
|
23
|
+
interval: 0.5, # Initial delay between retries (seconds)
|
|
24
|
+
backoff_factor: 2, # Exponential backoff multiplier
|
|
25
|
+
retry_statuses: [ 429, 500, 502, 503, 504 ], # HTTP status codes to retry
|
|
26
|
+
exceptions: [
|
|
27
|
+
Faraday::TimeoutError,
|
|
28
|
+
Faraday::ConnectionFailed,
|
|
29
|
+
Faraday::SSLError
|
|
30
|
+
]
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
def build_connection(base_url:, auth_middleware:, extra_headers: {}, retry_options: {})
|
|
34
|
+
@base_url = base_url
|
|
35
|
+
|
|
36
|
+
# Merge custom retry options with defaults
|
|
37
|
+
final_retry_opts = DEFAULT_RETRY_OPTS.merge(retry_options)
|
|
38
|
+
|
|
39
|
+
@connection = Faraday.new(url: base_url) do |conn|
|
|
40
|
+
auth_middleware.call(conn) # Let the adapter sprinkle its secret sauce here.
|
|
41
|
+
conn.request :retry, **final_retry_opts # Intelligent retry with configurable options
|
|
42
|
+
conn.response :raise_error # Yes, we want the failure loud and flaming.
|
|
43
|
+
default_adapter = ENV.fetch("AP_DEFAULT_ADAPTER", "net_http").to_sym
|
|
44
|
+
conn.adapter default_adapter
|
|
45
|
+
conn.headers.merge!(DEFAULT_HEADERS.transform_values { |v| v.respond_to?(:call) ? v.call : v })
|
|
46
|
+
conn.headers.merge!(extra_headers) # Add your weird little header tweaks here.
|
|
47
|
+
yield conn if block_given? # Optional: make it worse with your own block.
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Sends the HTTP request like a brave little toaster.
|
|
52
|
+
def request(method, path, body: nil, query: nil, headers: {})
|
|
53
|
+
raise "HTTP connection not initialised" unless connection # You forgot to plug it in. Classic.
|
|
54
|
+
|
|
55
|
+
json_body = if body.is_a?(String)
|
|
56
|
+
body
|
|
57
|
+
else
|
|
58
|
+
(body ? JSON.generate(body) : nil)
|
|
59
|
+
end
|
|
60
|
+
response = connection.run_request(method, path, json_body, headers) do |req|
|
|
61
|
+
req.params.update(query) if query&.any?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
@last_response = response
|
|
65
|
+
|
|
66
|
+
return nil if response.status == 204 || response.body.to_s.empty?
|
|
67
|
+
|
|
68
|
+
JSON.parse(response.body)
|
|
69
|
+
rescue Faraday::Error => e
|
|
70
|
+
raise translate_http_error(e)
|
|
71
|
+
rescue JSON::ParserError => e
|
|
72
|
+
raise ActiveProject::ApiError.new("Non-JSON response from #{path}",
|
|
73
|
+
original_error: e,
|
|
74
|
+
status_code: response&.status,
|
|
75
|
+
response_body: response&.body)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveProject
|
|
4
|
+
module Connections
|
|
5
|
+
# Relay + Link-header pagination helpers usable for REST and GraphQL
|
|
6
|
+
module Pagination
|
|
7
|
+
include HttpClient
|
|
8
|
+
# Generic RFC-5988 “Link” header paginator (GitHub/Jira/Trello style)
|
|
9
|
+
#
|
|
10
|
+
# @yieldparam page [Object] parsed JSON for each HTTP page
|
|
11
|
+
def each_page(path, method: :get, body: nil, query: {}, headers: {})
|
|
12
|
+
next_url = path
|
|
13
|
+
loop do
|
|
14
|
+
page = request(method, next_url, body: body, query: query, headers: headers)
|
|
15
|
+
yield page
|
|
16
|
+
link_header = @last_response&.headers&.[]("Link")
|
|
17
|
+
next_url = parse_link_header(link_header)["next"]
|
|
18
|
+
break unless next_url
|
|
19
|
+
|
|
20
|
+
# After first request we follow absolute URLs; zero out body/query for GETs
|
|
21
|
+
body = nil if method == :get
|
|
22
|
+
query = {}
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Relay-style paginator (pageInfo{ hasNextPage, endCursor })
|
|
27
|
+
#
|
|
28
|
+
# @param connection_path [Array<String>] path inside JSON to the connection node
|
|
29
|
+
# @yieldparam node [Object] each edge.node yielded
|
|
30
|
+
def each_edge(query:, connection_path:, variables: {}, after_key: "after")
|
|
31
|
+
cursor = nil
|
|
32
|
+
loop do
|
|
33
|
+
vars = variables.merge(after_key => cursor)
|
|
34
|
+
data = yield(vars) # caller executes GraphQL request, returns data hash
|
|
35
|
+
conn = data.dig(*connection_path)
|
|
36
|
+
conn["edges"].each { |edge| yield edge["node"] }
|
|
37
|
+
break unless conn["pageInfo"]["hasNextPage"]
|
|
38
|
+
|
|
39
|
+
cursor = conn["pageInfo"]["endCursor"]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveProject
|
|
4
|
+
module Connections
|
|
5
|
+
# Reusable REST connection logic for normal, boring APIs like Jira, Basecamp, and Trello.
|
|
6
|
+
# You know, ones that don’t require a decoder ring and a theological debate to use.
|
|
7
|
+
module Rest
|
|
8
|
+
include Base
|
|
9
|
+
include Pagination
|
|
10
|
+
|
|
11
|
+
# Must be called from the concrete adapter's initialize.
|
|
12
|
+
#
|
|
13
|
+
# @yieldparam conn [Faraday::Connection] A lovely, dependable object where you slap on your auth.
|
|
14
|
+
# Unlike GraphQL, this one doesn’t need you to bend the knee or cite the Book of Steve Job.
|
|
15
|
+
def init_rest(base_url:, auth_middleware:, extra_headers: {}, retry_options: {})
|
|
16
|
+
build_connection(
|
|
17
|
+
base_url: base_url,
|
|
18
|
+
auth_middleware: auth_middleware,
|
|
19
|
+
extra_headers: extra_headers,
|
|
20
|
+
retry_options: retry_options
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Wrapper around HttpClient#request.
|
|
25
|
+
# Adapters may override this if their APIs have... *quirks* (read: sins).
|
|
26
|
+
def request_rest(method, path, body = nil, query = nil, headers = {})
|
|
27
|
+
request(method, path, body: body, query: query, headers: headers)
|
|
28
|
+
rescue Faraday::Error => e
|
|
29
|
+
raise map_faraday_error(e) # Wrap Faraday errors in our custom trauma response.
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveProject
|
|
4
|
+
module ErrorMapper
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
# one hash per subclass, inherited & copy-on-write
|
|
9
|
+
class_attribute :error_map, instance_accessor: false, default: {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
# rescue_status 401..403, with: AuthenticationError
|
|
14
|
+
def rescue_status(*codes, with:)
|
|
15
|
+
new_map = error_map.dup
|
|
16
|
+
codes.flat_map { |c| c.is_a?(Range) ? c.to_a : c }
|
|
17
|
+
.each { |status| new_map[status] = with }
|
|
18
|
+
self.error_map = new_map.freeze
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def translate_http_error(err)
|
|
25
|
+
status = err.response_status
|
|
26
|
+
message = begin
|
|
27
|
+
JSON.parse(err.response_body.to_s)["message"]
|
|
28
|
+
rescue StandardError
|
|
29
|
+
err.response_body
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
exc = self.class.error_map[status] ||
|
|
33
|
+
ActiveProject::ApiError
|
|
34
|
+
|
|
35
|
+
raise exc, message, err.backtrace
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -13,6 +13,19 @@ module ActiveProject
|
|
|
13
13
|
# Raised when the external API rate limit is exceeded
|
|
14
14
|
class RateLimitError < Error; end
|
|
15
15
|
|
|
16
|
+
# Raised for configuration errors (e.g., missing required settings, invalid mappings)
|
|
17
|
+
class ConfigurationError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised for connection errors (e.g., network failures, timeouts)
|
|
20
|
+
class ConnectionError < Error
|
|
21
|
+
attr_reader :original_error
|
|
22
|
+
|
|
23
|
+
def initialize(message = nil, original_error: nil)
|
|
24
|
+
super(message)
|
|
25
|
+
@original_error = original_error
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
16
29
|
# Raised for general API errors (e.g., 5xx status codes)
|
|
17
30
|
class ApiError < Error
|
|
18
31
|
attr_reader :original_error, :status_code, :response_body
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rails/railtie"
|
|
4
|
-
require "async"
|
|
4
|
+
require "async" # async is now a hard dependency
|
|
5
5
|
|
|
6
6
|
module ActiveProject
|
|
7
7
|
class Railtie < ::Rails::Railtie
|
|
@@ -10,10 +10,8 @@ module ActiveProject
|
|
|
10
10
|
# config.active_project.use_async_scheduler = false
|
|
11
11
|
config.active_project.use_async_scheduler = true
|
|
12
12
|
|
|
13
|
-
# ──────────────────────────────────────────────────────
|
|
14
13
|
# We run BEFORE Zeitwerk starts autoloading so that
|
|
15
14
|
# every thread inherits the scheduler.
|
|
16
|
-
# ──────────────────────────────────────────────────────
|
|
17
15
|
initializer "active_project.set_async_scheduler",
|
|
18
16
|
before: :initialize_dependency_mechanism do |app|
|
|
19
17
|
# 1. Allow opt-out
|
|
@@ -46,23 +46,22 @@ module ActiveProject
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
# Defines expected members for the resource class.
|
|
49
|
-
def self.def_members(*
|
|
49
|
+
def self.def_members(*names)
|
|
50
50
|
@members ||= []
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def update(attributes)
|
|
61
|
-
raise NotImplementedError, "#update not yet implemented for #{self.class.name}"
|
|
51
|
+
names.each do |name|
|
|
52
|
+
sym = name.to_sym
|
|
53
|
+
@members << sym
|
|
54
|
+
define_method(sym) { @attributes[sym] } # reader
|
|
55
|
+
define_method("#{sym}=") do |val| # writer
|
|
56
|
+
@attributes[sym] = val
|
|
57
|
+
end
|
|
58
|
+
end
|
|
62
59
|
end
|
|
63
60
|
|
|
64
|
-
def
|
|
65
|
-
|
|
61
|
+
def to_h
|
|
62
|
+
self.class.members.each_with_object({}) do |name, hash|
|
|
63
|
+
hash[name] = public_send(name)
|
|
64
|
+
end
|
|
66
65
|
end
|
|
67
66
|
end
|
|
68
67
|
end
|
|
@@ -3,9 +3,53 @@
|
|
|
3
3
|
module ActiveProject
|
|
4
4
|
module Resources
|
|
5
5
|
# Represents a Comment on an Issue
|
|
6
|
-
class Comment <
|
|
6
|
+
class Comment < PersistableResource
|
|
7
7
|
def_members :id, :body, :author, :created_at, :updated_at, :issue_id,
|
|
8
|
-
:adapter_source
|
|
8
|
+
:project_id, :adapter_source
|
|
9
|
+
|
|
10
|
+
# For new comments (no id) call add_comment; otherwise update.
|
|
11
|
+
def save
|
|
12
|
+
fresh = if id.nil?
|
|
13
|
+
@adapter.add_comment(issue_id, body, adapter_context)
|
|
14
|
+
else
|
|
15
|
+
@adapter.update_comment(id, body, adapter_context)
|
|
16
|
+
end
|
|
17
|
+
copy_from(fresh)
|
|
18
|
+
true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Shorthand that mutates +body+ and persists.
|
|
22
|
+
def update(attrs = {})
|
|
23
|
+
self.body = attrs[:body] if attrs[:body]
|
|
24
|
+
save
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Remove comment remotely and freeze this instance.
|
|
28
|
+
def delete
|
|
29
|
+
raise "id missing – not persisted" if id.nil?
|
|
30
|
+
|
|
31
|
+
@adapter.delete_comment(id, adapter_context)
|
|
32
|
+
freeze
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
alias destroy delete
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Build adapter-specific context for comment operations
|
|
41
|
+
def adapter_context
|
|
42
|
+
case @adapter
|
|
43
|
+
when ActiveProject::Adapters::BasecampAdapter
|
|
44
|
+
{ project_id: project_id }
|
|
45
|
+
when ActiveProject::Adapters::JiraAdapter
|
|
46
|
+
{ issue_id: issue_id }
|
|
47
|
+
when ActiveProject::Adapters::TrelloAdapter
|
|
48
|
+
{ card_id: issue_id }
|
|
49
|
+
else
|
|
50
|
+
{}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
9
53
|
end
|
|
10
54
|
end
|
|
11
55
|
end
|