activeproject 0.0.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.
@@ -0,0 +1,637 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "json"
6
+ require "time"
7
+
8
+ module ActiveProject
9
+ module Adapters
10
+ # Adapter for interacting with the Jira REST API.
11
+ # Implements the interface defined in ActiveProject::Adapters::Base.
12
+ class JiraAdapter < Base
13
+ attr_reader :config # Store the config object
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
35
+
36
+ # --- Resource Factories ---
37
+
38
+ # Returns a factory for Project resources.
39
+ # @return [ResourceFactory<Resources::Project>]
40
+ def projects
41
+ ResourceFactory.new(adapter: self, resource_class: Resources::Project)
42
+ end
43
+
44
+ # Returns a factory for Issue resources.
45
+ # @return [ResourceFactory<Resources::Issue>]
46
+ def issues
47
+ ResourceFactory.new(adapter: self, resource_class: Resources::Issue)
48
+ end
49
+
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
+ # Retrieves details for the currently authenticated user.
460
+ # @return [ActiveProject::Resources::User] The user object.
461
+ # @raise [ActiveProject::AuthenticationError] if authentication fails.
462
+ # @raise [ActiveProject::ApiError] for other API-related errors.
463
+ def get_current_user
464
+ user_data = make_request(:get, "/rest/api/3/myself")
465
+ map_user_data(user_data)
466
+ end
467
+
468
+ # Checks if the adapter can successfully authenticate and connect to the service.
469
+ # Calls #get_current_user internally and catches authentication errors.
470
+ # @return [Boolean] true if connection is successful, false otherwise.
471
+ def connected?
472
+ get_current_user
473
+ true
474
+ rescue ActiveProject::AuthenticationError
475
+ false
476
+ end
477
+
478
+
479
+ private
480
+
481
+ # 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
+
499
+ # Makes an HTTP request. Returns parsed JSON or raises appropriate error.
500
+ def make_request(method, path, body = nil)
501
+ response = @connection.run_request(method, path, body, nil)
502
+
503
+ # Check for AUTHENTICATED_FAILED header even on 200 OK
504
+ if response.status == 200 && response.headers["x-seraph-loginreason"]&.include?("AUTHENTICATED_FAILED")
505
+ raise AuthenticationError, "Jira authentication failed (X-Seraph-Loginreason: AUTHENTICATED_FAILED)"
506
+ end
507
+
508
+ # Check for other errors if not successful
509
+ handle_faraday_error(response) unless response.success?
510
+
511
+ # Return parsed body on success, or nil if body is empty/invalid
512
+ JSON.parse(response.body) if response.body && !response.body.empty?
513
+ 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)
516
+ rescue Faraday::Error => e
517
+ # Handle connection errors etc. that occur before the response object is available
518
+ status = e.response&.status
519
+ 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)
521
+ end
522
+
523
+ # Handles Faraday errors based on the response object (for non-2xx responses).
524
+ def handle_faraday_error(response)
525
+ status = response.status
526
+ body = response.body
527
+ # headers = response.headers # Headers already checked in make_request for the special case
528
+ parsed_body = JSON.parse(body) rescue {}
529
+ error_messages = parsed_body["errorMessages"] || [ parsed_body["message"] ].compact || []
530
+ errors_hash = parsed_body["errors"] || {}
531
+ message = error_messages.join(", ")
532
+
533
+ # No need to check the 200 OK + header case here anymore
534
+
535
+ case status
536
+ when 401, 403
537
+ raise AuthenticationError, "Jira authentication failed (Status: #{status})#{': ' + message unless message.empty?}"
538
+ when 404
539
+ raise NotFoundError, "Jira resource not found (Status: 404)#{': ' + message unless message.empty?}"
540
+ when 429
541
+ raise RateLimitError, "Jira rate limit exceeded (Status: 429)"
542
+ 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)
544
+ else
545
+ # 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)
547
+ end
548
+ end
549
+
550
+ # Maps raw Jira issue data hash to an Issue resource.
551
+ def map_issue_data(issue_data)
552
+ fields = issue_data["fields"]
553
+ # Ensure assignee is mapped correctly into an array
554
+ assignee_user = map_user_data(fields["assignee"])
555
+ assignees_array = assignee_user ? [ assignee_user ] : []
556
+
557
+ 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
+ )
573
+ end
574
+
575
+ # Maps raw Jira comment data hash to a Comment resource.
576
+ def map_comment_data(comment_data, issue_id_or_key)
577
+ 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
+ )
587
+ end
588
+
589
+ # Maps raw Jira user data hash to a User resource.
590
+ # @return [Resources::User, nil]
591
+ def map_user_data(user_data)
592
+ return nil unless user_data && user_data["accountId"]
593
+ 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
+ )
600
+ end
601
+
602
+ # Basic parser for Atlassian Document Format (ADF).
603
+ def map_adf_description(adf_data)
604
+ return nil unless adf_data.is_a?(Hash) && adf_data["content"].is_a?(Array)
605
+ adf_data["content"].map do |block|
606
+ next unless block.is_a?(Hash) && block["content"].is_a?(Array)
607
+ block["content"].map do |inline|
608
+ inline["text"] if inline.is_a?(Hash) && inline["type"] == "text"
609
+ end.compact.join
610
+ end.compact.join("\n")
611
+ end
612
+
613
+ # Normalizes Jira status based on its category.
614
+ def normalize_jira_status(status_data)
615
+ return :unknown unless status_data.is_a?(Hash)
616
+ category_key = status_data.dig("statusCategory", "key")
617
+ case category_key
618
+ when "new", "undefined" then :open
619
+ when "indeterminate" then :in_progress
620
+ when "done" then :closed
621
+ else :unknown
622
+ end
623
+ end
624
+
625
+ # Parses the changelog from a Jira webhook payload.
626
+ def parse_changelog(changelog_data)
627
+ return nil unless changelog_data.is_a?(Hash) && changelog_data["items"].is_a?(Array)
628
+ changes = {}
629
+ changelog_data["items"].each do |item|
630
+ field_name = item["field"] || item["fieldId"]
631
+ changes[field_name.to_sym] = [ item["fromString"] || item["from"], item["toString"] || item["to"] ]
632
+ end
633
+ changes.empty? ? nil : changes
634
+ end
635
+ end
636
+ end
637
+ end