activeproject 0.0.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.
@@ -0,0 +1,535 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "json"
6
+ require "time"
7
+ require "openssl" # For webhook signature
8
+ require "base64" # For webhook signature
9
+
10
+ module ActiveProject
11
+ module Adapters
12
+ # Adapter for interacting with the Trello REST API.
13
+ # Implements the interface defined in ActiveProject::Adapters::Base.
14
+ # API Docs: https://developer.atlassian.com/cloud/trello/rest/
15
+ class TrelloAdapter < Base
16
+ BASE_URL = "https://api.trello.com/1/"
17
+ USER_AGENT = "ActiveProject Gem (github.com/seuros/activeproject)"
18
+
19
+ # Computes the expected Trello webhook signature.
20
+ # @param callback_url [String] The exact URL registered for the webhook.
21
+ # @param response_body [String] The raw response body received from Trello.
22
+ # @param api_secret [String] The Trello API Secret (OAuth Secret or Application Secret).
23
+ # @return [String] The Base64 encoded HMAC-SHA1 signature.
24
+ def self.compute_webhook_signature(callback_url, response_body, api_secret)
25
+ digest = OpenSSL::Digest.new("sha1")
26
+ hmac = OpenSSL::HMAC.digest(digest, api_secret, response_body + callback_url)
27
+ Base64.strict_encode64(hmac)
28
+ end
29
+
30
+ attr_reader :config
31
+
32
+ # Initializes the Trello Adapter.
33
+ # @param config [Configurations::TrelloConfiguration] The configuration object for Trello.
34
+ # @raise [ArgumentError] if required configuration options (:api_key, :api_token) are missing.
35
+ def initialize(config:)
36
+ unless config.is_a?(ActiveProject::Configurations::TrelloConfiguration)
37
+ raise ArgumentError, "TrelloAdapter requires a TrelloConfiguration object"
38
+ end
39
+ @config = config
40
+
41
+ unless @config.api_key && !@config.api_key.empty? && @config.api_token && !@config.api_token.empty?
42
+ raise ArgumentError, "TrelloAdapter configuration requires :api_key and :api_token"
43
+ end
44
+
45
+ @connection = initialize_connection
46
+ end
47
+
48
+
49
+ # --- Resource Factories ---
50
+
51
+ # Returns a factory for Project resources.
52
+ # @return [ResourceFactory<Resources::Project>]
53
+ def projects
54
+ ResourceFactory.new(adapter: self, resource_class: Resources::Project)
55
+ end
56
+
57
+ # Returns a factory for Issue resources (Cards).
58
+ # @return [ResourceFactory<Resources::Issue>]
59
+ def issues
60
+ ResourceFactory.new(adapter: self, resource_class: Resources::Issue)
61
+ end
62
+
63
+ # --- Implementation of Base methods ---
64
+
65
+ # Lists Trello boards accessible by the configured token.
66
+ # @return [Array<ActiveProject::Resources::Project>] An array of project resources.
67
+ def list_projects
68
+ path = "members/me/boards"
69
+ query = { fields: "id,name,desc" }
70
+ boards_data = make_request(:get, path, nil, query)
71
+
72
+ return [] unless boards_data.is_a?(Array)
73
+
74
+ boards_data.map do |board_data|
75
+ Resources::Project.new(self, # Pass adapter instance
76
+ id: board_data["id"],
77
+ key: nil,
78
+ name: board_data["name"],
79
+ adapter_source: :trello,
80
+ raw_data: board_data
81
+ )
82
+ end
83
+ end
84
+
85
+ # Finds a specific Trello Board by its ID.
86
+ # @param board_id [String] The ID of the Trello Board.
87
+ # @return [ActiveProject::Resources::Project] The project resource.
88
+ def find_project(board_id)
89
+ path = "boards/#{board_id}"
90
+ query = { fields: "id,name,desc" }
91
+ board_data = make_request(:get, path, nil, query)
92
+
93
+ Resources::Project.new(self, # Pass adapter instance
94
+ id: board_data["id"],
95
+ key: nil,
96
+ name: board_data["name"],
97
+ adapter_source: :trello,
98
+ raw_data: board_data
99
+ )
100
+ end
101
+
102
+ # Creates a new board in Trello.
103
+ # @param attributes [Hash] Board attributes. Required: :name. Optional: :description, :default_lists.
104
+ # @return [ActiveProject::Resources::Project] The created project resource.
105
+ def create_project(attributes)
106
+ unless attributes[:name] && !attributes[:name].empty?
107
+ raise ArgumentError, "Missing required attribute for Trello board creation: :name"
108
+ end
109
+
110
+ path = "boards/"
111
+ query_params = {
112
+ name: attributes[:name],
113
+ desc: attributes[:description],
114
+ defaultLists: attributes.fetch(:default_lists, true) # Default to creating lists
115
+ # Add other board options here if needed (e.g., idOrganization)
116
+ }.compact
117
+
118
+ board_data = make_request(:post, path, nil, query_params)
119
+
120
+ Resources::Project.new(self, # Pass adapter instance
121
+ id: board_data["id"],
122
+ key: nil,
123
+ name: board_data["name"],
124
+ adapter_source: :trello,
125
+ raw_data: board_data
126
+ )
127
+ end
128
+
129
+ # Creates a new list on a Trello board.
130
+ # @param board_id [String] The ID of the board.
131
+ # @param attributes [Hash] List attributes. Required: :name. Optional: :pos.
132
+ # @return [Hash] The raw data hash of the created list.
133
+ def create_list(board_id, attributes)
134
+ unless attributes[:name] && !attributes[:name].empty?
135
+ raise ArgumentError, "Missing required attribute for Trello list creation: :name"
136
+ end
137
+
138
+ path = "boards/#{board_id}/lists"
139
+ query_params = {
140
+ name: attributes[:name],
141
+ pos: attributes[:pos]
142
+ }.compact
143
+
144
+ make_request(:post, path, nil, query_params)
145
+ end
146
+
147
+ # Deletes a board in Trello.
148
+ # WARNING: This is a permanent deletion.
149
+ # @param board_id [String] The ID of the board to delete.
150
+ # @return [Boolean] true if deletion was successful (API returns 200).
151
+ # @raise [NotFoundError] if the board is not found.
152
+ # @raise [AuthenticationError] if credentials lack permission.
153
+ # @raise [ApiError] for other errors.
154
+ def delete_project(board_id)
155
+ path = "/boards/#{board_id}"
156
+ make_request(:delete, path) # DELETE returns 200 OK on success
157
+ true # Return true if make_request doesn't raise an error
158
+ end
159
+
160
+
161
+
162
+
163
+ # Lists Trello cards on a specific board.
164
+ # @param board_id [String] The ID of the Trello board.
165
+ # @param options [Hash] Optional filtering options.
166
+ # @return [Array<ActiveProject::Resources::Issue>] An array of issue resources.
167
+ def list_issues(board_id, options = {})
168
+ path = "boards/#{board_id}/cards"
169
+ # Fetch idMembers and list name for potential name mapping fallback
170
+ query = { fields: "id,name,desc,closed,idList,idBoard,due,dueComplete,idMembers", list: true }
171
+ query[:filter] = options[:filter] if options[:filter]
172
+
173
+ cards_data = make_request(:get, path, nil, query)
174
+ return [] unless cards_data.is_a?(Array)
175
+
176
+ cards_data.map { |card_data| map_card_data(card_data, board_id) }
177
+ end
178
+
179
+ # Finds a specific Card by its ID.
180
+ # @param card_id [String] The ID of the Trello Card.
181
+ # @param context [Hash] Optional context (ignored).
182
+ # @return [ActiveProject::Resources::Issue] The issue resource.
183
+ def find_issue(card_id, context = {})
184
+ path = "cards/#{card_id}"
185
+ # Fetch idMembers and list name for potential name mapping fallback
186
+ query = { fields: "id,name,desc,closed,idList,idBoard,due,dueComplete,idMembers", list: true }
187
+ card_data = make_request(:get, path, nil, query)
188
+ map_card_data(card_data, card_data["idBoard"])
189
+ end
190
+
191
+ # Creates a new Card in Trello.
192
+ # @param _board_id [String] Ignored (context).
193
+ # @param attributes [Hash] Card attributes. Required: :list_id, :title. Optional: :description, :assignee_ids, :due_on.
194
+ # @return [ActiveProject::Resources::Issue] The created issue resource.
195
+ def create_issue(_board_id, attributes)
196
+ list_id = attributes[:list_id]
197
+ title = attributes[:title]
198
+
199
+ unless list_id && title && !title.empty?
200
+ raise ArgumentError, "Missing required attributes for Trello card creation: :list_id, :title"
201
+ end
202
+
203
+ path = "cards"
204
+ query_params = {
205
+ idList: list_id,
206
+ name: title,
207
+ desc: attributes[:description],
208
+ # Use assignee_ids (expects an array of Trello member IDs)
209
+ idMembers: attributes[:assignee_ids]&.join(","),
210
+ due: attributes[:due_on]&.iso8601
211
+ }.compact
212
+
213
+ card_data = make_request(:post, path, nil, query_params)
214
+ map_card_data(card_data, card_data["idBoard"])
215
+ end
216
+
217
+ # Updates an existing Card in Trello.
218
+ # @param card_id [String] The ID of the Trello Card.
219
+ # @param attributes [Hash] Attributes to update (e.g., :title, :description, :list_id, :closed, :due_on, :assignee_ids, :status).
220
+ # @param context [Hash] Optional context (ignored).
221
+ # @return [ActiveProject::Resources::Issue] The updated issue resource.
222
+ def update_issue(card_id, attributes, context = {})
223
+ # Make a mutable copy of attributes
224
+ update_attributes = attributes.dup
225
+
226
+ # Handle :status mapping to :list_id
227
+ if update_attributes.key?(:status)
228
+ target_status = update_attributes.delete(:status) # Remove status key
229
+
230
+ # Fetch board_id efficiently if not already known
231
+ # We need the board_id to look up the correct status mapping
232
+ board_id = update_attributes[:board_id] || begin
233
+ find_issue(card_id).project_id # Fetch the issue to get its board_id
234
+ rescue NotFoundError
235
+ # Re-raise NotFoundError if the card itself doesn't exist
236
+ raise NotFoundError, "Trello card with ID '#{card_id}' not found."
237
+ end
238
+
239
+ unless board_id
240
+ # This should theoretically not happen if find_issue succeeded or board_id was passed
241
+ raise ApiError, "Could not determine board ID for card '#{card_id}' to perform status mapping."
242
+ end
243
+
244
+ # Use stored config for status mappings
245
+ board_mappings = @config.status_mappings[board_id]
246
+ unless board_mappings
247
+ raise ConfigurationError, "Trello status mapping not configured for board ID '#{board_id}'. Cannot map status ':#{target_status}'."
248
+ end
249
+
250
+ # Find the target list ID by looking up the status symbol in the board's mappings.
251
+ # We iterate through the mappings hash { list_id => status_symbol }
252
+ target_list_id = board_mappings.key(target_status)
253
+
254
+ unless target_list_id
255
+ raise ConfigurationError, "Target status ':#{target_status}' not found in configured Trello status mappings for board ID '#{board_id}'."
256
+ end
257
+
258
+ # Add the resolved list_id to the attributes to be updated
259
+ update_attributes[:list_id] = target_list_id
260
+ end
261
+
262
+
263
+ path = "cards/#{card_id}"
264
+
265
+ # Build query parameters from the potentially modified update_attributes
266
+ query_params = {}
267
+ query_params[:name] = update_attributes[:title] if update_attributes.key?(:title)
268
+ query_params[:desc] = update_attributes[:description] if update_attributes.key?(:description)
269
+ query_params[:closed] = update_attributes[:closed] if update_attributes.key?(:closed)
270
+ query_params[:idList] = update_attributes[:list_id] if update_attributes.key?(:list_id) # Use the mapped list_id if status was provided
271
+ query_params[:due] = update_attributes[:due_on]&.iso8601 if update_attributes.key?(:due_on)
272
+ query_params[:dueComplete] = update_attributes[:dueComplete] if update_attributes.key?(:dueComplete)
273
+ # Use assignee_ids (expects an array of Trello member IDs)
274
+ query_params[:idMembers] = update_attributes[:assignee_ids]&.join(",") if update_attributes.key?(:assignee_ids)
275
+
276
+ # If after processing :status, there are no actual changes, just return the current issue state
277
+ return find_issue(card_id, context) if query_params.empty?
278
+
279
+ # Make the PUT request to update the card
280
+ card_data = make_request(:put, path, nil, query_params.compact)
281
+
282
+ # Return the updated issue resource, mapped with potentially new status
283
+ map_card_data(card_data, card_data["idBoard"])
284
+ end
285
+
286
+ # Adds a comment to a Card in Trello.
287
+ # @param card_id [String] The ID of the Trello Card.
288
+ # @param comment_body [String] The comment text (Markdown).
289
+ # @param context [Hash] Optional context (ignored).
290
+ # @return [ActiveProject::Resources::Comment] The created comment resource.
291
+ def add_comment(card_id, comment_body, context = {})
292
+ path = "cards/#{card_id}/actions/comments"
293
+ query_params = { text: comment_body }
294
+ comment_data = make_request(:post, path, nil, query_params)
295
+ map_comment_action_data(comment_data, card_id)
296
+ end
297
+
298
+ # Parses an incoming Trello webhook payload.
299
+ # @param request_body [String] The raw JSON request body.
300
+ # @param headers [Hash] Request headers (unused).
301
+ # @return [ActiveProject::WebhookEvent, nil] Parsed event or nil if unhandled.
302
+ def parse_webhook(request_body, headers = {})
303
+ payload = JSON.parse(request_body) rescue nil
304
+ return nil unless payload.is_a?(Hash) && payload["action"].is_a?(Hash)
305
+
306
+ action = payload["action"]
307
+ action_type = action["type"]
308
+ actor_data = action.dig("memberCreator")
309
+ timestamp = Time.parse(action["date"]) rescue nil
310
+ board_id = action.dig("data", "board", "id")
311
+ card_data = action.dig("data", "card")
312
+ comment_text = action.dig("data", "text")
313
+ old_data = action.dig("data", "old") # For updateCard events
314
+
315
+ event_type = nil
316
+ object_kind = nil
317
+ event_object_id = nil
318
+ object_key = nil
319
+ changes = nil
320
+ object_data = nil
321
+
322
+ case action_type
323
+ when "createCard"
324
+ event_type = :issue_created
325
+ object_kind = :issue
326
+ event_object_id = card_data["id"]
327
+ object_key = card_data["idShort"]
328
+ when "updateCard"
329
+ event_type = :issue_updated
330
+ object_kind = :issue
331
+ event_object_id = card_data["id"]
332
+ object_key = card_data["idShort"]
333
+ # Parse changes for updateCard
334
+ if old_data.is_a?(Hash)
335
+ changes = {}
336
+ old_data.each do |field, old_value|
337
+ # Find the corresponding new value in the card data if possible
338
+ new_value = card_data[field]
339
+ changes[field.to_sym] = [ old_value, new_value ]
340
+ end
341
+ end
342
+ when "commentCard"
343
+ event_type = :comment_added
344
+ object_kind = :comment
345
+ event_object_id = action["id"] # Action ID is comment ID
346
+ object_key = nil
347
+ when "addMemberToCard", "removeMemberFromCard"
348
+ event_type = :issue_updated
349
+ object_kind = :issue
350
+ event_object_id = card_data["id"]
351
+ object_key = card_data["idShort"]
352
+ changes = { assignees: true } # Indicate assignees changed, specific diff not easily available
353
+ else
354
+ return nil # Unhandled action type
355
+ end
356
+
357
+ WebhookEvent.new(
358
+ event_type: event_type,
359
+ object_kind: object_kind,
360
+ event_object_id: event_object_id,
361
+ object_key: object_key,
362
+ project_id: board_id,
363
+ actor: map_user_data(actor_data), # Use helper
364
+ timestamp: timestamp,
365
+ adapter_source: :trello,
366
+ changes: changes,
367
+ object_data: object_data, # Keep nil for now
368
+ raw_data: payload
369
+ )
370
+ rescue JSON::ParserError
371
+ nil # Ignore unparseable payloads
372
+ end
373
+
374
+
375
+ # Retrieves details for the currently authenticated user.
376
+ # @return [ActiveProject::Resources::User] The user object.
377
+ # @raise [ActiveProject::AuthenticationError] if authentication fails.
378
+ # @raise [ActiveProject::ApiError] for other API-related errors.
379
+ def get_current_user
380
+ user_data = make_request(:get, "members/me")
381
+ map_user_data(user_data)
382
+ end
383
+
384
+ # Checks if the adapter can successfully authenticate and connect to the service.
385
+ # Calls #get_current_user internally and catches authentication errors.
386
+ # @return [Boolean] true if connection is successful, false otherwise.
387
+ def connected?
388
+ get_current_user
389
+ true
390
+ rescue ActiveProject::AuthenticationError
391
+ false
392
+ end
393
+
394
+
395
+ private
396
+
397
+ # Initializes the Faraday connection object.
398
+ def initialize_connection
399
+ Faraday.new(url: BASE_URL) do |conn|
400
+ conn.request :retry
401
+ conn.headers["Accept"] = "application/json"
402
+ conn.response :raise_error
403
+ conn.headers["User-Agent"] = ActiveProject.user_agent
404
+ end
405
+ end
406
+
407
+ # Helper method for making requests.
408
+ def make_request(method, path, body = nil, query_params = {})
409
+ # Use config object for credentials
410
+ auth_params = { key: @config.api_key, token: @config.api_token }
411
+ all_params = auth_params.merge(query_params)
412
+ json_body = body ? JSON.generate(body) : nil
413
+ headers = {}
414
+ headers["Content-Type"] = "application/json" if json_body
415
+
416
+ response = @connection.run_request(method, path, json_body, headers) do |req|
417
+ req.params.update(all_params)
418
+ end
419
+
420
+ return nil if response.status == 204 || response.body.empty?
421
+ JSON.parse(response.body)
422
+ rescue Faraday::Error => e
423
+ handle_faraday_error(e)
424
+ rescue JSON::ParserError => e
425
+ raise ApiError.new("Trello API returned non-JSON response: #{response&.body}", original_error: e)
426
+ end
427
+
428
+ # Handles Faraday errors.
429
+ def handle_faraday_error(error)
430
+ status = error.response_status
431
+ body = error.response_body
432
+ message = body || "Unknown Trello Error"
433
+
434
+ case status
435
+ when 401, 403
436
+ raise AuthenticationError, "Trello authentication/authorization failed (Status: #{status}): #{message}"
437
+ when 404
438
+ raise NotFoundError, "Trello resource not found (Status: 404): #{message}"
439
+ when 429
440
+ raise RateLimitError, "Trello rate limit exceeded (Status: 429): #{message}"
441
+ when 400, 422
442
+ if status == 400 && message&.include?("invalid id")
443
+ raise NotFoundError, "Trello resource not found (Status: 400, Message: #{message})"
444
+ else
445
+ raise ValidationError.new("Trello validation failed (Status: #{status}): #{message}", status_code: status, response_body: body)
446
+ end
447
+ else
448
+ raise ApiError.new("Trello API error (Status: #{status || 'N/A'}): #{message}", original_error: error, status_code: status, response_body: body)
449
+ end
450
+ end
451
+
452
+ # Maps raw Trello card data hash to an Issue resource.
453
+ def map_card_data(card_data, board_id)
454
+ list_id = card_data["idList"]
455
+ status = :open # Default status
456
+
457
+ # Use stored config for status mappings
458
+ board_mappings = @config.status_mappings[board_id]
459
+
460
+ # Try mapping by List ID first (most reliable)
461
+ if board_mappings && list_id && board_mappings.key?(list_id)
462
+ status = board_mappings[list_id]
463
+ # Fallback: Try mapping by List Name if ID mapping failed and list data is present
464
+ elsif board_mappings && card_data["list"] && board_mappings.key?(card_data["list"]["name"])
465
+ status = board_mappings[card_data["list"]["name"]]
466
+ end
467
+
468
+ # Override status if the card is archived (closed)
469
+ status = :closed if card_data["closed"]
470
+
471
+ created_timestamp = Time.at(card_data["id"][0..7].to_i(16)) rescue nil
472
+ due_on = card_data["due"] ? Date.parse(card_data["due"]) : nil rescue nil
473
+
474
+ Resources::Issue.new(self, # Pass adapter instance
475
+ id: card_data["id"],
476
+ key: nil,
477
+ title: card_data["name"],
478
+ description: card_data["desc"],
479
+ status: status, # Use the determined status
480
+ assignees: map_member_ids_to_users(card_data["idMembers"]), # Use new method
481
+ reporter: nil, # Trello cards don't have a distinct reporter
482
+ project_id: board_id,
483
+ created_at: created_timestamp,
484
+ updated_at: nil, # Trello API doesn't provide a standard updated_at for cards easily
485
+ due_on: due_on,
486
+ priority: nil, # Trello doesn't have priority
487
+ adapter_source: :trello,
488
+ raw_data: card_data
489
+ )
490
+ end
491
+
492
+ # Maps raw Trello comment action data hash to a Comment resource.
493
+ def map_comment_action_data(action_data, card_id)
494
+ author_data = action_data.dig("memberCreator")
495
+ comment_text = action_data.dig("data", "text")
496
+
497
+ Resources::Comment.new(self, # Pass adapter instance
498
+ id: action_data["id"],
499
+ body: comment_text,
500
+ author: map_user_data(author_data), # Use user mapping
501
+ created_at: action_data["date"] ? Time.parse(action_data["date"]) : nil,
502
+ updated_at: nil,
503
+ issue_id: card_id,
504
+ adapter_source: :trello,
505
+ raw_data: action_data
506
+ )
507
+ end
508
+
509
+ # Maps an array of Trello member IDs to an array of User resources.
510
+ # Currently only populates the ID. Fetching full member details would require extra API calls.
511
+ # @param member_ids [Array<String>, nil]
512
+ # @return [Array<Resources::User>]
513
+ def map_member_ids_to_users(member_ids)
514
+ return [] unless member_ids.is_a?(Array)
515
+ member_ids.map do |id|
516
+ Resources::User.new(self, id: id, adapter_source: :trello, raw_data: { id: id })
517
+ end
518
+ end
519
+
520
+ # Maps raw Trello member data hash to a User resource.
521
+ # @param member_data [Hash, nil] Raw member data from Trello API.
522
+ # @return [Resources::User, nil]
523
+ def map_user_data(member_data)
524
+ return nil unless member_data && member_data["id"]
525
+ Resources::User.new(self, # Pass adapter instance
526
+ id: member_data["id"],
527
+ name: member_data["fullName"], # Trello uses fullName
528
+ email: nil, # Trello API often doesn't provide email directly here
529
+ adapter_source: :trello,
530
+ raw_data: member_data
531
+ )
532
+ end
533
+ end
534
+ end
535
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ # Represents an association between resources (e.g., project.issues).
5
+ # Delegates finding/creating methods to the adapter, providing owner context.
6
+ class AssociationProxy
7
+ # @param owner [Resources::BaseResource] The resource instance owning the association.
8
+ # @param adapter [Adapters::Base] The adapter instance.
9
+ # @param association_name [Symbol] The name of the association (e.g., :issues, :comments).
10
+ def initialize(owner:, adapter:, association_name:)
11
+ @owner = owner
12
+ @adapter = adapter
13
+ @association_name = association_name
14
+ # Determine target resource class based on association name (simple heuristic for now)
15
+ @target_resource_class = case association_name
16
+ when :issues then Resources::Issue
17
+ when :comments then Resources::Comment
18
+ # Add other associations like :project for an issue?
19
+ else raise "Unknown association: #{association_name}"
20
+ end
21
+ end # End initialize
22
+
23
+ # --- Proxy Methods ---
24
+
25
+ # Fetches all associated resources.
26
+ # Example: project.issues.all -> adapter.list_issues(project.id)
27
+ # @param options [Hash] Additional options for the list method.
28
+ # @return [Array<BaseResource>]
29
+ def all(options = {})
30
+ list_method = determine_list_method
31
+ # Pass owner's ID as the primary context, then options
32
+ # Ensure owner.id is accessed correctly
33
+ owner_id = @owner.respond_to?(:id) ? @owner.id : nil
34
+ raise "Owner object #{@owner.inspect} does not have an ID for association call." unless owner_id
35
+ @adapter.send(list_method, owner_id, options)
36
+ end
37
+
38
+ # Finds a specific associated resource by ID.
39
+ # Example: project.issues.find(issue_id) -> adapter.find_issue(issue_id, { project_id: project.id })
40
+ # @param id [String, Integer] The ID of the resource to find.
41
+ # @return [BaseResource, nil]
42
+ def find(id)
43
+ find_method = determine_find_method
44
+ # Pass owner context needed by the find method
45
+ context = determine_context
46
+ @adapter.send(find_method, id, context)
47
+ rescue ActiveProject::NotFoundError
48
+ nil
49
+ end
50
+
51
+ # Filters associated resources based on conditions.
52
+ # Example: project.issues.where(status: :open)
53
+ # Currently performs client-side filtering on #all results.
54
+ # @param conditions [Hash] Conditions to filter by.
55
+ # @return [Array<BaseResource>]
56
+ def where(conditions)
57
+ # Basic client-side filtering for now
58
+ # Note: This calls the proxy's #all method, which passes owner context
59
+ all.select do |resource|
60
+ conditions.all? do |key, value|
61
+ resource.respond_to?(key) && resource.send(key) == value
62
+ end
63
+ end
64
+ end
65
+
66
+ # Builds a new, unsaved associated resource instance.
67
+ # Example: project.issues.build(title: 'New')
68
+ # @param attributes [Hash] Attributes for the new resource.
69
+ # @return [BaseResource]
70
+ def build(attributes = {})
71
+ # Automatically add owner context (e.g., project_id)
72
+ owner_key = :"#{@owner.class.name.split('::').last.downcase}_id"
73
+ merged_attrs = attributes.merge(
74
+ owner_key => @owner.id,
75
+ adapter_source: @adapter.class.name.split("::").last.sub("Adapter", "").downcase.to_sym,
76
+ raw_data: attributes
77
+ )
78
+ # Ensure target resource class is correctly determined and used
79
+ @target_resource_class.new(@adapter, merged_attrs)
80
+ end
81
+
82
+ # Creates and saves a new associated resource.
83
+ # Example: project.issues.create(title: 'New', list_id: '...')
84
+ # @param attributes [Hash] Attributes for the new resource.
85
+ # @return [BaseResource]
86
+ # @raise [NotImplementedError] Currently raises because #save is not fully implemented on resource.
87
+ def create(attributes = {})
88
+ create_method = determine_create_method
89
+ context = determine_context # Get owner context
90
+ # Pass owner ID/context first, then attributes
91
+ owner_id = @owner.respond_to?(:id) ? @owner.id : nil
92
+ raise "Owner object #{@owner.inspect} does not have an ID for association call." unless owner_id
93
+ @adapter.send(create_method, owner_id, attributes)
94
+ # Note: This currently returns the result from the adapter directly.
95
+ # A full implementation would likely build and then save, or re-fetch.
96
+ end
97
+
98
+ private
99
+
100
+ # Determines the context hash needed for adapter calls based on the owner.
101
+ def determine_context
102
+ # Basecamp needs project_id for issue/comment operations
103
+ if @adapter.is_a?(Adapters::BasecampAdapter) && (@association_name == :issues || @association_name == :comments)
104
+ { project_id: @owner.id }
105
+ else
106
+ {} # Other adapters might not need explicit context hash for find_issue/find_comment
107
+ end
108
+ end
109
+
110
+ # Determines the correct adapter list method based on association name.
111
+ def determine_list_method
112
+ method_name = :"list_#{@association_name}"
113
+ unless @adapter.respond_to?(method_name)
114
+ raise NotImplementedError, "#{@adapter.class.name} does not implement ##{method_name}"
115
+ end
116
+ method_name
117
+ end
118
+
119
+ # Determines the correct adapter find method based on association name.
120
+ def determine_find_method
121
+ # Assume find method name matches singular association name (issue, comment)
122
+ # Need ActiveSupport::Inflector for singularize, or implement basic logic
123
+ singular_name = @association_name == :issues ? :issue : @association_name.to_s.chomp("s").to_sym
124
+ method_name = :"find_#{singular_name}"
125
+ unless @adapter.respond_to?(method_name)
126
+ raise NotImplementedError, "#{@adapter.class.name} does not implement ##{method_name}"
127
+ end
128
+ method_name
129
+ end
130
+
131
+ # Determines the correct adapter create method based on association name.
132
+ def determine_create_method
133
+ # Assume create method name matches singular association name (issue, comment)
134
+ singular_name = @association_name == :issues ? :issue : @association_name.to_s.chomp("s").to_sym
135
+ method_name = :"create_#{singular_name}"
136
+ unless @adapter.respond_to?(method_name)
137
+ raise NotImplementedError, "#{@adapter.class.name} does not implement ##{method_name}"
138
+ end
139
+ method_name
140
+ end
141
+ end
142
+ end