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
@@ -12,26 +12,12 @@ module ActiveProject
12
12
  class JiraAdapter < Base
13
13
  attr_reader :config # Store the config object
14
14
 
15
- # Initializes the Jira Adapter.
16
- # @param config [Configurations::BaseAdapterConfiguration] The configuration object for Jira.
17
- # @raise [ArgumentError] if required configuration options (:site_url, :username, :api_token) are missing.
18
- def initialize(config:)
19
- # For now, Jira uses the base config. If specific Jira options are added,
20
- # create JiraConfiguration and check for that type.
21
- unless config.is_a?(ActiveProject::Configurations::BaseAdapterConfiguration)
22
- raise ArgumentError, "JiraAdapter requires a BaseAdapterConfiguration object"
23
- end
24
- @config = config
25
-
26
- # Validate presence of required config options within the config object
27
- unless @config.options[:site_url] && !@config.options[:site_url].empty? &&
28
- @config.options[:username] && !@config.options[:username].empty? &&
29
- @config.options[:api_token] && !@config.options[:api_token].empty?
30
- raise ArgumentError, "JiraAdapter configuration requires :site_url, :username, and :api_token"
31
- end
32
-
33
- @connection = initialize_connection
34
- end
15
+ include Jira::Connection
16
+ include Jira::Projects
17
+ include Jira::Issues
18
+ include Jira::Comments
19
+ include Jira::Transitions
20
+ include Jira::Webhooks
35
21
 
36
22
  # --- Resource Factories ---
37
23
 
@@ -47,414 +33,6 @@ module ActiveProject
47
33
  ResourceFactory.new(adapter: self, resource_class: Resources::Issue)
48
34
  end
49
35
 
