activeproject 0.1.0 → 0.1.1
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/Rakefile +4 -2
- data/lib/active_project/adapters/base.rb +3 -7
- data/lib/active_project/adapters/basecamp/comments.rb +27 -0
- data/lib/active_project/adapters/basecamp/connection.rb +49 -0
- data/lib/active_project/adapters/basecamp/issues.rb +139 -0
- data/lib/active_project/adapters/basecamp/lists.rb +54 -0
- data/lib/active_project/adapters/basecamp/projects.rb +110 -0
- data/lib/active_project/adapters/basecamp/webhooks.rb +73 -0
- data/lib/active_project/adapters/basecamp_adapter.rb +46 -437
- data/lib/active_project/adapters/jira/comments.rb +28 -0
- data/lib/active_project/adapters/jira/connection.rb +47 -0
- data/lib/active_project/adapters/jira/issues.rb +132 -0
- data/lib/active_project/adapters/jira/projects.rb +100 -0
- data/lib/active_project/adapters/jira/transitions.rb +68 -0
- data/lib/active_project/adapters/jira/webhooks.rb +89 -0
- data/lib/active_project/adapters/jira_adapter.rb +57 -483
- data/lib/active_project/adapters/trello/comments.rb +21 -0
- data/lib/active_project/adapters/trello/connection.rb +37 -0
- data/lib/active_project/adapters/trello/issues.rb +117 -0
- data/lib/active_project/adapters/trello/lists.rb +27 -0
- data/lib/active_project/adapters/trello/projects.rb +82 -0
- data/lib/active_project/adapters/trello/webhooks.rb +91 -0
- data/lib/active_project/adapters/trello_adapter.rb +54 -376
- data/lib/active_project/association_proxy.rb +9 -2
- data/lib/active_project/configuration.rb +1 -3
- data/lib/active_project/configurations/trello_configuration.rb +1 -3
- data/lib/active_project/resource_factory.rb +20 -10
- data/lib/active_project/resources/issue.rb +0 -3
- data/lib/active_project/resources/project.rb +0 -1
- data/lib/active_project/version.rb +3 -1
- data/lib/activeproject.rb +2 -2
- metadata +20 -5
| @@ -13,8 +13,6 @@ module ActiveProject | |
| 13 13 | 
             
                # Implements the interface defined in ActiveProject::Adapters::Base.
         | 
| 14 14 | 
             
                # API Docs: https://developer.atlassian.com/cloud/trello/rest/
         | 
| 15 15 | 
             
                class TrelloAdapter < Base
         | 
| 16 | 
            -
                  BASE_URL = "https://api.trello.com/1/"
         | 
| 17 | 
            -
             | 
| 18 16 | 
             
                  # Computes the expected Trello webhook signature.
         | 
| 19 17 | 
             
                  # @param callback_url [String] The exact URL registered for the webhook.
         | 
| 20 18 | 
             
                  # @param response_body [String] The raw response body received from Trello.
         | 
| @@ -28,22 +26,12 @@ module ActiveProject | |
| 28 26 |  | 
| 29 27 | 
             
                  attr_reader :config
         | 
| 30 28 |  | 
| 31 | 
            -
                   | 
| 32 | 
            -
                   | 
| 33 | 
            -
                   | 
| 34 | 
            -
                   | 
| 35 | 
            -
             | 
| 36 | 
            -
             | 
| 37 | 
            -
                    end
         | 
| 38 | 
            -
                    @config = config
         | 
| 39 | 
            -
             | 
| 40 | 
            -
                    unless @config.api_key && !@config.api_key.empty? && @config.api_token && !@config.api_token.empty?
         | 
| 41 | 
            -
                      raise ArgumentError, "TrelloAdapter configuration requires :api_key and :api_token"
         | 
| 42 | 
            -
                    end
         | 
| 43 | 
            -
             | 
| 44 | 
            -
                    @connection = initialize_connection
         | 
| 45 | 
            -
                  end
         | 
| 46 | 
            -
             | 
| 29 | 
            +
                  include Trello::Connection
         | 
| 30 | 
            +
                  include Trello::Projects
         | 
| 31 | 
            +
                  include Trello::Issues
         | 
| 32 | 
            +
                  include Trello::Comments
         | 
| 33 | 
            +
                  include Trello::Lists
         | 
| 34 | 
            +
                  include Trello::Webhooks
         | 
| 47 35 |  | 
| 48 36 | 
             
                  # --- Resource Factories ---
         | 
| 49 37 |  | 
| @@ -59,318 +47,6 @@ module ActiveProject | |
| 59 47 | 
             
                    ResourceFactory.new(adapter: self, resource_class: Resources::Issue)
         | 
| 60 48 | 
             
                  end
         | 
| 61 49 |  | 
| 62 | 
            -
                  # --- Implementation of Base methods ---
         | 
| 63 | 
            -
             | 
| 64 | 
            -
                  # Lists Trello boards accessible by the configured token.
         | 
| 65 | 
            -
                  # @return [Array<ActiveProject::Resources::Project>] An array of project resources.
         | 
| 66 | 
            -
                  def list_projects
         | 
| 67 | 
            -
                    path = "members/me/boards"
         | 
| 68 | 
            -
                    query = { fields: "id,name,desc" }
         | 
| 69 | 
            -
                    boards_data = make_request(:get, path, nil, query)
         | 
| 70 | 
            -
             | 
| 71 | 
            -
                    return [] unless boards_data.is_a?(Array)
         | 
| 72 | 
            -
             | 
| 73 | 
            -
                    boards_data.map do |board_data|
         | 
| 74 | 
            -
                      Resources::Project.new(self, # Pass adapter instance
         | 
| 75 | 
            -
                        id: board_data["id"],
         | 
| 76 | 
            -
                        key: nil,
         | 
| 77 | 
            -
                        name: board_data["name"],
         | 
| 78 | 
            -
                        adapter_source: :trello,
         | 
| 79 | 
            -
                        raw_data: board_data
         | 
| 80 | 
            -
                      )
         | 
| 81 | 
            -
                    end
         | 
| 82 | 
            -
                  end
         | 
