activeproject 0.0.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +28 -82
  3. data/Rakefile +4 -2
  4. data/lib/active_project/adapters/base.rb +3 -14
  5. data/lib/active_project/adapters/basecamp/comments.rb +27 -0
  6. data/lib/active_project/adapters/basecamp/connection.rb +49 -0
  7. data/lib/active_project/adapters/basecamp/issues.rb +139 -0
  8. data/lib/active_project/adapters/basecamp/lists.rb +54 -0
  9. data/lib/active_project/adapters/basecamp/projects.rb +110 -0
  10. data/lib/active_project/adapters/basecamp/webhooks.rb +73 -0
  11. data/lib/active_project/adapters/basecamp_adapter.rb +46 -449
  12. data/lib/active_project/adapters/jira/comments.rb +28 -0
  13. data/lib/active_project/adapters/jira/connection.rb +47 -0
  14. data/lib/active_project/adapters/jira/issues.rb +132 -0
  15. data/lib/active_project/adapters/jira/projects.rb +100 -0
  16. data/lib/active_project/adapters/jira/transitions.rb +68 -0
  17. data/lib/active_project/adapters/jira/webhooks.rb +89 -0
  18. data/lib/active_project/adapters/jira_adapter.rb +59 -486
  19. data/lib/active_project/adapters/trello/comments.rb +21 -0
  20. data/lib/active_project/adapters/trello/connection.rb +37 -0
  21. data/lib/active_project/adapters/trello/issues.rb +117 -0
  22. data/lib/active_project/adapters/trello/lists.rb +27 -0
  23. data/lib/active_project/adapters/trello/projects.rb +82 -0
  24. data/lib/active_project/adapters/trello/webhooks.rb +91 -0
  25. data/lib/active_project/adapters/trello_adapter.rb +54 -377
  26. data/lib/active_project/association_proxy.rb +10 -3
  27. data/lib/active_project/configuration.rb +23 -17
  28. data/lib/active_project/configurations/trello_configuration.rb +1 -3
  29. data/lib/active_project/resource_factory.rb +20 -10
  30. data/lib/active_project/resources/comment.rb +0 -5
  31. data/lib/active_project/resources/issue.rb +0 -5
  32. data/lib/active_project/resources/project.rb +0 -3
  33. data/lib/active_project/resources/user.rb +0 -1
  34. data/lib/active_project/version.rb +3 -1
  35. data/lib/activeproject.rb +67 -15
  36. metadata +26 -8
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Basecamp
6
+ module Webhooks
7
+ # Parses an incoming Basecamp webhook payload.
8
+ # @param request_body [String] The raw JSON request body.
9
+ # @param headers [Hash] Request headers (unused).
10
+ # @return [ActiveProject::WebhookEvent, nil] Parsed event or nil if unhandled.
11
+ def parse_webhook(request_body, _headers = {})
12
+ payload = begin
13
+ JSON.parse(request_body)
14
+ rescue StandardError
15
+ nil
16
+ end
17
+ return nil unless payload.is_a?(Hash)
18
+
19
+ kind = payload["kind"]
20
+ recording = payload["recording"]
21
+ creator = payload["creator"]
22
+ timestamp = begin
23
+ Time.parse(payload["created_at"])
24
+ rescue StandardError
25
+ nil
26
+ end
27
+ return nil unless recording && kind
28
+
29
+ event_type = nil
30
+ object_kind = nil
31
+ event_object_id = recording["id"]
32
+ object_key = nil
33
+ project_id = recording.dig("bucket", "id")
34
+ changes = nil
35
+ object_data = nil
36
+
37
+ case kind
38
+ when /todo_created$/
39
+ event_type = :issue_created
40
+ object_kind = :issue
41
+ when /todo_assignment_changed$/, /todo_completion_changed$/, /todo_content_updated$/, /todo_description_changed$/, /todo_due_on_changed$/
42
+ event_type = :issue_updated
43
+ object_kind = :issue
44
+ when /comment_created$/
45
+ event_type = :comment_added
46
+ object_kind = :comment
47
+ when /comment_content_changed$/
48
+ event_type = :comment_updated
49
+ object_kind = :comment
50
+ else
51
+ return nil
52
+ end
53
+
54
+ WebhookEvent.new(
55
+ event_type: event_type,
56
+ object_kind: object_kind,
57
+ event_object_id: event_object_id,
58
+ object_key: object_key,
59
+ project_id: project_id,
60
+ actor: map_user_data(creator),
61
+ timestamp: timestamp,
62
+ adapter_source: :basecamp,
63
+ changes: changes,
64
+ object_data: object_data,
65
+ raw_data: payload
66
+ )
67
+ rescue JSON::ParserError
68
+ nil
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -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,364 +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
-
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
39
  # Retrieves details for the currently authenticated user.
