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,577 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "faraday"
|
4
|
+
require "faraday/retry"
|
5
|
+
require "json"
|
6
|
+
require "time"
|
7
|
+
|
8
|
+
module ActiveProject
|
9
|
+
module Adapters
|
10
|
+
# Adapter for interacting with the Basecamp 3 API.
|
11
|
+
# Implements the interface defined in ActiveProject::Adapters::Base.
|
12
|
+
# API Docs: https://github.com/basecamp/bc3-api
|
13
|
+
class BasecampAdapter < Base
|
14
|
+
BASE_URL_TEMPLATE = "https://3.basecampapi.com/%<account_id>s/"
|
15
|
+
|
16
|
+
attr_reader :config, :base_url
|
17
|
+
|
18
|
+
# Initializes the Basecamp Adapter.
|
19
|
+
# @param config [Configurations::BaseAdapterConfiguration] The configuration object for Basecamp.
|
20
|
+
# @raise [ArgumentError] if required configuration options (:account_id, :access_token) are missing.
|
21
|
+
def initialize(config:)
|
22
|
+
# For now, Basecamp uses the base config. If specific Basecamp options are added,
|
23
|
+
# create BasecampConfiguration and check for that type.
|
24
|
+
unless config.is_a?(ActiveProject::Configurations::BaseAdapterConfiguration)
|
25
|
+
raise ArgumentError, "BasecampAdapter requires a BaseAdapterConfiguration object"
|
26
|
+
end
|
27
|
+
@config = config
|
28
|
+
|
29
|
+
account_id = @config.options[:account_id].to_s # Ensure it's a string
|
30
|
+
access_token = @config.options[:access_token]
|
31
|
+
|
32
|
+
unless account_id && !account_id.empty? && access_token && !access_token.empty?
|
33
|
+
raise ArgumentError, "BasecampAdapter configuration requires :account_id and :access_token"
|
34
|
+
end
|
35
|
+
|
36
|
+
@base_url = format(BASE_URL_TEMPLATE, account_id: account_id)
|
37
|
+
@connection = initialize_connection
|
38
|
+
end
|
39
|
+
|
40
|
+
# --- Resource Factories ---
|
41
|
+
|
42
|
+
# Returns a factory for Project resources.
|
43
|
+
# @return [ResourceFactory<Resources::Project>]
|
44
|
+
def projects
|
45
|
+
ResourceFactory.new(adapter: self, resource_class: Resources::Project)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Returns a factory for Issue resources (To-dos).
|
49
|
+
# @return [ResourceFactory<Resources::Issue>]
|
50
|
+
def issues
|
51
|
+
ResourceFactory.new(adapter: self, resource_class: Resources::Issue)
|
52
|
+
end
|
53
|
+
|
54
|
+
|
55
|
+
# --- Implementation of Base methods ---
|
56
|
+
|
57
|
+
# Lists projects accessible by the configured credentials.
|
58
|
+
# Handles pagination automatically using the Link header.
|
59
|
+
# @return [Array<ActiveProject::Resources::Project>] An array of project resources.
|
60
|
+
def list_projects
|
61
|
+
all_projects = []
|
62
|
+
path = "projects.json"
|
63
|
+
|
64
|
+
loop do
|
65
|
+
# Use connection directly to access headers for Link header parsing
|
66
|
+
response = @connection.get(path)
|
67
|
+
projects_data = JSON.parse(response.body) rescue []
|
68
|
+
break if projects_data.empty?
|
69
|
+
|
70
|
+
projects_data.each do |project_data|
|
71
|
+
all_projects << Resources::Project.new(self, # Pass adapter instance
|
72
|
+
id: project_data["id"],
|
73
|
+
key: nil, # Basecamp doesn't have a short project key like Jira
|
74
|
+
name: project_data["name"],
|
75
|
+
adapter_source: :basecamp,
|
76
|
+
raw_data: project_data
|
77
|
+
)
|
78
|
+
end
|
79
|
+
|
80
|
+
# Handle pagination via Link header
|
81
|
+
link_header = response.headers["Link"]
|
82
|
+
next_url = parse_next_link(link_header)
|
83
|
+
break unless next_url
|
84
|
+
|
85
|
+
# Extract path from the next URL relative to the base URL
|
86
|
+
path = next_url.sub(@base_url, "").sub(%r{^/}, "")
|
87
|
+
end
|
88
|
+
|
89
|
+
all_projects
|
90
|
+
rescue Faraday::Error => e
|
91
|
+
handle_faraday_error(e) # Ensure errors during GET are handled
|
92
|
+
end
|
93
|
+
|
94
|
+
# Finds a specific project by its ID.
|
95
|
+
# @param project_id [String, Integer] The ID of the Basecamp project.
|
96
|
+
# @return [ActiveProject::Resources::Project] The project resource.
|
97
|
+
def find_project(project_id)
|
98
|
+
path = "projects/#{project_id}.json"
|
99
|
+
project_data = make_request(:get, path)
|
100
|
+
|
101
|
+
# Raise NotFoundError if the project is trashed
|
102
|
+
if project_data["status"] == "trashed"
|
103
|
+
raise NotFoundError, "Basecamp project ID #{project_id} is trashed."
|
104
|
+
end
|
105
|
+
|
106
|
+
Resources::Project.new(self, # Pass adapter instance
|
107
|
+
id: project_data["id"],
|
108
|
+
key: nil,
|
109
|
+
name: project_data["name"],
|
110
|
+
adapter_source: :basecamp,
|
111
|
+
raw_data: project_data
|
112
|
+
)
|
113
|
+
# Note: make_request handles raising NotFoundError on 404
|
114
|
+
end
|
115
|
+
|
116
|
+
# Creates a new project in Basecamp.
|
117
|
+
# @param attributes [Hash] Project attributes. Required: :name. Optional: :description.
|
118
|
+
# @return [ActiveProject::Resources::Project] The created project resource.
|
119
|
+
def create_project(attributes)
|
120
|
+
unless attributes[:name] && !attributes[:name].empty?
|
121
|
+
raise ArgumentError, "Missing required attribute for Basecamp project creation: :name"
|
122
|
+
end
|
123
|
+
|
124
|
+
path = "projects.json"
|
125
|
+
payload = {
|
126
|
+
name: attributes[:name],
|
127
|
+
description: attributes[:description]
|
128
|
+
}.compact
|
129
|
+
|
130
|
+
project_data = make_request(:post, path, payload.to_json)
|
131
|
+
|
132
|
+
# Map response to Project resource
|
133
|
+
Resources::Project.new(self, # Pass adapter instance
|
134
|
+
id: project_data["id"],
|
135
|
+
key: nil,
|
136
|
+
name: project_data["name"],
|
137
|
+
adapter_source: :basecamp,
|
138
|
+
raw_data: project_data
|
139
|
+
)
|
140
|
+
end
|
141
|
+
|
142
|
+
# Creates a new Todolist within a project.
|
143
|
+
# @param project_id [String, Integer] The ID of the Basecamp project (bucket).
|
144
|
+
# @param attributes [Hash] Todolist attributes. Required: :name. Optional: :description.
|
145
|
+
# @return [Hash] The raw data hash of the created todolist.
|
146
|
+
def create_list(project_id, attributes)
|
147
|
+
unless attributes[:name] && !attributes[:name].empty?
|
148
|
+
raise ArgumentError, "Missing required attribute for Basecamp todolist creation: :name"
|
149
|
+
end
|
150
|
+
|
151
|
+
# Need to find the 'todoset' ID first
|
152
|
+
project_data = make_request(:get, "projects/#{project_id}.json")
|
153
|
+
todoset_dock_entry = project_data&.dig("dock")&.find { |d| d["name"] == "todoset" }
|
154
|
+
todoset_url = todoset_dock_entry&.dig("url")
|
155
|
+
unless todoset_url
|
156
|
+
raise ApiError, "Could not find todoset URL for project #{project_id}"
|
157
|
+
end
|
158
|
+
todoset_id = todoset_url.match(/todosets\/(\d+)\.json$/)&.captures&.first
|
159
|
+
unless todoset_id
|
160
|
+
raise ApiError, "Could not extract todoset ID from URL: #{todoset_url}"
|
161
|
+
end
|
162
|
+
|
163
|
+
path = "buckets/#{project_id}/todosets/#{todoset_id}/todolists.json"
|
164
|
+
payload = {
|
165
|
+
name: attributes[:name],
|
166
|
+
description: attributes[:description]
|
167
|
+
}.compact
|
168
|
+
|
169
|
+
# POST returns the created todolist object
|
170
|
+
make_request(:post, path, payload.to_json)
|
171
|
+
end
|
172
|
+
|
173
|
+
# Archives (trashes) a project in Basecamp.
|
174
|
+
# Note: Basecamp API doesn't offer permanent deletion via this endpoint.
|
175
|
+
# @param project_id [String, Integer] The ID of the project to trash.
|
176
|
+
# @return [Boolean] true if trashing was successful (API returns 204).
|
177
|
+
# @raise [NotFoundError] if the project is not found.
|
178
|
+
# @raise [AuthenticationError] if credentials lack permission.
|
179
|
+
# @raise [ApiError] for other errors.
|
180
|
+
|
181
|
+
# Recovers a trashed project in Basecamp.
|
182
|
+
# @param project_id [String, Integer] The ID of the project to recover.
|
183
|
+
# @return [Boolean] true if recovery was successful (API returns 204).
|
184
|
+
def untrash_project(project_id)
|
185
|
+
path = "projects/#{project_id}/trash/recover.json"
|
186
|
+
make_request(:put, path)
|
187
|
+
true # Return true if make_request doesn't raise an error
|
188
|
+
end
|
189
|
+
|
190
|
+
def delete_project(project_id)
|
191
|
+
path = "projects/#{project_id}.json"
|
192
|
+
make_request(:delete, path) # PUT returns 204 No Content on success
|
193
|
+
true # Return true if make_request doesn't raise an error
|
194
|
+
end
|
195
|
+
|
196
|
+
|
197
|
+
|
198
|
+
|
199
|
+
# Lists To-dos within a specific project.
|
200
|
+
# @param project_id [String, Integer] The ID of the Basecamp project.
|
201
|
+
# @param options [Hash] Optional options. Accepts :todolist_id.
|
202
|
+
# @return [Array<ActiveProject::Resources::Issue>] An array of issue resources.
|
203
|
+
def list_issues(project_id, options = {})
|
204
|
+
all_todos = []
|
205
|
+
todolist_id = options[:todolist_id]
|
206
|
+
|
207
|
+
unless todolist_id
|
208
|
+
todolist_id = find_first_todolist_id(project_id)
|
209
|
+
return [] unless todolist_id
|
210
|
+
end
|
211
|
+
|
212
|
+
path = "buckets/#{project_id}/todolists/#{todolist_id}/todos.json"
|
213
|
+
|
214
|
+
loop do
|
215
|
+
response = @connection.get(path)
|
216
|
+
todos_data = JSON.parse(response.body) rescue []
|
217
|
+
break if todos_data.empty?
|
218
|
+
|
219
|
+
todos_data.each do |todo_data|
|
220
|
+
all_todos << map_todo_data(todo_data, project_id)
|
221
|
+
end
|
222
|
+
|
223
|
+
link_header = response.headers["Link"]
|
224
|
+
next_url = parse_next_link(link_header)
|
225
|
+
break unless next_url
|
226
|
+
|
227
|
+
path = next_url.sub(@base_url, "").sub(%r{^/}, "")
|
228
|
+
end
|
229
|
+
|
230
|
+
all_todos
|
231
|
+
rescue Faraday::Error => e
|
232
|
+
handle_faraday_error(e)
|
233
|
+
end
|
234
|
+
|
235
|
+
# Finds a specific To-do by its ID.
|
236
|
+
# @param todo_id [String, Integer] The ID of the Basecamp To-do.
|
237
|
+
# @param context [Hash] Required context: { project_id: '...' }.
|
238
|
+
# @return [ActiveProject::Resources::Issue] The issue resource.
|
239
|
+
def find_issue(todo_id, context = {})
|
240
|
+
project_id = context[:project_id]
|
241
|
+
unless project_id
|
242
|
+
raise ArgumentError, "Missing required context: :project_id must be provided for BasecampAdapter#find_issue"
|
243
|
+
end
|
244
|
+
|
245
|
+
path = "buckets/#{project_id}/todos/#{todo_id}.json"
|
246
|
+
todo_data = make_request(:get, path)
|
247
|
+
map_todo_data(todo_data, project_id)
|
248
|
+
end
|
249
|
+
|
250
|
+
# Creates a new To-do in Basecamp.
|
251
|
+
# @param project_id [String, Integer] The ID of the Basecamp project.
|
252
|
+
# @param attributes [Hash] To-do attributes. Required: :todolist_id, :title. Optional: :description, :due_on, :assignee_ids.
|
253
|
+
# @return [ActiveProject::Resources::Issue] The created issue resource.
|
254
|
+
def create_issue(project_id, attributes)
|
255
|
+
todolist_id = attributes[:todolist_id]
|
256
|
+
title = attributes[:title]
|
257
|
+
|
258
|
+
unless todolist_id && title && !title.empty?
|
259
|
+
raise ArgumentError, "Missing required attributes for Basecamp to-do creation: :todolist_id, :title"
|
260
|
+
end
|
261
|
+
|
262
|
+
path = "buckets/#{project_id}/todolists/#{todolist_id}/todos.json"
|
263
|
+
|
264
|
+
payload = {
|
265
|
+
content: title,
|
266
|
+
description: attributes[:description],
|
267
|
+
due_on: attributes[:due_on].respond_to?(:strftime) ? attributes[:due_on].strftime("%Y-%m-%d") : attributes[:due_on],
|
268
|
+
# Basecamp expects an array of numeric IDs for assignees
|
269
|
+
assignee_ids: attributes[:assignee_ids]
|
270
|
+
}.compact
|
271
|
+
|
272
|
+
todo_data = make_request(:post, path, payload.to_json)
|
273
|
+
map_todo_data(todo_data, project_id)
|
274
|
+
end
|
275
|
+
|
276
|
+
# Updates an existing To-do in Basecamp.
|
277
|
+
# Handles updates to standard fields via PUT and status changes via POST/DELETE completion endpoints.
|
278
|
+
# @param todo_id [String, Integer] The ID of the Basecamp To-do.
|
279
|
+
# @param attributes [Hash] Attributes to update (e.g., :title, :description, :status, :assignee_ids, :due_on).
|
280
|
+
# @param context [Hash] Required context: { project_id: '...' }.
|
281
|
+
# @return [ActiveProject::Resources::Issue] The updated issue resource (fetched after updates).
|
282
|
+
def update_issue(todo_id, attributes, context = {})
|
283
|
+
project_id = context[:project_id]
|
284
|
+
unless project_id
|
285
|
+
raise ArgumentError, "Missing required context: :project_id must be provided for BasecampAdapter#update_issue"
|
286
|
+
end
|
287
|
+
|
288
|
+
# Separate attributes for PUT payload and status change
|
289
|
+
put_payload = {}
|
290
|
+
put_payload[:content] = attributes[:title] if attributes.key?(:title)
|
291
|
+
put_payload[:description] = attributes[:description] if attributes.key?(:description)
|
292
|
+
# Format due_on if present
|
293
|
+
if attributes.key?(:due_on)
|
294
|
+
due_on_val = attributes[:due_on]
|
295
|
+
put_payload[:due_on] = due_on_val.respond_to?(:strftime) ? due_on_val.strftime("%Y-%m-%d") : due_on_val
|
296
|
+
end
|
297
|
+
put_payload[:assignee_ids] = attributes[:assignee_ids] if attributes.key?(:assignee_ids)
|
298
|
+
|
299
|
+
status_change_required = attributes.key?(:status)
|
300
|
+
target_status = attributes[:status] if status_change_required
|
301
|
+
|
302
|
+
# Check if any update action is requested
|
303
|
+
unless !put_payload.empty? || status_change_required
|
304
|
+
raise ArgumentError, "No attributes provided to update for BasecampAdapter#update_issue"
|
305
|
+
end
|
306
|
+
|
307
|
+
# 1. Perform PUT request for standard fields if needed
|
308
|
+
if !put_payload.empty?
|
309
|
+
put_path = "buckets/#{project_id}/todos/#{todo_id}.json"
|
310
|
+
# We make the request but ignore the immediate response body,
|
311
|
+
# as it might not reflect the update immediately or consistently.
|
312
|
+
make_request(:put, put_path, put_payload.compact.to_json)
|
313
|
+
end
|
314
|
+
|
315
|
+
# 2. Perform status change via completion endpoints if needed
|
316
|
+
if status_change_required
|
317
|
+
completion_path = "buckets/#{project_id}/todos/#{todo_id}/completion.json"
|
318
|
+
begin
|
319
|
+
if target_status == :closed
|
320
|
+
# POST to complete - returns 204 No Content on success
|
321
|
+
make_request(:post, completion_path)
|
322
|
+
elsif target_status == :open
|
323
|
+
# DELETE to reopen - returns 204 No Content on success
|
324
|
+
make_request(:delete, completion_path)
|
325
|
+
# else: Ignore invalid status symbols for now
|
326
|
+
end
|
327
|
+
rescue NotFoundError
|
328
|
+
# Ignore 404 on DELETE if trying to reopen an already open todo
|
329
|
+
raise unless target_status == :open
|
330
|
+
end
|
331
|
+
end
|
332
|
+
|
333
|
+
# 3. Always fetch the final state after all updates are performed
|
334
|
+
find_issue(todo_id, context)
|
335
|
+
end
|
336
|
+
|
337
|
+
# Adds a comment to a To-do in Basecamp.
|
338
|
+
# @param todo_id [String, Integer] The ID of the Basecamp To-do.
|
339
|
+
# @param comment_body [String] The comment text (HTML).
|
340
|
+
# @param context [Hash] Required context: { project_id: '...' }.
|
341
|
+
# @return [ActiveProject::Resources::Comment] The created comment resource.
|
342
|
+
def add_comment(todo_id, comment_body, context = {})
|
343
|
+
project_id = context[:project_id]
|
344
|
+
unless project_id
|
345
|
+
raise ArgumentError, "Missing required context: :project_id must be provided for BasecampAdapter#add_comment"
|
346
|
+
end
|
347
|
+
|
348
|
+
path = "buckets/#{project_id}/recordings/#{todo_id}/comments.json"
|
349
|
+
payload = { content: comment_body }.to_json
|
350
|
+
comment_data = make_request(:post, path, payload)
|
351
|
+
map_comment_data(comment_data, todo_id.to_i)
|
352
|
+
end
|
353
|
+
|
354
|
+
# Parses an incoming Basecamp webhook payload.
|
355
|
+
# @param request_body [String] The raw JSON request body.
|
356
|
+
# @param headers [Hash] Request headers (unused).
|
357
|
+
# @return [ActiveProject::WebhookEvent, nil] Parsed event or nil if unhandled.
|
358
|
+
def parse_webhook(request_body, headers = {})
|
359
|
+
payload = JSON.parse(request_body) rescue nil
|
360
|
+
return nil unless payload.is_a?(Hash)
|
361
|
+
|
362
|
+
kind = payload["kind"]
|
363
|
+
recording = payload["recording"]
|
364
|
+
creator = payload["creator"]
|
365
|
+
timestamp = Time.parse(payload["created_at"]) rescue nil
|
366
|
+
return nil unless recording && kind
|
367
|
+
|
368
|
+
event_type = nil
|
369
|
+
object_kind = nil
|
370
|
+
event_object_id = recording["id"]
|
371
|
+
object_key = nil
|
372
|
+
project_id = recording.dig("bucket", "id")
|
373
|
+
changes = nil
|
374
|
+
object_data = nil
|
375
|
+
|
376
|
+
case kind
|
377
|
+
when /todo_created$/
|
378
|
+
event_type = :issue_created
|
379
|
+
object_kind = :issue
|
380
|
+
when /todo_assignment_changed$/, /todo_completion_changed$/, /todo_content_updated$/, /todo_description_changed$/, /todo_due_on_changed$/
|
381
|
+
event_type = :issue_updated
|
382
|
+
object_kind = :issue
|
383
|
+
# Changes could be parsed from payload['details'] if needed
|
384
|
+
when /comment_created$/
|
385
|
+
event_type = :comment_added
|
386
|
+
object_kind = :comment
|
387
|
+
when /comment_content_changed$/
|
388
|
+
event_type = :comment_updated
|
389
|
+
object_kind = :comment
|
390
|
+
else
|
391
|
+
return nil # Unhandled kind
|
392
|
+
end
|
393
|
+
|
394
|
+
WebhookEvent.new(
|
395
|
+
event_type: event_type,
|
396
|
+
object_kind: object_kind,
|
397
|
+
event_object_id: event_object_id,
|
398
|
+
object_key: object_key,
|
399
|
+
project_id: project_id,
|
400
|
+
actor: map_user_data(creator),
|
401
|
+
timestamp: timestamp,
|
402
|
+
adapter_source: :basecamp,
|
403
|
+
changes: changes,
|
404
|
+
object_data: object_data, # Keep nil for now
|
405
|
+
raw_data: payload
|
406
|
+
)
|
407
|
+
rescue JSON::ParserError
|
408
|
+
nil # Ignore unparseable payloads
|
409
|
+
end
|
410
|
+
|
411
|
+
|
412
|
+
# Retrieves details for the currently authenticated user.
|
413
|
+
# @return [ActiveProject::Resources::User] The user object.
|
414
|
+
# @raise [ActiveProject::AuthenticationError] if authentication fails.
|
415
|
+
# @raise [ActiveProject::ApiError] for other API-related errors.
|
416
|
+
def get_current_user
|
417
|
+
user_data = make_request(:get, "my/profile.json")
|
418
|
+
map_user_data(user_data)
|
419
|
+
end
|
420
|
+
|
421
|
+
# Checks if the adapter can successfully authenticate and connect to the service.
|
422
|
+
# Calls #get_current_user internally and catches authentication errors.
|
423
|
+
# @return [Boolean] true if connection is successful, false otherwise.
|
424
|
+
def connected?
|
425
|
+
get_current_user
|
426
|
+
true
|
427
|
+
rescue ActiveProject::AuthenticationError
|
428
|
+
false
|
429
|
+
end
|
430
|
+
|
431
|
+
|
432
|
+
private
|
433
|
+
|
434
|
+
# Initializes the Faraday connection object.
|
435
|
+
def initialize_connection
|
436
|
+
# Read connection details from the config object
|
437
|
+
access_token = @config.options[:access_token]
|
438
|
+
|
439
|
+
Faraday.new(url: @base_url) do |conn|
|
440
|
+
conn.request :authorization, :bearer, access_token
|
441
|
+
conn.request :retry
|
442
|
+
conn.response :raise_error
|
443
|
+
conn.headers["Content-Type"] = "application/json"
|
444
|
+
conn.headers["Accept"] = "application/json"
|
445
|
+
conn.headers["User-Agent"] = ActiveProject.user_agent
|
446
|
+
end
|
447
|
+
end
|
448
|
+
|
449
|
+
# Helper method for making requests.
|
450
|
+
def make_request(method, path, body = nil, query_params = {})
|
451
|
+
full_path = path.start_with?("/") ? path[1..] : path
|
452
|
+
# Removed debug puts for cleaner output
|
453
|
+
# puts "[DEBUG BC Request] Method: #{method.upcase}"
|
454
|
+
# puts "[DEBUG BC Request] Path: #{full_path}"
|
455
|
+
# puts "[DEBUG BC Request] Body: #{body.inspect}"
|
456
|
+
# puts "[DEBUG BC Request] Query Params: #{query_params.inspect}"
|
457
|
+
|
458
|
+
response = @connection.run_request(method, full_path, body, nil) do |req|
|
459
|
+
req.params.update(query_params) unless query_params.empty?
|
460
|
+
# puts "[DEBUG BC Request] Headers: #{req.headers.inspect}"
|
461
|
+
end
|
462
|
+
return nil if response.status == 204 # Handle No Content for POST/DELETE completion
|
463
|
+
JSON.parse(response.body) if response.body && !response.body.empty?
|
464
|
+
rescue Faraday::Error => e
|
465
|
+
handle_faraday_error(e)
|
466
|
+
end
|
467
|
+
|
468
|
+
# Handles Faraday errors.
|
469
|
+
def handle_faraday_error(error)
|
470
|
+
status = error.response_status
|
471
|
+
body = error.response_body
|
472
|
+
# Removed debug puts for cleaner output
|
473
|
+
# puts "[DEBUG BC Response] Status: #{error.response_status}"
|
474
|
+
# puts "[DEBUG BC Response] Headers: #{error.response_headers.inspect}"
|
475
|
+
# puts "[DEBUG BC Response] Body: #{error.response_body.inspect}"
|
476
|
+
|
477
|
+
parsed_body = JSON.parse(body) rescue { "error" => body }
|
478
|
+
message = parsed_body["error"] || parsed_body["message"] || "Unknown Basecamp Error"
|
479
|
+
|
480
|
+
case status
|
481
|
+
when 401, 403
|
482
|
+
raise AuthenticationError, "Basecamp authentication/authorization failed (Status: #{status}): #{message}"
|
483
|
+
when 404
|
484
|
+
raise NotFoundError, "Basecamp resource not found (Status: 404): #{message}"
|
485
|
+
when 429
|
486
|
+
retry_after = error.response_headers["Retry-After"]
|
487
|
+
msg = "Basecamp rate limit exceeded (Status: 429)"
|
488
|
+
msg += ". Retry after #{retry_after} seconds." if retry_after
|
489
|
+
raise RateLimitError, msg
|
490
|
+
when 400, 422
|
491
|
+
raise ValidationError.new("Basecamp validation failed (Status: #{status}): #{message}", status_code: status, response_body: body)
|
492
|
+
else
|
493
|
+
raise ApiError.new("Basecamp API error (Status: #{status || 'N/A'}): #{message}", original_error: error, status_code: status, response_body: body)
|
494
|
+
end
|
495
|
+
end
|
496
|
+
|
497
|
+
# Parses the 'next' link URL from the Link header.
|
498
|
+
def parse_next_link(link_header)
|
499
|
+
return nil unless link_header
|
500
|
+
links = link_header.split(",").map(&:strip)
|
501
|
+
next_link = links.find { |link| link.end_with?('rel="next"') }
|
502
|
+
return nil unless next_link
|
503
|
+
match = next_link.match(/<([^>]+)>/)
|
504
|
+
match ? match[1] : nil
|
505
|
+
end
|
506
|
+
|
507
|
+
# Maps raw Basecamp To-do data hash to an Issue resource.
|
508
|
+
def map_todo_data(todo_data, project_id)
|
509
|
+
status = todo_data["completed"] ? :closed : :open
|
510
|
+
# Map assignees using map_user_data
|
511
|
+
assignees = (todo_data["assignees"] || []).map { |a| map_user_data(a) }.compact
|
512
|
+
# Map reporter using map_user_data
|
513
|
+
reporter = map_user_data(todo_data["creator"])
|
514
|
+
|
515
|
+
Resources::Issue.new(self, # Pass adapter instance
|
516
|
+
id: todo_data["id"],
|
517
|
+
key: nil,
|
518
|
+
title: todo_data["content"],
|
519
|
+
description: todo_data["description"],
|
520
|
+
status: status,
|
521
|
+
assignees: assignees, # Use mapped User resources
|
522
|
+
reporter: reporter, # Use mapped User resource
|
523
|
+
project_id: project_id.to_i,
|
524
|
+
created_at: todo_data["created_at"] ? Time.parse(todo_data["created_at"]) : nil,
|
525
|
+
updated_at: todo_data["updated_at"] ? Time.parse(todo_data["updated_at"]) : nil,
|
526
|
+
due_on: todo_data["due_on"] ? Date.parse(todo_data["due_on"]) : nil,
|
527
|
+
priority: nil, # Basecamp doesn't have priority
|
528
|
+
adapter_source: :basecamp,
|
529
|
+
raw_data: todo_data
|
530
|
+
)
|
531
|
+
end
|
532
|
+
|
533
|
+
# Maps raw Basecamp Person data hash to a User resource.
|
534
|
+
# @param person_data [Hash, nil] Raw person data from Basecamp API.
|
535
|
+
# @return [Resources::User, nil]
|
536
|
+
def map_user_data(person_data)
|
537
|
+
return nil unless person_data && person_data["id"]
|
538
|
+
Resources::User.new(self, # Pass adapter instance
|
539
|
+
id: person_data["id"],
|
540
|
+
name: person_data["name"],
|
541
|
+
email: person_data["email_address"],
|
542
|
+
adapter_source: :basecamp,
|
543
|
+
raw_data: person_data
|
544
|
+
)
|
545
|
+
end
|
546
|
+
|
547
|
+
# Helper to map Basecamp comment data to a Comment resource.
|
548
|
+
def map_comment_data(comment_data, todo_id)
|
549
|
+
Resources::Comment.new(self, # Pass adapter instance
|
550
|
+
id: comment_data["id"],
|
551
|
+
body: comment_data["content"], # HTML
|
552
|
+
author: map_user_data(comment_data["creator"]), # Use user mapping
|
553
|
+
created_at: comment_data["created_at"] ? Time.parse(comment_data["created_at"]) : nil,
|
554
|
+
updated_at: comment_data["updated_at"] ? Time.parse(comment_data["updated_at"]) : nil,
|
555
|
+
issue_id: todo_id.to_i,
|
556
|
+
adapter_source: :basecamp,
|
557
|
+
raw_data: comment_data
|
558
|
+
)
|
559
|
+
end
|
560
|
+
|
561
|
+
# Finds the ID of the first todolist in a project.
|
562
|
+
def find_first_todolist_id(project_id)
|
563
|
+
project_data = make_request(:get, "projects/#{project_id}.json")
|
564
|
+
todoset_dock_entry = project_data&.dig("dock")&.find { |d| d["name"] == "todoset" }
|
565
|
+
todoset_url = todoset_dock_entry&.dig("url")
|
566
|
+
return nil unless todoset_url
|
567
|
+
todoset_id = todoset_url.match(/todosets\/(\d+)\.json$/)&.captures&.first
|
568
|
+
return nil unless todoset_id
|
569
|
+
todolists_url_path = "buckets/#{project_id}/todosets/#{todoset_id}/todolists.json"
|
570
|
+
todolists_data = make_request(:get, todolists_url_path)
|
571
|
+
todolists_data&.first&.dig("id")
|
572
|
+
rescue NotFoundError
|
573
|
+
nil
|
574
|
+
end
|
575
|
+
end
|
576
|
+
end
|
577
|
+
end
|