| 83 | 
            -
             | 
| 84 | 
            -
                  # Finds a specific Trello Board by its ID.
         | 
| 85 | 
            -
                  # @param board_id [String] The ID of the Trello Board.
         | 
| 86 | 
            -
                  # @return [ActiveProject::Resources::Project] The project resource.
         | 
| 87 | 
            -
                  def find_project(board_id)
         | 
| 88 | 
            -
                     path = "boards/#{board_id}"
         | 
| 89 | 
            -
                     query = { fields: "id,name,desc" }
         | 
| 90 | 
            -
                     board_data = make_request(:get, path, nil, query)
         | 
| 91 | 
            -
             | 
| 92 | 
            -
                     Resources::Project.new(self, # Pass adapter instance
         | 
| 93 | 
            -
                       id: board_data["id"],
         | 
| 94 | 
            -
                       key: nil,
         | 
| 95 | 
            -
                       name: board_data["name"],
         | 
| 96 | 
            -
                       adapter_source: :trello,
         | 
| 97 | 
            -
                       raw_data: board_data
         | 
| 98 | 
            -
                     )
         | 
| 99 | 
            -
                  end
         | 
| 100 | 
            -
             | 
| 101 | 
            -
                  # Creates a new board in Trello.
         | 
| 102 | 
            -
                  # @param attributes [Hash] Board attributes. Required: :name. Optional: :description, :default_lists.
         | 
| 103 | 
            -
                  # @return [ActiveProject::Resources::Project] The created project resource.
         | 
| 104 | 
            -
                  def create_project(attributes)
         | 
| 105 | 
            -
                    unless attributes[:name] && !attributes[:name].empty?
         | 
| 106 | 
            -
                      raise ArgumentError, "Missing required attribute for Trello board creation: :name"
         | 
| 107 | 
            -
                    end
         | 
| 108 | 
            -
             | 
| 109 | 
            -
                    path = "boards/"
         | 
| 110 | 
            -
                    query_params = {
         | 
| 111 | 
            -
                      name: attributes[:name],
         | 
| 112 | 
            -
                      desc: attributes[:description],
         | 
| 113 | 
            -
                      defaultLists: attributes.fetch(:default_lists, true) # Default to creating lists
         | 
| 114 | 
            -
                      # Add other board options here if needed (e.g., idOrganization)
         | 
| 115 | 
            -
                    }.compact
         | 
| 116 | 
            -
             | 
| 117 | 
            -
                    board_data = make_request(:post, path, nil, query_params)
         | 
| 118 | 
            -
             | 
| 119 | 
            -
                    Resources::Project.new(self, # Pass adapter instance
         | 
| 120 | 
            -
                      id: board_data["id"],
         | 
| 121 | 
            -
                      key: nil,
         | 
| 122 | 
            -
                      name: board_data["name"],
         | 
| 123 | 
            -
                      adapter_source: :trello,
         | 
| 124 | 
            -
                      raw_data: board_data
         | 
| 125 | 
            -
                    )
         | 
| 126 | 
            -
                  end
         | 
| 127 | 
            -
             | 
| 128 | 
            -
                  # Creates a new list on a Trello board.
         | 
| 129 | 
            -
                  # @param board_id [String] The ID of the board.
         | 
| 130 | 
            -
                  # @param attributes [Hash] List attributes. Required: :name. Optional: :pos.
         | 
| 131 | 
            -
                  # @return [Hash] The raw data hash of the created list.
         | 
| 132 | 
            -
                  def create_list(board_id, attributes)
         | 
| 133 | 
            -
                    unless attributes[:name] && !attributes[:name].empty?
         | 
| 134 | 
            -
                      raise ArgumentError, "Missing required attribute for Trello list creation: :name"
         | 
| 135 | 
            -
                    end
         | 
| 136 | 
            -
             | 
| 137 | 
            -
                    path = "boards/#{board_id}/lists"
         | 
| 138 | 
            -
                    query_params = {
         | 
| 139 | 
            -
                      name: attributes[:name],
         | 
| 140 | 
            -
                      pos: attributes[:pos]
         | 
| 141 | 
            -
                    }.compact
         | 
| 142 | 
            -
             | 
| 143 | 
            -
                    make_request(:post, path, nil, query_params)
         | 
| 144 | 
            -
                  end
         | 
| 145 | 
            -
             | 
| 146 | 
            -
                  # Deletes a board in Trello.
         | 
| 147 | 
            -
                  # WARNING: This is a permanent deletion.
         | 
| 148 | 
            -
                  # @param board_id [String] The ID of the board to delete.
         | 
| 149 | 
            -
                  # @return [Boolean] true if deletion was successful (API returns 200).
         | 
| 150 | 
            -
                  # @raise [NotFoundError] if the board is not found.
         | 
| 151 | 
            -
                  # @raise [AuthenticationError] if credentials lack permission.
         | 
| 152 | 
            -
                  # @raise [ApiError] for other errors.
         | 
| 153 | 
            -
                  def delete_project(board_id)
         | 
| 154 | 
            -
                    path = "/boards/#{board_id}"
         | 
| 155 | 
            -
                    make_request(:delete, path) # DELETE returns 200 OK on success
         | 
| 156 | 
            -
                    true # Return true if make_request doesn't raise an error
         | 
| 157 | 
            -
                  end
         | 
| 158 | 
            -
             | 
| 159 | 
            -
             | 
| 160 | 
            -
             | 
| 161 | 
            -
             | 
| 162 | 
            -
                  # Lists Trello cards on a specific board.
         | 
| 163 | 
            -
                  # @param board_id [String] The ID of the Trello board.
         | 
| 164 | 
            -
                  # @param options [Hash] Optional filtering options.
         | 
| 165 | 
            -
                  # @return [Array<ActiveProject::Resources::Issue>] An array of issue resources.
         | 
| 166 | 
            -
                  def list_issues(board_id, options = {})
         | 
| 167 | 
            -
                    path = "boards/#{board_id}/cards"
         | 
| 168 | 
            -
                    # Fetch idMembers and list name for potential name mapping fallback
         | 
