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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +304 -0
- data/Rakefile +3 -0
- data/lib/active_project/adapters/base.rb +134 -0
- data/lib/active_project/adapters/basecamp_adapter.rb +577 -0
- data/lib/active_project/adapters/jira_adapter.rb +637 -0
- data/lib/active_project/adapters/trello_adapter.rb +535 -0
- data/lib/active_project/association_proxy.rb +142 -0
- data/lib/active_project/configuration.rb +59 -0
- data/lib/active_project/configurations/base_adapter_configuration.rb +32 -0
- data/lib/active_project/configurations/trello_configuration.rb +31 -0
- data/lib/active_project/errors.rb +40 -0
- data/lib/active_project/resource_factory.rb +130 -0
- data/lib/active_project/resources/base_resource.rb +69 -0
- data/lib/active_project/resources/comment.rb +16 -0
- data/lib/active_project/resources/issue.rb +41 -0
- data/lib/active_project/resources/project.rb +20 -0
- data/lib/active_project/resources/user.rb +13 -0
- data/lib/active_project/version.rb +3 -0
- data/lib/active_project/webhook_event.rb +20 -0
- data/lib/activeproject.rb +61 -0
- metadata +128 -0
@@ -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
|