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.
- checksums.yaml +4 -4
- data/README.md +28 -82
- data/Rakefile +4 -2
- data/lib/active_project/adapters/base.rb +3 -14
- 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 +139 -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 -449
- 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 +132 -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 +59 -486
- 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 +117 -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 +54 -377
- data/lib/active_project/association_proxy.rb +10 -3
- data/lib/active_project/configuration.rb +23 -17
- data/lib/active_project/configurations/trello_configuration.rb +1 -3
- data/lib/active_project/resource_factory.rb +20 -10
- data/lib/active_project/resources/comment.rb +0 -5
- data/lib/active_project/resources/issue.rb +0 -5
- data/lib/active_project/resources/project.rb +0 -3
- data/lib/active_project/resources/user.rb +0 -1
- data/lib/active_project/version.rb +3 -1
- data/lib/activeproject.rb +67 -15
- metadata +26 -8
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveProject
|
4
|
+
module Adapters
|
5
|
+
module Basecamp
|
6
|
+
module Webhooks
|
7
|
+
# Parses an incoming Basecamp webhook payload.
|
8
|
+
# @param request_body [String] The raw JSON request body.
|
9
|
+
# @param headers [Hash] Request headers (unused).
|
10
|
+
# @return [ActiveProject::WebhookEvent, nil] Parsed event or nil if unhandled.
|
11
|
+
def parse_webhook(request_body, _headers = {})
|
12
|
+
payload = begin
|
13
|
+
JSON.parse(request_body)
|
14
|
+
rescue StandardError
|
15
|
+
nil
|
16
|
+
end
|
17
|
+
return nil unless payload.is_a?(Hash)
|
18
|
+
|
19
|
+
kind = payload["kind"]
|
20
|
+
recording = payload["recording"]
|
21
|
+
creator = payload["creator"]
|
22
|
+
timestamp = begin
|
23
|
+
Time.parse(payload["created_at"])
|
24
|
+
rescue StandardError
|
25
|
+
nil
|
26
|
+
end
|
27
|
+
return nil unless recording && kind
|
28
|
+
|
29
|
+
event_type = nil
|
30
|
+
object_kind = nil
|
31
|
+
event_object_id = recording["id"]
|
32
|
+
object_key = nil
|
33
|
+
project_id = recording.dig("bucket", "id")
|
34
|
+
changes = nil
|
35
|
+
object_data = nil
|
36
|
+
|
37
|
+
case kind
|
38
|
+
when /todo_created$/
|
39
|
+
event_type = :issue_created
|
40
|
+
object_kind = :issue
|
41
|
+
when /todo_assignment_changed$/, /todo_completion_changed$/, /todo_content_updated$/, /todo_description_changed$/, /todo_due_on_changed$/
|
42
|
+
event_type = :issue_updated
|
43
|
+
object_kind = :issue
|
44
|
+
when /comment_created$/
|
45
|
+
event_type = :comment_added
|
46
|
+
object_kind = :comment
|
47
|
+
when /comment_content_changed$/
|
48
|
+
event_type = :comment_updated
|
49
|
+
object_kind = :comment
|
50
|
+
else
|
51
|
+
return nil
|
52
|
+
end
|
53
|
+
|
54
|
+
WebhookEvent.new(
|
55
|
+
event_type: event_type,
|
56
|
+
object_kind: object_kind,
|
57
|
+
event_object_id: event_object_id,
|
58
|
+
object_key: object_key,
|
59
|
+
project_id: project_id,
|
60
|
+
actor: map_user_data(creator),
|
61
|
+
timestamp: timestamp,
|
62
|
+
adapter_source: :basecamp,
|
63
|
+
changes: changes,
|
64
|
+
object_data: object_data,
|
65
|
+
raw_data: payload
|
66
|
+
)
|
67
|
+
rescue JSON::ParserError
|
68
|
+
nil
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -11,31 +11,14 @@ module ActiveProject
|
|
11
11
|
# Implements the interface defined in ActiveProject::Adapters::Base.
|
12
12
|
# API Docs: https://github.com/basecamp/bc3-api
|
13
13
|
class BasecampAdapter < Base
|
14
|
-
BASE_URL_TEMPLATE = "https://3.basecampapi.com/%<account_id>s/"
|
15
|
-
|
16
14
|
attr_reader :config, :base_url
|
17
15
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
unless config.is_a?(ActiveProject::Configurations::BaseAdapterConfiguration)
|
25
|
-
raise ArgumentError, "BasecampAdapter requires a BaseAdapterConfiguration object"
|
26
|
-
end
|
27
|
-
@config = config
|
28
|
-
|
29
|
-
account_id = @config.options[:account_id].to_s # Ensure it's a string
|
30
|
-
access_token = @config.options[:access_token]
|
31
|
-
|
32
|
-
unless account_id && !account_id.empty? && access_token && !access_token.empty?
|
33
|
-
raise ArgumentError, "BasecampAdapter configuration requires :account_id and :access_token"
|
34
|
-
end
|
35
|
-
|
36
|
-
@base_url = format(BASE_URL_TEMPLATE, account_id: account_id)
|
37
|
-
@connection = initialize_connection
|
38
|
-
end
|
16
|
+
include Basecamp::Connection
|
17
|
+
include Basecamp::Projects
|
18
|
+
include Basecamp::Issues
|
19
|
+
include Basecamp::Comments
|
20
|
+
include Basecamp::Lists
|
21
|
+
include Basecamp::Webhooks
|
39
22
|
|
40
23
|
# --- Resource Factories ---
|
41
24
|
|
@@ -51,364 +34,8 @@ module ActiveProject
|
|
51
34
|
ResourceFactory.new(adapter: self, resource_class: Resources::Issue)
|
52
35
|
end
|
53
36
|
|
54
|
-
|
55
37
|
# --- Implementation of Base methods ---
|
56
38
|
|
57
|
-
# Lists projects accessible by the configured credentials.
|
58
|
-
# Handles pagination automatically using the Link header.
|
59
|
-
# @return [Array<ActiveProject::Resources::Project>] An array of project resources.
|
60
|
-
def list_projects
|
61
|
-
all_projects = []
|
62
|
-
path = "projects.json"
|
63
|
-
|
64
|
-
loop do
|
65
|
-
# Use connection directly to access headers for Link header parsing
|
66
|
-
response = @connection.get(path)
|
67
|
-
projects_data = JSON.parse(response.body) rescue []
|
68
|
-
break if projects_data.empty?
|
69
|
-
|
70
|
-
projects_data.each do |project_data|
|
71
|
-
all_projects << Resources::Project.new(self, # Pass adapter instance
|
72
|
-
id: project_data["id"],
|
73
|
-
key: nil, # Basecamp doesn't have a short project key like Jira
|
74
|
-
name: project_data["name"],
|
75
|
-
adapter_source: :basecamp,
|
76
|
-
raw_data: project_data
|
77
|
-
)
|
78
|
-
end
|
79
|
-
|
80
|
-
# Handle pagination via Link header
|
81
|
-
link_header = response.headers["Link"]
|
82
|
-
next_url = parse_next_link(link_header)
|
83
|
-
break unless next_url
|
84
|
-
|
85
|
-
# Extract path from the next URL relative to the base URL
|
86
|
-
path = next_url.sub(@base_url, "").sub(%r{^/}, "")
|
87
|
-
end
|
88
|
-
|
89
|
-
all_projects
|
90
|
-
rescue Faraday::Error => e
|
91
|
-
handle_faraday_error(e) # Ensure errors during GET are handled
|
92
|
-
end
|
93
|
-
|
94
|
-
# Finds a specific project by its ID.
|
95
|
-
# @param project_id [String, Integer] The ID of the Basecamp project.
|
96
|
-
# @return [ActiveProject::Resources::Project] The project resource.
|
97
|
-
def find_project(project_id)
|
98
|
-
path = "projects/#{project_id}.json"
|
99
|
-
project_data = make_request(:get, path)
|
100
|
-
|
101
|
-
# Raise NotFoundError if the project is trashed
|
102
|
-
if project_data["status"] == "trashed"
|
103
|
-
raise NotFoundError, "Basecamp project ID #{project_id} is trashed."
|
104
|
-
end
|
105
|
-
|
106
|
-
Resources::Project.new(self, # Pass adapter instance
|
107
|
-
id: project_data["id"],
|
108
|
-
key: nil,
|
109
|
-
name: project_data["name"],
|
110
|
-
adapter_source: :basecamp,
|
111
|
-
raw_data: project_data
|
112
|
-
)
|
113
|
-
# Note: make_request handles raising NotFoundError on 404
|
114
|
-
end
|
115
|
-
|
116
|
-
# Creates a new project in Basecamp.
|
117
|
-
# @param attributes [Hash] Project attributes. Required: :name. Optional: :description.
|
118
|
-
# @return [ActiveProject::Resources::Project] The created project resource.
|
119
|
-
def create_project(attributes)
|
120
|
-
unless attributes[:name] && !attributes[:name].empty?
|
121
|
-
raise ArgumentError, "Missing required attribute for Basecamp project creation: :name"
|
122
|
-
end
|
123
|
-
|
124
|
-
path = "projects.json"
|
125
|
-
payload = {
|
126
|
-
name: attributes[:name],
|
127
|
-
description: attributes[:description]
|
128
|
-
}.compact
|
129
|
-
|
130
|
-
project_data = make_request(:post, path, payload.to_json)
|
131
|
-
|
132
|
-
# Map response to Project resource
|
133
|
-
Resources::Project.new(self, # Pass adapter instance
|
134
|
-
id: project_data["id"],
|
135
|
-
key: nil,
|
136
|
-
name: project_data["name"],
|
137
|
-
adapter_source: :basecamp,
|
138
|
-
raw_data: project_data
|
139
|
-
)
|
140
|
-
end
|
141
|
-
|
142
|
-
# Creates a new Todolist within a project.
|
143
|
-
# @param project_id [String, Integer] The ID of the Basecamp project (bucket).
|
144
|
-
# @param attributes [Hash] Todolist attributes. Required: :name. Optional: :description.
|
145
|
-
# @return [Hash] The raw data hash of the created todolist.
|
146
|
-
def create_list(project_id, attributes)
|
147
|
-
unless attributes[:name] && !attributes[:name].empty?
|
148
|
-
raise ArgumentError, "Missing required attribute for Basecamp todolist creation: :name"
|
149
|
-
end
|
150
|
-
|
151
|
-
# Need to find the 'todoset' ID first
|
152
|
-
project_data = make_request(:get, "projects/#{project_id}.json")
|
153
|
-
todoset_dock_entry = project_data&.dig("dock")&.find { |d| d["name"] == "todoset" }
|
154
|
-
todoset_url = todoset_dock_entry&.dig("url")
|
155
|
-
unless todoset_url
|
156
|
-
raise ApiError, "Could not find todoset URL for project #{project_id}"
|
157
|
-
end
|
158
|
-
todoset_id = todoset_url.match(/todosets\/(\d+)\.json$/)&.captures&.first
|
159
|
-
unless todoset_id
|
160
|
-
raise ApiError, "Could not extract todoset ID from URL: #{todoset_url}"
|
161
|
-
end
|
162
|
-
|
163
|
-
path = "buckets/#{project_id}/todosets/#{todoset_id}/todolists.json"
|
164
|
-
payload = {
|
165
|
-
name: attributes[:name],
|
166
|
-
description: attributes[:description]
|
167
|
-
}.compact
|
168
|
-
|
169
|
-
# POST returns the created todolist object
|
170
|
-
make_request(:post, path, payload.to_json)
|
171
|
-
end
|
172
|
-
|
173
|
-
# Archives (trashes) a project in Basecamp.
|
174
|
-
# Note: Basecamp API doesn't offer permanent deletion via this endpoint.
|
175
|
-
# @param project_id [String, Integer] The ID of the project to trash.
|
176
|
-
# @return [Boolean] true if trashing was successful (API returns 204).
|
177
|
-
# @raise [NotFoundError] if the project is not found.
|
178
|
-
# @raise [AuthenticationError] if credentials lack permission.
|
179
|
-
# @raise [ApiError] for other errors.
|
180
|
-
|
181
|
-
# Recovers a trashed project in Basecamp.
|
182
|
-
# @param project_id [String, Integer] The ID of the project to recover.
|
183
|
-
# @return [Boolean] true if recovery was successful (API returns 204).
|
184
|
-
def untrash_project(project_id)
|
185
|
-
path = "projects/#{project_id}/trash/recover.json"
|
186
|
-
make_request(:put, path)
|
187
|
-
true # Return true if make_request doesn't raise an error
|
188
|
-
end
|
189
|
-
|
190
|
-
def delete_project(project_id)
|
191
|
-
path = "projects/#{project_id}.json"
|
192
|
-
make_request(:delete, path) # PUT returns 204 No Content on success
|
193
|
-
true # Return true if make_request doesn't raise an error
|
194
|
-
end
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
# Lists To-dos within a specific project.
|
200
|
-
# @param project_id [String, Integer] The ID of the Basecamp project.
|
201
|
-
# @param options [Hash] Optional options. Accepts :todolist_id.
|
202
|
-
# @return [Array<ActiveProject::Resources::Issue>] An array of issue resources.
|
203
|
-
def list_issues(project_id, options = {})
|
204
|
-
all_todos = []
|
205
|
-
todolist_id = options[:todolist_id]
|
206
|
-
|
207
|
-
unless todolist_id
|
208
|
-
todolist_id = find_first_todolist_id(project_id)
|
209
|
-
return [] unless todolist_id
|
210
|
-
end
|
211
|
-
|
212
|
-
path = "buckets/#{project_id}/todolists/#{todolist_id}/todos.json"
|
213
|
-
|
214
|
-
loop do
|
215
|
-
response = @connection.get(path)
|
216
|
-
todos_data = JSON.parse(response.body) rescue []
|
217
|
-
break if todos_data.empty?
|
218
|
-
|
219
|
-
todos_data.each do |todo_data|
|
220
|
-
all_todos << map_todo_data(todo_data, project_id)
|
221
|
-
end
|
222
|
-
|
223
|
-
link_header = response.headers["Link"]
|
224
|
-
next_url = parse_next_link(link_header)
|
225
|
-
break unless next_url
|
226
|
-
|
227
|
-
path = next_url.sub(@base_url, "").sub(%r{^/}, "")
|
228
|
-
end
|
229
|
-
|
230
|
-
all_todos
|
231
|
-
rescue Faraday::Error => e
|
232
|
-
handle_faraday_error(e)
|
233
|
-
end
|
234
|
-
|
235
|
-
# Finds a specific To-do by its ID.
|
236
|
-
# @param todo_id [String, Integer] The ID of the Basecamp To-do.
|
237
|
-
# @param context [Hash] Required context: { project_id: '...' }.
|
238
|
-
# @return [ActiveProject::Resources::Issue] The issue resource.
|
239
|
-
def find_issue(todo_id, context = {})
|
240
|
-
project_id = context[:project_id]
|
241
|
-
unless project_id
|
242
|
-
raise ArgumentError, "Missing required context: :project_id must be provided for BasecampAdapter#find_issue"
|
243
|
-
end
|
244
|
-
|
245
|
-
path = "buckets/#{project_id}/todos/#{todo_id}.json"
|
246
|
-
todo_data = make_request(:get, path)
|
247
|
-
map_todo_data(todo_data, project_id)
|
248
|
-
end
|
249
|
-
|
250
|
-
# Creates a new To-do in Basecamp.
|
251
|
-
# @param project_id [String, Integer] The ID of the Basecamp project.
|
252
|
-
# @param attributes [Hash] To-do attributes. Required: :todolist_id, :title. Optional: :description, :due_on, :assignee_ids.
|
253
|
-
# @return [ActiveProject::Resources::Issue] The created issue resource.
|
254
|
-
def create_issue(project_id, attributes)
|
255
|
-
todolist_id = attributes[:todolist_id]
|
256
|
-
title = attributes[:title]
|
257
|
-
|
258
|
-
unless todolist_id && title && !title.empty?
|
259
|
-
raise ArgumentError, "Missing required attributes for Basecamp to-do creation: :todolist_id, :title"
|
260
|
-
end
|
261
|
-
|
262
|
-
path = "buckets/#{project_id}/todolists/#{todolist_id}/todos.json"
|
263
|
-
|
264
|
-
payload = {
|
265
|
-
content: title,
|
266
|
-
description: attributes[:description],
|
267
|
-
due_on: attributes[:due_on].respond_to?(:strftime) ? attributes[:due_on].strftime("%Y-%m-%d") : attributes[:due_on],
|
268
|
-
# Basecamp expects an array of numeric IDs for assignees
|
269
|
-
assignee_ids: attributes[:assignee_ids]
|
270
|
-
}.compact
|
271
|
-
|
272
|
-
todo_data = make_request(:post, path, payload.to_json)
|
273
|
-
map_todo_data(todo_data, project_id)
|
274
|
-
end
|
275
|
-
|
276
|
-
# Updates an existing To-do in Basecamp.
|
277
|
-
# Handles updates to standard fields via PUT and status changes via POST/DELETE completion endpoints.
|
278
|
-
# @param todo_id [String, Integer] The ID of the Basecamp To-do.
|
279
|
-
# @param attributes [Hash] Attributes to update (e.g., :title, :description, :status, :assignee_ids, :due_on).
|
280
|
-
# @param context [Hash] Required context: { project_id: '...' }.
|
281
|
-
# @return [ActiveProject::Resources::Issue] The updated issue resource (fetched after updates).
|
282
|
-
def update_issue(todo_id, attributes, context = {})
|
283
|
-
project_id = context[:project_id]
|
284
|
-
unless project_id
|
285
|
-
raise ArgumentError, "Missing required context: :project_id must be provided for BasecampAdapter#update_issue"
|
286
|
-
end
|
287
|
-
|
288
|
-
# Separate attributes for PUT payload and status change
|
289
|
-
put_payload = {}
|
290
|
-
put_payload[:content] = attributes[:title] if attributes.key?(:title)
|
291
|
-
put_payload[:description] = attributes[:description] if attributes.key?(:description)
|
292
|
-
# Format due_on if present
|
293
|
-
if attributes.key?(:due_on)
|
294
|
-
due_on_val = attributes[:due_on]
|
295
|
-
put_payload[:due_on] = due_on_val.respond_to?(:strftime) ? due_on_val.strftime("%Y-%m-%d") : due_on_val
|
296
|
-
end
|
297
|
-
put_payload[:assignee_ids] = attributes[:assignee_ids] if attributes.key?(:assignee_ids)
|
298
|
-
|
299
|
-
status_change_required = attributes.key?(:status)
|
300
|
-
target_status = attributes[:status] if status_change_required
|
301
|
-
|
302
|
-
# Check if any update action is requested
|
303
|
-
unless !put_payload.empty? || status_change_required
|
304
|
-
raise ArgumentError, "No attributes provided to update for BasecampAdapter#update_issue"
|
305
|
-
end
|
306
|
-
|
307
|
-
# 1. Perform PUT request for standard fields if needed
|
308
|
-
if !put_payload.empty?
|
309
|
-
put_path = "buckets/#{project_id}/todos/#{todo_id}.json"
|
310
|
-
# We make the request but ignore the immediate response body,
|
311
|
-
# as it might not reflect the update immediately or consistently.
|
312
|
-
make_request(:put, put_path, put_payload.compact.to_json)
|
313
|
-
end
|
314
|
-
|
315
|
-
# 2. Perform status change via completion endpoints if needed
|
316
|
-
if status_change_required
|
317
|
-
completion_path = "buckets/#{project_id}/todos/#{todo_id}/completion.json"
|
318
|
-
begin
|
319
|
-
if target_status == :closed
|
320
|
-
# POST to complete - returns 204 No Content on success
|
321
|
-
make_request(:post, completion_path)
|
322
|
-
elsif target_status == :open
|
323
|
-
# DELETE to reopen - returns 204 No Content on success
|
324
|
-
make_request(:delete, completion_path)
|
325
|
-
# else: Ignore invalid status symbols for now
|
326
|
-
end
|
327
|
-
rescue NotFoundError
|
328
|
-
# Ignore 404 on DELETE if trying to reopen an already open todo
|
329
|
-
raise unless target_status == :open
|
330
|
-
end
|
331
|
-
end
|
332
|
-
|
333
|
-
# 3. Always fetch the final state after all updates are performed
|
334
|
-
find_issue(todo_id, context)
|
335
|
-
end
|
336
|
-
|
337
|
-
# Adds a comment to a To-do in Basecamp.
|
338
|
-
# @param todo_id [String, Integer] The ID of the Basecamp To-do.
|
339
|
-
# @param comment_body [String] The comment text (HTML).
|
340
|
-
# @param context [Hash] Required context: { project_id: '...' }.
|
341
|
-
# @return [ActiveProject::Resources::Comment] The created comment resource.
|
342
|
-
def add_comment(todo_id, comment_body, context = {})
|
343
|
-
project_id = context[:project_id]
|
344
|
-
unless project_id
|
345
|
-
raise ArgumentError, "Missing required context: :project_id must be provided for BasecampAdapter#add_comment"
|
346
|
-
end
|
347
|
-
|
348
|
-
path = "buckets/#{project_id}/recordings/#{todo_id}/comments.json"
|
349
|
-
payload = { content: comment_body }.to_json
|
350
|
-
comment_data = make_request(:post, path, payload)
|
351
|
-
map_comment_data(comment_data, todo_id.to_i)
|
352
|
-
end
|
353
|
-
|
354
|
-
# Parses an incoming Basecamp webhook payload.
|
355
|
-
# @param request_body [String] The raw JSON request body.
|
356
|
-
# @param headers [Hash] Request headers (unused).
|
357
|
-
# @return [ActiveProject::WebhookEvent, nil] Parsed event or nil if unhandled.
|
358
|
-
def parse_webhook(request_body, headers = {})
|
359
|
-
payload = JSON.parse(request_body) rescue nil
|
360
|
-
return nil unless payload.is_a?(Hash)
|
361
|
-
|
362
|
-
kind = payload["kind"]
|
363
|
-
recording = payload["recording"]
|
364
|
-
creator = payload["creator"]
|
365
|
-
timestamp = Time.parse(payload["created_at"]) rescue nil
|
366
|
-
return nil unless recording && kind
|
367
|
-
|
368
|
-
event_type = nil
|
369
|
-
object_kind = nil
|
370
|
-
event_object_id = recording["id"]
|
371
|
-
object_key = nil
|
372
|
-
project_id = recording.dig("bucket", "id")
|
373
|
-
changes = nil
|
374
|
-
object_data = nil
|
375
|
-
|
376
|
-
case kind
|
377
|
-
when /todo_created$/
|
378
|
-
event_type = :issue_created
|
379
|
-
object_kind = :issue
|
380
|
-
when /todo_assignment_changed$/, /todo_completion_changed$/, /todo_content_updated$/, /todo_description_changed$/, /todo_due_on_changed$/
|
381
|
-
event_type = :issue_updated
|
382
|
-
object_kind = :issue
|
383
|
-
# Changes could be parsed from payload['details'] if needed
|
384
|
-
when /comment_created$/
|
385
|
-
event_type = :comment_added
|
386
|
-
object_kind = :comment
|
387
|
-
when /comment_content_changed$/
|
388
|
-
event_type = :comment_updated
|
389
|
-
object_kind = :comment
|
390
|
-
else
|
391
|
-
return nil # Unhandled kind
|
392
|
-
end
|
393
|
-
|
394
|
-
WebhookEvent.new(
|
395
|
-
event_type: event_type,
|
396
|
-
object_kind: object_kind,
|
397
|
-
event_object_id: event_object_id,
|
398
|
-
object_key: object_key,
|
399
|
-
project_id: project_id,
|
400
|
-
actor: map_user_data(creator),
|
401
|
-
timestamp: timestamp,
|
402
|
-
adapter_source: :basecamp,
|
403
|
-
changes: changes,
|
404
|
-
object_data: object_data, # Keep nil for now
|
405
|
-
raw_data: payload
|
406
|
-
)
|
407
|
-
rescue JSON::ParserError
|
408
|
-
nil # Ignore unparseable payloads
|
409
|
-
end
|
410
|
-
|
411
|
-
|
412
39
|
# Retrieves details for the currently authenticated user.
|
413
40
|
# @return [ActiveProject::Resources::User] The user object.
|
414
41
|
# @raise [ActiveProject::AuthenticationError] if authentication fails.
|
@@ -428,38 +55,19 @@ module ActiveProject
|
|
428
55
|
false
|
429
56
|
end
|
430
57
|
|
431
|
-
|
432
58
|
private
|
433
59
|
|
434
60
|
# Initializes the Faraday connection object.
|
435
|
-
def initialize_connection
|
436
|
-
# Read connection details from the config object
|
437
|
-
access_token = @config.options[:access_token]
|
438
|
-
|
439
|
-
Faraday.new(url: @base_url) do |conn|
|
440
|
-
conn.request :authorization, :bearer, access_token
|
441
|
-
conn.request :retry
|
442
|
-
conn.response :raise_error
|
443
|
-
conn.headers["Content-Type"] = "application/json"
|
444
|
-
conn.headers["Accept"] = "application/json"
|
445
|
-
conn.headers["User-Agent"] = ActiveProject.user_agent
|
446
|
-
end
|
447
|
-
end
|
448
61
|
|
449
62
|
# Helper method for making requests.
|
450
63
|
def make_request(method, path, body = nil, query_params = {})
|
451
64
|
full_path = path.start_with?("/") ? path[1..] : path
|
452
|
-
# Removed debug puts for cleaner output
|
453
|
-
# puts "[DEBUG BC Request] Method: #{method.upcase}"
|
454
|
-
# puts "[DEBUG BC Request] Path: #{full_path}"
|
455
|
-
# puts "[DEBUG BC Request] Body: #{body.inspect}"
|
456
|
-
# puts "[DEBUG BC Request] Query Params: #{query_params.inspect}"
|
457
65
|
|
458
66
|
response = @connection.run_request(method, full_path, body, nil) do |req|
|
459
67
|
req.params.update(query_params) unless query_params.empty?
|
460
|
-
# puts "[DEBUG BC Request] Headers: #{req.headers.inspect}"
|
461
68
|
end
|
462
69
|
return nil if response.status == 204 # Handle No Content for POST/DELETE completion
|
70
|
+
|
463
71
|
JSON.parse(response.body) if response.body && !response.body.empty?
|
464
72
|
rescue Faraday::Error => e
|
465
73
|
handle_faraday_error(e)
|
@@ -469,12 +77,12 @@ module ActiveProject
|
|
469
77
|
def handle_faraday_error(error)
|
470
78
|
status = error.response_status
|
471
79
|
body = error.response_body
|
472
|
-
# Removed debug puts for cleaner output
|
473
|
-
# puts "[DEBUG BC Response] Status: #{error.response_status}"
|
474
|
-
# puts "[DEBUG BC Response] Headers: #{error.response_headers.inspect}"
|
475
|
-
# puts "[DEBUG BC Response] Body: #{error.response_body.inspect}"
|
476
80
|
|
477
|
-
parsed_body =
|
81
|
+
parsed_body = begin
|
82
|
+
JSON.parse(body)
|
83
|
+
rescue StandardError
|
84
|
+
{ "error" => body }
|
85
|
+
end
|
478
86
|
message = parsed_body["error"] || parsed_body["message"] || "Unknown Basecamp Error"
|
479
87
|
|
480
88
|
case status
|
@@ -488,18 +96,22 @@ module ActiveProject
|
|
488
96
|
msg += ". Retry after #{retry_after} seconds." if retry_after
|
489
97
|
raise RateLimitError, msg
|
490
98
|
when 400, 422
|
491
|
-
raise ValidationError.new("Basecamp validation failed (Status: #{status}): #{message}", status_code: status,
|
99
|
+
raise ValidationError.new("Basecamp validation failed (Status: #{status}): #{message}", status_code: status,
|
100
|
+
response_body: body)
|
492
101
|
else
|
493
|
-
raise ApiError.new("Basecamp API error (Status: #{status || 'N/A'}): #{message}", original_error: error,
|
102
|
+
raise ApiError.new("Basecamp API error (Status: #{status || 'N/A'}): #{message}", original_error: error,
|
103
|
+
status_code: status, response_body: body)
|
494
104
|
end
|
495
105
|
end
|
496
106
|
|
497
107
|
# Parses the 'next' link URL from the Link header.
|
498
108
|
def parse_next_link(link_header)
|
499
109
|
return nil unless link_header
|
110
|
+
|
500
111
|
links = link_header.split(",").map(&:strip)
|
501
112
|
next_link = links.find { |link| link.end_with?('rel="next"') }
|
502
113
|
return nil unless next_link
|
114
|
+
|
503
115
|
match = next_link.match(/<([^>]+)>/)
|
504
116
|
match ? match[1] : nil
|
505
117
|
end
|
@@ -513,21 +125,20 @@ module ActiveProject
|
|
513
125
|
reporter = map_user_data(todo_data["creator"])
|
514
126
|
|
515
127
|
Resources::Issue.new(self, # Pass adapter instance
|
516
|
-
|
517
|
-
|
518
|
-
|
519
|
-
|
520
|
-
|
521
|
-
|
522
|
-
|
523
|
-
|
524
|
-
|
525
|
-
|
526
|
-
|
527
|
-
|
528
|
-
|
529
|
-
|
530
|
-
)
|
128
|
+
id: todo_data["id"],
|
129
|
+
key: nil,
|
130
|
+
title: todo_data["content"],
|
131
|
+
description: todo_data["description"],
|
132
|
+
status: status,
|
133
|
+
assignees: assignees, # Use mapped User resources
|
134
|
+
reporter: reporter, # Use mapped User resource
|
135
|
+
project_id: project_id,
|
136
|
+
created_at: todo_data["created_at"] ? Time.parse(todo_data["created_at"]) : nil,
|
137
|
+
updated_at: todo_data["updated_at"] ? Time.parse(todo_data["updated_at"]) : nil,
|
138
|
+
due_on: todo_data["due_on"] ? Date.parse(todo_data["due_on"]) : nil,
|
139
|
+
priority: nil, # Basecamp doesn't have priority
|
140
|
+
adapter_source: :basecamp,
|
141
|
+
raw_data: todo_data)
|
531
142
|
end
|
532
143
|
|
533
144
|
# Maps raw Basecamp Person data hash to a User resource.
|
@@ -535,43 +146,29 @@ module ActiveProject
|
|
535
146
|
# @return [Resources::User, nil]
|
536
147
|
def map_user_data(person_data)
|
537
148
|
return nil unless person_data && person_data["id"]
|
149
|
+
|
538
150
|
Resources::User.new(self, # Pass adapter instance
|
539
|
-
|
540
|
-
|
541
|
-
|
542
|
-
|
543
|
-
|
544
|
-
)
|
151
|
+
id: person_data["id"],
|
152
|
+
name: person_data["name"],
|
153
|
+
email: person_data["email_address"],
|
154
|
+
adapter_source: :basecamp,
|
155
|
+
raw_data: person_data)
|
545
156
|
end
|
546
157
|
|
547
158
|
# Helper to map Basecamp comment data to a Comment resource.
|
548
159
|
def map_comment_data(comment_data, todo_id)
|
549
160
|
Resources::Comment.new(self, # Pass adapter instance
|
550
|
-
|
551
|
-
|
552
|
-
|
553
|
-
|
554
|
-
|
555
|
-
|
556
|
-
|
557
|
-
|
558
|
-
)
|
161
|
+
id: comment_data["id"],
|
162
|
+
body: comment_data["content"], # HTML
|
163
|
+
author: map_user_data(comment_data["creator"]), # Use user mapping
|
164
|
+
created_at: comment_data["created_at"] ? Time.parse(comment_data["created_at"]) : nil,
|
165
|
+
updated_at: comment_data["updated_at"] ? Time.parse(comment_data["updated_at"]) : nil,
|
166
|
+
issue_id: todo_id.to_i,
|
167
|
+
adapter_source: :basecamp,
|
168
|
+
raw_data: comment_data)
|
559
169
|
end
|
560
170
|
|
561
171
|
# Finds the ID of the first todolist in a project.
|
562
|
-
def find_first_todolist_id(project_id)
|
563
|
-
project_data = make_request(:get, "projects/#{project_id}.json")
|
564
|
-
todoset_dock_entry = project_data&.dig("dock")&.find { |d| d["name"] == "todoset" }
|
565
|
-
todoset_url = todoset_dock_entry&.dig("url")
|
566
|
-
return nil unless todoset_url
|
567
|
-
todoset_id = todoset_url.match(/todosets\/(\d+)\.json$/)&.captures&.first
|
568
|
-
return nil unless todoset_id
|
569
|
-
todolists_url_path = "buckets/#{project_id}/todosets/#{todoset_id}/todolists.json"
|
570
|
-
todolists_data = make_request(:get, todolists_url_path)
|
571
|
-
todolists_data&.first&.dig("id")
|
572
|
-
rescue NotFoundError
|
573
|
-
nil
|
574
|
-
end
|
575
172
|
end
|
576
173
|
end
|
577
174
|
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveProject
|
4
|
+
module Adapters
|
5
|
+
module Jira
|
6
|
+
module Comments
|
7
|
+
# Adds a comment to an issue in Jira using the V3 endpoint.
|
8
|
+
# @param issue_id_or_key [String, Integer] The ID or key of the issue.
|
9
|
+
# @param comment_body [String] The text of the comment.
|
10
|
+
# @param context [Hash] Optional context (ignored).
|
11
|
+
# @return [ActiveProject::Resources::Comment] The created comment resource.
|
12
|
+
def add_comment(issue_id_or_key, comment_body, _context = {})
|
13
|
+
path = "/rest/api/3/issue/#{issue_id_or_key}/comment"
|
14
|
+
|
15
|
+
payload = {
|
16
|
+
body: {
|
17
|
+
type: "doc", version: 1,
|
18
|
+
content: [ { type: "paragraph", content: [ { type: "text", text: comment_body } ] } ]
|
19
|
+
}
|
20
|
+
}.to_json
|
21
|
+
|
22
|
+
comment_data = make_request(:post, path, payload)
|
23
|
+
map_comment_data(comment_data, issue_id_or_key)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|