activeproject 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +248 -51
  3. data/lib/active_project/adapters/base.rb +154 -14
  4. data/lib/active_project/adapters/basecamp/comments.rb +34 -0
  5. data/lib/active_project/adapters/basecamp/connection.rb +10 -23
  6. data/lib/active_project/adapters/basecamp/issues.rb +6 -5
  7. data/lib/active_project/adapters/basecamp/webhooks.rb +7 -8
  8. data/lib/active_project/adapters/basecamp_adapter.rb +2 -11
  9. data/lib/active_project/adapters/fizzy/columns.rb +116 -0
  10. data/lib/active_project/adapters/fizzy/comments.rb +129 -0
  11. data/lib/active_project/adapters/fizzy/connection.rb +41 -0
  12. data/lib/active_project/adapters/fizzy/issues.rb +221 -0
  13. data/lib/active_project/adapters/fizzy/projects.rb +105 -0
  14. data/lib/active_project/adapters/fizzy_adapter.rb +151 -0
  15. data/lib/active_project/adapters/github_project/comments.rb +91 -0
  16. data/lib/active_project/adapters/github_project/connection.rb +58 -0
  17. data/lib/active_project/adapters/github_project/helpers.rb +100 -0
  18. data/lib/active_project/adapters/github_project/issues.rb +287 -0
  19. data/lib/active_project/adapters/github_project/projects.rb +139 -0
  20. data/lib/active_project/adapters/github_project/webhooks.rb +168 -0
  21. data/lib/active_project/adapters/github_project.rb +8 -0
  22. data/lib/active_project/adapters/github_project_adapter.rb +65 -0
  23. data/lib/active_project/adapters/github_repo/connection.rb +62 -0
  24. data/lib/active_project/adapters/github_repo/issues.rb +242 -0
  25. data/lib/active_project/adapters/github_repo/projects.rb +116 -0
  26. data/lib/active_project/adapters/github_repo/webhooks.rb +354 -0
  27. data/lib/active_project/adapters/github_repo_adapter.rb +134 -0
  28. data/lib/active_project/adapters/jira/attribute_normalizer.rb +16 -0
  29. data/lib/active_project/adapters/jira/comments.rb +41 -0
  30. data/lib/active_project/adapters/jira/connection.rb +43 -24
  31. data/lib/active_project/adapters/jira/issues.rb +21 -7
  32. data/lib/active_project/adapters/jira/projects.rb +3 -1
  33. data/lib/active_project/adapters/jira/transitions.rb +2 -1
  34. data/lib/active_project/adapters/jira/webhooks.rb +5 -7
  35. data/lib/active_project/adapters/jira_adapter.rb +23 -30
  36. data/lib/active_project/adapters/trello/comments.rb +34 -0
  37. data/lib/active_project/adapters/trello/connection.rb +28 -21
  38. data/lib/active_project/adapters/trello/issues.rb +7 -5
  39. data/lib/active_project/adapters/trello/webhooks.rb +5 -7
  40. data/lib/active_project/adapters/trello_adapter.rb +5 -25
  41. data/lib/active_project/association_proxy.rb +3 -2
  42. data/lib/active_project/async.rb +9 -0
  43. data/lib/active_project/configuration.rb +6 -3
  44. data/lib/active_project/configurations/base_adapter_configuration.rb +102 -0
  45. data/lib/active_project/configurations/basecamp_configuration.rb +42 -0
  46. data/lib/active_project/configurations/fizzy_configuration.rb +47 -0
  47. data/lib/active_project/configurations/github_configuration.rb +57 -0
  48. data/lib/active_project/configurations/jira_configuration.rb +54 -0
  49. data/lib/active_project/configurations/trello_configuration.rb +24 -2
  50. data/lib/active_project/connections/base.rb +35 -0
  51. data/lib/active_project/connections/graph_ql.rb +83 -0
  52. data/lib/active_project/connections/http_client.rb +79 -0
  53. data/lib/active_project/connections/pagination.rb +44 -0
  54. data/lib/active_project/connections/rest.rb +33 -0
  55. data/lib/active_project/error_mapper.rb +38 -0
  56. data/lib/active_project/errors.rb +13 -0
  57. data/lib/active_project/railtie.rb +33 -0
  58. data/lib/active_project/resource_factory.rb +18 -0
  59. data/lib/active_project/resources/base_resource.rb +13 -14
  60. data/lib/active_project/resources/comment.rb +46 -2
  61. data/lib/active_project/resources/issue.rb +106 -18
  62. data/lib/active_project/resources/persistable_resource.rb +47 -0
  63. data/lib/active_project/resources/project.rb +1 -1
  64. data/lib/active_project/status_mapper.rb +145 -0
  65. data/lib/active_project/version.rb +1 -1
  66. data/lib/active_project/webhook_event.rb +34 -12
  67. data/lib/activeproject.rb +11 -6
  68. metadata +107 -6
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Configurations
5
+ class GithubConfiguration < BaseAdapterConfiguration
6
+ # expected options:
7
+ # :access_token – PAT or GitHub App installation token
8
+ # :owner – user/org login the adapter should default to (optional for github_project)
9
+ # :repo – repository name (required for github_repo adapter)
10
+ # optional:
11
+ # :status_mappings – Maps GitHub project status names to normalized symbols
12
+ # Example: { "Todo" => :open, "In Progress" => :in_progress, "Blocked" => :blocked, "Done" => :closed }
13
+ # Supports: :open, :in_progress, :blocked, :on_hold, :closed
14
+ # :webhook_secret – For webhook signature verification
15
+
16
+ attr_accessor :status_mappings
17
+
18
+ def initialize(options = {})
19
+ # Set up default status mappings before calling super
20
+ @status_mappings = options.delete(:status_mappings) || {
21
+ "open" => :open,
22
+ "closed" => :closed
23
+ }
24
+ super
25
+ end
26
+
27
+ def freeze
28
+ # Ensure nested hashes are also frozen
29
+ @status_mappings.freeze
30
+ super
31
+ end
32
+
33
+ protected
34
+
35
+ def validate_configuration!
36
+ require_options(:access_token)
37
+ validate_option_type(:access_token, String)
38
+ validate_option_type(:owner, String, allow_nil: true)
39
+ validate_option_type(:repo, String, allow_nil: true)
40
+ validate_option_type(:webhook_secret, String, allow_nil: true)
41
+ validate_option_type(:status_mappings, Hash, allow_nil: true)
42
+
43
+ # Skip format validation in test environment with dummy values
44
+ return if test_environment_with_dummy_values?
45
+
46
+ # Validate access_token format (GitHub tokens start with specific prefixes)
47
+ token = options[:access_token]
48
+ unless token.match?(/^(gh[pousr]_|github_pat_)/i) || token.length >= 20
49
+ raise ArgumentError,
50
+ "GitHub access token appears to be invalid. Expected a GitHub PAT " \
51
+ "(starting with 'ghp_', 'gho_', 'ghu_', 'ghs_', 'ghr_', or 'github_pat_') " \
52
+ "or a token at least 20 characters long."
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Configurations
5
+ class JiraConfiguration < BaseAdapterConfiguration
6
+ # expected options:
7
+ # :site_url – Jira instance URL (e.g., "https://company.atlassian.net")
8
+ # :username – Jira username/email
9
+ # :api_token – Jira API token (not password)
10
+ # optional:
11
+ # :timeout – Request timeout in seconds (default: 30)
12
+ # :verify_ssl – Whether to verify SSL certificates (default: true)
13
+
14
+ protected
15
+
16
+ def validate_configuration!
17
+ super # Call parent validation for retry options
18
+ require_options(:site_url, :username, :api_token)
19
+ validate_option_type(:site_url, String)
20
+ validate_option_type(:username, String)
21
+ validate_option_type(:api_token, String)
22
+ validate_option_type(:timeout, Integer, allow_nil: true)
23
+ validate_option_type(:verify_ssl, [ TrueClass, FalseClass ], allow_nil: true)
24
+
25
+ # Skip format validation in test environment with dummy values
26
+ return if test_environment_with_dummy_values?
27
+
28
+ # Validate site_url format
29
+ site_url = options[:site_url]
30
+ unless site_url.match?(/^https?:\/\/.+/)
31
+ raise ArgumentError,
32
+ "Jira site_url must be a valid URL starting with http:// or https://, " \
33
+ "got: #{site_url.inspect}"
34
+ end
35
+
36
+ # Validate username format (email or username)
37
+ username = options[:username]
38
+ unless username.include?("@") || username.match?(/^[a-zA-Z0-9._-]+$/)
39
+ raise ArgumentError,
40
+ "Jira username should be an email address or valid username, " \
41
+ "got: #{username.inspect}"
42
+ end
43
+
44
+ # Validate API token format (should be base64-ish)
45
+ api_token = options[:api_token]
46
+ if api_token.length < 10
47
+ raise ArgumentError,
48
+ "Jira API token appears to be too short. Expected an API token from " \
49
+ "Atlassian Account Settings, got token of length #{api_token.length}"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -6,10 +6,16 @@ module ActiveProject
6
6
  class TrelloConfiguration < BaseAdapterConfiguration
