activeproject 0.3.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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +201 -55
  3. data/lib/active_project/adapters/base.rb +154 -14
  4. data/lib/active_project/adapters/basecamp/comments.rb +34 -0
  5. data/lib/active_project/adapters/basecamp/connection.rb +6 -24
  6. data/lib/active_project/adapters/basecamp/issues.rb +6 -5
  7. data/lib/active_project/adapters/basecamp/webhooks.rb +7 -8
  8. data/lib/active_project/adapters/fizzy/columns.rb +116 -0
  9. data/lib/active_project/adapters/fizzy/comments.rb +129 -0
  10. data/lib/active_project/adapters/fizzy/connection.rb +41 -0
  11. data/lib/active_project/adapters/fizzy/issues.rb +221 -0
  12. data/lib/active_project/adapters/fizzy/projects.rb +105 -0
  13. data/lib/active_project/adapters/fizzy_adapter.rb +151 -0
  14. data/lib/active_project/adapters/github_project/comments.rb +91 -0
  15. data/lib/active_project/adapters/github_project/connection.rb +58 -0
  16. data/lib/active_project/adapters/github_project/helpers.rb +100 -0
  17. data/lib/active_project/adapters/github_project/issues.rb +287 -0
  18. data/lib/active_project/adapters/github_project/projects.rb +139 -0
  19. data/lib/active_project/adapters/github_project/webhooks.rb +168 -0
  20. data/lib/active_project/adapters/github_project.rb +8 -0
  21. data/lib/active_project/adapters/github_project_adapter.rb +65 -0
  22. data/lib/active_project/adapters/github_repo/connection.rb +62 -0
  23. data/lib/active_project/adapters/github_repo/issues.rb +242 -0
  24. data/lib/active_project/adapters/github_repo/projects.rb +116 -0
  25. data/lib/active_project/adapters/github_repo/webhooks.rb +354 -0
  26. data/lib/active_project/adapters/github_repo_adapter.rb +134 -0
  27. data/lib/active_project/adapters/jira/attribute_normalizer.rb +16 -0
  28. data/lib/active_project/adapters/jira/comments.rb +41 -0
  29. data/lib/active_project/adapters/jira/connection.rb +15 -15
  30. data/lib/active_project/adapters/jira/issues.rb +21 -7
  31. data/lib/active_project/adapters/jira/projects.rb +3 -1
  32. data/lib/active_project/adapters/jira/transitions.rb +2 -1
  33. data/lib/active_project/adapters/jira/webhooks.rb +5 -7
  34. data/lib/active_project/adapters/jira_adapter.rb +23 -3
  35. data/lib/active_project/adapters/trello/comments.rb +34 -0
  36. data/lib/active_project/adapters/trello/connection.rb +12 -9
  37. data/lib/active_project/adapters/trello/issues.rb +7 -5
  38. data/lib/active_project/adapters/trello/webhooks.rb +5 -7
  39. data/lib/active_project/adapters/trello_adapter.rb +5 -3
  40. data/lib/active_project/association_proxy.rb +3 -2
  41. data/lib/active_project/configuration.rb +6 -3
  42. data/lib/active_project/configurations/base_adapter_configuration.rb +102 -0
  43. data/lib/active_project/configurations/basecamp_configuration.rb +42 -0
  44. data/lib/active_project/configurations/fizzy_configuration.rb +47 -0
  45. data/lib/active_project/configurations/github_configuration.rb +57 -0
  46. data/lib/active_project/configurations/jira_configuration.rb +54 -0
  47. data/lib/active_project/configurations/trello_configuration.rb +24 -2
  48. data/lib/active_project/connections/base.rb +35 -0
  49. data/lib/active_project/connections/graph_ql.rb +83 -0
  50. data/lib/active_project/connections/http_client.rb +79 -0
  51. data/lib/active_project/connections/pagination.rb +44 -0
  52. data/lib/active_project/connections/rest.rb +33 -0
  53. data/lib/active_project/error_mapper.rb +38 -0
  54. data/lib/active_project/errors.rb +13 -0
  55. data/lib/active_project/railtie.rb +1 -3
  56. data/lib/active_project/resources/base_resource.rb +13 -14
  57. data/lib/active_project/resources/comment.rb +46 -2
  58. data/lib/active_project/resources/issue.rb +106 -18
  59. data/lib/active_project/resources/persistable_resource.rb +47 -0
  60. data/lib/active_project/resources/project.rb +1 -1
  61. data/lib/active_project/status_mapper.rb +145 -0
  62. data/lib/active_project/version.rb +1 -1
  63. data/lib/active_project/webhook_event.rb +34 -12
  64. data/lib/activeproject.rb +9 -6
  65. metadata +74 -16
  66. data/lib/active_project/adapters/http_client.rb +0 -71
  67. data/lib/active_project/adapters/pagination.rb +0 -68
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module GithubRepo
6
+ module Projects
7
+ # Lists projects accessible by the configured credentials.
8
+ # In GitHub's context, this returns the configured repository as a "project".
9
+ # @return [Array<ActiveProject::Resources::Project>] Array containing the repository as a project
10
+ def list_projects
11
+ # Get the configured repository
12
+ repo_data = make_request(:get, @repo_path)
13
+ [ map_repository_to_project(repo_data) ]
14
+ end
15
+
16
+ # Finds a specific project by its ID or name.
17
+ # In GitHub's context, this finds a repository.
18
+ # @param id [String, Integer] The ID or full_name of the repository
19
+ # @return [ActiveProject::Resources::Project, nil] The repository as a project
20
+ def find_project(id)
21
+ # If id is nil or empty, use the configured repo
22
+ if id.nil? || id.to_s.empty?
23
+ id = @config.options[:repo]
24
+ end
25
+
26
+ # If id matches our configured repo, return that
27
+ if id.to_s == @config.options[:repo] || id.to_s == "#{@config.options[:owner]}/#{@config.options[:repo]}"
28
+ repo_data = make_request(:get, @repo_path)
29
+ return map_repository_to_project(repo_data)
30
+ end
31
+
32
+ # Otherwise, try to find by ID or full name
33
+ begin
34
+ repo_data = make_request(:get, "repositories/#{id}")
35
+ map_repository_to_project(repo_data)
36
+ rescue NotFoundError
37
+ # Try with full name path format (owner/repo)
38
+ if id.to_s.include?("/")
39
+ repo_data = make_request(:get, "repos/#{id}")
40
+ map_repository_to_project(repo_data)
41
+ else
42
+ # Try with owner + repo name
43
+ begin
44
+ repo_data = make_request(:get, "repos/#{@config.options[:owner]}/#{id}")
45
+ map_repository_to_project(repo_data)
46
+ rescue NotFoundError
47
+ raise NotFoundError, "GitHub repository with ID or name '#{id}' not found"
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ # Creates a new repository (project).
54
+ # Note: In most cases users will already have repositories set up.
55
+ # @param attributes [Hash] Repository attributes (name, description, etc.)
56
+ # @return [ActiveProject::Resources::Project] The created repository as a project
57
+ def create_project(attributes)
58
+ # Create in organization or user account based on config
59
+ owner = @config.options[:owner]
60
+
61
+ # Determine if creating in org or personal account
62
+ begin
63
+ make_request(:get, "orgs/#{owner}")
64
+ path = "orgs/#{owner}/repos"
65
+ rescue NotFoundError
66
+ path = "user/repos"
67
+ end
68
+
69
+ data = {
70
+ name: attributes[:name],
71
+ description: attributes[:description],
72
+ private: attributes[:private] || false,
73
+ has_issues: attributes[:has_issues] || true
74
+ }
75
+
76
+ repo_data = make_request(:post, path, data)
77
+ map_repository_to_project(repo_data)
78
+ end
79
+
80
+ # Deletes a repository.
81
+ # Note: This is a destructive operation and generally not recommended.
82
+ # @param repo_id [String] The ID or full_name of the repository
83
+ # @return [Boolean] True if successfully deleted
84
+ def delete_project(repo_id)
85
+ # Find the repository first to get its full path
86
+ repo = find_project(repo_id)
87
+ raise NotFoundError, "Repository not found" unless repo
88
+
89
+ # Delete requires the full path in "owner/repo" format
90
+ full_path = repo.name # We store full_name in the name field
91
+ make_request(:delete, "repos/#{full_path}")
92
+ true
93
+ rescue NotFoundError
94
+ false
95
+ end
96
+
97
+ private
98
+
99
+ # Maps a GitHub repository to an ActiveProject project resource.
100
+ # @param repo_data [Hash] Raw repository data from GitHub API
101
+ # @return [ActiveProject::Resources::Project] The mapped project resource
102
+ def map_repository_to_project(repo_data)
103
+ Resources::Project.new(
104
+ self,
105
+ id: repo_data["id"].to_s,
106
+ key: repo_data["name"], # Repository name (without owner)
107
+ name: repo_data["full_name"], # Full repository name (owner/repo)
108
+ description: repo_data["description"],
109
+ adapter_source: :github,
110
+ raw_data: repo_data
111
+ )
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -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