50
-
51
- # --- Implementation of Base methods ---
52
-
53
- # Lists projects accessible by the configured credentials using the V3 endpoint.
54
- # Handles pagination automatically.
55
- # @return [Array<ActiveProject::Resources::Project>] An array of project resources.
56
- def list_projects
57
- start_at = 0
58
- max_results = 50 # Jira default is 50
59
- all_projects = []
60
-
61
- loop do
62
- path = "/rest/api/3/project/search?startAt=#{start_at}&maxResults=#{max_results}"
63
- # make_request now handles the auth check internally
64
- response_data = make_request(:get, path)
65
-
66
- projects_data = response_data["values"] || []
67
- break if projects_data.empty?
68
-
69
- projects_data.each do |project_data|
70
- all_projects << Resources::Project.new(self, # Pass adapter instance
71
- id: project_data["id"], # Convert to integer
72
- key: project_data["key"],
73
- name: project_data["name"],
74
- adapter_source: :jira,
75
- raw_data: project_data
76
- )
77
- end
78
-
79
- # Check if this is the last page
80
- is_last = response_data["isLast"]
81
- break if is_last || projects_data.size < max_results # Exit if last page or less than max results returned
82
-
83
- start_at += projects_data.size
84
- end
85
-
86
- all_projects
87
- end
88
-
89
- # Finds a specific project by its ID or key.
90
- # @param id_or_key [String, Integer] The ID or key of the project.
91
- # @return [ActiveProject::Resources::Project] The project resource.
92
- def find_project(id_or_key)
93
- path = "/rest/api/3/project/#{id_or_key}"
94
- project_data = make_request(:get, path)
95
-
96
- Resources::Project.new(self, # Pass adapter instance
97
- id: project_data["id"].to_i, # Convert to integer
98
- key: project_data["key"],
99
- name: project_data["name"],
100
- adapter_source: :jira,
101
- raw_data: project_data
102
- )
103
- end
104
-
105
- # Creates a new project in Jira.
106
- # @param attributes [Hash] Project attributes. Required: :key, :name, :project_type_key, :lead_account_id. Optional: :description, :assignee_type.
107
- # @return [ActiveProject::Resources::Project] The created project resource.
108
- def create_project(attributes)
109
- # Validate required attributes
110
- required_keys = [ :key, :name, :project_type_key, :lead_account_id ]
111
- missing_keys = required_keys.reject { |k| attributes.key?(k) && !attributes[k].to_s.empty? }
112
- unless missing_keys.empty?
113
- raise ArgumentError, "Missing required attributes for Jira project creation: #{missing_keys.join(', ')}"
114
- end
115
-
116
- path = "/rest/api/3/project"
117
- payload = {
118
- key: attributes[:key],
119
- name: attributes[:name],
120
- projectTypeKey: attributes[:project_type_key],
121
- leadAccountId: attributes[:lead_account_id],
122
- description: attributes[:description],
123
- assigneeType: attributes[:assignee_type]
124
- }.compact # Use compact to remove optional nil values
125
-
126
- project_data = make_request(:post, path, payload.to_json)
127
-
128
- # Map response to Project resource
129
- Resources::Project.new(self, # Pass adapter instance
130
- id: project_data["id"]&.to_i, # Convert to integer
131
- key: project_data["key"],
132
- name: project_data["name"], # Name might not be in create response, fetch if needed?
133
- adapter_source: :jira,
134
- raw_data: project_data
135
- )
136
- end
137
-
138
-
139
- # Deletes a project in Jira.
140
- # WARNING: This is a permanent deletion and requires admin permissions.
141
- # @param project_id_or_key [String, Integer] The ID or key of the project to delete.
142
- # @return [Boolean] true if deletion was successful (API returns 204).
143
- # @raise [NotFoundError] if the project is not found.
144
- # @raise [AuthenticationError] if credentials lack permission.
145
- # @raise [ApiError] for other errors.
146
- def delete_project(project_id_or_key)
147
- path = "/rest/api/3/project/#{project_id_or_key}"
148
- make_request(:delete, path) # DELETE returns 204 No Content on success
149
- true # Return true if make_request doesn't raise an error
150
- end
151
-
152
-
153
- # Note: create_list is not implemented for Jira as statuses and workflows
154
- # are typically managed via the Jira UI or more complex API interactions,
155
- # not simple list creation. The base class raises NotImplementedError.
156
-
157
-
158
- # Lists issues within a specific project, optionally filtered by JQL.
159
- # @param project_id_or_key [String, Integer] The ID or key of the project.
160
- # @param options [Hash] Optional filtering/pagination options.
161
- # @return [Array<ActiveProject::Resources::Issue>] An array of issue resources.
162
- def list_issues(project_id_or_key, options = {})
163
- start_at = options.fetch(:start_at, 0)
164
- max_results = options.fetch(:max_results, 50)
165
- jql = options.fetch(:jql, "project = '#{project_id_or_key}' ORDER BY created DESC")
166
-
167
- all_issues = []
168
- path = "/rest/api/3/search" # Using V3 search for issues
169
-
170
- payload = {
171
- jql: jql,
172
- startAt: start_at,
173
- maxResults: max_results,
174
- # Request specific fields for efficiency
175
- fields: [ "summary", "description", "status", "assignee", "reporter", "created", "updated", "project", "issuetype", "duedate", "priority" ]
176
- }.to_json
177
-
178
- response_data = make_request(:post, path, payload)
179
-
180
- issues_data = response_data["issues"] || []
181
- issues_data.each do |issue_data|
182
- all_issues << map_issue_data(issue_data)
183
- end
184
-
185
- all_issues
186
- end
187
-
188
- # Finds a specific issue by its ID or key using the V3 endpoint.
189
- # @param id_or_key [String, Integer] The ID or key of the issue.
190
- # @param context [Hash] Optional context (ignored).
191
- # @return [ActiveProject::Resources::Issue] The issue resource.
192
- def find_issue(id_or_key, context = {})
193
- fields = "summary,description,status,assignee,reporter,created,updated,project,issuetype,duedate,priority"
194
- path = "/rest/api/3/issue/#{id_or_key}?fields=#{fields}" # Using V3
195
-
196
- issue_data = make_request(:get, path)
197
- map_issue_data(issue_data)
198
- end
199
-
200
- # Creates a new issue in Jira using the V3 endpoint.
201
- # @param _project_id_or_key [String, Integer] Ignored (project info is in attributes).
202
- # @param attributes [Hash] Issue attributes. Required: :project, :summary, :issue_type. Optional: :description, :assignee_id, :due_on, :priority.
203
- # @return [ActiveProject::Resources::Issue] The created issue resource.
204
- def create_issue(_project_id_or_key, attributes)
205
- path = "/rest/api/3/issue" # Using V3
206
-
207
- unless attributes[:project] && (attributes[:project][:id] || attributes[:project][:key]) &&
208
- attributes[:summary] && !attributes[:summary].empty? &&
209
- attributes[:issue_type] && (attributes[:issue_type][:id] || attributes[:issue_type][:name])
210
- raise ArgumentError, "Missing required attributes for issue creation: :project (with id/key), :summary, :issue_type (with id/name)"
211
- end
212
-
213
- fields_payload = {
214
- project: attributes[:project],
215
- summary: attributes[:summary],
216
- issuetype: attributes[:issue_type]
217
- }
218
-
219
- # Handle description conversion to ADF
220
- if attributes.key?(:description)
221
- fields_payload[:description] = if attributes[:description].is_a?(String)
222
- { type: "doc", version: 1, content: [ { type: "paragraph", content: [ { type: "text", text: attributes[:description] } ] } ] }
223
- elsif attributes[:description].is_a?(Hash)
224
- attributes[:description] # Assume pre-formatted ADF
225
- end # nil description is handled by Jira if key is absent
226
- end
227
-
228
- # Map assignee if provided (expects accountId)
229
- if attributes.key?(:assignee_id)
230
- fields_payload[:assignee] = { accountId: attributes[:assignee_id] }
231
- end
232
- # Map due date if provided
233
- if attributes.key?(:due_on)
234
- fields_payload[:duedate] = attributes[:due_on].respond_to?(:strftime) ? attributes[:due_on].strftime("%Y-%m-%d") : attributes[:due_on]
235
- end
236
- # Map priority if provided
237
- if attributes.key?(:priority)
238
- fields_payload[:priority] = attributes[:priority] # Expects { name: 'High' } or { id: '...' }
239
- end
240
- # TODO: Map other common attributes (:labels) to fields_payload
241
-
242
- payload = { fields: fields_payload }.to_json
243
- response_data = make_request(:post, path, payload)
244
-
245
- # Fetch the full issue after creation to return consistent data
246
- find_issue(response_data["key"])
247
- end
248
-
249
- # Updates an existing issue in Jira using the V3 endpoint.
250
- # @param id_or_key [String, Integer] The ID or key of the issue to update.
251
- # @param attributes [Hash] Issue attributes to update (e.g., :summary, :description, :assignee_id, :due_on, :priority).
252
- # @param context [Hash] Optional context (ignored).
253
- # @return [ActiveProject::Resources::Issue] The updated issue resource.
254
- def update_issue(id_or_key, attributes, context = {})
255
- path = "/rest/api/3/issue/#{id_or_key}" # Using V3
256
-
257
- update_fields = {}
258
- update_fields[:summary] = attributes[:summary] if attributes.key?(:summary)
259
-
260
- if attributes.key?(:description)
261
- update_fields[:description] = if attributes[:description].is_a?(String)
262
- { type: "doc", version: 1, content: [ { type: "paragraph", content: [ { type: "text", text: attributes[:description] } ] } ] }
263
- elsif attributes[:description].is_a?(Hash)
264
- attributes[:description] # Assume pre-formatted ADF
265
- else # Allow clearing description by passing nil explicitly
266
- nil
267
- end
268
- end
269
-
270
- # Map assignee if provided (expects accountId)
271
- if attributes.key?(:assignee_id)
272
- # Allow passing nil to unassign
273
- update_fields[:assignee] = attributes[:assignee_id] ? { accountId: attributes[:assignee_id] } : nil
274
- end
275
- # Map due date if provided
276
- if attributes.key?(:due_on)
277
- update_fields[:duedate] = attributes[:due_on].respond_to?(:strftime) ? attributes[:due_on].strftime("%Y-%m-%d") : attributes[:due_on]
278
- end
279
- # Map priority if provided
280
- if attributes.key?(:priority)
281
- update_fields[:priority] = attributes[:priority] # Expects { name: 'High' } or { id: '...' }
282
- end
283
- # TODO: Map other common attributes for update
284
-
285
- return find_issue(id_or_key) if update_fields.empty? # No fields to update
286
-
287
- payload = { fields: update_fields }.to_json
288
- make_request(:put, path, payload) # PUT returns 204 No Content on success
289
-
290
- # Fetch the updated issue to return consistent data
291
- find_issue(id_or_key)
292
- end
293
-
294
- # Adds a comment to an issue in Jira using the V3 endpoint.
295
- # @param issue_id_or_key [String, Integer] The ID or key of the issue.
296
- # @param comment_body [String] The text of the comment.
297
- # @param context [Hash] Optional context (ignored).
298
- # @return [ActiveProject::Resources::Comment] The created comment resource.
299
- def add_comment(issue_id_or_key, comment_body, context = {})
300
- path = "/rest/api/3/issue/#{issue_id_or_key}/comment" # Using V3
301
-
302
- # Construct basic ADF payload for the comment body
303
- payload = {
304
- body: {
305
- type: "doc", version: 1,
306
- content: [ { type: "paragraph", content: [ { type: "text", text: comment_body } ] } ]
307
- }
308
- }.to_json
309
-
310
- comment_data = make_request(:post, path, payload)
311
- map_comment_data(comment_data, issue_id_or_key)
312
- end
313
-
314
- # Transitions a Jira issue to a new status by finding and executing the appropriate workflow transition.
315
- # @param issue_id_or_key [String, Integer] The ID or key of the issue.
316
- # @param target_status_name_or_id [String, Integer] The name or ID of the target status.
317
- # @param options [Hash] Optional parameters for the transition (e.g., :resolution, :comment).
318
- # - :resolution [Hash] e.g., `{ name: 'Done' }`
319
- # - :comment [String] Comment body to add during transition.
320
- # @return [Boolean] true if successful.
321
- # @raise [NotFoundError] if the issue or target transition is not found.
322
- # @raise [ApiError] for other API errors.
323
- def transition_issue(issue_id_or_key, target_status_name_or_id, options = {})
324
- # 1. Get available transitions
325
- transitions_path = "/rest/api/3/issue/#{issue_id_or_key}/transitions"
326
- begin
327
- response_data = make_request(:get, transitions_path)
328
- rescue NotFoundError
329
- # Re-raise with a more specific message if the issue itself wasn't found
330
- raise NotFoundError, "Jira issue '#{issue_id_or_key}' not found."
331
- end
332
- available_transitions = response_data["transitions"] || []
333
-
334
- # 2. Find the target transition by name or ID (case-insensitive for name)
335
- target_transition = available_transitions.find do |t|
336
- t["id"] == target_status_name_or_id.to_s ||
337
- t.dig("to", "name")&.casecmp?(target_status_name_or_id.to_s) ||
338
- t.dig("to", "id") == target_status_name_or_id.to_s
339
- end
340
-
341
- unless target_transition
342
- available_names = available_transitions.map { |t| t.dig("to", "name") }.compact.join(", ")
343
- raise NotFoundError, "Target transition '#{target_status_name_or_id}' not found or not available for issue '#{issue_id_or_key}'. Available transitions: [#{available_names}]"
344
- end
345
-
346
- # 3. Construct payload for executing the transition
347
- payload = {
348
- transition: { id: target_transition["id"] }
349
- }
350
-
351
- # Add optional fields like resolution
352
- if options[:resolution]
353
- payload[:fields] ||= {}
354
- payload[:fields][:resolution] = options[:resolution]
355
- end
356
-
357
- # Add optional comment
358
- if options[:comment] && !options[:comment].empty?
359
- payload[:update] ||= {}
360
- payload[:update][:comment] ||= []
361
- payload[:update][:comment] << {
362
- add: {
363
- body: {
364
- type: "doc", version: 1,
365
- content: [ { type: "paragraph", content: [ { type: "text", text: options[:comment] } ] } ]
366
- }
367
- }
368
- }
369
- end
370
-
371
- # 4. Execute the transition
372
- make_request(:post, transitions_path, payload.to_json)
373
- true # POST returns 204 No Content on success, make_request doesn't raise error
374
- rescue Faraday::Error => e
375
- # Let handle_faraday_error raise the appropriate specific error
376
- handle_faraday_error(e)
377
- # We shouldn't reach here if handle_faraday_error raises, but as a fallback:
378
- raise ApiError.new("Failed to transition Jira issue '#{issue_id_or_key}'", original_error: e)
379
- end
380
-
381
- # Parses an incoming Jira webhook payload.
382
- # @param request_body [String] The raw JSON request body.
383
- # @param headers [Hash] Request headers.
384
- # @return [ActiveProject::WebhookEvent, nil] Parsed event or nil if unhandled.
385
- def parse_webhook(request_body, headers = {})
386
- payload = JSON.parse(request_body) rescue nil
387
- return nil unless payload.is_a?(Hash)
388
-
389
- event_name = payload["webhookEvent"]
390
- timestamp = payload["timestamp"] ? Time.at(payload["timestamp"] / 1000) : nil
391
- # Determine actor based on event type
392
- actor_data = if event_name.start_with?("comment_")
393
- payload.dig("comment", "author")
394
- else
395
- payload["user"] # User for issue events
396
- end
397
- issue_data = payload["issue"]
398
- comment_data = payload["comment"]
399
- changelog = payload["changelog"] # For issue_updated
400
-
401
- event_type = nil
402
- object_kind = nil
403
- object_id = nil
404
- object_key = nil
405
- project_id = nil
406
- changes = nil
407
- object_data = nil
408
-
409
- case event_name
410
- when "jira:issue_created"
411
- event_type = :issue_created
412
- object_kind = :issue
413
- event_object_id = issue_data["id"]
414
- object_key = issue_data["key"]
415
- project_id = issue_data.dig("fields", "project", "id")&.to_i
416
- when "jira:issue_updated"
417
- event_type = :issue_updated
418
- object_kind = :issue
419
- event_object_id = issue_data["id"]
420
- object_key = issue_data["key"]
421
- project_id = issue_data.dig("fields", "project", "id")&.to_i
422
- changes = parse_changelog(changelog)
423
- when "comment_created"
424
- event_type = :comment_added
425
- object_kind = :comment
426
- event_object_id = comment_data["id"]
427
- object_key = nil
428
- project_id = issue_data.dig("fields", "project", "id")&.to_i
429
- when "comment_updated"
430
- event_type = :comment_updated
431
- object_kind = :comment
432
- event_object_id = comment_data["id"]
433
- object_key = nil
434
- project_id = issue_data.dig("fields", "project", "id")&.to_i
435
- else
436
- return nil # Unhandled event type
437
- end
438
-
439
- WebhookEvent.new(
440
- event_type: event_type,
441
- object_kind: object_kind,
442
- event_object_id: event_object_id,
443
- object_key: object_key,
444
- project_id: project_id,
445
- actor: map_user_data(actor_data),
446
- timestamp: timestamp,
447
- adapter_source: :jira,
448
- changes: changes,
449
- object_data: object_data, # Keep nil for now
450
- raw_data: payload
451
- )
452
- rescue JSON::ParserError
453
- nil # Ignore unparseable payloads
454
- end
455
-
456
-
457
-
458
36
  # Retrieves details for the currently authenticated user.
