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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +248 -51
  3. data/lib/active_project/adapters/base.rb +154 -14
  4. data/lib/active_project/adapters/basecamp/comments.rb +34 -0
  5. data/lib/active_project/adapters/basecamp/connection.rb +10 -23
  6. data/lib/active_project/adapters/basecamp/issues.rb +6 -5
  7. data/lib/active_project/adapters/basecamp/webhooks.rb +7 -8
  8. data/lib/active_project/adapters/basecamp_adapter.rb +2 -11
  9. data/lib/active_project/adapters/fizzy/columns.rb +116 -0
  10. data/lib/active_project/adapters/fizzy/comments.rb +129 -0
  11. data/lib/active_project/adapters/fizzy/connection.rb +41 -0
  12. data/lib/active_project/adapters/fizzy/issues.rb +221 -0
  13. data/lib/active_project/adapters/fizzy/projects.rb +105 -0
  14. data/lib/active_project/adapters/fizzy_adapter.rb +151 -0
  15. data/lib/active_project/adapters/github_project/comments.rb +91 -0
  16. data/lib/active_project/adapters/github_project/connection.rb +58 -0
  17. data/lib/active_project/adapters/github_project/helpers.rb +100 -0
  18. data/lib/active_project/adapters/github_project/issues.rb +287 -0
  19. data/lib/active_project/adapters/github_project/projects.rb +139 -0
  20. data/lib/active_project/adapters/github_project/webhooks.rb +168 -0
  21. data/lib/active_project/adapters/github_project.rb +8 -0
  22. data/lib/active_project/adapters/github_project_adapter.rb +65 -0
  23. data/lib/active_project/adapters/github_repo/connection.rb +62 -0
  24. data/lib/active_project/adapters/github_repo/issues.rb +242 -0
  25. data/lib/active_project/adapters/github_repo/projects.rb +116 -0
  26. data/lib/active_project/adapters/github_repo/webhooks.rb +354 -0
  27. data/lib/active_project/adapters/github_repo_adapter.rb +134 -0
  28. data/lib/active_project/adapters/jira/attribute_normalizer.rb +16 -0
  29. data/lib/active_project/adapters/jira/comments.rb +41 -0
  30. data/lib/active_project/adapters/jira/connection.rb +43 -24
  31. data/lib/active_project/adapters/jira/issues.rb +21 -7
  32. data/lib/active_project/adapters/jira/projects.rb +3 -1
  33. data/lib/active_project/adapters/jira/transitions.rb +2 -1
  34. data/lib/active_project/adapters/jira/webhooks.rb +5 -7
  35. data/lib/active_project/adapters/jira_adapter.rb +23 -30
  36. data/lib/active_project/adapters/trello/comments.rb +34 -0
  37. data/lib/active_project/adapters/trello/connection.rb +28 -21
  38. data/lib/active_project/adapters/trello/issues.rb +7 -5
  39. data/lib/active_project/adapters/trello/webhooks.rb +5 -7
  40. data/lib/active_project/adapters/trello_adapter.rb +5 -25
  41. data/lib/active_project/association_proxy.rb +3 -2
  42. data/lib/active_project/async.rb +9 -0
  43. data/lib/active_project/configuration.rb +6 -3
  44. data/lib/active_project/configurations/base_adapter_configuration.rb +102 -0
  45. data/lib/active_project/configurations/basecamp_configuration.rb +42 -0
  46. data/lib/active_project/configurations/fizzy_configuration.rb +47 -0
  47. data/lib/active_project/configurations/github_configuration.rb +57 -0
  48. data/lib/active_project/configurations/jira_configuration.rb +54 -0
  49. data/lib/active_project/configurations/trello_configuration.rb +24 -2
  50. data/lib/active_project/connections/base.rb +35 -0
  51. data/lib/active_project/connections/graph_ql.rb +83 -0
  52. data/lib/active_project/connections/http_client.rb +79 -0
  53. data/lib/active_project/connections/pagination.rb +44 -0
  54. data/lib/active_project/connections/rest.rb +33 -0
  55. data/lib/active_project/error_mapper.rb +38 -0
  56. data/lib/active_project/errors.rb +13 -0
  57. data/lib/active_project/railtie.rb +33 -0
  58. data/lib/active_project/resource_factory.rb +18 -0
  59. data/lib/active_project/resources/base_resource.rb +13 -14
  60. data/lib/active_project/resources/comment.rb +46 -2
  61. data/lib/active_project/resources/issue.rb +106 -18
  62. data/lib/active_project/resources/persistable_resource.rb +47 -0
  63. data/lib/active_project/resources/project.rb +1 -1
  64. data/lib/active_project/status_mapper.rb +145 -0
  65. data/lib/active_project/version.rb +1 -1
  66. data/lib/active_project/webhook_event.rb +34 -12
  67. data/lib/activeproject.rb +11 -6
  68. 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 priority].freeze
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. Required: :project, :summary, :issue_type. Optional: :description, :assignee_id, :due_on, :priority.
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
- attributes[:summary] && !attributes[:summary].empty? &&
61
- attributes[:issue_type] && (attributes[:issue_type][:id] || attributes[:issue_type][:name])
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 (must be a Hash with id/key), :summary, :issue_type (with id/name)"
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
- { type: "doc", version: 1, content: [ { type: "paragraph", content: [ { type: "text", text: attributes[:description] } ] } ] }
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
- { type: "doc", version: 1, content: [ { type: "paragraph", content: [ { type: "text", text: attributes[:description] } ] } ] }
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. Required: :key, :name, :project_type_key, :lead_account_id. Optional: :description, :assignee_type.
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 or not available for issue '#{issue_id_or_key}'. Available transitions: [#{available_names}]"
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
- event_type: event_type,
72
- object_kind: object_kind,
73
- event_object_id: event_object_id,
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
- adapter_source: :jira,
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})#{unless message.empty?
113
- ": #{message}"
114
- end}. Errors: #{errors_hash.inspect}", errors: errors_hash, status_code: status, response_body: body
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
- BASE_URL = "https://api.trello.com/1/"
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
- @config = config
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
- @connection = initialize_connection
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
- private
24
-
25
- # Initializes the Faraday connection object.
26
- def initialize_connection
27
- Faraday.new(url: BASE_URL) do |conn|
28
- conn.request :retry
29
- conn.headers["Accept"] = "application/json"
30
- conn.response :raise_error
31
- conn.headers["User-Agent"] = ActiveProject.user_agent
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
- find_issue(card_id).project_id
78
- rescue NotFoundError
79
- raise NotFoundError, "Trello card with ID '#{card_id}' not found."
80
- end
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
- event_type: event_type,
74
- object_kind: object_kind,
75
- event_object_id: event_object_id,
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
- adapter_source: :trello,
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
- body = JSON.parse(body) if body.is_a?(String) && !body.empty? rescue body
100
- if body.is_a?(Hash)
101
- message = body["message"]
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 needs project_id for issue/comment operations
107
- if @adapter.is_a?(Adapters::BasecampAdapter) && (@association_name == :issues || @association_name == :comments)
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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Async
5
+ require "async"
6
+ require "async/http/faraday"
7
+ def self.run(&block) = Async(&block)
8
+ end
9
+ end
@@ -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
- # :jira => Configurations::JiraConfiguration,
14
- # :basecamp => Configurations::BasecampConfiguration,
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