7
7
  # @!attribute [rw] status_mappings
8
8
  # @return [Hash] Mappings from Board IDs to List ID/Name to Status Symbol.
9
+ # Supports expanded status vocabulary: :open, :in_progress, :blocked, :on_hold, :closed
9
10
  # @example
10
11
  # {
11
- # 'board_id_1' => { 'list_id_open' => :open, 'list_id_closed' => :closed },
12
- # 'board_id_2' => { 'Done List Name' => :closed } # Example using names (less reliable)
12
+ # 'board_id_1' => {
13
+ # 'list_id_backlog' => :open,
14
+ # 'list_id_progress' => :in_progress,
15
+ # 'list_id_blocked' => :blocked,
16
+ # 'list_id_done' => :closed
17
+ # },
18
+ # 'board_id_2' => { 'Done List Name' => :closed } # Using names (less reliable)
13
19
  # }
14
20
  attr_accessor :status_mappings
15
21
 
@@ -18,6 +24,22 @@ module ActiveProject
18
24
  @status_mappings = options.delete(:status_mappings) || {}
19
25
  end
20
26
 
27
+ protected
28
+
29
+ def validate_configuration!
30
+ require_options(:key, :token)
31
+ validate_option_type(:key, String)
32
+ validate_option_type(:token, String)
33
+ validate_option_type(:status_mappings, Hash, allow_nil: true) if options[:status_mappings]
34
+
35
+ # Skip format validation in test environment with dummy values
36
+ nil if test_environment_with_dummy_values?
37
+
38
+ # Additional validation for actual tokens could go here
39
+ end
40
+
41
+ public
42
+
21
43
  def freeze