459
37
  # @return [ActiveProject::Resources::User] The user object.
460
38
  # @raise [ActiveProject::AuthenticationError] if authentication fails.
@@ -474,30 +52,15 @@ module ActiveProject
474
52
  false
475
53
  end
476
54
 
477
-
478
55
  private
479
56
 
480
57
  # Initializes the Faraday connection object.
481
- def initialize_connection
482
- # Read connection details from the config object
483
- site_url = @config.options[:site_url].chomp("/")
484
- username = @config.options[:username]
485
- api_token = @config.options[:api_token]
486
-
487
- Faraday.new(url: site_url) do |conn|
488
- conn.request :authorization, :basic, username, api_token
489
- conn.request :retry
490
- # Important: Keep raise_error middleware *after* retry
491
- # conn.response :raise_error # Defer raising error to handle_faraday_error
492
- conn.headers["Content-Type"] = "application/json"
493
- conn.headers["Accept"] = "application/json"
494
- conn.headers["User-Agent"] = ActiveProject.user_agent
495
- end
496
- end
497
58
 
498
59
  # Makes an HTTP request. Returns parsed JSON or raises appropriate error.
499
- def make_request(method, path, body = nil)
500
- response = @connection.run_request(method, path, body, nil)
60
+ def make_request(method, path, body = nil, query = nil)
61
+ response = @connection.run_request(method, path, body, nil) do |req|
62
+ req.params = query if query # Add query params to the request
63
+ end
501
64
 