| 169 | 
            -
                    query = { fields: "id,name,desc,closed,idList,idBoard,due,dueComplete,idMembers", list: true }
         | 
| 170 | 
            -
                    query[:filter] = options[:filter] if options[:filter]
         | 
| 171 | 
            -
             | 
| 172 | 
            -
                    cards_data = make_request(:get, path, nil, query)
         | 
| 173 | 
            -
                    return [] unless cards_data.is_a?(Array)
         | 
| 174 | 
            -
             | 
| 175 | 
            -
                    cards_data.map { |card_data| map_card_data(card_data, board_id) }
         | 
| 176 | 
            -
                  end
         | 
| 177 | 
            -
             | 
| 178 | 
            -
                  # Finds a specific Card by its ID.
         | 
| 179 | 
            -
                  # @param card_id [String] The ID of the Trello Card.
         | 
| 180 | 
            -
                  # @param context [Hash] Optional context (ignored).
         | 
| 181 | 
            -
                  # @return [ActiveProject::Resources::Issue] The issue resource.
         | 
| 182 | 
            -
                  def find_issue(card_id, context = {})
         | 
| 183 | 
            -
                    path = "cards/#{card_id}"
         | 
| 184 | 
            -
                    # Fetch idMembers and list name for potential name mapping fallback
         | 
| 185 | 
            -
                    query = { fields: "id,name,desc,closed,idList,idBoard,due,dueComplete,idMembers", list: true }
         | 
| 186 | 
            -
                    card_data = make_request(:get, path, nil, query)
         | 
| 187 | 
            -
                    map_card_data(card_data, card_data["idBoard"])
         | 
| 188 | 
            -
                  end
         | 
| 189 | 
            -
             | 
| 190 | 
            -
                  # Creates a new Card in Trello.
         | 
| 191 | 
            -
                  # @param _board_id [String] Ignored (context).
         | 
| 192 | 
            -
                  # @param attributes [Hash] Card attributes. Required: :list_id, :title. Optional: :description, :assignee_ids, :due_on.
         | 
| 193 | 
            -
                  # @return [ActiveProject::Resources::Issue] The created issue resource.
         | 
| 194 | 
            -
                  def create_issue(_board_id, attributes)
         | 
| 195 | 
            -
                    list_id = attributes[:list_id]
         | 
| 196 | 
            -
                    title = attributes[:title]
         | 
| 197 | 
            -
             | 
| 198 | 
            -
                    unless list_id && title && !title.empty?
         | 
| 199 | 
            -
                      raise ArgumentError, "Missing required attributes for Trello card creation: :list_id, :title"
         | 
| 200 | 
            -
                    end
         | 
| 201 | 
            -
             | 
| 202 | 
            -
                    path = "cards"
         | 
| 203 | 
            -
                    query_params = {
         | 
| 204 | 
            -
                      idList: list_id,
         | 
| 205 | 
            -
                      name: title,
         | 
| 206 | 
            -
                      desc: attributes[:description],
         | 
| 207 | 
            -
                      # Use assignee_ids (expects an array of Trello member IDs)
         | 
| 208 | 
            -
                      idMembers: attributes[:assignee_ids]&.join(","),
         | 
| 209 | 
            -
                      due: attributes[:due_on]&.iso8601
         | 
| 210 | 
            -
                    }.compact
         | 
| 211 | 
            -
             | 
| 212 | 
            -
                    card_data = make_request(:post, path, nil, query_params)
         | 
| 213 | 
            -
                    map_card_data(card_data, card_data["idBoard"])
         | 
| 214 | 
            -
                  end
         | 
| 215 | 
            -
             | 
| 216 | 
            -
                  # Updates an existing Card in Trello.
         | 
| 217 | 
            -
                  # @param card_id [String] The ID of the Trello Card.
         | 
| 218 | 
            -
                  # @param attributes [Hash] Attributes to update (e.g., :title, :description, :list_id, :closed, :due_on, :assignee_ids, :status).
         | 
| 219 | 
            -
                  # @param context [Hash] Optional context (ignored).
         | 
| 220 | 
            -
                  # @return [ActiveProject::Resources::Issue] The updated issue resource.
         | 
| 221 | 
            -
                  def update_issue(card_id, attributes, context = {})
         | 
| 222 | 
            -
                    # Make a mutable copy of attributes
         | 
| 223 | 
            -
                    update_attributes = attributes.dup
         | 
| 224 | 
            -
             | 
| 225 | 
            -
                    # Handle :status mapping to :list_id
         | 
| 226 | 
            -
                    if update_attributes.key?(:status)
         | 
| 227 | 
            -
                      target_status = update_attributes.delete(:status) # Remove status key
         | 
| 228 | 
            -
             | 
| 229 | 
            -
                      # Fetch board_id efficiently if not already known
         | 
| 230 | 
            -
                      # We need the board_id to look up the correct status mapping
         | 
| 231 | 
            -
                      board_id = update_attributes[:board_id] || begin
         | 
| 232 | 
            -
                        find_issue(card_id).project_id # Fetch the issue to get its board_id
         | 
| 233 | 
            -
                      rescue NotFoundError
         | 
| 234 | 
            -
                        # Re-raise NotFoundError if the card itself doesn't exist
         | 
| 235 | 
            -
                        raise NotFoundError, "Trello card with ID '#{card_id}' not found."
         | 
| 236 | 
            -
                      end
         | 
| 237 | 
            -
             | 
| 238 | 
            -
                      unless board_id
         | 
| 239 | 
            -
                        # This should theoretically not happen if find_issue succeeded or board_id was passed
         | 
| 240 | 
            -
                        raise ApiError, "Could not determine board ID for card '#{card_id}' to perform status mapping."
         | 
| 241 | 
            -
                      end
         | 
| 242 | 
            -
             | 
| 243 | 
            -
                      # Use stored config for status mappings
         | 
| 244 | 
            -
                      board_mappings = @config.status_mappings[board_id]
         | 
| 245 | 
            -
                      unless board_mappings
         | 
| 246 | 
            -
                        raise ConfigurationError, "Trello status mapping not configured for board ID '#{board_id}'. Cannot map status ':#{target_status}'."
         | 
