activeproject 0.2.0 → 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: 9960da80d5e32d0cdfa4fce3ffe824ebf3c36a8710d0a7782dbac3ec7001b863
4
- data.tar.gz: 1ca92b2e165afbe314b70d970cb42817e5987ae3bfaa5a7d0ba58987999afe45
3
+ metadata.gz: 9fc9c393c3294c98f99863d698a7288077f6e71977e868c3a2ed0c2a5a5f7cbc
4
+ data.tar.gz: ff6f35f9cb6f58529be940efddb58d84f60490ee0bf02d1a6aba5f4e655dca51
5
5
  SHA512:
6
- metadata.gz: 46a0f331e8f7e956d643e6070d21dc20abe3825308c0761067df3773b3a20fcea7b94aa9c07f333c0fc794443945419436486b3daec49e4a19b5e473b05b4c6c
7
- data.tar.gz: d78685242c2ea12a5a522a74e64b8f438d613dd2bb2977c14aca0ed7ba918ef953719f17b23b8cf6400723ea23886f7a63df898a9b804214eab4db635c8fb794
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.
@@ -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"
@@ -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
@@ -56,33 +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, 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
59
  # Handles Faraday errors based on the response object (for non-2xx responses).
87
60
  def handle_faraday_error(response)
88
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
@@ -70,28 +70,6 @@ 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
@@ -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.2.0"
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,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeproject
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2025-04-10 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activesupport
@@ -58,6 +57,48 @@ dependencies:
58
57
  - - ">="
59
58
  - !ruby/object:Gem::Version
60
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'
61
102
  - !ruby/object:Gem::Dependency
62
103
  name: mocha
63
104
  requirement: !ruby/object:Gem::Requirement
@@ -92,6 +133,7 @@ files:
92
133
  - lib/active_project/adapters/basecamp/projects.rb
93
134
  - lib/active_project/adapters/basecamp/webhooks.rb
94
135
  - lib/active_project/adapters/basecamp_adapter.rb
136
+ - lib/active_project/adapters/http_client.rb
95
137
  - lib/active_project/adapters/jira/comments.rb
96
138
  - lib/active_project/adapters/jira/connection.rb
97
139
  - lib/active_project/adapters/jira/issues.rb
@@ -99,6 +141,7 @@ files:
99
141
  - lib/active_project/adapters/jira/transitions.rb
100
142
  - lib/active_project/adapters/jira/webhooks.rb
101
143
  - lib/active_project/adapters/jira_adapter.rb
144
+ - lib/active_project/adapters/pagination.rb
102
145
  - lib/active_project/adapters/trello/comments.rb
103
146
  - lib/active_project/adapters/trello/connection.rb
104
147
  - lib/active_project/adapters/trello/issues.rb
@@ -107,10 +150,12 @@ files:
107
150
  - lib/active_project/adapters/trello/webhooks.rb
108
151
  - lib/active_project/adapters/trello_adapter.rb
109
152
  - lib/active_project/association_proxy.rb
153
+ - lib/active_project/async.rb
110
154
  - lib/active_project/configuration.rb
111
155
  - lib/active_project/configurations/base_adapter_configuration.rb
112
156
  - lib/active_project/configurations/trello_configuration.rb
113
157
  - lib/active_project/errors.rb
158
+ - lib/active_project/railtie.rb
114
159
  - lib/active_project/resource_factory.rb
115
160
  - lib/active_project/resources/base_resource.rb
116
161
  - lib/active_project/resources/comment.rb
@@ -126,7 +171,6 @@ licenses:
126
171
  metadata:
127
172
  homepage_uri: https://github.com/seuros/active_project
128
173
  source_code_uri: https://github.com/seuros/active_project
129
- post_install_message:
130
174
  rdoc_options: []
131
175
  require_paths:
132
176
  - lib
@@ -141,8 +185,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
141
185
  - !ruby/object:Gem::Version
142
186
  version: '0'
143
187
  requirements: []
144
- rubygems_version: 3.5.22
145
- signing_key:
188
+ rubygems_version: 3.6.7
146
189
  specification_version: 4
147
190
  summary: A standardized Ruby interface for multiple project management APIs (Jira,
148
191
  Basecamp, Trello, etc.).