413
40
  # @return [ActiveProject::Resources::User] The user object.
414
41
  # @raise [ActiveProject::AuthenticationError] if authentication fails.
@@ -428,38 +55,19 @@ module ActiveProject
428
55
  false
429
56
  end
430
57
 
431
-
432
58
  private
433
59
 
434
60
  # 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
61
 
449
62
  # Helper method for making requests.
450
63
  def make_request(method, path, body = nil, query_params = {})
451
64
  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
65
 
458
66
  response = @connection.run_request(method, full_path, body, nil) do |req|
459
67
  req.params.update(query_params) unless query_params.empty?
460
- # puts "[DEBUG BC Request] Headers: #{req.headers.inspect}"
461
68
  end
462
69
  return nil if response.status == 204 # Handle No Content for POST/DELETE completion
70
+
463
71
  JSON.parse(response.body) if response.body && !response.body.empty?
464
72
  rescue Faraday::Error => e
465
73
  handle_faraday_error(e)
@@ -469,12 +77,12 @@ module ActiveProject
469
77
  def handle_faraday_error(error)
470
78
  status = error.response_status
471
79
  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
80
 
477
- parsed_body = JSON.parse(body) rescue { "error" => body }
81
+ parsed_body = begin
82
+ JSON.parse(body)
83
+ rescue StandardError
84
+ { "error" => body }
85
+ end
478
86
  message = parsed_body["error"] || parsed_body["message"] || "Unknown Basecamp Error"
479
87
 
480
88
  case status
@@ -488,18 +96,22 @@ module ActiveProject
488
96
  msg += ". Retry after #{retry_after} seconds." if retry_after
489
97
  raise RateLimitError, msg
490
98
  when 400, 422
491
- 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)
492
101
  else
493
- 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)
494
104
  end
495
105
  end
496
106
 
497
107
  # Parses the 'next' link URL from the Link header.
498
108
  def parse_next_link(link_header)
499
109
  return nil unless link_header
110
+
500
111
  links = link_header.split(",").map(&:strip)
501
112
  next_link = links.find { |link| link.end_with?('rel="next"') }
502
113
  return nil unless next_link
114
+
503
115
  match = next_link.match(/<([^>]+)>/)
504
116
  match ? match[1] : nil
505
117
  end
@@ -513,21 +125,20 @@ module ActiveProject
513
125
  reporter = map_user_data(todo_data["creator"])
514
126
 
515
127
  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
- )
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)
531
142
  end
532
143
 
533
144
  # Maps raw Basecamp Person data hash to a User resource.
@@ -535,43 +146,29 @@ module ActiveProject
535
146
  # @return [Resources::User, nil]
536
147
  def map_user_data(person_data)
537
148
  return nil unless person_data && person_data["id"]
149
+
538
150
  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
- )
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)
545
156
  end
546
157
 
547
158
  # Helper to map Basecamp comment data to a Comment resource.
548
159
  def map_comment_data(comment_data, todo_id)
549
160
  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
- )
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)
559
169
  end
560
170
 
561
171
  # 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
172
  end
576
173
  end
577
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