| 247 | 
            -
                      end
         | 
| 248 | 
            -
             | 
| 249 | 
            -
                      # Find the target list ID by looking up the status symbol in the board's mappings.
         | 
| 250 | 
            -
                      # We iterate through the mappings hash { list_id => status_symbol }
         | 
| 251 | 
            -
                      target_list_id = board_mappings.key(target_status)
         | 
| 252 | 
            -
             | 
| 253 | 
            -
                      unless target_list_id
         | 
| 254 | 
            -
                        raise ConfigurationError, "Target status ':#{target_status}' not found in configured Trello status mappings for board ID '#{board_id}'."
         | 
| 255 | 
            -
                      end
         | 
| 256 | 
            -
             | 
| 257 | 
            -
                      # Add the resolved list_id to the attributes to be updated
         | 
| 258 | 
            -
                      update_attributes[:list_id] = target_list_id
         | 
| 259 | 
            -
                    end
         | 
| 260 | 
            -
             | 
| 261 | 
            -
             | 
| 262 | 
            -
                    path = "cards/#{card_id}"
         | 
| 263 | 
            -
             | 
| 264 | 
            -
                    # Build query parameters from the potentially modified update_attributes
         | 
| 265 | 
            -
                    query_params = {}
         | 
| 266 | 
            -
                    query_params[:name] = update_attributes[:title] if update_attributes.key?(:title)
         | 
| 267 | 
            -
                    query_params[:desc] = update_attributes[:description] if update_attributes.key?(:description)
         | 
| 268 | 
            -
                    query_params[:closed] = update_attributes[:closed] if update_attributes.key?(:closed)
         | 
| 269 | 
            -
                    query_params[:idList] = update_attributes[:list_id] if update_attributes.key?(:list_id) # Use the mapped list_id if status was provided
         | 
| 270 | 
            -
                    query_params[:due] = update_attributes[:due_on]&.iso8601 if update_attributes.key?(:due_on)
         | 
| 271 | 
            -
                    query_params[:dueComplete] = update_attributes[:dueComplete] if update_attributes.key?(:dueComplete)
         | 
| 272 | 
            -
                    # Use assignee_ids (expects an array of Trello member IDs)
         | 
| 273 | 
            -
                    query_params[:idMembers] = update_attributes[:assignee_ids]&.join(",") if update_attributes.key?(:assignee_ids)
         | 
| 274 | 
            -
             | 
| 275 | 
            -
                    # If after processing :status, there are no actual changes, just return the current issue state
         | 
| 276 | 
            -
                    return find_issue(card_id, context) if query_params.empty?
         | 
| 277 | 
            -
             | 
| 278 | 
            -
                    # Make the PUT request to update the card
         | 
| 279 | 
            -
                    card_data = make_request(:put, path, nil, query_params.compact)
         | 
| 280 | 
            -
             | 
| 281 | 
            -
                    # Return the updated issue resource, mapped with potentially new status
         | 
| 282 | 
            -
                    map_card_data(card_data, card_data["idBoard"])
         | 
| 283 | 
            -
                  end
         | 
| 284 | 
            -
             | 
| 285 | 
            -
                  # Adds a comment to a Card in Trello.
         | 
| 286 | 
            -
                  # @param card_id [String] The ID of the Trello Card.
         | 
| 287 | 
            -
                  # @param comment_body [String] The comment text (Markdown).
         | 
| 288 | 
            -
                  # @param context [Hash] Optional context (ignored).
         | 
| 289 | 
            -
                  # @return [ActiveProject::Resources::Comment] The created comment resource.
         | 
| 290 | 
            -
                  def add_comment(card_id, comment_body, context = {})
         | 
| 291 | 
            -
                    path = "cards/#{card_id}/actions/comments"
         | 
| 292 | 
            -
                    query_params = { text: comment_body }
         | 
| 293 | 
            -
                    comment_data = make_request(:post, path, nil, query_params)
         | 
| 294 | 
            -
                    map_comment_action_data(comment_data, card_id)
         | 
| 295 | 
            -
                  end
         | 
| 296 | 
            -
             | 
| 297 | 
            -
                  # Parses an incoming Trello webhook payload.
         | 
| 298 | 
            -
                  # @param request_body [String] The raw JSON request body.
         | 
| 299 | 
            -
                  # @param headers [Hash] Request headers (unused).
         | 
| 300 | 
            -
                  # @return [ActiveProject::WebhookEvent, nil] Parsed event or nil if unhandled.
         | 
| 301 | 
            -
                  def parse_webhook(request_body, headers = {})
         | 
| 302 | 
            -
                    payload = JSON.parse(request_body) rescue nil
         | 
| 303 | 
            -
                    return nil unless payload.is_a?(Hash) && payload["action"].is_a?(Hash)
         | 
| 304 | 
            -
             | 
| 305 | 
            -
                    action = payload["action"]
         | 
| 306 | 
            -
                    action_type = action["type"]
         | 
| 307 | 
            -
                    actor_data = action.dig("memberCreator")
         | 
| 308 | 
            -
                    timestamp = Time.parse(action["date"]) rescue nil
         | 
| 309 | 
            -
                    board_id = action.dig("data", "board", "id")
         | 
| 310 | 
            -
                    card_data = action.dig("data", "card")
         | 
| 311 | 
            -
                    comment_text = action.dig("data", "text")
         | 
| 312 | 
            -
                    old_data = action.dig("data", "old") # For updateCard events
         | 
| 313 | 
            -
             | 
| 314 | 
            -
                    event_type = nil
         | 
| 315 | 
            -
                    object_kind = nil
         | 
| 316 | 
            -
                    event_object_id = nil
         | 
| 317 | 
            -
                    object_key = nil
         | 
| 318 | 
            -
                    changes = nil
         | 
| 319 | 
            -
                    object_data = nil
         | 
| 320 | 
            -
             | 
| 321 | 
            -
                    case action_type
         | 
| 322 | 
            -
                    when "createCard"
         | 
| 323 | 
            -
                      event_type = :issue_created
         | 
