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 +4 -4
- data/README.md +53 -2
- data/lib/active_project/adapters/basecamp/connection.rb +8 -3
- data/lib/active_project/adapters/basecamp_adapter.rb +2 -11
- data/lib/active_project/adapters/http_client.rb +71 -0
- data/lib/active_project/adapters/jira/connection.rb +45 -26
- data/lib/active_project/adapters/jira_adapter.rb +0 -27
- data/lib/active_project/adapters/pagination.rb +68 -0
- data/lib/active_project/adapters/trello/connection.rb +25 -21
- data/lib/active_project/adapters/trello_adapter.rb +0 -22
- data/lib/active_project/async.rb +9 -0
- data/lib/active_project/railtie.rb +35 -0
- data/lib/active_project/resource_factory.rb +18 -0
- data/lib/active_project/version.rb +1 -1
- data/lib/activeproject.rb +2 -0
- metadata +49 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9fc9c393c3294c98f99863d698a7288077f6e71977e868c3a2ed0c2a5a5f7cbc
|
4
|
+
data.tar.gz: ff6f35f9cb6f58529be940efddb58d84f60490ee0bf02d1a6aba5f4e655dca51
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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`, `
|
14
|
-
* **Standardized Operations:** Consistent methods for creating, reading, updating, and transitioning
|
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
|
21
|
-
access_token = @config.options
|
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,
|
64
|
-
|
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
|
-
|
8
|
-
|
9
|
-
|
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
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
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
|
-
|
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
|
-
|
27
|
-
|
28
|
-
#
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
24
|
-
|
25
|
-
#
|
26
|
-
def
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
20
|
+
# ------------------------------------------------------------------
|
21
|
+
# Adapter-specific wrapper around HttpClient#request
|
22
|
+
# ------------------------------------------------------------------
|
23
|
+
def make_request(method, path, body = nil, query_params = {})
|
24
|
+
auth = { key: @config.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,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}"
|
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.
|
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:
|
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.
|
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.).
|