activeproject 0.2.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 +248 -51
- 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 +10 -23
- 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/basecamp_adapter.rb +2 -11
- 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 +43 -24
- 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 -30
- data/lib/active_project/adapters/trello/comments.rb +34 -0
- data/lib/active_project/adapters/trello/connection.rb +28 -21
- 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 -25
- data/lib/active_project/association_proxy.rb +3 -2
- data/lib/active_project/async.rb +9 -0
- 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 +33 -0
- data/lib/active_project/resource_factory.rb +18 -0
- 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 +11 -6
- metadata +107 -6
|
@@ -4,7 +4,16 @@ module ActiveProject
|
|
|
4
4
|
module Adapters
|
|
5
5
|
module Jira
|
|
6
6
|
module Issues
|
|
7
|
-
DEFAULT_FIELDS = %w[summary description status assignee reporter created updated project issuetype duedate
|
|
7
|
+
DEFAULT_FIELDS = %w[summary description status assignee reporter created updated project issuetype duedate
|
|
8
|
+
priority].freeze
|
|
9
|
+
|
|
10
|
+
# Converts plain text to Atlassian Document Format (ADF)
|
|
11
|
+
def adf_text(text)
|
|
12
|
+
{
|
|
13
|
+
type: "doc", version: 1,
|
|
14
|
+
content: [ { type: "paragraph", content: [ { type: "text", text: text } ] } ]
|
|
15
|
+
}
|
|
16
|
+
end
|
|
8
17
|
|
|
9
18
|
# Lists issues within a specific project, optionally filtered by JQL.
|
|
10
19
|
# @param project_id_or_key [String, Integer] The ID or key of the project.
|
|
@@ -51,16 +60,19 @@ module ActiveProject
|
|
|
51
60
|
|
|
52
61
|
# Creates a new issue in Jira using the V3 endpoint.
|
|
53
62
|
# @param _project_id_or_key [String, Integer] Ignored (project info is in attributes).
|
|
54
|
-
# @param attributes [Hash] Issue attributes.
|
|
63
|
+
# @param attributes [Hash] Issue attributes.
|
|
64
|
+
# Required: :project, :summary, :issue_type.
|
|
65
|
+
# Optional: :description, :assignee_id, :due_on, :priority.
|
|
55
66
|
# @return [ActiveProject::Resources::Issue]
|
|
56
67
|
def create_issue(_project_id_or_key, attributes)
|
|
57
68
|
path = "/rest/api/3/issue"
|
|
58
69
|
|
|
59
70
|
unless attributes[:project].is_a?(Hash) && (attributes[:project][:id] || attributes[:project][:key]) &&
|
|
60
|
-
|
|
61
|
-
|
|
71
|
+
attributes[:summary] && !attributes[:summary].empty? &&
|
|
72
|
+
attributes[:issue_type] && (attributes[:issue_type][:id] || attributes[:issue_type][:name])
|
|
62
73
|
raise ArgumentError,
|
|
63
|
-
"Missing required attributes for issue creation: :project (
|
|
74
|
+
"Missing required attributes for issue creation: :project (Hash with id/key), " \
|
|
75
|
+
":summary, :issue_type (with id/name)"
|
|
64
76
|
end
|
|
65
77
|
|
|
66
78
|
fields_payload = {
|
|
@@ -71,7 +83,7 @@ module ActiveProject
|
|
|
71
83
|
|
|
72
84
|
if attributes.key?(:description)
|
|
73
85
|
fields_payload[:description] = if attributes[:description].is_a?(String)
|
|
74
|
-
|
|
86
|
+
adf_text(attributes[:description])
|
|
75
87
|
elsif attributes[:description].is_a?(Hash)
|
|
76
88
|
attributes[:description]
|
|
77
89
|
end
|
|
@@ -100,6 +112,8 @@ module ActiveProject
|
|
|
100
112
|
# @param context [Hash] Optional context. Accepts :fields for field selection on return.
|
|
101
113
|
# @return [ActiveProject::Resources::Issue]
|
|
102
114
|
def update_issue(id_or_key, attributes, context = {})
|
|
115
|
+
raise ArgumentError, "attributes must be a Hash" unless attributes.is_a?(Hash)
|
|
116
|
+
|
|
103
117
|
path = "/rest/api/3/issue/#{id_or_key}"
|
|
104
118
|
|
|
105
119
|
update_fields = {}
|
|
@@ -107,7 +121,7 @@ module ActiveProject
|
|
|
107
121
|
|
|
108
122
|
if attributes.key?(:description)
|
|
109
123
|
update_fields[:description] = if attributes[:description].is_a?(String)
|
|
110
|
-
|
|
124
|
+
adf_text(attributes[:description])
|
|
111
125
|
elsif attributes[:description].is_a?(Hash)
|
|
112
126
|
attributes[:description]
|
|
113
127
|
end
|
|
@@ -53,7 +53,9 @@ module ActiveProject
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
# Creates a new project in Jira.
|
|
56
|
-
# @param attributes [Hash] Project attributes.
|
|
56
|
+
# @param attributes [Hash] Project attributes.
|
|
57
|
+
# Required: :key, :name, :project_type_key, :lead_account_id.
|
|
58
|
+
# Optional: :description, :assignee_type.
|
|
57
59
|
# @return [ActiveProject::Resources::Project]
|
|
58
60
|
def create_project(attributes)
|
|
59
61
|
required_keys = %i[key name project_type_key lead_account_id]
|
|
@@ -31,7 +31,8 @@ module ActiveProject
|
|
|
31
31
|
unless target_transition
|
|
32
32
|
available_names = available_transitions.map { |t| t.dig("to", "name") }.compact.join(", ")
|
|
33
33
|
raise NotFoundError,
|
|
34
|
-
"Target transition '#{target_status_name_or_id}' not found
|
|
34
|
+
"Target transition '#{target_status_name_or_id}' not found for issue " \
|
|
35
|
+
"'#{issue_id_or_key}'. Available: [#{available_names}]"
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
payload = {
|
|
@@ -68,16 +68,14 @@ module ActiveProject
|
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
WebhookEvent.new(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
object_key: object_key,
|
|
71
|
+
type: event_type,
|
|
72
|
+
resource_type: object_kind,
|
|
73
|
+
resource_id: event_object_id,
|
|
75
74
|
project_id: project_id,
|
|
76
75
|
actor: map_user_data(actor_data),
|
|
77
76
|
timestamp: timestamp,
|
|
78
|
-
|
|
79
|
-
changes: changes,
|
|
80
|
-
object_data: object_data,
|
|
77
|
+
source: webhook_type,
|
|
78
|
+
data: (object_data || {}).merge(changes: changes, object_key: object_key),
|
|
81
79
|
raw_data: payload
|
|
82
80
|
)
|
|
83
81
|
rescue JSON::ParserError
|
|
@@ -10,6 +10,8 @@ module ActiveProject
|
|
|
10
10
|
# Adapter for interacting with the Jira REST API.
|
|
11
11
|
# Implements the interface defined in ActiveProject::Adapters::Base.
|
|
12
12
|
class JiraAdapter < Base
|
|
13
|
+
include Jira::AttributeNormalizer
|
|
14
|
+
|
|
13
15
|
attr_reader :config # Store the config object
|
|
14
16
|
|
|
15
17
|
include Jira::Connection
|
|
@@ -33,6 +35,23 @@ module ActiveProject
|
|
|
33
35
|
ResourceFactory.new(adapter: self, resource_class: Resources::Issue)
|
|
34
36
|
end
|
|
35
37
|
|
|
38
|
+
# Creates an issue in Jira.
|
|
39
|
+
# @param project_id_or_key [String, Integer] The project ID or key (used by Jira to determine project).
|
|
40
|
+
# @param attributes [Hash] Issue attributes including :project, :summary, :issue_type.
|
|
41
|
+
# @return [ActiveProject::Resources::Issue] The created issue.
|
|
42
|
+
def create_issue(project_id_or_key, attributes)
|
|
43
|
+
super(project_id_or_key, normalize_issue_attrs(attributes))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Updates an issue in Jira.
|
|
47
|
+
# @param id_or_key [String, Integer] The issue ID or key.
|
|
48
|
+
# @param attributes [Hash] Attributes to update.
|
|
49
|
+
# @param context [Hash] Optional context (e.g., :fields for return selection).
|
|
50
|
+
# @return [ActiveProject::Resources::Issue] The updated issue.
|
|
51
|
+
def update_issue(id_or_key, attributes, context = {})
|
|
52
|
+
super(id_or_key, normalize_issue_attrs(attributes), context)
|
|
53
|
+
end
|
|
54
|
+
|
|
36
55
|
# Retrieves details for the currently authenticated user.
|
|
37
56
|
# @return [ActiveProject::Resources::User] The user object.
|
|
38
57
|
# @raise [ActiveProject::AuthenticationError] if authentication fails.
|
|
@@ -56,33 +75,6 @@ module ActiveProject
|
|
|
56
75
|
|
|
57
76
|
# Initializes the Faraday connection object.
|
|
58
77
|
|
|
59
|
-
# Makes an HTTP request. Returns parsed JSON or raises appropriate error.
|
|
60
|
-
def make_request(method, path, body = nil, query = nil)
|
|
61
|
-
response = @connection.run_request(method, path, body, nil) do |req|
|
|
62
|
-
req.params = query if query # Add query params to the request
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Check for AUTHENTICATED_FAILED header even on 200 OK
|
|
66
|
-
if response.status == 200 && response.headers["x-seraph-loginreason"]&.include?("AUTHENTICATED_FAILED")
|
|
67
|
-
raise AuthenticationError, "Jira authentication failed (X-Seraph-Loginreason: AUTHENTICATED_FAILED)"
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# Check for other errors if not successful
|
|
71
|
-
handle_faraday_error(response) unless response.success?
|
|
72
|
-
|
|
73
|
-
# Return parsed body on success, or nil if body is empty/invalid
|
|
74
|
-
JSON.parse(response.body) if response.body && !response.body.empty?
|
|
75
|
-
rescue JSON::ParserError => e
|
|
76
|
-
# Raise specific error if JSON parsing fails on a successful response body
|
|
77
|
-
raise ApiError.new("Jira API returned non-JSON response: #{response&.body}", original_error: e)
|
|
78
|
-
rescue Faraday::Error => e
|
|
79
|
-
# Handle connection errors etc. that occur before the response object is available
|
|
80
|
-
status = e.response&.status
|
|
81
|
-
body = e.response&.body
|
|
82
|
-
raise ApiError.new("Jira API connection error (Status: #{status || 'N/A'}): #{e.message}", original_error: e,
|
|
83
|
-
status_code: status, response_body: body)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
78
|
# Handles Faraday errors based on the response object (for non-2xx responses).
|
|
87
79
|
def handle_faraday_error(response)
|
|
88
80
|
status = response.status
|
|
@@ -109,9 +101,10 @@ module ActiveProject
|
|
|
109
101
|
raise RateLimitError, "Jira rate limit exceeded (Status: 429)"
|
|
110
102
|
when 400, 422
|
|
111
103
|
raise ValidationError.new(
|
|
112
|
-
"Jira validation failed (Status: #{status})#{
|
|
113
|
-
|
|
114
|
-
|
|
104
|
+
"Jira validation failed (Status: #{status})#{
|
|
105
|
+
unless message.empty?
|
|
106
|
+
": #{message}"
|
|
107
|
+
end}. Errors: #{errors_hash.inspect}", errors: errors_hash, status_code: status, response_body: body
|
|
115
108
|
)
|
|
116
109
|
else
|
|
117
110
|
# Raise generic ApiError for other non-success statuses
|
|
@@ -15,6 +15,40 @@ module ActiveProject
|
|
|
15
15
|
comment_data = make_request(:post, path, nil, query_params)
|
|
16
16
|
map_comment_action_data(comment_data, card_id)
|
|
17
17
|
end
|
|
18
|
+
|
|
19
|
+
# Updates a comment on a Card in Trello.
|
|
20
|
+
# @param comment_id [String] The ID of the comment action.
|
|
21
|
+
# @param body [String] The new comment text (Markdown).
|
|
22
|
+
# @param context [Hash] Required context: { card_id: '...' }.
|
|
23
|
+
# @return [ActiveProject::Resources::Comment] The updated comment resource.
|
|
24
|
+
def update_comment(comment_id, body, context = {})
|
|
25
|
+
card_id = context[:card_id]
|
|
26
|
+
unless card_id
|
|
27
|
+
raise ArgumentError,
|
|
28
|
+
"Missing required context: :card_id must be provided for TrelloAdapter#update_comment"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
path = "cards/#{card_id}/actions/#{comment_id}/comments"
|
|
32
|
+
query_params = { text: body }
|
|
33
|
+
comment_data = make_request(:put, path, nil, query_params)
|
|
34
|
+
map_comment_action_data(comment_data, card_id)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Deletes a comment from a Card in Trello.
|
|
38
|
+
# @param comment_id [String] The ID of the comment action to delete.
|
|
39
|
+
# @param context [Hash] Required context: { card_id: '...' }.
|
|
40
|
+
# @return [Boolean] True if successfully deleted.
|
|
41
|
+
def delete_comment(comment_id, context = {})
|
|
42
|
+
card_id = context[:card_id]
|
|
43
|
+
unless card_id
|
|
44
|
+
raise ArgumentError,
|
|
45
|
+
"Missing required context: :card_id must be provided for TrelloAdapter#delete_comment"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
path = "cards/#{card_id}/actions/#{comment_id}/comments"
|
|
49
|
+
make_request(:delete, path)
|
|
50
|
+
true
|
|
51
|
+
end
|
|
18
52
|
end
|
|
19
53
|
end
|
|
20
54
|
end
|
|
@@ -4,33 +4,40 @@ module ActiveProject
|
|
|
4
4
|
module Adapters
|
|
5
5
|
module Trello
|
|
6
6
|
module Connection
|
|
7
|
-
|
|
8
|
-
# @raise [ArgumentError] if required configuration options (:api_key, :api_token) are missing.
|
|
9
|
-
def initialize(config:)
|
|
10
|
-
unless config.is_a?(ActiveProject::Configurations::TrelloConfiguration)
|
|
11
|
-
raise ArgumentError, "TrelloAdapter requires a TrelloConfiguration object"
|
|
12
|
-
end
|
|
7
|
+
include Connections::Rest
|
|
13
8
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
unless @config.api_key && !@config.api_key.empty? && @config.api_token && !@config.api_token.empty?
|
|
17
|
-
raise ArgumentError, "TrelloAdapter configuration requires :api_key and :api_token"
|
|
18
|
-
end
|
|
9
|
+
BASE_URL = "https://api.trello.com/1/"
|
|
19
10
|
|
|
20
|
-
|
|
11
|
+
def initialize(config:)
|
|
12
|
+
super(config: config)
|
|
13
|
+
init_rest(
|
|
14
|
+
base_url: BASE_URL,
|
|
15
|
+
auth_middleware: ->(_c) { }, # Trello uses query-string auth
|
|
16
|
+
extra_headers: { "Accept" => "application/json" }
|
|
17
|
+
)
|
|
21
18
|
end
|
|
22
19
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
#
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
20
|
+
# ------------------------------------------------------------------
|
|
21
|
+
# Adapter-specific wrapper around HttpClient#request
|
|
22
|
+
# ------------------------------------------------------------------
|
|
23
|
+
def make_request(method, path, body = nil, query_params = {})
|
|
24
|
+
auth = { key: @config.key, token: @config.token }
|
|
25
|
+
request(method, path,
|
|
26
|
+
body: body,
|
|
27
|
+
query: auth.merge(query_params))
|
|
28
|
+
rescue ActiveProject::ValidationError => e
|
|
29
|
+
# Trello signals “resource not found / malformed id” with 400 "invalid id"
|
|
30
|
+
invalid_id = /invalid id/i
|
|
31
|
+
if (e.status_code.nil? || e.status_code == 400) &&
|
|
32
|
+
(e.message&.match?(invalid_id) ||
|
|
33
|
+
e.response_body.to_s.match?(invalid_id))
|
|
34
|
+
raise ActiveProject::NotFoundError, e.message
|
|
35
|
+
else
|
|
36
|
+
raise
|
|
32
37
|
end
|
|
33
38
|
end
|
|
39
|
+
|
|
40
|
+
private :make_request
|
|
34
41
|
end
|
|
35
42
|
end
|
|
36
43
|
end
|
|
@@ -74,10 +74,11 @@ module ActiveProject
|
|
|
74
74
|
target_status = update_attributes.delete(:status)
|
|
75
75
|
|
|
76
76
|
board_id = update_attributes[:board_id] || begin
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
find_issue(card_id).project_id
|
|
78
|
+
rescue NotFoundError
|
|
79
|
+
raise NotFoundError,
|
|
80
|
+
"Trello card with ID '#{card_id}' not found."
|
|
81
|
+
end
|
|
81
82
|
|
|
82
83
|
unless board_id
|
|
83
84
|
raise ApiError, "Could not determine board ID for card '#{card_id}' to perform status mapping."
|
|
@@ -121,8 +122,9 @@ module ActiveProject
|
|
|
121
122
|
|
|
122
123
|
# Deletes a Trello card.
|
|
123
124
|
# @param card_id [String] The ID of the Trello Card to delete.
|
|
125
|
+
# @param context [Hash] Optional context (ignored).
|
|
124
126
|
# @return [Boolean] True if successfully deleted.
|
|
125
|
-
def delete_issue(card_id,
|
|
127
|
+
def delete_issue(card_id, _context = {})
|
|
126
128
|
path = "cards/#{card_id}"
|
|
127
129
|
make_request(:delete, path)
|
|
128
130
|
true
|
|
@@ -70,16 +70,14 @@ module ActiveProject
|
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
WebhookEvent.new(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
object_key: object_key,
|
|
73
|
+
type: event_type,
|
|
74
|
+
resource_type: object_kind,
|
|
75
|
+
resource_id: event_object_id,
|
|
77
76
|
project_id: board_id,
|
|
78
77
|
actor: map_user_data(actor_data),
|
|
79
78
|
timestamp: timestamp,
|
|
80
|
-
|
|
81
|
-
changes: changes,
|
|
82
|
-
object_data: object_data,
|
|
79
|
+
source: webhook_type,
|
|
80
|
+
data: (object_data || {}).merge(changes: changes, object_key: object_key),
|
|
83
81
|
raw_data: payload
|
|
84
82
|
)
|
|
85
83
|
rescue JSON::ParserError
|
|
@@ -70,36 +70,16 @@ module ActiveProject
|
|
|
70
70
|
|
|
71
71
|
# Initializes the Faraday connection object.
|
|
72
72
|
|
|
73
|
-
# Helper method for making requests.
|
|
74
|
-
def make_request(method, path, body = nil, query_params = {})
|
|
75
|
-
# Use config object for credentials
|
|
76
|
-
auth_params = { key: @config.api_key, token: @config.api_token }
|
|
77
|
-
all_params = auth_params.merge(query_params)
|
|
78
|
-
json_body = body ? JSON.generate(body) : nil
|
|
79
|
-
headers = {}
|
|
80
|
-
headers["Content-Type"] = "application/json" if json_body
|
|
81
|
-
|
|
82
|
-
response = @connection.run_request(method, path, json_body, headers) do |req|
|
|
83
|
-
req.params.update(all_params)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
return nil if response.status == 204 || response.body.empty?
|
|
87
|
-
|
|
88
|
-
JSON.parse(response.body)
|
|
89
|
-
rescue Faraday::Error => e
|
|
90
|
-
handle_faraday_error(e)
|
|
91
|
-
rescue JSON::ParserError => e
|
|
92
|
-
raise ApiError.new("Trello API returned non-JSON response: #{response&.body}", original_error: e)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
73
|
# Handles Faraday errors.
|
|
96
74
|
def handle_faraday_error(error)
|
|
97
75
|
status = error.response_status
|
|
98
76
|
body = error.response_body
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
77
|
+
begin
|
|
78
|
+
body = JSON.parse(body) if body.is_a?(String) && !body.empty?
|
|
79
|
+
rescue StandardError
|
|
80
|
+
body
|
|
102
81
|
end
|
|
82
|
+
message = body["message"] if body.is_a?(Hash)
|
|
103
83
|
message ||= body || "Unknown Trello Error"
|
|
104
84
|
|
|
105
85
|
case status
|
|
@@ -103,8 +103,9 @@ module ActiveProject
|
|
|
103
103
|
|
|
104
104
|
# Determines the context hash needed for adapter calls based on the owner.
|
|
105
105
|
def determine_context
|
|
106
|
-
# Basecamp
|
|
107
|
-
if @adapter.is_a?(Adapters::BasecampAdapter)
|
|
106
|
+
# Basecamp and GitHub Project need project_id for issue/comment operations
|
|
107
|
+
if (@adapter.is_a?(Adapters::BasecampAdapter) || @adapter.is_a?(Adapters::GithubProjectAdapter)) &&
|
|
108
|
+
(@association_name == :issues || @association_name == :comments)
|
|
108
109
|
{ project_id: @owner.id }
|
|
109
110
|
else
|
|
110
111
|
{} # Other adapters might not need explicit context hash for find_issue/find_comment
|
|
@@ -9,9 +9,12 @@ module ActiveProject
|
|
|
9
9
|
# Maps adapter names (symbols) to their specific configuration classes.
|
|
10
10
|
# Add other adapters here when they need specific config classes.
|
|
11
11
|
ADAPTER_CONFIG_CLASSES = {
|
|
12
|
-
trello: Configurations::TrelloConfiguration
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
trello: Configurations::TrelloConfiguration,
|
|
13
|
+
jira: Configurations::JiraConfiguration,
|
|
14
|
+
basecamp: Configurations::BasecampConfiguration,
|
|
15
|
+
github_repo: Configurations::GithubConfiguration,
|
|
16
|
+
github_project: Configurations::GithubConfiguration,
|
|
17
|
+
fizzy: Configurations::FizzyConfiguration
|
|
15
18
|
}.freeze
|
|
16
19
|
|
|
17
20
|
def initialize
|
|
@@ -8,6 +8,7 @@ module ActiveProject
|
|
|
8
8
|
|
|
9
9
|
def initialize(options = {})
|
|
10
10
|
@options = options.dup # Duplicate to allow modification before freezing
|
|
11
|
+
validate_configuration!
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
# Allow accessing options via method calls
|
|
@@ -23,10 +24,111 @@ module ActiveProject
|
|
|
23
24
|
options.key?(method_name) || super
|
|
24
25
|
end
|
|
25
26
|
|
|
27
|
+
public
|
|
28
|
+
|
|
26
29
|
def freeze
|
|
27
30
|
@options.freeze
|
|
28
31
|
super
|
|
29
32
|
end
|
|
33
|
+
|
|
34
|
+
# Returns retry options for HTTP connections
|
|
35
|
+
# Can be overridden by configuration options or adapter-specific settings
|
|
36
|
+
# @return [Hash] Retry configuration hash
|
|
37
|
+
def retry_options
|
|
38
|
+
options[:retry_options] || {}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
protected
|
|
42
|
+
|
|
43
|
+
# Override in subclasses to add specific validation rules.
|
|
44
|
+
# Should raise ArgumentError with descriptive messages for invalid configurations.
|
|
45
|
+
def validate_configuration!
|
|
46
|
+
# Validate retry options if provided
|
|
47
|
+
validate_retry_options! if options[:retry_options]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Validates retry options configuration
|
|
51
|
+
def validate_retry_options!
|
|
52
|
+
retry_opts = options[:retry_options]
|
|
53
|
+
validate_option_type(:retry_options, Hash)
|
|
54
|
+
|
|
55
|
+
# Validate specific retry option types
|
|
56
|
+
if retry_opts[:max]
|
|
57
|
+
unless retry_opts[:max].is_a?(Integer) && retry_opts[:max] > 0
|
|
58
|
+
raise ArgumentError, "retry_options[:max] must be a positive integer, got #{retry_opts[:max].inspect}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if retry_opts[:interval]
|
|
63
|
+
unless retry_opts[:interval].is_a?(Numeric) && retry_opts[:interval] > 0
|
|
64
|
+
raise ArgumentError,
|
|
65
|
+
"retry_options[:interval] must be a positive number, got #{retry_opts[:interval].inspect}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if retry_opts[:backoff_factor]
|
|
70
|
+
unless retry_opts[:backoff_factor].is_a?(Numeric) && retry_opts[:backoff_factor] > 0
|
|
71
|
+
raise ArgumentError,
|
|
72
|
+
"retry_options[:backoff_factor] must be a positive number, got #{retry_opts[:backoff_factor].inspect}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Helper method for validating required options
|
|
78
|
+
def require_options(*required_keys)
|
|
79
|
+
missing = required_keys.select { |key| options[key].nil? || options[key].to_s.strip.empty? }
|
|
80
|
+
return if missing.empty?
|
|
81
|
+
|
|
82
|
+
# Skip validation in test environment with dummy values
|
|
83
|
+
return if test_environment_with_dummy_values?
|
|
84
|
+
|
|
85
|
+
adapter_name = self.class.name.split("::").last.gsub("Configuration", "").downcase
|
|
86
|
+
missing_list = missing.map(&:inspect).join(", ")
|
|
87
|
+
|
|
88
|
+
raise ArgumentError,
|
|
89
|
+
"#{adapter_name.capitalize} adapter configuration is missing required options: #{missing_list}. " \
|
|
90
|
+
"Please provide these values in your configuration."
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Detects if we're in a test environment with dummy values
|
|
94
|
+
def test_environment_with_dummy_values?
|
|
95
|
+
# Check if Rails is defined and in test environment, OR if we have dummy values
|
|
96
|
+
is_test_env = defined?(Rails) ? Rails.env.test? : false
|
|
97
|
+
|
|
98
|
+
# Check for common dummy value patterns used in tests
|
|
99
|
+
dummy_patterns = [ "DUMMY_", "TEST_", "FAKE_" ]
|
|
100
|
+
has_dummy_values = options.values.any? { |value|
|
|
101
|
+
value.is_a?(String) && dummy_patterns.any? { |pattern| value.start_with?(pattern) }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Return true if either in test env with dummy values, OR has dummy values (for non-Rails contexts)
|
|
105
|
+
(is_test_env && has_dummy_values) || (!defined?(Rails) && has_dummy_values)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Helper method for validating option types
|
|
109
|
+
def validate_option_type(key, expected_type, allow_nil: false)
|
|
110
|
+
value = options[key]
|
|
111
|
+
return if allow_nil && value.nil?
|
|
112
|
+
|
|
113
|
+
# Skip validation in test environment with dummy values
|
|
114
|
+
return if test_environment_with_dummy_values?
|
|
115
|
+
|
|
116
|
+
# Handle arrays of types (for boolean validation)
|
|
117
|
+
if expected_type.is_a?(Array)
|
|
118
|
+
return if expected_type.any? { |type| value.is_a?(type) }
|
|
119
|
+
expected_names = expected_type.map(&:name).join(" or ")
|
|
120
|
+
else
|
|
121
|
+
return if value.is_a?(expected_type)
|
|
122
|
+
expected_names = expected_type.name
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
adapter_name = self.class.name.split("::").last.gsub("Configuration", "").downcase
|
|
126
|
+
actual_type = value.class.name
|
|
127
|
+
|
|
128
|
+
raise ArgumentError,
|
|
129
|
+
"#{adapter_name.capitalize} adapter option :#{key} must be a #{expected_names}, " \
|
|
130
|
+
"got #{actual_type}: #{value.inspect}"
|
|
131
|
+
end
|
|
30
132
|
end
|
|
31
133
|
end
|
|
32
134
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveProject
|
|
4
|
+
module Configurations
|
|
5
|
+
class BasecampConfiguration < BaseAdapterConfiguration
|
|
6
|
+
# expected options:
|
|
7
|
+
# :account_id – Basecamp account ID (numeric)
|
|
8
|
+
# :access_token – OAuth access token from Basecamp
|
|
9
|
+
# optional:
|
|
10
|
+
# :user_agent – Custom user agent string
|
|
11
|
+
# :timeout – Request timeout in seconds (default: 30)
|
|
12
|
+
|
|
13
|
+
protected
|
|
14
|
+
|
|
15
|
+
def validate_configuration!
|
|
16
|
+
require_options(:account_id, :access_token)
|
|
17
|
+
validate_option_type(:access_token, String)
|
|
18
|
+
validate_option_type(:user_agent, String, allow_nil: true)
|
|
19
|
+
validate_option_type(:timeout, Integer, allow_nil: true)
|
|
20
|
+
|
|
21
|
+
# Skip format validation in test environment with dummy values
|
|
22
|
+
return if test_environment_with_dummy_values?
|
|
23
|
+
|
|
24
|
+
# Validate account_id (can be string or integer, but should be numeric)
|
|
25
|
+
account_id = options[:account_id]
|
|
26
|
+
unless account_id.to_s.match?(/^\d+$/)
|
|
27
|
+
raise ArgumentError,
|
|
28
|
+
"Basecamp account_id must be numeric, got: #{account_id.inspect}. " \
|
|
29
|
+
"Find your account ID in your Basecamp URL: https://3.basecamp.com/YOUR_ACCOUNT_ID/"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Validate access_token format (Basecamp tokens are typically long)
|
|
33
|
+
access_token = options[:access_token]
|
|
34
|
+
if access_token.length < 20
|
|
35
|
+
raise ArgumentError,
|
|
36
|
+
"Basecamp access token appears to be too short. Expected an OAuth token " \
|
|
37
|
+
"from Basecamp OAuth flow, got token of length #{access_token.length}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveProject
|
|
4
|
+
module Configurations
|
|
5
|
+
# Holds Fizzy-specific configuration options.
|
|
6
|
+
class FizzyConfiguration < BaseAdapterConfiguration
|
|
7
|
+
# @!attribute [rw] status_mappings
|
|
8
|
+
# @return [Hash] Mappings from Board IDs to Column Name to Status Symbol.
|
|
9
|
+
# Supports expanded status vocabulary: :open, :in_progress, :blocked, :on_hold, :closed
|
|
10
|
+
# @example
|
|
11
|
+
# {
|
|
12
|
+
# 'board_id_1' => {
|
|
13
|
+
# 'In Progress' => :in_progress,
|
|
14
|
+
# 'Blocked' => :blocked,
|
|
15
|
+
# 'Done' => :closed
|
|
16
|
+
# }
|
|
17
|
+
# }
|
|
18
|
+
attr_accessor :status_mappings
|
|
19
|
+
|
|
20
|
+
def initialize(options = {})
|
|
21
|
+
super
|
|
22
|
+
@status_mappings = options.delete(:status_mappings) || {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
protected
|
|
26
|
+
|
|
27
|
+
def validate_configuration!
|
|
28
|
+
require_options(:account_slug, :access_token)
|
|
29
|
+
validate_option_type(:account_slug, String)
|
|
30
|
+
validate_option_type(:access_token, String)
|
|
31
|
+
validate_option_type(:base_url, String, allow_nil: true) if options[:base_url]
|
|
32
|
+
validate_option_type(:status_mappings, Hash, allow_nil: true) if options[:status_mappings]
|
|
33
|
+
|
|
34
|
+
super # Validate retry_options if present
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
public
|
|
38
|
+
|
|
39
|
+
def freeze
|
|
40
|
+
# Ensure nested hashes are also frozen
|
|
41
|
+
@status_mappings.each_value(&:freeze)
|
|
42
|
+
@status_mappings.freeze
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|