| 324 | 
            -
                      object_kind = :issue
         | 
| 325 | 
            -
                      event_object_id = card_data["id"]
         | 
| 326 | 
            -
                      object_key = card_data["idShort"]
         | 
| 327 | 
            -
                    when "updateCard"
         | 
| 328 | 
            -
                      event_type = :issue_updated
         | 
| 329 | 
            -
                      object_kind = :issue
         | 
| 330 | 
            -
                      event_object_id = card_data["id"]
         | 
| 331 | 
            -
                      object_key = card_data["idShort"]
         | 
| 332 | 
            -
                      # Parse changes for updateCard
         | 
| 333 | 
            -
                      if old_data.is_a?(Hash)
         | 
| 334 | 
            -
                        changes = {}
         | 
| 335 | 
            -
                        old_data.each do |field, old_value|
         | 
| 336 | 
            -
                          # Find the corresponding new value in the card data if possible
         | 
| 337 | 
            -
                          new_value = card_data[field]
         | 
| 338 | 
            -
                          changes[field.to_sym] = [ old_value, new_value ]
         | 
| 339 | 
            -
                        end
         | 
| 340 | 
            -
                      end
         | 
| 341 | 
            -
                    when "commentCard"
         | 
| 342 | 
            -
                      event_type = :comment_added
         | 
| 343 | 
            -
                      object_kind = :comment
         | 
| 344 | 
            -
                      event_object_id = action["id"] # Action ID is comment ID
         | 
| 345 | 
            -
                      object_key = nil
         | 
| 346 | 
            -
                    when "addMemberToCard", "removeMemberFromCard"
         | 
| 347 | 
            -
                      event_type = :issue_updated
         | 
| 348 | 
            -
                      object_kind = :issue
         | 
| 349 | 
            -
                      event_object_id = card_data["id"]
         | 
| 350 | 
            -
                      object_key = card_data["idShort"]
         | 
| 351 | 
            -
                      changes = { assignees: true } # Indicate assignees changed, specific diff not easily available
         | 
| 352 | 
            -
                    else
         | 
| 353 | 
            -
                      return nil # Unhandled action type
         | 
| 354 | 
            -
                    end
         | 
| 355 | 
            -
             | 
| 356 | 
            -
                    WebhookEvent.new(
         | 
| 357 | 
            -
                      event_type: event_type,
         | 
| 358 | 
            -
                      object_kind: object_kind,
         | 
| 359 | 
            -
                      event_object_id: event_object_id,
         | 
| 360 | 
            -
                      object_key: object_key,
         | 
| 361 | 
            -
                      project_id: board_id,
         | 
| 362 | 
            -
                      actor: map_user_data(actor_data), # Use helper
         | 
| 363 | 
            -
                      timestamp: timestamp,
         | 
| 364 | 
            -
                      adapter_source: :trello,
         | 
| 365 | 
            -
                      changes: changes,
         | 
| 366 | 
            -
                      object_data: object_data, # Keep nil for now
         | 
| 367 | 
            -
                      raw_data: payload
         | 
| 368 | 
            -
                    )
         | 
| 369 | 
            -
                  rescue JSON::ParserError
         | 
| 370 | 
            -
                    nil # Ignore unparseable payloads
         | 
| 371 | 
            -
                  end
         | 
| 372 | 
            -
             | 
| 373 | 
            -
             | 
| 374 50 | 
             
                  # Retrieves details for the currently authenticated user.
         | 
| 375 51 | 
             
                  # @return [ActiveProject::Resources::User] The user object.
         | 
| 376 52 | 
             
                  # @raise [ActiveProject::AuthenticationError] if authentication fails.
         | 
| @@ -390,18 +66,9 @@ module ActiveProject | |
| 390 66 | 
             
                    false
         | 
| 391 67 | 
             
                  end
         | 
| 392 68 |  | 
| 393 | 
            -
             | 
| 394 69 | 
             
                  private
         | 
| 395 70 |  | 
| 396 71 | 
             
                  # Initializes the Faraday connection object.
         | 
| 397 | 
            -
                  def initialize_connection
         | 
| 398 | 
            -
                    Faraday.new(url: BASE_URL) do |conn|
         | 
| 399 | 
            -
                      conn.request :retry
         | 
| 400 | 
            -
                      conn.headers["Accept"] = "application/json"
         | 
| 401 | 
            -
                      conn.response :raise_error
         | 
| 402 | 
            -
                      conn.headers["User-Agent"] = ActiveProject.user_agent
         | 
| 403 | 
            -
                    end
         | 
| 404 | 
            -
                  end
         | 
| 405 72 |  | 
| 406 73 | 
             
                  # Helper method for making requests.
         | 
| 407 74 | 
             
                  def make_request(method, path, body = nil, query_params = {})
         | 
| @@ -417,6 +84,7 @@ module ActiveProject | |
| 417 84 | 
             
                    end
         | 
| 418 85 |  | 
| 419 86 | 
             
                    return nil if response.status == 204 || response.body.empty?
         | 
| 87 | 
            +
             | 
| 420 88 | 
             
                    JSON.parse(response.body)
         | 
| 421 89 | 
             
                  rescue Faraday::Error => e
         | 
| 422 90 | 
             
                    handle_faraday_error(e)
         | 
| @@ -440,11 +108,14 @@ module ActiveProject | |
| 440 108 | 
             
                    when 400, 422
         | 
| 441 109 | 
             
                      if status == 400 && message&.include?("invalid id")
         | 
| 442 110 | 
             
                        raise NotFoundError, "Trello resource not found (Status: 400, Message: #{message})"
         | 
| 443 | 
            -
                      else
         | 
| 444 | 
            -
                        raise ValidationError.new("Trello validation failed (Status: #{status}): #{message}", status_code: status, response_body: body)
         | 
| 445 111 | 
             
                      end
         | 
| 112 | 
            +
             | 
| 113 | 
            +
                      raise ValidationError.new("Trello validation failed (Status: #{status}): #{message}", status_code: status,
         | 
| 114 | 
            +
                                                                                                            response_body: body)
         | 
| 115 | 
            +
             | 
