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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +304 -0
- data/Rakefile +3 -0
- data/lib/active_project/adapters/base.rb +134 -0
- data/lib/active_project/adapters/basecamp_adapter.rb +577 -0
- data/lib/active_project/adapters/jira_adapter.rb +637 -0
- data/lib/active_project/adapters/trello_adapter.rb +535 -0
- data/lib/active_project/association_proxy.rb +142 -0
- data/lib/active_project/configuration.rb +59 -0
- data/lib/active_project/configurations/base_adapter_configuration.rb +32 -0
- data/lib/active_project/configurations/trello_configuration.rb +31 -0
- data/lib/active_project/errors.rb +40 -0
- data/lib/active_project/resource_factory.rb +130 -0
- data/lib/active_project/resources/base_resource.rb +69 -0
- data/lib/active_project/resources/comment.rb +16 -0
- data/lib/active_project/resources/issue.rb +41 -0
- data/lib/active_project/resources/project.rb +20 -0
- data/lib/active_project/resources/user.rb +13 -0
- data/lib/active_project/version.rb +3 -0
- data/lib/active_project/webhook_event.rb +20 -0
- data/lib/activeproject.rb +61 -0
- metadata +128 -0
@@ -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
|