502
65
  # Check for AUTHENTICATED_FAILED header even on 200 OK
503
66
  if response.status == 200 && response.headers["x-seraph-loginreason"]&.include?("AUTHENTICATED_FAILED")
@@ -510,13 +73,14 @@ module ActiveProject
510
73
  # Return parsed body on success, or nil if body is empty/invalid
511
74
  JSON.parse(response.body) if response.body && !response.body.empty?
512
75
  rescue JSON::ParserError => e
513
- # Raise specific error if JSON parsing fails on a successful response body
514
- raise ApiError.new("Jira API returned non-JSON response: #{response&.body}", original_error: e)
76
+ # Raise specific error if JSON parsing fails on a successful response body
77
+ raise ApiError.new("Jira API returned non-JSON response: #{response&.body}", original_error: e)
515
78
  rescue Faraday::Error => e
516
79
  # Handle connection errors etc. that occur before the response object is available
517
80
  status = e.response&.status
518
81
  body = e.response&.body
519
- raise ApiError.new("Jira API connection error (Status: #{status || 'N/A'}): #{e.message}", original_error: e, status_code: status, response_body: body)
82
+ raise ApiError.new("Jira API connection error (Status: #{status || 'N/A'}): #{e.message}", original_error: e,
83
+ status_code: status, response_body: body)
520
84
  end