| 446 116 | 
             
                    else
         | 
| 447 | 
            -
                      raise ApiError.new("Trello API error (Status: #{status || 'N/A'}): #{message}", original_error: error, | 
| 117 | 
            +
                      raise ApiError.new("Trello API error (Status: #{status || 'N/A'}): #{message}", original_error: error,
         | 
| 118 | 
            +
                                                                                                      status_code: status, response_body: body)
         | 
| 448 119 | 
             
                    end
         | 
| 449 120 | 
             
                  end
         | 
| 450 121 |  | 
| @@ -461,48 +132,54 @@ module ActiveProject | |
| 461 132 | 
             
                      status = board_mappings[list_id]
         | 
| 462 133 | 
             
                    # Fallback: Try mapping by List Name if ID mapping failed and list data is present
         | 
| 463 134 | 
             
                    elsif board_mappings && card_data["list"] && board_mappings.key?(card_data["list"]["name"])
         | 
| 464 | 
            -
             | 
| 135 | 
            +
                      status = board_mappings[card_data["list"]["name"]]
         | 
| 465 136 | 
             
                    end
         | 
| 466 137 |  | 
| 467 138 | 
             
                    # Override status if the card is archived (closed)
         | 
| 468 139 | 
             
                    status = :closed if card_data["closed"]
         | 
| 469 140 |  | 
| 470 | 
            -
                    created_timestamp =  | 
| 471 | 
            -
             | 
| 141 | 
            +
                    created_timestamp = begin
         | 
| 142 | 
            +
                      Time.at(card_data["id"][0..7].to_i(16))
         | 
| 143 | 
            +
                    rescue StandardError
         | 
| 144 | 
            +
                      nil
         | 
| 145 | 
            +
                    end
         | 
| 146 | 
            +
                    due_on = begin
         | 
| 147 | 
            +
                      card_data["due"] ? Date.parse(card_data["due"]) : nil
         | 
| 148 | 
            +
                    rescue StandardError
         | 
| 149 | 
            +
                      nil
         | 
| 150 | 
            +
                    end
         | 
| 472 151 |  | 
| 473 152 | 
             
                    Resources::Issue.new(self, # Pass adapter instance
         | 
| 474 | 
            -
             | 
| 475 | 
            -
             | 
| 476 | 
            -
             | 
| 477 | 
            -
             | 
| 478 | 
            -
             | 
| 479 | 
            -
             | 
| 480 | 
            -
             | 
| 481 | 
            -
             | 
| 482 | 
            -
             | 
| 483 | 
            -
             | 
| 484 | 
            -
             | 
| 485 | 
            -
             | 
| 486 | 
            -
             | 
| 487 | 
            -
             | 
| 488 | 
            -
                    )
         | 
| 153 | 
            +
                                         id: card_data["id"],
         | 
| 154 | 
            +
                                         key: nil,
         | 
| 155 | 
            +
                                         title: card_data["name"],
         | 
| 156 | 
            +
                                         description: card_data["desc"],
         | 
| 157 | 
            +
                                         status: status, # Use the determined status
         | 
| 158 | 
            +
                                         assignees: map_member_ids_to_users(card_data["idMembers"]), # Use new method
         | 
| 159 | 
            +
                                         reporter: nil, # Trello cards don't have a distinct reporter
         | 
| 160 | 
            +
                                         project_id: board_id,
         | 
| 161 | 
            +
                                         created_at: created_timestamp,
         | 
| 162 | 
            +
                                         updated_at: nil, # Trello API doesn't provide a standard updated_at for cards easily
         | 
| 163 | 
            +
                                         due_on: due_on,
         | 
| 164 | 
            +
                                         priority: nil, # Trello doesn't have priority
         | 
| 165 | 
            +
                                         adapter_source: :trello,
         | 
| 166 | 
            +
                                         raw_data: card_data)
         | 
| 489 167 | 
             
                  end
         | 
| 490 168 |  | 
| 491 169 | 
             
                  # Maps raw Trello comment action data hash to a Comment resource.
         | 
| 492 170 | 
             
                  def map_comment_action_data(action_data, card_id)
         | 
| 493 | 
            -
                    author_data = action_data | 
| 171 | 
            +
                    author_data = action_data["memberCreator"]
         | 
| 494 172 | 
             
                    comment_text = action_data.dig("data", "text")
         | 
| 495 173 |  | 
| 496 174 | 
             
                    Resources::Comment.new(self, # Pass adapter instance
         | 
| 497 | 
            -
             | 
| 498 | 
            -
             | 
| 499 | 
            -
             | 
| 500 | 
            -
             | 
| 501 | 
            -
             | 
| 502 | 
            -
             | 
| 503 | 
            -
             | 
| 504 | 
            -
             | 
| 505 | 
            -
                    )
         | 
| 175 | 
            +
                                           id: action_data["id"],
         | 
| 176 | 
            +
                                           body: comment_text,
         | 
| 177 | 
            +
                                           author: map_user_data(author_data), # Use user mapping
         | 
| 178 | 
            +
                                           created_at: action_data["date"] ? Time.parse(action_data["date"]) : nil,
         | 
| 179 | 
            +
                                           updated_at: nil,
         | 
| 180 | 
            +
                                           issue_id: card_id,
         | 
| 181 | 
            +
                                           adapter_source: :trello,
         | 
| 182 | 
            +
                                           raw_data: action_data)
         | 
| 506 183 | 
             
                  end
         | 
| 507 184 |  | 
| 508 185 | 
             
                  # Maps an array of Trello member IDs to an array of User resources.
         | 
| @@ -511,6 +188,7 @@ module ActiveProject | |
| 511 188 | 
             
                  # @return [Array<Resources::User>]
         | 
| 512 189 | 
             
                  def map_member_ids_to_users(member_ids)
         | 
| 513 190 | 
             
                    return [] unless member_ids.is_a?(Array)
         | 
| 191 | 
            +
             | 
| 514 192 | 
             
                    member_ids.map do |id|
         | 
| 515 193 | 
             
                      Resources::User.new(self, id: id, adapter_source: :trello, raw_data: { id: id })
         | 
