activeproject 0.1.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46d0e95fea9e696d7f35f2f8ad2cc32a82f9c068aaf834a9daa2650824650620
4
- data.tar.gz: 709c98e0c3bab903db0447c7d53559c55e5c7c98cffc511a771c3b9f08cfa2fc
3
+ metadata.gz: 9fc9c393c3294c98f99863d698a7288077f6e71977e868c3a2ed0c2a5a5f7cbc
4
+ data.tar.gz: ff6f35f9cb6f58529be940efddb58d84f60490ee0bf02d1a6aba5f4e655dca51
5
5
  SHA512:
6
- metadata.gz: 7fb8db4e4f952b10364e91727ebec93e6f0e65f6ebf7d5bd331dab77013bd3fc34d100f522a76d5f8d7014d947081a16fdee34535a6327cbabfe6bb74cbfc00b
7
- data.tar.gz: 15231c9e5b94a103f21b643f219ffebf4747223f2662a410389ecbf142cc1f618c96384dd5314ba56d00afa6bdf4d0f81cfc0ba33bd580f2fa4999dae8a954eb
6
+ metadata.gz: 251021a9cbb4f3e0556092d7e8667b84324fcacbfee601683a3fadf3822b5a67b9a2c4b68744648d49a892c9690b99600879e16a5132d32ceac8d6177dc9cb60
7
+ data.tar.gz: 72a0056a416007bcc6abcfde009ec26674a37b6fe1864a01f7d9f01a13c8059adba6cf44208657e442170db5dc7fcdebfff6c0b7dee1d36b06f8dcfb8465ea28
data/README.md CHANGED
@@ -10,9 +10,10 @@ Integrating with various project management platforms like Jira, Basecamp, and T
10
10
 
11
11
  The ActiveProject gem aims to solve this by providing a unified, opinionated interface built on the **Adapter pattern**. It abstracts away the complexities of individual APIs, offering:
12
12
 
13
- * **Normalized Data Models:** Common Ruby objects for core concepts like `Project`, `Task` (Issue/Card/To-do), `Comment`, and `User`.
14
- * **Standardized Operations:** Consistent methods for creating, reading, updating, and transitioning tasks (e.g., `task.close!`, `task.reopen!`).
13
+ * **Normalized Data Models:** Common Ruby objects for core concepts like `Project`, `Issue` (Issue/Task/Card/To-do), `Comment`, and `User`.
14
+ * **Standardized Operations:** Consistent methods for creating, reading, updating, and transitioning issues (e.g., `issue.close!`, `issue.reopen!`).
15
15
  * **Unified Error Handling:** A common set of exceptions (`AuthenticationError`, `NotFoundError`, `RateLimitError`, etc.) regardless of the underlying platform.