22
44
  # Ensure nested hashes are also frozen
23
45
  @status_mappings.each_value(&:freeze)
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Connections
5
+ # Shared helpers used by both REST and GraphQL modules,
6
+ # because every cult needs a doctrine of "Don't Repeat Yourself."
7
+ module Base
8
+ # ------------------------------------------------------------------
9
+ # RFC 5988 Link header parsing
10
+ # ------------------------------------------------------------------
11
+ #
12
+ # Parses your classic overengineered pagination headers.
13
+ #
14
+ # <https://api.example.com/…?page=2>; rel="next",
15
+ # <https://api.example.com/…?page=5>; rel="last"
16
+ #
17
+ # REST’s way of pretending it’s not just guessing how many pages there are.
18
+ #
19
+ # @param header [String, nil]
20
+ # @return [Hash{String => String}] map of rel => absolute URL
21
+ def parse_link_header(header)
22
+ return {} unless header # Always a good first step: check if we’ve been given absolutely nothing.
23
+
24
+ header.split(",").each_with_object({}) do |part, acc|
25
+ url, rel = part.split(";", 2)
26
+ next unless url && rel
27
+
28
+ url = url[/<([^>]+)>/, 1] # Pull the sacred URL from its <> temple.
29
+ rel = rel[/rel="?([^";]+)"?/, 1] # Decode the rel tag, likely “next,” “prev,” or “you tried.”
30
+ acc[rel] = url if url && rel
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Connections
5
+ # Supposedly "reusable" GraphQL connection logic.
6
+ # Because clearly REST wasn't performative enough, and now we need a whole query language
7
+ # to fetch a user's email address.
8
+ module GraphQl
9
+ include Base
10
+ include Pagination # Because apparently, every five-item list deserves its own saga.
11
+
12
+ # Initializes the GraphQL connection. Requires an endpoint, a token,
13
+ # an optional auth header, and—if it still doesn't work—maybe a goat sacrifice.
14
+ # Bonus points if you time it around Eid al-Adha or Yom Kippur.
15
+ # Nothing says "API design" like invoking Abrahamic tension.
16
+ def init_graphql(endpoint:, token:, auth_header: "Authorization", extra_headers: {})
17
+ default_headers = {
18
+ "Content-Type" => "application/json"
19
+ }
20
+
21
+ build_connection(
22
+ base_url: endpoint,
23
+ auth_middleware: ->(c) { c.headers[auth_header] = "Bearer #{token}" },
24
+ extra_headers: default_headers.merge(extra_headers)
25
+ )
26
+ end
27
+
28
+ # Executes a GraphQL POST request. Because normal HTTP verbs had too much dignity.
29
+ #
30
+ # @return [Hash] The "data" part, which is always buried under a mountain of abstract misery.
31
+ def request_gql(query:, variables: {})
32
+ payload = { query: query, variables: variables }.to_json
33
+ res = request(:post, "", body: payload)
34
+ raise_graphql_errors!(res) # Make sure to decode the latest prophecy from the Error Oracle.
35
+ res["data"]
36
+ end
37
+
38
+ private
39
+
40
+ # Handle GraphQL errors with configurable behavior for partial success.
41
+ # GitHub sometimes returns "partial success" (data + errors) when we query
42
+ # both `user` and `organization` for the same login.
43
+ #
44
+ # @param result [Hash] The GraphQL response containing "data" and/or "errors"
45
+ # @raise [ActiveProject::AuthenticationError] for auth-related errors
46
+ # @raise [ActiveProject::NotFoundError] for not-found errors
47
+ # @raise [ActiveProject::ValidationError] for other GraphQL errors
48
+ def raise_graphql_errors!(result)
49
+ errs = result["errors"]
50
+ return unless errs&.any? # no errors → nothing to do
51
+
52
+ data = result["data"]
53
+
54
+ has_useful_data =
55
+ case data
56
+ when Hash then data.values.compact.any?
57
+ when Array then data.compact.any?
58
+ else !data.nil?
59
+ end
60
+
61
+ # Log partial errors for debugging even when we have useful data
62
+ if has_useful_data && defined?(ActiveProject.logger) && ActiveProject.logger
63
+ partial_msg = errs.map { |e| e["message"] }.join("; ")
64
+ ActiveProject.logger.warn("[ActiveProject::GraphQL] Partial errors ignored: #{partial_msg}")
65
+ end
66
+
67
+ return if has_useful_data # partial success – continue with data
68
+
69
+ # ─── No useful data. Treat as fatal. Map message to our error hierarchy. ──
70
+ msg = errs.map { |e| e["message"] }.join("; ")
71
+
72
+ case msg
73
+ when /unauth/i
74
+ raise ActiveProject::AuthenticationError, msg
75
+ when /not\s+found|unknown id|resolve to a User/i
76
+ raise ActiveProject::NotFoundError, msg
77
+ else
78
+ raise ActiveProject::ValidationError.new(msg, errors: errs)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "async/http/faraday"
6
+ require "json"
7
+
8
+ module ActiveProject
9
+ module Connections
10
+ module HttpClient
11
+ include Base
12
+ attr_reader :connection, :last_response
13
+
14
+ DEFAULT_HEADERS = {
15
+ "Content-Type" => "application/json",
16
+ "Accept" => "application/json",
17
+ "User-Agent" => -> { ActiveProject.user_agent }
18
+ }.freeze
19
+
20
+ # Default retry configuration - can be overridden in adapter configs
21
+ DEFAULT_RETRY_OPTS = {
22
+ max: 3, # Maximum number of retries
23
+ interval: 0.5, # Initial delay between retries (seconds)
24
+ backoff_factor: 2, # Exponential backoff multiplier
25
+ retry_statuses: [ 429, 500, 502, 503, 504 ], # HTTP status codes to retry
26
+ exceptions: [
27
+ Faraday::TimeoutError,
28
+ Faraday::ConnectionFailed,
29
+ Faraday::SSLError
30
+ ]
31
+ }.freeze
32
+
33
+ def build_connection(base_url:, auth_middleware:, extra_headers: {}, retry_options: {})
34
+ @base_url = base_url
35
+
36
+ # Merge custom retry options with defaults
37
+ final_retry_opts = DEFAULT_RETRY_OPTS.merge(retry_options)
38
+
39
+ @connection = Faraday.new(url: base_url) do |conn|
40
+ auth_middleware.call(conn) # Let the adapter sprinkle its secret sauce here.
41
+ conn.request :retry, **final_retry_opts # Intelligent retry with configurable options
42
+ conn.response :raise_error # Yes, we want the failure loud and flaming.
43
+ default_adapter = ENV.fetch("AP_DEFAULT_ADAPTER", "net_http").to_sym
44
+ conn.adapter default_adapter
45
+ conn.headers.merge!(DEFAULT_HEADERS.transform_values { |v| v.respond_to?(:call) ? v.call : v })
46
+ conn.headers.merge!(extra_headers) # Add your weird little header tweaks here.
47
+ yield conn if block_given? # Optional: make it worse with your own block.
48
+ end
49
+ end
50
+
51
+ # Sends the HTTP request like a brave little toaster.
52
+ def request(method, path, body: nil, query: nil, headers: {})
53
+ raise "HTTP connection not initialised" unless connection # You forgot to plug it in. Classic.
54
+
55
+ json_body = if body.is_a?(String)
56
+ body
57
+ else
58
+ (body ? JSON.generate(body) : nil)
59
+ end
60
+ response = connection.run_request(method, path, json_body, headers) do |req|
61
+ req.params.update(query) if query&.any?
62
+ end
63
+
64
+ @last_response = response
65
+
66
+ return nil if response.status == 204 || response.body.to_s.empty?
67
+
68
+ JSON.parse(response.body)
69
+ rescue Faraday::Error => e
70
+ raise translate_http_error(e)
71
+ rescue JSON::ParserError => e
72
+ raise ActiveProject::ApiError.new("Non-JSON response from #{path}",
73
+ original_error: e,
74
+ status_code: response&.status,
75
+ response_body: response&.body)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Connections
5
+ # Relay + Link-header pagination helpers usable for REST and GraphQL
6
+ module Pagination
7
+ include HttpClient
8
+ # Generic RFC-5988 “Link” header paginator (GitHub/Jira/Trello style)
9
+ #
10
+ # @yieldparam page [Object] parsed JSON for each HTTP page
11
+ def each_page(path, method: :get, body: nil, query: {}, headers: {})
12
+ next_url = path
13
+ loop do
14
+ page = request(method, next_url, body: body, query: query, headers: headers)
15
+ yield page
16
+ link_header = @last_response&.headers&.[]("Link")
17
+ next_url = parse_link_header(link_header)["next"]
18
+ break unless next_url
19
+
20
+ # After first request we follow absolute URLs; zero out body/query for GETs
21
+ body = nil if method == :get
22
+ query = {}
23
+ end
24
+ end
25
+
26
+ # Relay-style paginator (pageInfo{ hasNextPage, endCursor })
27
+ #
28
+ # @param connection_path [Array<String>] path inside JSON to the connection node
29
+ # @yieldparam node [Object] each edge.node yielded
30
+ def each_edge(query:, connection_path:, variables: {}, after_key: "after")
31
+ cursor = nil
32
+ loop do
33
+ vars = variables.merge(after_key => cursor)
34
+ data = yield(vars) # caller executes GraphQL request, returns data hash
35
+ conn = data.dig(*connection_path)
36
+ conn["edges"].each { |edge| yield edge["node"] }
37
+ break unless conn["pageInfo"]["hasNextPage"]
38
+
39
+ cursor = conn["pageInfo"]["endCursor"]
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Connections
5
+ # Reusable REST connection logic for normal, boring APIs like Jira, Basecamp, and Trello.
6
+ # You know, ones that don’t require a decoder ring and a theological debate to use.
7
+ module Rest
8
+ include Base
9
+ include Pagination
10
+
11
+ # Must be called from the concrete adapter's initialize.
12
+ #
13
+ # @yieldparam conn [Faraday::Connection] A lovely, dependable object where you slap on your auth.
14
+ # Unlike GraphQL, this one doesn’t need you to bend the knee or cite the Book of Steve Job.
15
+ def init_rest(base_url:, auth_middleware:, extra_headers: {}, retry_options: {})
16
+ build_connection(
17
+ base_url: base_url,
18
+ auth_middleware: auth_middleware,
19
+ extra_headers: extra_headers,
20
+ retry_options: retry_options
21
+ )
22
+ end
23
+
24
+ # Wrapper around HttpClient#request.
25
+ # Adapters may override this if their APIs have... *quirks* (read: sins).
26
+ def request_rest(method, path, body = nil, query = nil, headers = {})
27
+ request(method, path, body: body, query: query, headers: headers)
28
+ rescue Faraday::Error => e
29
+ raise map_faraday_error(e) # Wrap Faraday errors in our custom trauma response.
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module ErrorMapper
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ # one hash per subclass, inherited & copy-on-write
9
+ class_attribute :error_map, instance_accessor: false, default: {}
10
+ end
11
+
12
+ class_methods do
13
+ # rescue_status 401..403, with: AuthenticationError
14
+ def rescue_status(*codes, with:)
15
+ new_map = error_map.dup
16
+ codes.flat_map { |c| c.is_a?(Range) ? c.to_a : c }
17
+ .each { |status| new_map[status] = with }
18
+ self.error_map = new_map.freeze
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def translate_http_error(err)
25
+ status = err.response_status
26
+ message = begin
27
+ JSON.parse(err.response_body.to_s)["message"]
28
+ rescue StandardError
29
+ err.response_body
30
+ end
31
+
32
+ exc = self.class.error_map[status] ||
33
+ ActiveProject::ApiError
34
+
35
+ raise exc, message, err.backtrace
36
+ end
37
+ end
38
+ end
@@ -13,6 +13,19 @@ module ActiveProject
13
13
  # Raised when the external API rate limit is exceeded