521
85
 
522
86
  # Handles Faraday errors based on the response object (for non-2xx responses).
@@ -524,7 +88,11 @@ module ActiveProject
524
88
  status = response.status
525
89
  body = response.body
526
90
  # headers = response.headers # Headers already checked in make_request for the special case
527
- parsed_body = JSON.parse(body) rescue {}
91
+ parsed_body = begin
92
+ JSON.parse(body)
93
+ rescue StandardError
94
+ {}
95
+ end
528
96
  error_messages = parsed_body["errorMessages"] || [ parsed_body["message"] ].compact || []
529
97
  errors_hash = parsed_body["errors"] || {}
530
98
  message = error_messages.join(", ")
@@ -533,16 +101,22 @@ module ActiveProject
533
101
 
534
102
  case status
535
103
  when 401, 403
536
- raise AuthenticationError, "Jira authentication failed (Status: #{status})#{': ' + message unless message.empty?}"
104
+ raise AuthenticationError,
105
+ "Jira authentication failed (Status: #{status})#{": #{message}" unless message.empty?}"
537
106
  when 404
538
- raise NotFoundError, "Jira resource not found (Status: 404)#{': ' + message unless message.empty?}"
107
+ raise NotFoundError, "Jira resource not found (Status: 404)#{": #{message}" unless message.empty?}"
539
108
  when 429
