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