14
14
  class RateLimitError < Error; end
15
15
 
16
+ # Raised for configuration errors (e.g., missing required settings, invalid mappings)
17
+ class ConfigurationError < Error; end
18
+
19
+ # Raised for connection errors (e.g., network failures, timeouts)
20
+ class ConnectionError < Error
21
+ attr_reader :original_error
22
+
23
+ def initialize(message = nil, original_error: nil)
24
+ super(message)
25
+ @original_error = original_error
26
+ end
27
+ end
28
+
16
29
  # Raised for general API errors (e.g., 5xx status codes)
17
30
  class ApiError < Error
18
31
  attr_reader :original_error, :status_code, :response_body
@@ -0,0 +1,33 @@
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
+ # We run BEFORE Zeitwerk starts autoloading so that
14
+ # every thread inherits the scheduler.
15
+ initializer "active_project.set_async_scheduler",
16
+ before: :initialize_dependency_mechanism do |app|
17
+ # 1. Allow opt-out
18
+ next unless app.config.active_project.use_async_scheduler
19
+ next if ENV["AP_NO_ASYNC_SCHEDULER"] == "1"
20
+
21
+ # 2. Don’t clobber a scheduler the host already set
22
+ next if Fiber.scheduler
23
+
24
+ # 3. Install Async’s cooperative scheduler
25
+ Fiber.set_scheduler ::Async::Scheduler.new
26
+
27
+ ActiveSupport::Notifications.instrument(
28
+ "active_project.async_scheduler_set",
29
+ scheduler: Fiber.scheduler.class.name
30
+ )
31
+ end
32
+ end
33
+ 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}"
@@ -46,23 +46,22 @@ module ActiveProject
46
46
  end