| 516 194 | 
             
                    end
         | 
| @@ -521,13 +199,13 @@ module ActiveProject | |
| 521 199 | 
             
                  # @return [Resources::User, nil]
         | 
| 522 200 | 
             
                  def map_user_data(member_data)
         | 
| 523 201 | 
             
                    return nil unless member_data && member_data["id"]
         | 
| 202 | 
            +
             | 
| 524 203 | 
             
                    Resources::User.new(self, # Pass adapter instance
         | 
| 525 | 
            -
             | 
| 526 | 
            -
             | 
| 527 | 
            -
             | 
| 528 | 
            -
             | 
| 529 | 
            -
             | 
| 530 | 
            -
                    )
         | 
| 204 | 
            +
                                        id: member_data["id"],
         | 
| 205 | 
            +
                                        name: member_data["fullName"], # Trello uses fullName
         | 
| 206 | 
            +
                                        email: nil, # Trello API often doesn't provide email directly here
         | 
| 207 | 
            +
                                        adapter_source: :trello,
         | 
| 208 | 
            +
                                        raw_data: member_data)
         | 
| 531 209 | 
             
                  end
         | 
| 532 210 | 
             
                end
         | 
| 533 211 | 
             
              end
         | 
| @@ -32,6 +32,7 @@ module ActiveProject | |
| 32 32 | 
             
                  # Ensure owner.id is accessed correctly
         | 
| 33 33 | 
             
                  owner_id = @owner.respond_to?(:id) ? @owner.id : nil
         | 
| 34 34 | 
             
                  raise "Owner object #{@owner.inspect} does not have an ID for association call." unless owner_id
         | 
| 35 | 
            +
             | 
| 35 36 | 
             
                  @adapter.send(list_method, owner_id, options)
         | 
| 36 37 | 
             
                end
         | 
| 37 38 |  | 
| @@ -79,6 +80,8 @@ module ActiveProject | |
| 79 80 | 
             
                  @target_resource_class.new(@adapter, merged_attrs)
         | 
| 80 81 | 
             
                end
         | 
| 81 82 |  | 
| 83 | 
            +
                alias new build
         | 
| 84 | 
            +
             | 
| 82 85 | 
             
                # Creates and saves a new associated resource.
         | 
| 83 86 | 
             
                # Example: project.issues.create(title: 'New', list_id: '...')
         | 
| 84 87 | 
             
                # @param attributes [Hash] Attributes for the new resource.
         | 
| @@ -86,12 +89,13 @@ module ActiveProject | |
| 86 89 | 
             
                # @raise [NotImplementedError] Currently raises because #save is not fully implemented on resource.
         | 
| 87 90 | 
             
                def create(attributes = {})
         | 
| 88 91 | 
             
                  create_method = determine_create_method
         | 
| 89 | 
            -
                   | 
| 92 | 
            +
                  determine_context # Get owner context
         | 
| 90 93 | 
             
                  # Pass owner ID/context first, then attributes
         | 
| 91 94 | 
             
                  owner_id = @owner.respond_to?(:id) ? @owner.id : nil
         | 
| 92 95 | 
             
                  raise "Owner object #{@owner.inspect} does not have an ID for association call." unless owner_id
         | 
| 96 | 
            +
             | 
| 93 97 | 
             
                  @adapter.send(create_method, owner_id, attributes)
         | 
| 94 | 
            -
                  #  | 
| 98 | 
            +
                  # NOTE: This currently returns the result from the adapter directly.
         | 
| 95 99 | 
             
                  # A full implementation would likely build and then save, or re-fetch.
         | 
| 96 100 | 
             
                end
         | 
| 97 101 |  | 
| @@ -113,6 +117,7 @@ module ActiveProject | |
| 113 117 | 
             
                  unless @adapter.respond_to?(method_name)
         | 
| 114 118 | 
             
                    raise NotImplementedError, "#{@adapter.class.name} does not implement ##{method_name}"
         | 
| 115 119 | 
             
                  end
         | 
| 120 | 
            +
             | 
| 116 121 | 
             
                  method_name
         | 
| 117 122 | 
             
                end
         | 
| 118 123 |  | 
| @@ -125,6 +130,7 @@ module ActiveProject | |
| 125 130 | 
             
                  unless @adapter.respond_to?(method_name)
         | 
| 126 131 | 
             
                    raise NotImplementedError, "#{@adapter.class.name} does not implement ##{method_name}"
         | 
| 127 132 | 
             
                  end
         | 
| 133 | 
            +
             | 
| 128 134 | 
             
                  method_name
         | 
| 129 135 | 
             
                end
         | 
| 130 136 |  | 
| @@ -136,6 +142,7 @@ module ActiveProject | |
| 136 142 | 
             
                  unless @adapter.respond_to?(method_name)
         | 
| 137 143 | 
             
                    raise NotImplementedError, "#{@adapter.class.name} does not implement ##{method_name}"
         | 
| 138 144 | 
             
                  end
         | 
| 145 | 
            +
             | 
| 139 146 | 
             
                  method_name
         | 
| 140 147 | 
             
                end
         | 
| 141 148 | 
             
              end
         | 
| @@ -29,9 +29,7 @@ module ActiveProject | |
| 29 29 | 
             
                # @param options [Hash] Configuration options for the adapter (e.g., site, api_key, token).
         | 
| 30 30 | 
             
                # @yield [BaseAdapterConfiguration] Yields an adapter-specific configuration object if a block is given.
         | 
| 31 31 | 
             
                def add_adapter(adapter_type, instance_name = :primary, options = {}, &block)
         | 
| 32 | 
            -
                  unless adapter_type.is_a?(Symbol)
         | 
| 33 | 
            -
                    raise ArgumentError, "Adapter type must be a Symbol (e.g., :basecamp)"
         | 
| 34 | 
            -
                  end
         | 
| 32 | 
            +
                  raise ArgumentError, "Adapter type must be a Symbol (e.g., :basecamp)" unless adapter_type.is_a?(Symbol)
         | 
| 35 33 |  | 
| 36 34 | 
             
                  # Handle the case where instance_name is actually the options hash
         | 
