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
|
@@ -7,9 +7,9 @@ module ActiveProject
|
|
|
7
7
|
module Jira
|
|
8
8
|
# Low-level HTTP concerns for JiraAdapter
|
|
9
9
|
module Connection
|
|
10
|
-
include
|
|
10
|
+
include Connections::Rest
|
|
11
11
|
|
|
12
|
-
SERAPH_HEADER = "x-seraph-loginreason"
|
|
12
|
+
SERAPH_HEADER = "x-seraph-loginreason"
|
|
13
13
|
|
|
14
14
|
# @param config [ActiveProject::Configurations::BaseAdapterConfiguration]
|
|
15
15
|
# Must expose :site_url, :username, :api_token.
|
|
@@ -18,25 +18,28 @@ module ActiveProject
|
|
|
18
18
|
unless config.is_a?(ActiveProject::Configurations::BaseAdapterConfiguration)
|
|
19
19
|
raise ArgumentError, "JiraAdapter requires a BaseAdapterConfiguration object"
|
|
20
20
|
end
|
|
21
|
-
|
|
21
|
+
|
|
22
|
+
super(config: config)
|
|
22
23
|
|
|
23
24
|
# --- Build an absolute base URL ------------------------------------
|
|
24
|
-
raw_url
|
|
25
|
+
raw_url = @config.options.fetch(:site_url)
|
|
25
26
|
site_url = raw_url =~ %r{\Ahttps?://}i ? raw_url.dup : +"https://#{raw_url}"
|
|
26
27
|
site_url.chomp!("/")
|
|
27
28
|
|
|
28
|
-
username
|
|
29
|
+
username = @config.options.fetch(:username)
|
|
29
30
|
api_token = @config.options.fetch(:api_token)
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
init_rest(
|
|
32
33
|
base_url: site_url,
|
|
33
|
-
auth_middleware:
|
|
34
|
+
auth_middleware: lambda do |conn|
|
|
34
35
|
# Faraday’s built-in basic-auth helper :contentReference[oaicite:0]{index=0}
|
|
35
36
|
conn.request :authorization, :basic, username, api_token
|
|
36
37
|
end
|
|
37
38
|
)
|
|
38
39
|
end
|
|
39
40
|
|
|
41
|
+
private
|
|
42
|
+
|
|
40
43
|
# --------------------------------------------------------------------
|
|
41
44
|
# Tiny wrapper around HttpClient#request that handles Jira quirks
|
|
42
45
|
# --------------------------------------------------------------------
|
|
@@ -49,16 +52,13 @@ module ActiveProject
|
|
|
49
52
|
#
|
|
50
53
|
# @raise [ActiveProject::AuthenticationError] if Jira signals
|
|
51
54
|
# AUTHENTICATED_FAILED via X-Seraph-LoginReason header.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
# Jira returns 200 + this header when credentials are wrong :contentReference[oaicite:1]{index=1}
|
|
57
|
-
raise ActiveProject::AuthenticationError,
|
|
58
|
-
"Jira authentication failed (#{SERAPH_HEADER}: AUTHENTICATED_FAILED)"
|
|
55
|
+
def make_request(method, path, body = nil, query = nil, headers = {})
|
|
56
|
+
res = request_rest(method, path, body, query, headers)
|
|
57
|
+
if last_response&.headers&.[](SERAPH_HEADER)&.include?("AUTHENTICATED_FAILED")
|
|
58
|
+
raise ActiveProject::AuthenticationError, "Jira authentication failed"
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
res
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
end
|
|
@@ -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.
|
|
@@ -82,9 +101,10 @@ module ActiveProject
|
|
|
82
101
|
raise RateLimitError, "Jira rate limit exceeded (Status: 429)"
|
|
83
102
|
when 400, 422
|
|
84
103
|
raise ValidationError.new(
|
|
85
|
-
"Jira validation failed (Status: #{status})#{
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
88
108
|
)
|
|
89
109
|
else
|
|
90
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,15 +4,15 @@ module ActiveProject
|
|
|
4
4
|
module Adapters
|
|
5
5
|
module Trello
|
|
6
6
|
module Connection
|
|
7
|
-
include
|
|
7
|
+
include Connections::Rest
|
|
8
8
|
|
|
9
|
-
BASE_URL = "https://api.trello.com/1/"
|
|
9
|
+
BASE_URL = "https://api.trello.com/1/"
|
|
10
10
|
|
|
11
11
|
def initialize(config:)
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
super(config: config)
|
|
13
|
+
init_rest(
|
|
14
14
|
base_url: BASE_URL,
|
|
15
|
-
auth_middleware: ->(_c) { },
|
|
15
|
+
auth_middleware: ->(_c) { }, # Trello uses query-string auth
|
|
16
16
|
extra_headers: { "Accept" => "application/json" }
|
|
17
17
|
)
|
|
18
18
|
end
|
|
@@ -21,13 +21,16 @@ module ActiveProject
|
|
|
21
21
|
# Adapter-specific wrapper around HttpClient#request
|
|
22
22
|
# ------------------------------------------------------------------
|
|
23
23
|
def make_request(method, path, body = nil, query_params = {})
|
|
24
|
-
auth = { key: @config.
|
|
24
|
+
auth = { key: @config.key, token: @config.token }
|
|
25
25
|
request(method, path,
|
|
26
|
-
body:
|
|
26
|
+
body: body,
|
|
27
27
|
query: auth.merge(query_params))
|
|
28
28
|
rescue ActiveProject::ValidationError => e
|
|
29
|
-
# Trello signals “resource not found / malformed id” with 400
|
|
30
|
-
|
|
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))
|
|
31
34
|
raise ActiveProject::NotFoundError, e.message
|
|
32
35
|
else
|
|
33
36
|
raise
|
|
@@ -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
|
|
@@ -74,10 +74,12 @@ module ActiveProject
|
|
|
74
74
|
def handle_faraday_error(error)
|
|
75
75
|
status = error.response_status
|
|
76
76
|
body = error.response_body
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
begin
|
|
78
|
+
body = JSON.parse(body) if body.is_a?(String) && !body.empty?
|
|
79
|
+
rescue StandardError
|
|
80
|
+
body
|
|
80
81
|
end
|
|
82
|
+
message = body["message"] if body.is_a?(Hash)
|
|
81
83
|
message ||= body || "Unknown Trello Error"
|
|
82
84
|
|
|
83
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
|