47
47
 
48
48
  # Defines expected members for the resource class.
49
- def self.def_members(*args)
49
+ def self.def_members(*names)
50
50
  @members ||= []
51
- @members.concat(args.map(&:to_sym))
52
- # No explicit attr_reader needed when using method_missing
53
- end
54
-
55
- # Placeholder methods for ORM-like behavior
56
- def save
57
- raise NotImplementedError, "#save not yet implemented for #{self.class.name}"
58
- end
59
-
60
- def update(attributes)
61
- raise NotImplementedError, "#update not yet implemented for #{self.class.name}"
51
+ names.each do |name|
52
+ sym = name.to_sym
53
+ @members << sym
54
+ define_method(sym) { @attributes[sym] } # reader
55
+ define_method("#{sym}=") do |val| # writer
56
+ @attributes[sym] = val
57
+ end
58
+ end
62
59
  end
63
60
 
64
- def delete
65
- raise NotImplementedError, "#delete not yet implemented for #{self.class.name}"
61
+ def to_h
62
+ self.class.members.each_with_object({}) do |name, hash|
63
+ hash[name] = public_send(name)
64
+ end
66
65
  end
67
66
  end
68
67
  end
@@ -3,9 +3,53 @@
3
3
  module ActiveProject
4
4
  module Resources
