activeproject 0.1.0 → 0.2.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +4 -2
  3. data/lib/active_project/adapters/base.rb +9 -7
  4. data/lib/active_project/adapters/basecamp/comments.rb +27 -0
  5. data/lib/active_project/adapters/basecamp/connection.rb +49 -0
  6. data/lib/active_project/adapters/basecamp/issues.rb +158 -0
  7. data/lib/active_project/adapters/basecamp/lists.rb +54 -0
  8. data/lib/active_project/adapters/basecamp/projects.rb +110 -0
  9. data/lib/active_project/adapters/basecamp/webhooks.rb +73 -0
  10. data/lib/active_project/adapters/basecamp_adapter.rb +46 -437
  11. data/lib/active_project/adapters/jira/comments.rb +28 -0
  12. data/lib/active_project/adapters/jira/connection.rb +47 -0
  13. data/lib/active_project/adapters/jira/issues.rb +150 -0
  14. data/lib/active_project/adapters/jira/projects.rb +100 -0
  15. data/lib/active_project/adapters/jira/transitions.rb +68 -0
  16. data/lib/active_project/adapters/jira/webhooks.rb +89 -0
  17. data/lib/active_project/adapters/jira_adapter.rb +61 -485
  18. data/lib/active_project/adapters/trello/comments.rb +21 -0
  19. data/lib/active_project/adapters/trello/connection.rb +37 -0
  20. data/lib/active_project/adapters/trello/issues.rb +133 -0
  21. data/lib/active_project/adapters/trello/lists.rb +27 -0
  22. data/lib/active_project/adapters/trello/projects.rb +82 -0
  23. data/lib/active_project/adapters/trello/webhooks.rb +91 -0
  24. data/lib/active_project/adapters/trello_adapter.rb +59 -377
  25. data/lib/active_project/association_proxy.rb +9 -2
  26. data/lib/active_project/configuration.rb +1 -3
  27. data/lib/active_project/configurations/trello_configuration.rb +1 -3
  28. data/lib/active_project/resource_factory.rb +20 -10
  29. data/lib/active_project/resources/issue.rb +0 -3
  30. data/lib/active_project/resources/project.rb +0 -1
  31. data/lib/active_project/version.rb +3 -1
  32. data/lib/activeproject.rb +2 -2
  33. metadata +19 -1
@@ -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
- # 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
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 = JSON.parse(body) rescue { "error" => 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, response_body: body)
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, status_code: status, response_body: body)
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
- id: todo_data["id"],
505
- key: nil,
506
- title: todo_data["content"],
507
- description: todo_data["description"],
508
- status: status,
509
- assignees: assignees, # Use mapped User resources
510
- reporter: reporter, # Use mapped User resource
511
- project_id: project_id,
512
- created_at: todo_data["created_at"] ? Time.parse(todo_data["created_at"]) : nil,
513
- updated_at: todo_data["updated_at"] ? Time.parse(todo_data["updated_at"]) : nil,
514
- due_on: todo_data["due_on"] ? Date.parse(todo_data["due_on"]) : nil,
515
- priority: nil, # Basecamp doesn't have priority
516
- adapter_source: :basecamp,
517
- raw_data: todo_data
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
- id: person_data["id"],
528
- name: person_data["name"],
529
- email: person_data["email_address"],
530
- adapter_source: :basecamp,
531
- raw_data: person_data
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
- id: comment_data["id"],
539
- body: comment_data["content"], # HTML
540
- author: map_user_data(comment_data["creator"]), # Use user mapping
541
- created_at: comment_data["created_at"] ? Time.parse(comment_data["created_at"]) : nil,
542
- updated_at: comment_data["updated_at"] ? Time.parse(comment_data["updated_at"]) : nil,
543
- issue_id: todo_id.to_i,
544
- adapter_source: :basecamp,
545
- raw_data: comment_data
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