16
+ * **Co-operative Concurrency:** Optional fiber-based I/O (powered by the [`async`](https://github.com/socketry/async) ecosystem) for bulk operations without threads.
16
17
 
17
18
  ## Supported Platforms
18
19
 
@@ -235,6 +236,56 @@ rescue => e
235
236
  end
236
237
  ```
237
238
 
239
+ ## Asynchronous I/O
240
+
241
+ ActiveProject ships with `async-http` under the hood.
242
+ Enable the non-blocking adapter by setting an ENV var **before** your process boots:
243
+
244
+ ```bash
245
+ AP_DEFAULT_ADAPTER=async_http
246
+ ```
247
+
248
+ ### Parallel fan-out example
249
+
250
+ ```ruby
251
+ ActiveProject::Async.run do |task|
252
+ jira = ActiveProject.adapter(:jira)
253
+ boards = %w[ACME DEV OPS]
254
+
255
+ tasks = boards.map do |key|
256
+ task.async { jira.list_issues(key, max_results: 100) }
257
+ end
258
+
259
+ issues = tasks.flat_map(&:wait)
260
+ puts "Fetched #{issues.size} issues in parallel."
261
+ end
262
+ ```
263
+
264
+ No threads, no Mutexes—just Ruby fibers.
265
+
266
+ ---
267
+
268
+ ## Rails auto-scheduler
269
+
270
+ If your app runs on Rails, ActiveProject’s Railtie installs `Async::Scheduler` automatically **before Zeitwerk boots**.
271
+
272
+ * Opt out per app:
273
+
274
+ ```ruby
275
+ # config/application.rb
276
+ config.active_project.use_async_scheduler = false
277
+ ```
278
+
279
+ * …or per environment:
280
+
281
+ ```bash
282
+ AP_NO_ASYNC_SCHEDULER=1
283
+ ```
284
+
285
+ If another gem (e.g. Falcon) already set a scheduler, ActiveProject detects it and does nothing.
286
+
287
+ ---
288
+
238
289
  ## Development
239
290
 
240
291
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -75,6 +75,12 @@ module ActiveProject
75
75
  raise NotImplementedError, "#{self.class.name} must implement #update_issue"
76
76
  end
77
77
 
78
+ # Base implementation of delete_issue that raises NotImplementedError
79
+ # This will be included in the base adapter class and overridden by specific adapters
80
+ def delete_issue(id, context = {})
81
+ raise NotImplementedError, "The #{self.class.name} adapter does not implement delete_issue"
82
+ end
83
+
78
84
  # Adds a comment to an issue.
79
85
  # @param issue_id [String, Integer] The ID or key of the issue.
80
86
  # @param comment_body [String] The text of the comment.
@@ -4,6 +4,7 @@ module ActiveProject
4
4
  module Adapters
5
5
  module Basecamp
6
6
  module Connection
7
+ include ActiveProject::Adapters::HttpClient
7
8
  BASE_URL_TEMPLATE = "https://3.basecampapi.com/%<account_id>s/"
8
9
  # Initializes the Basecamp Adapter.
9
10
  # @param config [Configurations::BaseAdapterConfiguration] The configuration object for Basecamp.
@@ -14,11 +15,15 @@ module ActiveProject
14
15
  unless config.is_a?(ActiveProject::Configurations::BaseAdapterConfiguration)
15
16
  raise ArgumentError, "BasecampAdapter requires a BaseAdapterConfiguration object"
16
17
  end
17
-
18
18
  @config = config
19
19
 
20
- account_id = @config.options[:account_id].to_s # Ensure it's a string
21
- access_token = @config.options[:access_token]
20
+ account_id = @config.options.fetch(:account_id)
21
+ access_token = @config.options.fetch(:access_token)
22
+
23
+ build_connection(
24
+ base_url: format(BASE_URL_TEMPLATE, account_id: account_id),
25
+ auth_middleware: ->(conn) { conn.request :authorization, :bearer, access_token }
26
+ )
22
27
 
23
28
  unless account_id && !account_id.empty? && access_token && !access_token.empty?
24
29
  raise ArgumentError, "BasecampAdapter configuration requires :account_id and :access_token"
@@ -6,11 +6,12 @@ module ActiveProject
6
6
  module Issues
7
7
  # Lists To-dos within a specific project.
8
8
  # @param project_id [String, Integer] The ID of the Basecamp project.
9
- # @param options [Hash] Optional options. Accepts :todolist_id.
9
+ # @param options [Hash] Optional options. Accepts :todolist_id and :page_size.
10
10
  # @return [Array<ActiveProject::Resources::Issue>] An array of issue resources.
11
11
  def list_issues(project_id, options = {})
12
12
  all_todos = []
13
13
  todolist_id = options[:todolist_id]
14
+ page_size = options[:page_size] || 50
14
15
 
15
16
  unless todolist_id
16
17
  todolist_id = find_first_todolist_id(project_id)
@@ -18,14 +19,16 @@ module ActiveProject
18
19
  end
19
20
 
20
21
  path = "buckets/#{project_id}/todolists/#{todolist_id}/todos.json"
22
+ query = {}
23
+ query[:per_page] = page_size if page_size
21
24
 
22
25
  loop do
23
- response = @connection.get(path)
26
+ response = @connection.get(path, query)
24
27
  todos_data = begin
25
- JSON.parse(response.body)
26
- rescue StandardError
27
- []
28
- end
28
+ JSON.parse(response.body)
29
+ rescue StandardError
30
+ []
31
+ end
29
32
  break if todos_data.empty?
30
33
 
31
34
  todos_data.each do |todo_data|
@@ -37,6 +40,7 @@ module ActiveProject
37
40
  break unless next_url
38
41
 
39
42
  path = next_url.sub(@base_url, "").sub(%r{^/}, "")
43
+ query = {} # Clear query as pagination is in the URL now
40
44
  end
41
45
 
42
46
  all_todos
@@ -133,6 +137,21 @@ module ActiveProject
133
137
 
134
138
  find_issue(todo_id, context)
135
139
  end
140
+
141
+ # Deletes a To-do in Basecamp.
142
+ # @param todo_id [String, Integer] The ID of the Basecamp To-do to delete.
143
+ # @param context [Hash] Required context: { project_id: '...' }.
144
+ # @return [Boolean] True if successfully deleted.
145
+ def delete_issue(todo_id, context = {})
146
+ project_id = context[:project_id]
147
+ unless project_id
148
+ raise ArgumentError, "Missing required context: :project_id must be provided for BasecampAdapter#delete_issue"
149
+ end
150
+
151
+ path = "buckets/#{project_id}/todos/#{todo_id}.json"
152
+ make_request(:delete, path)
153
+ true
154
+ end
136
155
  end
137
156
  end
138
157
  end
@@ -60,17 +60,8 @@ module ActiveProject
60
60
  # Initializes the Faraday connection object.
61
61
 
62
62
  # Helper method for making requests.
63
- def make_request(method, path, body = nil, query_params = {})
64
- full_path = path.start_with?("/") ? path[1..] : path
65
-
66
- response = @connection.run_request(method, full_path, body, nil) do |req|
67
- req.params.update(query_params) unless query_params.empty?
68
- end
69
- return nil if response.status == 204 # Handle No Content for POST/DELETE completion
70
-
71
- JSON.parse(response.body) if response.body && !response.body.empty?
72
- rescue Faraday::Error => e
73
- handle_faraday_error(e)
63
+ def make_request(method, path, body = nil, query = {})
64
+ request(method, path, body: body, query: query)
74
65
  end
75
66
 
76
67
  # Handles Faraday errors.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "json"
6
+
7
+ module ActiveProject
8
+ module Adapters
9
+ module HttpClient
10
+ include Pagination
11
+ DEFAULT_HEADERS = {
12
+ "Content-Type" => "application/json",
13
+ "Accept" => "application/json",
14
+ "User-Agent" => -> { ActiveProject.user_agent }
15
+ }.freeze
16
+ RETRY_OPTS = { max: 5, interval: 0.5, backoff_factor: 2 }.freeze
17
+
18
+ def build_connection(base_url:, auth_middleware:, extra_headers: {})
19
+ @connection = Faraday.new(url: base_url) do |conn|
20
+ auth_middleware.call(conn) # <-- adapter-specific
21
+ conn.request :retry, **RETRY_OPTS
22
+ conn.response :raise_error
23
+ default_adapter = ENV.fetch("AP_DEFAULT_ADAPTER", "net_http").to_sym
24
+ conn.adapter default_adapter
25
+ conn.headers.merge!(DEFAULT_HEADERS.transform_values { |v| v.respond_to?(:call) ? v.call : v })
26
+ conn.headers.merge!(extra_headers)
27
+ yield conn if block_given? # optional extra tweaks
28
+ end
29
+ end
30
+
31
+ def request(method, path, body: nil, query: nil, headers: {})
32
+ raise "HTTP connection not initialised" unless @connection
33
+
34
+ json_body = body.is_a?(String) ? body : (body ? JSON.generate(body) : nil)
35
+ response = @connection.run_request(method, path, json_body, headers) do |req|
36
+ req.params.update(query) if query&.any?
37
+ end
38
+
39
+ return nil if response.status == 204 || response.body.to_s.empty?
40
+ JSON.parse(response.body)
41
+ rescue Faraday::Error => e
42
+ raise translate_faraday_error(e)
43
+ rescue JSON::ParserError => e
44
+ raise ActiveProject::ApiError.new("Non-JSON response from #{path}",
45
+ original_error: e,
46
+ status_code: response&.status,
47
+ response_body: response&.body)
48
+ end
49
+
50
+ private
51
+
52
+ def translate_faraday_error(err)
53
+ status = err.response_status
54
+ body = err.response_body.to_s
55
+ msg = begin JSON.parse(body)["message"] rescue body end
56
+
57
+ case status
58
+ when 401, 403 then ActiveProject::AuthenticationError.new(msg)
59
+ when 404 then ActiveProject::NotFoundError.new(msg)
60
+ when 429 then ActiveProject::RateLimitError.new(msg)
61
+ when 400, 422 then ActiveProject::ValidationError.new(msg, status_code: status, response_body: body)
62
+ else
63
+ ActiveProject::ApiError.new("HTTP #{status || 'N/A'}: #{msg}",
64
+ original_error: err,
65
+ status_code: status,
66
+ response_body: body)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -1,45 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "uri"
4
+
3
5
  module ActiveProject
4
6
  module Adapters
5
7
  module Jira
8
+ # Low-level HTTP concerns for JiraAdapter
6
9
  module Connection
7
- # Initializes the Jira Adapter.
8
- # @param config [Configurations::BaseAdapterConfiguration] The configuration object for Jira.
9
- # @raise [ArgumentError] if required configuration options (:site_url, :username, :api_token) are missing.
10
+ include ActiveProject::Adapters::HttpClient
11
+
12
+ SERAPH_HEADER = "x-seraph-loginreason".freeze
13
+
14
+ # @param config [ActiveProject::Configurations::BaseAdapterConfiguration]
15
+ # Must expose :site_url, :username, :api_token.
16
+ # @raise [ArgumentError] if required keys are missing.
10
17
  def initialize(config:)
11
18
  unless config.is_a?(ActiveProject::Configurations::BaseAdapterConfiguration)
12
19
  raise ArgumentError, "JiraAdapter requires a BaseAdapterConfiguration object"
13
20
  end
14
-
15
21
  @config = config
16
22
 
17
- unless @config.options[:site_url] && !@config.options[:site_url].empty? &&
18
- @config.options[:username] && !@config.options[:username].empty? &&
19
- @config.options[:api_token] && !@config.options[:api_token].empty?
20
- raise ArgumentError, "JiraAdapter configuration requires :site_url, :username, and :api_token"
21
- end
23
+ # --- Build an absolute base URL ------------------------------------
24
+ raw_url = @config.options.fetch(:site_url)
25
+ site_url = raw_url =~ %r{\Ahttps?://}i ? raw_url.dup : +"https://#{raw_url}"
26
+ site_url.chomp!("/")
27
+
28
+ username = @config.options.fetch(:username)
29
+ api_token = @config.options.fetch(:api_token)
22
30
 
23
- @connection = initialize_connection
31
+ build_connection(
32
+ base_url: site_url,
33
+ auth_middleware: ->(conn) do
34
+ # Faraday’s built-in basic-auth helper :contentReference[oaicite:0]{index=0}
35
+ conn.request :authorization, :basic, username, api_token
36
+ end
37
+ )
24
38
  end
25
39
 
26
- private
27
-
28
- # Initializes the Faraday connection object.
29
- def initialize_connection
30
- site_url = @config.options[:site_url].chomp("/")
31
- username = @config.options[:username]
32
- api_token = @config.options[:api_token]
33
-
34
- Faraday.new(url: site_url) do |conn|
35
- conn.request :authorization, :basic, username, api_token
36
- conn.request :retry
37
- # Important: Keep raise_error middleware *after* retry
38
- # conn.response :raise_error # Defer raising error to handle_faraday_error
39
- conn.headers["Content-Type"] = "application/json"
40
- conn.headers["Accept"] = "application/json"
41
- conn.headers["User-Agent"] = ActiveProject.user_agent
40
+ # --------------------------------------------------------------------
41
+ # Tiny wrapper around HttpClient#request that handles Jira quirks
42
+ # --------------------------------------------------------------------
43
+ #
44
+ # @param method [Symbol] :get, :post, :put, :delete, …
45
+ # @param path [String] e.g. "/rest/api/3/issue/PROJ-1"
46
+ # @param body [String, Hash, nil]
47
+ # @param query [Hash,nil] additional query-string params
48
+ # @return [Hash, nil] parsed JSON response
49
+ #
50
+ # @raise [ActiveProject::AuthenticationError] if Jira signals
51
+ # AUTHENTICATED_FAILED via X-Seraph-LoginReason header.
52
+ private def make_request(method, path, body = nil, query = nil)
53
+ data = request(method, path, body: body, query: query)
54
+
55
+ if @connection.headers[SERAPH_HEADER]&.include?("AUTHENTICATED_FAILED")
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)"
42
59
  end
60
+
61
+ data
43
62
  end
44
63
  end
45
64
  end
@@ -4,14 +4,17 @@ 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
8
+
7
9
  # Lists issues within a specific project, optionally filtered by JQL.
8
10
  # @param project_id_or_key [String, Integer] The ID or key of the project.
9
- # @param options [Hash] Optional filtering/pagination options.
11
+ # @param options [Hash] Optional filtering/pagination options. Accepts :jql, :fields, :start_at, :max_results.
10
12
  # @return [Array<ActiveProject::Resources::Issue>]
11
13
  def list_issues(project_id_or_key, options = {})
12
14
  start_at = options.fetch(:start_at, 0)
13
15
  max_results = options.fetch(:max_results, 50)
14
16
  jql = options.fetch(:jql, "project = '#{project_id_or_key}' ORDER BY created DESC")
17
+ fields = options[:fields] || DEFAULT_FIELDS
15
18
 
16
19
  all_issues = []
17
20
  path = "/rest/api/3/search"
@@ -20,8 +23,7 @@ module ActiveProject
20
23
  jql: jql,
21
24
  startAt: start_at,
22
25
  maxResults: max_results,
23
- fields: %w[summary description status assignee reporter created updated project
24
- issuetype duedate priority]
26
+ fields: fields
25
27
  }.to_json
26
28
 
27
29
  response_data = make_request(:post, path, payload)
@@ -36,11 +38,12 @@ module ActiveProject
36
38
 
37
39
  # Finds a specific issue by its ID or key using the V3 endpoint.
38
40
  # @param id_or_key [String, Integer] The ID or key of the issue.
39
- # @param context [Hash] Optional context (ignored).
41
+ # @param context [Hash] Optional context. Accepts :fields for field selection.
40
42
  # @return [ActiveProject::Resources::Issue]
41
- def find_issue(id_or_key, _context = {})
42
- fields = "summary,description,status,assignee,reporter,created,updated,project,issuetype,duedate,priority"
43
- path = "/rest/api/3/issue/#{id_or_key}?fields=#{fields}"
43
+ def find_issue(id_or_key, context = {})
44
+ fields = context[:fields] || DEFAULT_FIELDS
45
+ fields_param = fields.is_a?(Array) ? fields.join(",") : fields
46
+ path = "/rest/api/3/issue/#{id_or_key}?fields=#{fields_param}"
44
47
 
45
48
  issue_data = make_request(:get, path)
46
49
  map_issue_data(issue_data)
@@ -54,8 +57,8 @@ module ActiveProject
54
57
  path = "/rest/api/3/issue"
55
58
 
56
59
  unless attributes[:project].is_a?(Hash) && (attributes[:project][:id] || attributes[:project][:key]) &&
57
- attributes[:summary] && !attributes[:summary].empty? &&
58
- attributes[:issue_type] && (attributes[:issue_type][:id] || attributes[:issue_type][:name])
60
+ attributes[:summary] && !attributes[:summary].empty? &&
61
+ attributes[:issue_type] && (attributes[:issue_type][:id] || attributes[:issue_type][:name])
59
62
  raise ArgumentError,
60
63
  "Missing required attributes for issue creation: :project (must be a Hash with id/key), :summary, :issue_type (with id/name)"
61
64
  end
@@ -83,6 +86,8 @@ module ActiveProject
83
86
 
84
87
  fields_payload[:priority] = attributes[:priority] if attributes.key?(:priority)
85
88
 
89
+ fields_payload[:parent] = attributes[:parent] if attributes.key?(:parent)
90
+
86
91
  payload = { fields: fields_payload }.to_json
87
92
  response_data = make_request(:post, path, payload)
88
93
 
@@ -92,9 +97,9 @@ module ActiveProject
92
97
  # Updates an existing issue in Jira using the V3 endpoint.
93
98
  # @param id_or_key [String, Integer] The ID or key of the issue to update.
94
99
  # @param attributes [Hash] Issue attributes to update (e.g., :summary, :description, :assignee_id, :due_on, :priority).
95
- # @param context [Hash] Optional context (ignored).
100
+ # @param context [Hash] Optional context. Accepts :fields for field selection on return.
96
101
  # @return [ActiveProject::Resources::Issue]
97
- def update_issue(id_or_key, attributes, _context = {})
102
+ def update_issue(id_or_key, attributes, context = {})
98
103
  path = "/rest/api/3/issue/#{id_or_key}"
99
104
 
100
105
  update_fields = {}
@@ -119,12 +124,25 @@ module ActiveProject
119
124
 
120
125
  update_fields[:priority] = attributes[:priority] if attributes.key?(:priority)
121
126
 
122
- return find_issue(id_or_key) if update_fields.empty?
127
+ return find_issue(id_or_key, context) if update_fields.empty?
123
128
 
124
129
  payload = { fields: update_fields }.to_json
125
130
  make_request(:put, path, payload)
126
131
 
127
- find_issue(id_or_key)
132
+ find_issue(id_or_key, context)
133
+ end
134
+
135
+ # Deletes an issue from Jira.
136
+ # @param id_or_key [String, Integer] The ID or key of the issue to delete.
137
+ # @param context [Hash] Optional context. Accepts :delete_subtasks to indicate whether subtasks should be deleted.
138
+ # @return [Boolean] True if successfully deleted.
139
+ def delete_issue(id_or_key, context = {})
140
+ delete_subtasks = context[:delete_subtasks] || false
141
+ path = "/rest/api/3/issue/#{id_or_key}"
142
+ query = { deleteSubtasks: delete_subtasks }
143
+
144
+ make_request(:delete, path, nil, query)
145
+ true
128
146
  end
129
147
  end
130
148
  end
@@ -56,31 +56,6 @@ module ActiveProject
56
56
 
57
57
  # Initializes the Faraday connection object.
58
58
 
59
- # Makes an HTTP request. Returns parsed JSON or raises appropriate error.
60
- def make_request(method, path, body = nil)
61
- response = @connection.run_request(method, path, body, nil)
62
-
63
- # Check for AUTHENTICATED_FAILED header even on 200 OK
64
- if response.status == 200 && response.headers["x-seraph-loginreason"]&.include?("AUTHENTICATED_FAILED")
65
- raise AuthenticationError, "Jira authentication failed (X-Seraph-Loginreason: AUTHENTICATED_FAILED)"
66
- end
67
-
68
- # Check for other errors if not successful
69
- handle_faraday_error(response) unless response.success?
70
-
71
- # Return parsed body on success, or nil if body is empty/invalid
72
- JSON.parse(response.body) if response.body && !response.body.empty?
73
- rescue JSON::ParserError => e
74
- # Raise specific error if JSON parsing fails on a successful response body
75
- raise ApiError.new("Jira API returned non-JSON response: #{response&.body}", original_error: e)
76
- rescue Faraday::Error => e
77
- # Handle connection errors etc. that occur before the response object is available
78
- status = e.response&.status
79
- body = e.response&.body
80
- raise ApiError.new("Jira API connection error (Status: #{status || 'N/A'}): #{e.message}", original_error: e,
81
- status_code: status, response_body: body)
82
- end
83
-
84
59
  # Handles Faraday errors based on the response object (for non-2xx responses).
85
60
  def handle_faraday_error(response)
86
61
  status = response.status
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Pagination
6
+ # RFC 5988 Link header parsing. Returns { "next" => url, "prev" => url, … }
7
+ def parse_link_header(link_header)
8
+ return {} unless link_header
9
+
10
+ link_header.split(",").each_with_object({}) do |part, acc|
11
+ url, rel = part.split(";")
12
+ next unless rel && url
13
+
14
+ url = url[/\<([^>]+)\>/, 1] # strip angle brackets
15
+ rel = rel[/rel=\"?([^\";]+)\"?/, 1]
16
+ acc[rel] = url if url && rel
17
+ end
18
+ end
19
+
20
+ # Synchronous enumerator – works with any Faraday adapter.
21
+ #
22
+ # @param path [String] first request path or full URL
23
+ # @param method [Symbol] :get or :post
24
+ # @param body [Hash,String,nil]
25
+ # @param query [Hash]
26
+ def each_page(path, method: :get, body: nil, query: {})
27
+ next_path = path
28
+ loop do
29
+ data, link_header = perform_page_request(method, next_path, body, query)
30
+ yield data
31
+ next_path = parse_link_header(link_header)["next"]
32
+ break unless next_path
33
+ # After the first hop the URL is absolute; zero-out body/query for GETs
34
+ body = nil if method == :get
35
+ query = {}
36
+ end
37
+ end
38
+
39
+ # Fibre-friendly variant; launches a child task for every hop _after_ the
40
+ # first one, so JSON parsing in the current page overlaps the download of
41
+ # the next page (requires AP_DEFAULT_ADAPTER=async_http + Async scheduler).
42
+ def each_page_async(path, method: :get, body: nil, query: {}, &block)
43
+ require "async" # soft-require so callers don’t need Async unless used
44
+ Async do |task|
45
+ current_path = path
46
+ while current_path
47
+ data, link = perform_page_request(method, current_path, body, query)
48
+ next_url = parse_link_header(link)["next"]
49
+ body = nil if method == :get # as above
50
+ query = {}
51
+ # Prefetch next page while the caller consumes this one
52
+ fut = next_url ? task.async { perform_page_request(method, next_url, body, query) } : nil
53
+ yield data
54
+ current_path, data, link = next_url, *fut&.wait
55
+ end
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ # One tiny helper so the logic is not duplicated.
62
+ def perform_page_request(method, path, body, query)
63
+ json = request(method, path, body: body, query: query)
64
+ [ json, @last_response.headers["Link"] ]
65
+ end
66
+ end
67
+ end
68
+ end
@@ -4,33 +4,37 @@ 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 ActiveProject::Adapters::HttpClient
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/".freeze
19
10
 
20
- @connection = initialize_connection
11
+ def initialize(config:)
12
+ @config = config
13
+ build_connection(
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.api_key, token: @config.api_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
+ if e.status_code == 400 && e.message&.match?(/invalid id/i)
31
+ raise ActiveProject::NotFoundError, e.message
32
+ else
33
+ raise
32
34
  end
33
35
  end
36
+
37
+ private :make_request
34
38
  end
35
39
  end
36
40
  end
@@ -4,13 +4,17 @@ module ActiveProject
4
4
  module Adapters
5
5
  module Trello
6
6
  module Issues
7
+ DEFAULT_FIELDS = %w[id name desc closed idList idBoard due dueComplete idMembers].freeze
8
+
7
9
  # Lists Trello cards on a specific board.
8
10
  # @param board_id [String] The ID of the Trello board.
9
- # @param options [Hash] Optional filtering options.
11
+ # @param options [Hash] Optional filtering options. Accepts :filter and :fields.
10
12
  # @return [Array<ActiveProject::Resources::Issue>]
11
13
  def list_issues(board_id, options = {})
12
14
  path = "boards/#{board_id}/cards"
13
- query = { fields: "id,name,desc,closed,idList,idBoard,due,dueComplete,idMembers", list: true }
15
+
16
+ fields = options[:fields] ? Array(options[:fields]).join(",") : DEFAULT_FIELDS.join(",")
17
+ query = { fields: fields, list: true }
14
18
  query[:filter] = options[:filter] if options[:filter]
15
19
 
16
20
  cards_data = make_request(:get, path, nil, query)
@@ -21,11 +25,14 @@ module ActiveProject
21
25
 
22
26
  # Finds a specific Card by its ID.
23
27
  # @param card_id [String] The ID of the Trello Card.
24
- # @param context [Hash] Optional context (ignored).
28
+ # @param context [Hash] Optional context. Accepts :fields for specific field selection.
25
29
  # @return [ActiveProject::Resources::Issue]
26
- def find_issue(card_id, _context = {})
30
+ def find_issue(card_id, context = {})
27
31
  path = "cards/#{card_id}"
28
- query = { fields: "id,name,desc,closed,idList,idBoard,due,dueComplete,idMembers", list: true }
32
+
33
+ fields = context[:fields] ? Array(context[:fields]).join(",") : DEFAULT_FIELDS.join(",")
34
+ query = { fields: fields, list: true }
35
+
29
36
  card_data = make_request(:get, path, nil, query)
30
37
  map_card_data(card_data, card_data["idBoard"])
31
38
  end
@@ -58,7 +65,7 @@ module ActiveProject
58
65
  # Updates an existing Card in Trello.
59
66
  # @param card_id [String] The ID of the Trello Card.
60
67
  # @param attributes [Hash] Attributes to update (e.g., :title, :description, :list_id, :closed, :due_on, :assignee_ids, :status).
61
- # @param context [Hash] Optional context (ignored).
68
+ # @param context [Hash] Optional context. Accepts :fields for return data field selection.
62
69
  # @return [ActiveProject::Resources::Issue]
63
70
  def update_issue(card_id, attributes, context = {})
64
71
  update_attributes = attributes.dup
@@ -67,10 +74,10 @@ module ActiveProject
67
74
  target_status = update_attributes.delete(:status)
68
75
 
69
76
  board_id = update_attributes[:board_id] || begin
70
- find_issue(card_id).project_id
71
- rescue NotFoundError
72
- raise NotFoundError, "Trello card with ID '#{card_id}' not found."
73
- end
77
+ find_issue(card_id).project_id
78
+ rescue NotFoundError
79
+ raise NotFoundError, "Trello card with ID '#{card_id}' not found."
80
+ end
74
81
 
75
82
  unless board_id
76
83
  raise ApiError, "Could not determine board ID for card '#{card_id}' to perform status mapping."
@@ -111,6 +118,15 @@ module ActiveProject
111
118
  card_data = make_request(:put, path, nil, query_params.compact)
112
119
  map_card_data(card_data, card_data["idBoard"])
113
120
  end
121
+
122
+ # Deletes a Trello card.
123
+ # @param card_id [String] The ID of the Trello Card to delete.
124
+ # @return [Boolean] True if successfully deleted.
125
+ def delete_issue(card_id, **)
126
+ path = "cards/#{card_id}"
127
+ make_request(:delete, path)
128
+ true
129
+ end
114
130
  end
115
131
  end
116
132
  end
@@ -70,33 +70,15 @@ 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
- message = body || "Unknown Trello Error"
77
+ body = JSON.parse(body) if body.is_a?(String) && !body.empty? rescue body
78
+ if body.is_a?(Hash)
79
+ message = body["message"]
80
+ end
81
+ message ||= body || "Unknown Trello Error"
100
82
 
101
83
  case status
102
84
  when 401, 403
@@ -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
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+ require "async" # async is now a hard dependency
5
+
6
+ module ActiveProject
7
+ class Railtie < ::Rails::Railtie
8
+ config.active_project = ActiveSupport::OrderedOptions.new
9
+ # Host apps may override this in application.rb:
10
+ # config.active_project.use_async_scheduler = false
11
+ config.active_project.use_async_scheduler = true
12
+
13
+ # ──────────────────────────────────────────────────────
14
+ # We run BEFORE Zeitwerk starts autoloading so that
15
+ # every thread inherits the scheduler.
16
+ # ──────────────────────────────────────────────────────
17
+ initializer "active_project.set_async_scheduler",
18
+ before: :initialize_dependency_mechanism do |app|
19
+ # 1. Allow opt-out
20
+ next unless app.config.active_project.use_async_scheduler
21
+ next if ENV["AP_NO_ASYNC_SCHEDULER"] == "1"
22
+
23
+ # 2. Don’t clobber a scheduler the host already set
24
+ next if Fiber.scheduler
25
+
26
+ # 3. Install Async’s cooperative scheduler
27
+ Fiber.set_scheduler ::Async::Scheduler.new
28
+
29
+ ActiveSupport::Notifications.instrument(
30
+ "active_project.async_scheduler_set",
31
+ scheduler: Fiber.scheduler.class.name
32
+ )
33
+ end
34
+ end
35
+ end
@@ -31,6 +31,17 @@ module ActiveProject
31
31
  end
32
32
  end
33
33
 
34
+ # Fetches all resources in parallel, because patience is for people with no deadlines.
35
+ # Wraps `#all` calls in async tasks for each provided ID.
36
+ # @param args [Array] List of IDs to fetch resources for.
37
+ # @return [Async::Task<BaseResource>] Flattened array of results from async fetches.
38
+ def all_async(*args)
39
+ ActiveProject::Async.run do |task|
40
+ tasks = args.map { |id| task.async { all(id) } }
41
+ tasks.flat_map(&:wait)
42
+ end
43
+ end
44
+
34
45
  # Finds a specific resource by its ID.
35
46
  # Delegates to the appropriate adapter find method.
36
47
  # @param id [String, Integer] The ID or key of the resource.
@@ -98,6 +109,8 @@ module ActiveProject
98
109
 
99
110
  private
100
111
 
112
+ # Determines the list method name for the adapter based on the resource class.
113
+ # Throws a tantrum if it can't figure it out or if the adapter doesn't support it.
101
114
  def determine_list_method
102
115
  method_name = case @resource_class.name
103
116
  when "ActiveProject::Resources::Project" then :list_projects
@@ -112,6 +125,8 @@ module ActiveProject
112
125
  method_name
113
126
  end
114
127
 
128
+ # Figures out which magical method to call to find a resource, based on class name.
129
+ # Explodes if it can't figure it out or if the adapter is slacking off and didn't implement it.
115
130
  def determine_find_method
116
131
  method_name = case @resource_class.name
117
132
  when "ActiveProject::Resources::Project" then :find_project
@@ -126,6 +141,9 @@ module ActiveProject
126
141
  method_name
127
142
  end
128
143
 
144
+ # Builds the appropriate create method name for the adapter by assuming
145
+ # naming conventions are law and chaos isn't real.
146
+ # Raises if the adapter has no idea how to make the thing.
129
147
  def determine_create_method
130
148
  singular_name = @resource_class.name.split("::").last.downcase.to_sym
131
149
  method_name = :"create_#{singular_name}"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveProject
4
- VERSION = "0.1.1"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/activeproject.rb CHANGED
@@ -2,6 +2,7 @@ require "zeitwerk"
2
2
  require "concurrent"
3
3
  require_relative "active_project/errors"
4
4
  require_relative "active_project/version"
5
+ require_relative "active_project/railtie" if defined?(Rails::Railtie)
5
6
 
6
7
  module ActiveProject
7
8
  class << self
@@ -110,4 +111,5 @@ loader.inflector.inflect("activeproject" => "ActiveProject")
110
111
  loader.do_not_eager_load("#{__dir__}/active_project/adapters")
111
112
  loader.ignore("#{__dir__}/active_project/errors.rb")
112
113
  loader.ignore("#{__dir__}/active_project/version.rb")
114
+ loader.ignore("#{__dir__}/active_project/railtie.rb")
113
115
  loader.setup
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeproject
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-04-10 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport
@@ -57,6 +57,48 @@ dependencies:
57
57
  - - ">="
58
58
  - !ruby/object:Gem::Version
59
59
  version: '0'
60
+ - !ruby/object:Gem::Dependency
61
+ name: async
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ type: :runtime
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ - !ruby/object:Gem::Dependency
75
+ name: async-http
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ type: :runtime
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ - !ruby/object:Gem::Dependency
89
+ name: async-http-faraday
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '0'
95
+ type: :runtime
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
60
102
  - !ruby/object:Gem::Dependency
61
103
  name: mocha
62
104
  requirement: !ruby/object:Gem::Requirement
@@ -91,6 +133,7 @@ files:
91
133
  - lib/active_project/adapters/basecamp/projects.rb
92
134
  - lib/active_project/adapters/basecamp/webhooks.rb
93
135
  - lib/active_project/adapters/basecamp_adapter.rb
136
+ - lib/active_project/adapters/http_client.rb
94
137
  - lib/active_project/adapters/jira/comments.rb
95
138
  - lib/active_project/adapters/jira/connection.rb
96
139
  - lib/active_project/adapters/jira/issues.rb
@@ -98,6 +141,7 @@ files:
98
141
  - lib/active_project/adapters/jira/transitions.rb
99
142
  - lib/active_project/adapters/jira/webhooks.rb
100
143
  - lib/active_project/adapters/jira_adapter.rb
144
+ - lib/active_project/adapters/pagination.rb
101
145
  - lib/active_project/adapters/trello/comments.rb
102
146
  - lib/active_project/adapters/trello/connection.rb
103
147
  - lib/active_project/adapters/trello/issues.rb
@@ -106,10 +150,12 @@ files:
106
150
  - lib/active_project/adapters/trello/webhooks.rb
107
151
  - lib/active_project/adapters/trello_adapter.rb
108
152
  - lib/active_project/association_proxy.rb
153
+ - lib/active_project/async.rb
109
154
  - lib/active_project/configuration.rb
110
155
  - lib/active_project/configurations/base_adapter_configuration.rb
111
156
  - lib/active_project/configurations/trello_configuration.rb
112
157
  - lib/active_project/errors.rb
158
+ - lib/active_project/railtie.rb
113
159
  - lib/active_project/resource_factory.rb
114
160
  - lib/active_project/resources/base_resource.rb
115
161
  - lib/active_project/resources/comment.rb
@@ -139,7 +185,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
139
185
  - !ruby/object:Gem::Version
140
186
  version: '0'
141
187
  requirements: []
142
- rubygems_version: 3.6.5
188
+ rubygems_version: 3.6.7
143
189
  specification_version: 4
144
190
  summary: A standardized Ruby interface for multiple project management APIs (Jira,
145
191
  Basecamp, Trello, etc.).