| 37 35 | 
             
                  if instance_name.is_a?(Hash) && options.empty?
         | 
| @@ -23,13 +23,11 @@ module ActiveProject | |
| 23 23 | 
             
                  # Call adapter method with appropriate arguments
         | 
| 24 24 | 
             
                  if primary_arg
         | 
| 25 25 | 
             
                    @adapter.send(list_method, primary_arg, options)
         | 
| 26 | 
            -
                   | 
| 26 | 
            +
                  elsif @adapter.method(list_method).arity.zero?
         | 
| 27 27 | 
             
                    # Handle case where list method might not take options (like list_projects)
         | 
| 28 | 
            -
                     | 
| 29 | 
            -
             | 
| 30 | 
            -
                     | 
| 31 | 
            -
                       @adapter.send(list_method, options)
         | 
| 32 | 
            -
                    end
         | 
| 28 | 
            +
                    @adapter.send(list_method)
         | 
| 29 | 
            +
                  else
         | 
| 30 | 
            +
                    @adapter.send(list_method, options)
         | 
| 33 31 | 
             
                  end
         | 
| 34 32 | 
             
                end
         | 
| 35 33 |  | 
| @@ -89,7 +87,7 @@ module ActiveProject | |
| 89 87 | 
             
                def create(attributes = {})
         | 
| 90 88 | 
             
                  # Determine the correct adapter create method based on resource type
         | 
| 91 89 | 
             
                  create_method = determine_create_method
         | 
| 92 | 
            -
                  #  | 
| 90 | 
            +
                  # NOTE: Assumes create methods on adapters take attributes hash directly
         | 
| 93 91 | 
             
                  # Context like project_id needs to be part of the attributes hash if required by adapter
         | 
| 94 92 | 
             
                  @adapter.send(create_method, attributes)
         | 
| 95 93 | 
             
                  # A full implementation would likely involve build then save:
         | 
| @@ -106,7 +104,11 @@ module ActiveProject | |
| 106 104 | 
             
                  when "ActiveProject::Resources::Issue" then :list_issues
         | 
| 107 105 | 
             
                  else raise "Cannot determine list method for #{@resource_class.name}"
         | 
| 108 106 | 
             
                  end
         | 
| 109 | 
            -
                   | 
| 107 | 
            +
                  unless @adapter.respond_to?(method_name)
         | 
| 108 | 
            +
                    raise NotImplementedError,
         | 
| 109 | 
            +
                          "#{@adapter.class.name} does not implement ##{method_name}"
         | 
| 110 | 
            +
                  end
         | 
| 111 | 
            +
             | 
| 110 112 | 
             
                  method_name
         | 
| 111 113 | 
             
                end
         | 
| 112 114 |  | 
| @@ -116,14 +118,22 @@ module ActiveProject | |
| 116 118 | 
             
                  when "ActiveProject::Resources::Issue" then :find_issue
         | 
| 117 119 | 
             
                  else raise "Cannot determine find method for #{@resource_class.name}"
         | 
| 118 120 | 
             
                  end
         | 
| 119 | 
            -
                   | 
| 121 | 
            +
                  unless @adapter.respond_to?(method_name)
         | 
| 122 | 
            +
                    raise NotImplementedError,
         | 
| 123 | 
            +
                          "#{@adapter.class.name} does not implement ##{method_name}"
         | 
| 124 | 
            +
                  end
         | 
| 125 | 
            +
             | 
| 120 126 | 
             
                  method_name
         | 
| 121 127 | 
             
                end
         | 
| 122 128 |  | 
| 123 129 | 
             
                def determine_create_method
         | 
| 124 130 | 
             
                  singular_name = @resource_class.name.split("::").last.downcase.to_sym
         | 
| 125 131 | 
             
                  method_name = :"create_#{singular_name}"
         | 
| 126 | 
            -
                   | 
| 132 | 
            +
                  unless @adapter.respond_to?(method_name)
         | 
| 133 | 
            +
                    raise NotImplementedError,
         | 
| 134 | 
            +
                          "#{@adapter.class.name} does not implement ##{method_name}"
         | 
| 135 | 
            +
                  end
         | 
| 136 | 
            +
             | 
| 127 137 | 
             
                  method_name
         | 
| 128 138 | 
             
                end
         | 
| 129 139 | 
             
              end
         | 
| @@ -7,8 +7,6 @@ module ActiveProject | |
| 7 7 | 
             
                  def_members :id, :key, :title, :description, :status, :assignees,
         | 
| 8 8 | 
             
                              :reporter, :project_id, :created_at, :updated_at, :due_on,
         | 
| 9 9 | 
             
                              :priority, :adapter_source
         | 
| 10 | 
            -
                  # raw_data and adapter are inherited from BaseResource
         | 
| 11 | 
            -
             | 
| 12 10 |  | 
| 13 11 | 
             
                  # Saves the issue (creates if new, updates if existing).
         | 
| 14 12 | 
             
                  # Placeholder - Full implementation requires attribute tracking and adapter delegation.
         | 
| @@ -28,7 +26,6 @@ module ActiveProject | |
| 28 26 | 
             
                    raise NotImplementedError, "#update not yet implemented for #{self.class.name}"
         | 
| 29 27 | 
             
                  end
         | 
| 30 28 |  | 
| 31 | 
            -
             | 
| 32 29 | 
             
                  # Returns an association proxy for accessing comments on this issue.
         | 
| 33 30 | 
             
                  # @return [AssociationProxy<Resources::Comment>]
         | 
| 34 31 | 
             
                  def comments
         | 
| @@ -7,7 +7,6 @@ module ActiveProject | |
| 7 7 | 
             
                  def_members :id, :key, :name, :adapter_source
         | 
| 8 8 | 
             
                  # raw_data and adapter are inherited from BaseResource
         | 
| 9 9 |  | 
| 10 | 
            -
             | 
| 11 10 | 
             
                  # Returns an association proxy for accessing issues within this project.
         | 
| 12 11 | 
             
                  # @return [AssociationProxy<Resources::Issue>]
         | 
| 13 12 | 
             
                  def issues
         |