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