activeproject 0.2.0 → 0.5.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/README.md +248 -51
- data/lib/active_project/adapters/base.rb +154 -14
- data/lib/active_project/adapters/basecamp/comments.rb +34 -0
- data/lib/active_project/adapters/basecamp/connection.rb +10 -23
- data/lib/active_project/adapters/basecamp/issues.rb +6 -5
- data/lib/active_project/adapters/basecamp/webhooks.rb +7 -8
- data/lib/active_project/adapters/basecamp_adapter.rb +2 -11
- data/lib/active_project/adapters/fizzy/columns.rb +116 -0
- data/lib/active_project/adapters/fizzy/comments.rb +129 -0
- data/lib/active_project/adapters/fizzy/connection.rb +41 -0
- data/lib/active_project/adapters/fizzy/issues.rb +221 -0
- data/lib/active_project/adapters/fizzy/projects.rb +105 -0
- data/lib/active_project/adapters/fizzy_adapter.rb +151 -0
- data/lib/active_project/adapters/github_project/comments.rb +91 -0
- data/lib/active_project/adapters/github_project/connection.rb +58 -0
- data/lib/active_project/adapters/github_project/helpers.rb +100 -0
- data/lib/active_project/adapters/github_project/issues.rb +287 -0
- data/lib/active_project/adapters/github_project/projects.rb +139 -0
- data/lib/active_project/adapters/github_project/webhooks.rb +168 -0
- data/lib/active_project/adapters/github_project.rb +8 -0
- data/lib/active_project/adapters/github_project_adapter.rb +65 -0
- data/lib/active_project/adapters/github_repo/connection.rb +62 -0
- data/lib/active_project/adapters/github_repo/issues.rb +242 -0
- data/lib/active_project/adapters/github_repo/projects.rb +116 -0
- data/lib/active_project/adapters/github_repo/webhooks.rb +354 -0
- data/lib/active_project/adapters/github_repo_adapter.rb +134 -0
- data/lib/active_project/adapters/jira/attribute_normalizer.rb +16 -0
- data/lib/active_project/adapters/jira/comments.rb +41 -0
- data/lib/active_project/adapters/jira/connection.rb +43 -24
- data/lib/active_project/adapters/jira/issues.rb +21 -7
- data/lib/active_project/adapters/jira/projects.rb +3 -1
- data/lib/active_project/adapters/jira/transitions.rb +2 -1
- data/lib/active_project/adapters/jira/webhooks.rb +5 -7
- data/lib/active_project/adapters/jira_adapter.rb +23 -30
- data/lib/active_project/adapters/trello/comments.rb +34 -0
- data/lib/active_project/adapters/trello/connection.rb +28 -21
- data/lib/active_project/adapters/trello/issues.rb +7 -5
- data/lib/active_project/adapters/trello/webhooks.rb +5 -7
- data/lib/active_project/adapters/trello_adapter.rb +5 -25
- data/lib/active_project/association_proxy.rb +3 -2
- data/lib/active_project/async.rb +9 -0
- data/lib/active_project/configuration.rb +6 -3
- data/lib/active_project/configurations/base_adapter_configuration.rb +102 -0
- data/lib/active_project/configurations/basecamp_configuration.rb +42 -0
- data/lib/active_project/configurations/fizzy_configuration.rb +47 -0
- data/lib/active_project/configurations/github_configuration.rb +57 -0
- data/lib/active_project/configurations/jira_configuration.rb +54 -0
- data/lib/active_project/configurations/trello_configuration.rb +24 -2
- data/lib/active_project/connections/base.rb +35 -0
- data/lib/active_project/connections/graph_ql.rb +83 -0
- data/lib/active_project/connections/http_client.rb +79 -0
- data/lib/active_project/connections/pagination.rb +44 -0
- data/lib/active_project/connections/rest.rb +33 -0
- data/lib/active_project/error_mapper.rb +38 -0
- data/lib/active_project/errors.rb +13 -0
- data/lib/active_project/railtie.rb +33 -0
- data/lib/active_project/resource_factory.rb +18 -0
- data/lib/active_project/resources/base_resource.rb +13 -14
- data/lib/active_project/resources/comment.rb +46 -2
- data/lib/active_project/resources/issue.rb +106 -18
- data/lib/active_project/resources/persistable_resource.rb +47 -0
- data/lib/active_project/resources/project.rb +1 -1
- data/lib/active_project/status_mapper.rb +145 -0
- data/lib/active_project/version.rb +1 -1
- data/lib/active_project/webhook_event.rb +34 -12
- data/lib/activeproject.rb +11 -6
- metadata +107 -6
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveProject
|
|
4
|
+
module Adapters
|
|
5
|
+
module GithubRepo
|
|
6
|
+
module Webhooks
|
|
7
|
+
# Validates incoming webhook signature using X-Hub-Signature-256 header
|
|
8
|
+
# @param request_body [String] The raw request body
|
|
9
|
+
# @param signature_header [String] The value of the X-Hub-Signature-256 header
|
|
10
|
+
# @return [Boolean] True if signature is valid or verification is not needed
|
|
11
|
+
def verify_webhook_signature(request_body, signature_header)
|
|
12
|
+
webhook_secret = @config.options[:webhook_secret]
|
|
13
|
+
|
|
14
|
+
# No webhook secret configured = no verification needed
|
|
15
|
+
return true if webhook_secret.nil? || webhook_secret.empty?
|
|
16
|
+
|
|
17
|
+
# Signature header is required when a secret is configured
|
|
18
|
+
return false unless signature_header
|
|
19
|
+
|
|
20
|
+
# GitHub uses 'sha256=' prefix for their signatures
|
|
21
|
+
algorithm, signature = signature_header.split("=", 2)
|
|
22
|
+
return false unless algorithm == "sha256" && signature
|
|
23
|
+
|
|
24
|
+
# Calculate expected signature
|
|
25
|
+
expected_signature = OpenSSL::HMAC.hexdigest(
|
|
26
|
+
OpenSSL::Digest.new("sha256"),
|
|
27
|
+
webhook_secret,
|
|
28
|
+
request_body
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Perform a secure comparison
|
|
32
|
+
secure_compare(signature, expected_signature)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Constant-time comparison to prevent timing attacks
|
|
36
|
+
# @param a [String] First string to compare
|
|
37
|
+
# @param b [String] Second string to compare
|
|
38
|
+
# @return [Boolean] True if strings are equal
|
|
39
|
+
def secure_compare(a, b)
|
|
40
|
+
return false if a.bytesize != b.bytesize
|
|
41
|
+
l = a.unpack("C*")
|
|
42
|
+
|
|
43
|
+
res = 0
|
|
44
|
+
b.each_byte { |byte| res |= byte ^ l.shift }
|
|
45
|
+
res == 0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Parses an incoming webhook payload into a standardized WebhookEvent struct
|
|
49
|
+
# @param request_body [String] The raw request body
|
|
50
|
+
# @param headers [Hash] Hash of request headers
|
|
51
|
+
# @return [ActiveProject::WebhookEvent, nil] The parsed event or nil if not relevant
|
|
52
|
+
def parse_webhook(request_body, headers = {})
|
|
53
|
+
data = JSON.parse(request_body)
|
|
54
|
+
event_type = headers["X-GitHub-Event"]
|
|
55
|
+
|
|
56
|
+
case event_type
|
|
57
|
+
when "issues"
|
|
58
|
+
parse_issue_event(data)
|
|
59
|
+
when "issue_comment"
|
|
60
|
+
parse_comment_event(data)
|
|
61
|
+
when "pull_request"
|
|
62
|
+
parse_pull_request_event(data)
|
|
63
|
+
else
|
|
64
|
+
nil # Unsupported event type
|
|
65
|
+
end
|
|
66
|
+
rescue JSON::ParserError
|
|
67
|
+
nil # Return nil for invalid JSON
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Parses an issue event into a WebhookEvent
|
|
73
|
+
# @param data [Hash] Parsed webhook payload
|
|
74
|
+
# @return [WebhookEvent, nil] The standardized event
|
|
75
|
+
def parse_issue_event(data)
|
|
76
|
+
return nil unless data["issue"]
|
|
77
|
+
|
|
78
|
+
action = data["action"]
|
|
79
|
+
issue_data = data["issue"]
|
|
80
|
+
repository = data["repository"]
|
|
81
|
+
|
|
82
|
+
# Map GitHub action to our event type
|
|
83
|
+
event_type = case action
|
|
84
|
+
when "opened" then :issue_created
|
|
85
|
+
when "edited" then :issue_updated
|
|
86
|
+
when "closed" then :issue_closed
|
|
87
|
+
when "reopened" then :issue_reopened
|
|
88
|
+
when "assigned", "unassigned" then :issue_assigned
|
|
89
|
+
when "labeled", "unlabeled" then :issue_labeled
|
|
90
|
+
else :issue_updated # Default for other actions
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Map the issue data
|
|
94
|
+
issue = map_webhook_issue(issue_data)
|
|
95
|
+
|
|
96
|
+
# Get project (repository) info
|
|
97
|
+
project_id = repository ? repository["full_name"] : nil
|
|
98
|
+
|
|
99
|
+
WebhookEvent.new(
|
|
100
|
+
source: webhook_type,
|
|
101
|
+
type: event_type,
|
|
102
|
+
resource_type: :issue,
|
|
103
|
+
resource_id: issue_data["number"].to_s,
|
|
104
|
+
project_id: project_id,
|
|
105
|
+
data: {
|
|
106
|
+
issue: issue,
|
|
107
|
+
action: action
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Parses a comment event into a WebhookEvent
|
|
113
|
+
# @param data [Hash] Parsed webhook payload
|
|
114
|
+
# @return [WebhookEvent, nil] The standardized event
|
|
115
|
+
def parse_comment_event(data)
|
|
116
|
+
return nil unless data["comment"] && data["issue"]
|
|
117
|
+
|
|
118
|
+
action = data["action"]
|
|
119
|
+
comment_data = data["comment"]
|
|
120
|
+
issue_data = data["issue"]
|
|
121
|
+
repository = data["repository"]
|
|
122
|
+
|
|
123
|
+
# Only handle supported actions
|
|
124
|
+
return nil unless [ "created", "edited", "deleted" ].include?(action)
|
|
125
|
+
|
|
126
|
+
# Map GitHub action to our event type
|
|
127
|
+
event_type = case action
|
|
128
|
+
when "created" then :comment_created
|
|
129
|
+
when "edited" then :comment_updated
|
|
130
|
+
when "deleted" then :comment_deleted
|
|
131
|
+
else nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
return nil unless event_type
|
|
135
|
+
|
|
136
|
+
# Get project (repository) info
|
|
137
|
+
project_id = repository ? repository["full_name"] : nil
|
|
138
|
+
|
|
139
|
+
# Create a webhook event with comment and issue data
|
|
140
|
+
WebhookEvent.new(
|
|
141
|
+
source: webhook_type,
|
|
142
|
+
type: event_type,
|
|
143
|
+
resource_type: :comment,
|
|
144
|
+
resource_id: comment_data["id"].to_s,
|
|
145
|
+
project_id: project_id,
|
|
146
|
+
data: {
|
|
147
|
+
# Map the comment data to a Comment resource
|
|
148
|
+
comment: map_webhook_comment(comment_data, issue_data["number"].to_s),
|
|
149
|
+
# Map the issue data to an Issue resource
|
|
150
|
+
issue: map_webhook_issue(issue_data),
|
|
151
|
+
action: action
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Parses a pull request event into a WebhookEvent
|
|
157
|
+
# GitHub PRs are mapped to issues for compatibility
|
|
158
|
+
# @param data [Hash] Parsed webhook payload
|
|
159
|
+
# @return [WebhookEvent, nil] The standardized event
|
|
160
|
+
def parse_pull_request_event(data)
|
|
161
|
+
return nil unless data["pull_request"]
|
|
162
|
+
|
|
163
|
+
action = data["action"]
|
|
164
|
+
pull_request_data = data["pull_request"]
|
|
165
|
+
repository = data["repository"]
|
|
166
|
+
|
|
167
|
+
# Map GitHub action to our event type (treating PRs as a type of issue)
|
|
168
|
+
event_type = case action
|
|
169
|
+
when "opened" then :issue_created
|
|
170
|
+
when "edited" then :issue_updated
|
|
171
|
+
when "closed"
|
|
172
|
+
pull_request_data["merged"] ? :issue_merged : :issue_closed
|
|
173
|
+
when "reopened" then :issue_reopened
|
|
174
|
+
else :issue_updated # Default for other actions
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Get project (repository) info
|
|
178
|
+
project_id = repository ? repository["full_name"] : nil
|
|
179
|
+
|
|
180
|
+
# Create a synthetic issue from the PR
|
|
181
|
+
# We map PRs to issues for the ActiveProject model
|
|
182
|
+
pr_issue = map_webhook_pull_request_to_issue(pull_request_data)
|
|
183
|
+
|
|
184
|
+
WebhookEvent.new(
|
|
185
|
+
source: webhook_type,
|
|
186
|
+
type: event_type,
|
|
187
|
+
resource_type: :issue, # Map PRs to issues for consistency
|
|
188
|
+
resource_id: pull_request_data["number"].to_s,
|
|
189
|
+
project_id: project_id,
|
|
190
|
+
data: {
|
|
191
|
+
issue: pr_issue,
|
|
192
|
+
action: action,
|
|
193
|
+
is_pull_request: true
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Map webhook issue data to an Issue resource
|
|
199
|
+
# @param issue_data [Hash] Issue data from GitHub webhook
|
|
200
|
+
# @return [ActiveProject::Resources::Issue] Mapped issue resource
|
|
201
|
+
def map_webhook_issue(issue_data)
|
|
202
|
+
return nil unless issue_data
|
|
203
|
+
|
|
204
|
+
# Map state to status
|
|
205
|
+
state = issue_data["state"]
|
|
206
|
+
status = @config.status_mappings[state] || (state == "open" ? :open : :closed)
|
|
207
|
+
|
|
208
|
+
# Map assignees
|
|
209
|
+
assignees = []
|
|
210
|
+
if issue_data["assignees"] && !issue_data["assignees"].empty?
|
|
211
|
+
assignees = issue_data["assignees"].map do |assignee|
|
|
212
|
+
Resources::User.new(
|
|
213
|
+
self,
|
|
214
|
+
id: assignee["id"].to_s,
|
|
215
|
+
name: assignee["login"],
|
|
216
|
+
adapter_source: :github,
|
|
217
|
+
raw_data: assignee
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Map reporter (creator)
|
|
223
|
+
reporter = nil
|
|
224
|
+
if issue_data["user"]
|
|
225
|
+
reporter = Resources::User.new(
|
|
226
|
+
self,
|
|
227
|
+
id: issue_data["user"]["id"].to_s,
|
|
228
|
+
name: issue_data["user"]["login"],
|
|
229
|
+
adapter_source: :github,
|
|
230
|
+
raw_data: issue_data["user"]
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Determine project ID (repository name)
|
|
235
|
+
project_id = issue_data["repository_url"]
|
|
236
|
+
if project_id
|
|
237
|
+
parts = project_id.split("/")
|
|
238
|
+
project_id = "#{parts[-2]}/#{parts[-1]}" if parts.size >= 2
|
|
239
|
+
else
|
|
240
|
+
project_id = "#{@config.options[:owner]}/#{@config.options[:repo]}"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
Resources::Issue.new(
|
|
244
|
+
self,
|
|
245
|
+
id: issue_data["id"].to_s,
|
|
246
|
+
key: issue_data["number"].to_s,
|
|
247
|
+
title: issue_data["title"],
|
|
248
|
+
description: issue_data["body"],
|
|
249
|
+
status: status,
|
|
250
|
+
assignees: assignees,
|
|
251
|
+
reporter: reporter,
|
|
252
|
+
project_id: project_id,
|
|
253
|
+
created_at: issue_data["created_at"] ? Time.parse(issue_data["created_at"]) : nil,
|
|
254
|
+
updated_at: issue_data["updated_at"] ? Time.parse(issue_data["updated_at"]) : nil,
|
|
255
|
+
due_on: nil, # GitHub issues don't have due dates
|
|
256
|
+
priority: nil, # GitHub issues don't have priorities
|
|
257
|
+
adapter_source: :github,
|
|
258
|
+
raw_data: issue_data
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Map webhook comment data to a Comment resource
|
|
263
|
+
# @param comment_data [Hash] Comment data from GitHub webhook
|
|
264
|
+
# @param issue_id [String] The issue ID/number this comment belongs to
|
|
265
|
+
# @return [ActiveProject::Resources::Comment] Mapped comment resource
|
|
266
|
+
def map_webhook_comment(comment_data, issue_id)
|
|
267
|
+
return nil unless comment_data
|
|
268
|
+
|
|
269
|
+
# Map author
|
|
270
|
+
author = nil
|
|
271
|
+
if comment_data["user"]
|
|
272
|
+
author = Resources::User.new(
|
|
273
|
+
self,
|
|
274
|
+
id: comment_data["user"]["id"].to_s,
|
|
275
|
+
name: comment_data["user"]["login"],
|
|
276
|
+
adapter_source: :github,
|
|
277
|
+
raw_data: comment_data["user"]
|
|
278
|
+
)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
Resources::Comment.new(
|
|
282
|
+
self,
|
|
283
|
+
id: comment_data["id"].to_s,
|
|
284
|
+
body: comment_data["body"],
|
|
285
|
+
author: author,
|
|
286
|
+
created_at: comment_data["created_at"] ? Time.parse(comment_data["created_at"]) : nil,
|
|
287
|
+
updated_at: comment_data["updated_at"] ? Time.parse(comment_data["updated_at"]) : nil,
|
|
288
|
+
issue_id: issue_id,
|
|
289
|
+
adapter_source: :github,
|
|
290
|
+
raw_data: comment_data
|
|
291
|
+
)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Maps a pull request to an issue for compatibility
|
|
295
|
+
# @param pull_request_data [Hash] Pull request data from webhook
|
|
296
|
+
# @return [ActiveProject::Resources::Issue] Issue representation of the PR
|
|
297
|
+
def map_webhook_pull_request_to_issue(pull_request_data)
|
|
298
|
+
return nil unless pull_request_data
|
|
299
|
+
|
|
300
|
+
# Get state from PR data
|
|
301
|
+
state = pull_request_data["state"]
|
|
302
|
+
status = @config.status_mappings[state] || (state == "open" ? :open : :closed)
|
|
303
|
+
|
|
304
|
+
# Extract assignees if present
|
|
305
|
+
assignees = []
|
|
306
|
+
if pull_request_data["assignees"]
|
|
307
|
+
assignees = pull_request_data["assignees"].map do |assignee|
|
|
308
|
+
Resources::User.new(
|
|
309
|
+
self,
|
|
310
|
+
id: assignee["id"].to_s,
|
|
311
|
+
name: assignee["login"],
|
|
312
|
+
adapter_source: :github,
|
|
313
|
+
raw_data: assignee
|
|
314
|
+
)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Extract reporter (user who created the PR)
|
|
319
|
+
reporter = nil
|
|
320
|
+
if pull_request_data["user"]
|
|
321
|
+
reporter = Resources::User.new(
|
|
322
|
+
self,
|
|
323
|
+
id: pull_request_data["user"]["id"].to_s,
|
|
324
|
+
name: pull_request_data["user"]["login"],
|
|
325
|
+
adapter_source: :github,
|
|
326
|
+
raw_data: pull_request_data["user"]
|
|
327
|
+
)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Determine project ID
|
|
331
|
+
project_id = pull_request_data.dig("base", "repo", "full_name") ||
|
|
332
|
+
"#{@config.options[:owner]}/#{@config.options[:repo]}"
|
|
333
|
+
|
|
334
|
+
# Create an issue resource from the PR data
|
|
335
|
+
Resources::Issue.new(
|
|
336
|
+
self,
|
|
337
|
+
id: pull_request_data["id"].to_s,
|
|
338
|
+
key: pull_request_data["number"].to_s,
|
|
339
|
+
title: pull_request_data["title"],
|
|
340
|
+
description: pull_request_data["body"],
|
|
341
|
+
status: status,
|
|
342
|
+
assignees: assignees,
|
|
343
|
+
reporter: reporter,
|
|
344
|
+
project_id: project_id,
|
|
345
|
+
created_at: pull_request_data["created_at"] ? Time.parse(pull_request_data["created_at"]) : nil,
|
|
346
|
+
updated_at: pull_request_data["updated_at"] ? Time.parse(pull_request_data["updated_at"]) : nil,
|
|
347
|
+
adapter_source: :github,
|
|
348
|
+
raw_data: pull_request_data
|
|
349
|
+
)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
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 GitHub REST API.
|
|
11
|
+
# Implements the interface defined in ActiveProject::Adapters::Base.
|
|
12
|
+
# API Docs: https://docs.github.com/en/rest
|
|
13
|
+
class GithubRepoAdapter < Base
|
|
14
|
+
attr_reader :config
|
|
15
|
+
|
|
16
|
+
include GithubRepo::Connection
|
|
17
|
+
include GithubRepo::Projects
|
|
18
|
+
include GithubRepo::Issues
|
|
19
|
+
include GithubRepo::Webhooks
|
|
20
|
+
|
|
21
|
+
# Retrieves details for the currently authenticated user.
|
|
22
|
+
# @return [ActiveProject::Resources::User] The user object.
|
|
23
|
+
# @raise [ActiveProject::AuthenticationError] if authentication fails.
|
|
24
|
+
# @raise [ActiveProject::ApiError] for other API-related errors.
|
|
25
|
+
def get_current_user
|
|
26
|
+
user_data = make_request(:get, "user")
|
|
27
|
+
map_user_data(user_data)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Checks if the adapter can successfully authenticate and connect to the service.
|
|
31
|
+
# Calls #get_current_user internally and catches authentication errors.
|
|
32
|
+
# @return [Boolean] true if connection is successful, false otherwise.
|
|
33
|
+
def connected?
|
|
34
|
+
get_current_user
|
|
35
|
+
true
|
|
36
|
+
rescue ActiveProject::AuthenticationError
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns a factory for Project resources.
|
|
41
|
+
# In GitHub's context, this is for interacting with repositories.
|
|
42
|
+
# @return [ResourceFactory<Resources::Project>]
|
|
43
|
+
def projects
|
|
44
|
+
ResourceFactory.new(adapter: self, resource_class: Resources::Project)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns a factory for Issue resources.
|
|
48
|
+
# @return [ResourceFactory<Resources::Issue>]
|
|
49
|
+
def issues
|
|
50
|
+
ResourceFactory.new(adapter: self, resource_class: Resources::Issue)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
protected
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Helper method for making requests to the GitHub API.
|
|
58
|
+
# @param method [Symbol] HTTP method (:get, :post, :patch, :delete, etc.)
|
|
59
|
+
# @param path [String] API endpoint path
|
|
60
|
+
# @param body [Hash, nil] Request body (for POST/PATCH requests)
|
|
61
|
+
# @param query [Hash, nil] Query parameters
|
|
62
|
+
# @return [Hash, Array, nil] Parsed JSON response or nil if response is empty
|
|
63
|
+
# @raise [ActiveProject::ApiError] for various API errors
|
|
64
|
+
def make_request(method, path, body = nil, query = nil)
|
|
65
|
+
json_body = body ? JSON.generate(body) : nil
|
|
66
|
+
|
|
67
|
+
response = @connection.run_request(method, path, json_body, nil) do |req|
|
|
68
|
+
req.params = query if query
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
return nil if response.status == 204 || response.body.empty?
|
|
72
|
+
|
|
73
|
+
JSON.parse(response.body)
|
|
74
|
+
rescue Faraday::Error => e
|
|
75
|
+
handle_faraday_error(e)
|
|
76
|
+
rescue JSON::ParserError => e
|
|
77
|
+
raise ApiError.new("GitHub API returned non-JSON response: #{response&.body}", original_error: e)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Handles Faraday errors and converts them to appropriate ActiveProject error types.
|
|
81
|
+
# @param error [Faraday::Error] The Faraday error to handle
|
|
82
|
+
# @raise [ActiveProject::AuthenticationError] for 401/403 errors
|
|
83
|
+
# @raise [ActiveProject::NotFoundError] for 404 errors
|
|
84
|
+
# @raise [ActiveProject::ValidationError] for 422 errors
|
|
85
|
+
# @raise [ActiveProject::RateLimitError] for 429 errors
|
|
86
|
+
# @raise [ActiveProject::ApiError] for other errors
|
|
87
|
+
def handle_faraday_error(error)
|
|
88
|
+
status = error.response_status
|
|
89
|
+
body = error.response_body
|
|
90
|
+
|
|
91
|
+
begin
|
|
92
|
+
parsed_body = JSON.parse(body)
|
|
93
|
+
message = parsed_body["message"]
|
|
94
|
+
rescue
|
|
95
|
+
message = body || "Unknown GitHub Error"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
case status
|
|
99
|
+
when 401, 403
|
|
100
|
+
raise AuthenticationError, "GitHub authentication failed (Status: #{status}): #{message}"
|
|
101
|
+
when 404
|
|
102
|
+
raise NotFoundError, "GitHub resource not found (Status: 404): #{message}"
|
|
103
|
+
when 422
|
|
104
|
+
raise ValidationError.new("GitHub validation failed (Status: 422): #{message}",
|
|
105
|
+
status_code: status,
|
|
106
|
+
response_body: body)
|
|
107
|
+
when 429
|
|
108
|
+
raise RateLimitError, "GitHub rate limit exceeded (Status: 429): #{message}"
|
|
109
|
+
else
|
|
110
|
+
raise ApiError.new("GitHub API error (Status: #{status || 'N/A'}): #{message}",
|
|
111
|
+
original_error: error,
|
|
112
|
+
status_code: status,
|
|
113
|
+
response_body: body)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Maps raw GitHub user data hash to a User resource.
|
|
118
|
+
# @param user_data [Hash, nil] Raw user data from GitHub API
|
|
119
|
+
# @return [Resources::User, nil] The mapped user object or nil if user_data is nil
|
|
120
|
+
def map_user_data(user_data)
|
|
121
|
+
return nil unless user_data && user_data["id"]
|
|
122
|
+
|
|
123
|
+
Resources::User.new(
|
|
124
|
+
self,
|
|
125
|
+
id: user_data["id"].to_s,
|
|
126
|
+
name: user_data["login"],
|
|
127
|
+
email: user_data["email"],
|
|
128
|
+
adapter_source: :github,
|
|
129
|
+
raw_data: user_data
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveProject
|
|
4
|
+
module Adapters
|
|
5
|
+
module Jira
|
|
6
|
+
module AttributeNormalizer
|
|
7
|
+
# Normalise Issue attributes before they hit Jira’s REST API
|
|
8
|
+
def normalize_issue_attrs(attrs)
|
|
9
|
+
attrs = attrs.dup
|
|
10
|
+
attrs[:summary] = attrs.delete(:title) if attrs.key?(:title)
|
|
11
|
+
attrs
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -22,6 +22,47 @@ module ActiveProject
|
|
|
22
22
|
comment_data = make_request(:post, path, payload)
|
|
23
23
|
map_comment_data(comment_data, issue_id_or_key)
|
|
24
24
|
end
|
|
25
|
+
|
|
26
|
+
# Updates a comment on an issue in Jira using the V3 endpoint.
|
|
27
|
+
# @param comment_id [String, Integer] The ID of the comment.
|
|
28
|
+
# @param body [String] The new comment text.
|
|
29
|
+
# @param context [Hash] Required context: { issue_id: '...' }.
|
|
30
|
+
# @return [ActiveProject::Resources::Comment] The updated comment resource.
|
|
31
|
+
def update_comment(comment_id, body, context = {})
|
|
32
|
+
issue_id_or_key = context[:issue_id]
|
|
33
|
+
unless issue_id_or_key
|
|
34
|
+
raise ArgumentError,
|
|
35
|
+
"Missing required context: :issue_id must be provided for JiraAdapter#update_comment"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
path = "/rest/api/3/issue/#{issue_id_or_key}/comment/#{comment_id}"
|
|
39
|
+
|
|
40
|
+
payload = {
|
|
41
|
+
body: {
|
|
42
|
+
type: "doc", version: 1,
|
|
43
|
+
content: [ { type: "paragraph", content: [ { type: "text", text: body } ] } ]
|
|
44
|
+
}
|
|
45
|
+
}.to_json
|
|
46
|
+
|
|
47
|
+
comment_data = make_request(:put, path, payload)
|
|
48
|
+
map_comment_data(comment_data, issue_id_or_key)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Deletes a comment from an issue in Jira.
|
|
52
|
+
# @param comment_id [String, Integer] The ID of the comment to delete.
|
|
53
|
+
# @param context [Hash] Required context: { issue_id: '...' }.
|
|
54
|
+
# @return [Boolean] True if successfully deleted.
|
|
55
|
+
def delete_comment(comment_id, context = {})
|
|
56
|
+
issue_id_or_key = context[:issue_id]
|
|
57
|
+
unless issue_id_or_key
|
|
58
|
+
raise ArgumentError,
|
|
59
|
+
"Missing required context: :issue_id must be provided for JiraAdapter#delete_comment"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
path = "/rest/api/3/issue/#{issue_id_or_key}/comment/#{comment_id}"
|
|
63
|
+
make_request(:delete, path)
|
|
64
|
+
true
|
|
65
|
+
end
|
|
25
66
|
end
|
|
26
67
|
end
|
|
27
68
|
end
|
|
@@ -1,45 +1,64 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
3
5
|
module ActiveProject
|
|
4
6
|
module Adapters
|
|
5
7
|
module Jira
|
|
8
|
+
# Low-level HTTP concerns for JiraAdapter
|
|
6
9
|
module Connection
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
include Connections::Rest
|
|
11
|
+
|
|
12
|
+
SERAPH_HEADER = "x-seraph-loginreason"
|
|
13
|
+
|
|
14
|
+
# @param config [ActiveProject::Configurations::BaseAdapterConfiguration]
|
|
15
|
+
# Must expose :site_url, :username, :api_token.
|
|
16
|
+
# @raise [ArgumentError] if required keys are missing.
|
|
10
17
|
def initialize(config:)
|
|
11
18
|
unless config.is_a?(ActiveProject::Configurations::BaseAdapterConfiguration)
|
|
12
19
|
raise ArgumentError, "JiraAdapter requires a BaseAdapterConfiguration object"
|
|
13
20
|
end
|
|
14
21
|
|
|
15
|
-
|
|
22
|
+
super(config: config)
|
|
16
23
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
# --- Build an absolute base URL ------------------------------------
|
|
25
|
+
raw_url = @config.options.fetch(:site_url)
|
|
26
|
+
site_url = raw_url =~ %r{\Ahttps?://}i ? raw_url.dup : +"https://#{raw_url}"
|
|
27
|
+
site_url.chomp!("/")
|
|
28
|
+
|
|
29
|
+
username = @config.options.fetch(:username)
|
|
30
|
+
api_token = @config.options.fetch(:api_token)
|
|
22
31
|
|
|
23
|
-
|
|
32
|
+
init_rest(
|
|
33
|
+
base_url: site_url,
|
|
34
|
+
auth_middleware: lambda do |conn|
|
|
35
|
+
# Faraday’s built-in basic-auth helper :contentReference[oaicite:0]{index=0}
|
|
36
|
+
conn.request :authorization, :basic, username, api_token
|
|
37
|
+
end
|
|
38
|
+
)
|
|
24
39
|
end
|
|
25
40
|
|
|
26
41
|
private
|
|
27
42
|
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
# --------------------------------------------------------------------
|
|
44
|
+
# Tiny wrapper around HttpClient#request that handles Jira quirks
|
|
45
|
+
# --------------------------------------------------------------------
|
|
46
|
+
#
|
|
47
|
+
# @param method [Symbol] :get, :post, :put, :delete, …
|
|
48
|
+
# @param path [String] e.g. "/rest/api/3/issue/PROJ-1"
|
|
49
|
+
# @param body [String, Hash, nil]
|
|
50
|
+
# @param query [Hash,nil] additional query-string params
|
|
51
|
+
# @return [Hash, nil] parsed JSON response
|
|
52
|
+
#
|
|
53
|
+
# @raise [ActiveProject::AuthenticationError] if Jira signals
|
|
54
|
+
# AUTHENTICATED_FAILED via X-Seraph-LoginReason header.
|
|
55
|
+
def make_request(method, path, body = nil, query = nil, headers = {})
|
|
56
|
+
res = request_rest(method, path, body, query, headers)
|
|
57
|
+
if last_response&.headers&.[](SERAPH_HEADER)&.include?("AUTHENTICATED_FAILED")
|
|
58
|
+
raise ActiveProject::AuthenticationError, "Jira authentication failed"
|
|
42
59
|
end
|
|
60
|
+
|
|
61
|
+
res
|
|
43
62
|
end
|
|
44
63
|
end
|
|
45
64
|
end
|