540
109
  raise RateLimitError, "Jira rate limit exceeded (Status: 429)"
541
110
  when 400, 422
542
- raise ValidationError.new("Jira validation failed (Status: #{status})#{': ' + message unless message.empty?}. Errors: #{errors_hash.inspect}", errors: errors_hash, status_code: status, response_body: body)
111
+ raise ValidationError.new(
112
+ "Jira validation failed (Status: #{status})#{unless message.empty?
113
+ ": #{message}"
114
+ end}. Errors: #{errors_hash.inspect}", errors: errors_hash, status_code: status, response_body: body
115
+ )
543
116
  else
544
117
  # Raise generic ApiError for other non-success statuses
545
- raise ApiError.new("Jira API error (Status: #{status || 'N/A'})#{': ' + message unless message.empty?}", status_code: status, response_body: body)
118
+ raise ApiError.new("Jira API error (Status: #{status || 'N/A'})#{": #{message}" unless message.empty?}",
119
+ status_code: status, response_body: body)
546
120
  end
547
121
  end
548
122
 
@@ -554,55 +128,55 @@ module ActiveProject
554
128
  assignees_array = assignee_user ? [ assignee_user ] : []
555
129
 
556
130
  Resources::Issue.new(self, # Pass adapter instance
557
- id: issue_data["id"], # Keep as string from Jira
558
- key: issue_data["key"],
559
- title: fields["summary"],
560
- description: map_adf_description(fields["description"]),
561
- status: normalize_jira_status(fields["status"]),
562
- assignees: assignees_array, # Use the mapped array
563
- reporter: map_user_data(fields["reporter"]),
564
- project_id: fields.dig("project", "id")&.to_i, # Convert to integer
565
- created_at: fields["created"] ? Time.parse(fields["created"]) : nil,
566
- updated_at: fields["updated"] ? Time.parse(fields["updated"]) : nil,
567
- due_on: fields["duedate"] ? Date.parse(fields["duedate"]) : nil,
568
- priority: fields.dig("priority", "name"),
569
- adapter_source: :jira,
570
- raw_data: issue_data
571
- )
131
+ id: issue_data["id"], # Keep as string from Jira
132
+ key: issue_data["key"],
133
+ title: fields["summary"],
134
+ description: map_adf_description(fields["description"]),
135
+ status: normalize_jira_status(fields["status"]),
136
+ assignees: assignees_array, # Use the mapped array
137
+ reporter: map_user_data(fields["reporter"]),
138
+ project_id: fields.dig("project", "id")&.to_i, # Convert to integer
139
+ created_at: fields["created"] ? Time.parse(fields["created"]) : nil,
140
+ updated_at: fields["updated"] ? Time.parse(fields["updated"]) : nil,
141
+ due_on: fields["duedate"] ? Date.parse(fields["duedate"]) : nil,
142
+ priority: fields.dig("priority", "name"),
143
+ adapter_source: :jira,
144
+ raw_data: issue_data)
572
145
  end