5
5
  # Represents a Comment on an Issue
6
- class Comment < BaseResource
6
+ class Comment < PersistableResource
7
7
  def_members :id, :body, :author, :created_at, :updated_at, :issue_id,
8
- :adapter_source
8
+ :project_id, :adapter_source
9
+
10
+ # For new comments (no id) call add_comment; otherwise update.
11
+ def save
12
+ fresh = if id.nil?
13
+ @adapter.add_comment(issue_id, body, adapter_context)
14
+ else
15
+ @adapter.update_comment(id, body, adapter_context)
16
+ end
17
+ copy_from(fresh)
18
+ true
19
+ end
20
+
21
+ # Shorthand that mutates +body+ and persists.
22
+ def update(attrs = {})
23
+ self.body = attrs[:body] if attrs[:body]
24
+ save
25
+ end
26
+
27
+ # Remove comment remotely and freeze this instance.
28
+ def delete
29
+ raise "id missing – not persisted" if id.nil?
30
+
31
+ @adapter.delete_comment(id, adapter_context)
32
+ freeze
33
+ true
34
+ end
35
+
36
+ alias destroy delete
37
+
38
+ private
39
+
40
+ # Build adapter-specific context for comment operations
41
+ def adapter_context
42
+ case @adapter
43
+ when ActiveProject::Adapters::BasecampAdapter
44
+ { project_id: project_id }
45
+ when ActiveProject::Adapters::JiraAdapter
46
+ { issue_id: issue_id }
47
+ when ActiveProject::Adapters::TrelloAdapter
48
+ { card_id: issue_id }
49
+ else
50
+ {}
51
+ end
52
+ end
9
53
  end
10
54
  end
11
55
  end