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.
- checksums.yaml +4 -4
- data/Rakefile +4 -2
- data/lib/active_project/adapters/base.rb +9 -7
- data/lib/active_project/adapters/basecamp/comments.rb +27 -0
- data/lib/active_project/adapters/basecamp/connection.rb +49 -0
- data/lib/active_project/adapters/basecamp/issues.rb +158 -0
- data/lib/active_project/adapters/basecamp/lists.rb +54 -0
- data/lib/active_project/adapters/basecamp/projects.rb +110 -0
- data/lib/active_project/adapters/basecamp/webhooks.rb +73 -0
- data/lib/active_project/adapters/basecamp_adapter.rb +46 -437
- data/lib/active_project/adapters/jira/comments.rb +28 -0
- data/lib/active_project/adapters/jira/connection.rb +47 -0
- data/lib/active_project/adapters/jira/issues.rb +150 -0
- data/lib/active_project/adapters/jira/projects.rb +100 -0
- data/lib/active_project/adapters/jira/transitions.rb +68 -0
- data/lib/active_project/adapters/jira/webhooks.rb +89 -0
- data/lib/active_project/adapters/jira_adapter.rb +61 -485
- data/lib/active_project/adapters/trello/comments.rb +21 -0
- data/lib/active_project/adapters/trello/connection.rb +37 -0
- data/lib/active_project/adapters/trello/issues.rb +133 -0
- data/lib/active_project/adapters/trello/lists.rb +27 -0
- data/lib/active_project/adapters/trello/projects.rb +82 -0
- data/lib/active_project/adapters/trello/webhooks.rb +91 -0
- data/lib/active_project/adapters/trello_adapter.rb +59 -377
- data/lib/active_project/association_proxy.rb +9 -2
- data/lib/active_project/configuration.rb +1 -3
- data/lib/active_project/configurations/trello_configuration.rb +1 -3
- data/lib/active_project/resource_factory.rb +20 -10
- data/lib/active_project/resources/issue.rb +0 -3
- data/lib/active_project/resources/project.rb +0 -1
- data/lib/active_project/version.rb +3 -1
- data/lib/activeproject.rb +2 -2
- 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
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
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
|
-
|
514
|
-
|
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,
|
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 =
|
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,
|
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)#{
|
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(
|
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'})#{
|
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
|
-
|
558
|
-
|
559
|
-
|
560
|
-
|
561
|
-
|
562
|
-
|
563
|
-
|
564
|
-
|
565
|
-
|
566
|
-
|
567
|
-
|
568
|
-
|
569
|
-
|
570
|
-
|
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
|
-
|
578
|
-
|
579
|
-
|
580
|
-
|
581
|
-
|
582
|
-
|
583
|
-
|
584
|
-
|
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
|
-
|
594
|
-
|
595
|
-
|
596
|
-
|
597
|
-
|
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
|