573
146
 
574
147
  # Maps raw Jira comment data hash to a Comment resource.
575
148
  def map_comment_data(comment_data, issue_id_or_key)
576
149
  Resources::Comment.new(self, # Pass adapter instance
577
- id: comment_data["id"],
578
- body: map_adf_description(comment_data["body"]),
579
- author: map_user_data(comment_data["author"]),
580
- created_at: comment_data["created"] ? Time.parse(comment_data["created"]) : nil,
581
- updated_at: comment_data["updated"] ? Time.parse(comment_data["updated"]) : nil,
582
- issue_id: issue_id_or_key, # Store the issue context
583
- adapter_source: :jira,
584
- raw_data: comment_data
585
- )
150
+ id: comment_data["id"],
151
+ body: map_adf_description(comment_data["body"]),
152
+ author: map_user_data(comment_data["author"]),
153
+ created_at: comment_data["created"] ? Time.parse(comment_data["created"]) : nil,
154
+ updated_at: comment_data["updated"] ? Time.parse(comment_data["updated"]) : nil,
155
+ issue_id: issue_id_or_key, # Store the issue context
156
+ adapter_source: :jira,
157
+ raw_data: comment_data)
586
158
  end
587
159
 
588
160
  # Maps raw Jira user data hash to a User resource.
589
161
  # @return [Resources::User, nil]
590
162
  def map_user_data(user_data)
591
163
  return nil unless user_data && user_data["accountId"]
164
+
592
165
  Resources::User.new(self, # Pass adapter instance
593
- id: user_data["accountId"],
594
- name: user_data["displayName"],
595
- email: user_data["emailAddress"],
596
- adapter_source: :jira,
597
- raw_data: user_data
598
- )
166
+ id: user_data["accountId"],
167
+ name: user_data["displayName"],
168
+ email: user_data["emailAddress"],
169
+ adapter_source: :jira,
170
+ raw_data: user_data)
599
171
  end
600
172
 
601
173
  # Basic parser for Atlassian Document Format (ADF).
602
174
  def map_adf_description(adf_data)
603
175
  return nil unless adf_data.is_a?(Hash) && adf_data["content"].is_a?(Array)
176
+
604
177
  adf_data["content"].map do |block|
605
178
  next unless block.is_a?(Hash) && block["content"].is_a?(Array)
179
+
606
180
  block["content"].map do |inline|
607
181
  inline["text"] if inline.is_a?(Hash) && inline["type"] == "text"
608
182
  end.compact.join
@@ -612,6 +186,7 @@ module ActiveProject
612
186
  # Normalizes Jira status based on its category.
613
187
  def normalize_jira_status(status_data)
614
188
  return :unknown unless status_data.is_a?(Hash)
189
+
615
190
  category_key = status_data.dig("statusCategory", "key")
616
191
  case category_key
617
192
  when "new", "undefined" then :open
@@ -624,6 +199,7 @@ module ActiveProject
624
199
  # Parses the changelog from a Jira webhook payload.
625
200
  def parse_changelog(changelog_data)
626
201
  return nil unless changelog_data.is_a?(Hash) && changelog_data["items"].is_a?(Array)
202
+
627
203
  changes = {}
628
204
  changelog_data["items"].each do |item|
629
205
  field_name = item["field"] || item["fieldId"]
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Trello
6
+ module Comments
7
+ # Adds a comment to a Card in Trello.
8
+ # @param card_id [String] The ID of the Trello Card.
9
+ # @param comment_body [String] The comment text (Markdown).
10
+ # @param context [Hash] Optional context (ignored).
11
+ # @return [ActiveProject::Resources::Comment] The created comment resource.
12
+ def add_comment(card_id, comment_body, _context = {})
13
+ path = "cards/#{card_id}/actions/comments"
14
+ query_params = { text: comment_body }
15
+ comment_data = make_request(:post, path, nil, query_params)
16
+ map_comment_action_data(comment_data, card_id)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end