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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9fc9c393c3294c98f99863d698a7288077f6e71977e868c3a2ed0c2a5a5f7cbc
4
- data.tar.gz: ff6f35f9cb6f58529be940efddb58d84f60490ee0bf02d1a6aba5f4e655dca51
3
+ metadata.gz: b9726d3cbaf8990833d420df514aeb0e7f283bc443756406f1600e95e73a3f79
4
+ data.tar.gz: f641d36b536206152d300ccc7f054a6ebe0768737eb2a6d658c45a044edd5938
5
5
  SHA512:
6
- metadata.gz: 251021a9cbb4f3e0556092d7e8667b84324fcacbfee601683a3fadf3822b5a67b9a2c4b68744648d49a892c9690b99600879e16a5132d32ceac8d6177dc9cb60
7
- data.tar.gz: 72a0056a416007bcc6abcfde009ec26674a37b6fe1864a01f7d9f01a13c8059adba6cf44208657e442170db5dc7fcdebfff6c0b7dee1d36b06f8dcfb8465ea28
6
+ metadata.gz: b86037bbf5ba508e1c7544bdd8aa5787db7a7b340c211029dcb7b4348a45b313b13a8d8e16e90ef083c86a291c332465efafe4e9079a4f8015ff3a4c14850786
7
+ data.tar.gz: 4dd7f59b51e515dae0a57299841573343983160a030adc62c146b9ad8858be262c249cb6b8f2a58e81c8241559a943263bf0b15dc5f31c1b35a86c8604eb6d5e
data/README.md CHANGED
@@ -1,50 +1,68 @@
1
1
  # ActiveProject Gem
2
2
 
3
- A standardized Ruby interface for multiple project management APIs (Jira, Basecamp, Trello, etc.).
3
+ A standardized Ruby interface for multiple project-management APIs
4
+ (Jira, Basecamp, Trello, GitHub Projects, …).
4
5
 
5
6
  ## Problem
6
7
 
7
- Integrating with various project management platforms like Jira, Basecamp, and Trello often requires writing separate, complex clients for each API. Developers face challenges in handling different authentication methods, error formats, data structures, and workflow concepts across these platforms.
8
+ Every platform—Jira, Basecamp, Trello, GitHub—ships its **own** authentication flow, error vocabulary, data model, and workflow quirks.
9
+ Teams end up maintaining a grab-bag of fragile, bespoke clients.
8
10
 
9
11
  ## Solution
10
12
 
11
- The ActiveProject gem aims to solve this by providing a unified, opinionated interface built on the **Adapter pattern**. It abstracts away the complexities of individual APIs, offering:
13
+ ActiveProject wraps those APIs behind a single, opinionated interface:
12
14
 
13
- * **Normalized Data Models:** Common Ruby objects for core concepts like `Project`, `Issue` (Issue/Task/Card/To-do), `Comment`, and `User`.
14
- * **Standardized Operations:** Consistent methods for creating, reading, updating, and transitioning issues (e.g., `issue.close!`, `issue.reopen!`).
15
- * **Unified Error Handling:** A common set of exceptions (`AuthenticationError`, `NotFoundError`, `RateLimitError`, etc.) regardless of the underlying platform.
16
- * **Co-operative Concurrency:** Optional fiber-based I/O (powered by the [`async`](https://github.com/socketry/async) ecosystem) for bulk operations without threads.
15
+ | Feature | What you get |
16
+ |---------|--------------|
17
+ | **Normalized models** | `Project`, `Issue` (Task/Card/To-do), `Comment`, `User`—same Ruby objects everywhere. |
18
+ | **Standard CRUD** | `issue.close!`, `issue.reopen!`, `project.list_issues`, etc. |
19
+ | **Unified errors** | `AuthenticationError`, `NotFoundError`, `RateLimitError`, … regardless of the backend. |
20
+ | **Co-operative concurrency** | Fiber-based I/O (via [`async`](https://github.com/socketry/async)) for painless parallel fan-out. |
17
21
 
18
- ## Supported Platforms
19
22
 
20
- The initial focus is on integrating with platforms primarily via their **REST APIs**:
23
+ ## Supported Platforms (initial wave)
21
24
 
22
- * **Jira (Cloud & Server):** REST API (v3)
23
- * **Basecamp (v3+):** REST API
24
- * **Trello:** REST API
25
+ | Platform | API | Notes |
26
+ |---------------------------|------------|------------------------------|
27
+ | **Jira** (Cloud & Server) | REST v3 | Full issue & project support |
28
+ | **Basecamp** | REST v3+ | Maps To-dos ↔ Issues |
29
+ | **Trello** | REST | Cards ↔ Issues |
30
+ | **GitHub Projects V2** | GraphQL v4 | |
31
+ | **GitHub** | REST v3 | Issues and repositories |
25
32
 
26
- Future integrations might include platforms like Asana (REST), Monday.com (GraphQL), and Linear (GraphQL). For GraphQL-based APIs, the adapter will encapsulate the query logic, maintaining a consistent interface for the gem user.
33
+ _Planned next_: Asana, Monday.com, Linear, etc.
27
34
 
28
35
  ## Core Concepts
29
36
 
30
- * **Project:** Represents a Jira Project, Basecamp Project, or Trello Board.
31
- * **Task:** A unified representation of a Jira Issue, Basecamp To-do, or Trello Card. Includes normalized fields like `title`, `description`, `assignees`, `status`, and `priority`.
32
- * **Status Normalization:** Maps platform-specific statuses (Jira statuses, Basecamp completion, Trello lists) to a common set like `:open`, `:in_progress`, `:closed`.
33
- * **Priority Normalization:** Maps priorities (where available, like in Jira) to a standard scale (e.g., `:low`, `:medium`, `:high`).
37
+ * **Project** Jira Project, Basecamp Project, Trello Board, GitHub ProjectV2, or GitHub Repository.
38
+ * **Issue** Unified wrapper around Jira Issue, Basecamp To-do, Trello Card, GitHub Issue/PR.
39
+ *GitHub DraftIssues intentionally omitted for now.*
40
+ * **Status** Normalized to `:open`, `:in_progress`, `:blocked`, `:on_hold`, `:closed`.
41
+ * **Priority** – Normalized to `:low`, `:medium`, `:high` (where supported).
42
+
43
+ ---
34
44
 
35
45
  ## Architecture
36
46
 
37
- The gem uses an **Adapter pattern**, with specific adapters (`Adapters::JiraAdapter`, `Adapters::BasecampAdapter`, etc.) implementing a common interface. This allows for easy extension to new platforms.
47
+ ```
48
+
49
+ ActiveProject
50
+ └── Adapters
51
+ ├── JiraAdapter
52
+ ├── BasecampAdapter
53
+ ├── TrelloAdapter
54
+ ├── GithubProjectAdapter
55
+ └── GithubAdapter
56
+
57
+ ````
58
+ Add a new platform by subclassing and conforming to the common contract.
59
+ ---
38
60
 
39
61
  ## Planned Features
40
62
 
41
- * CRUD operations for Projects and Tasks.
42
- * Unified status transitions.
43
- * Comment management.
44
- * Standardized error handling and reporting.
45
- * Webhook support for real-time updates from platforms.
46
- * Configuration management for API credentials.
47
- * Utilization of **Mermaid diagrams** to visualize workflows and integration logic within documentation.
63
+ * Webhook helpers for real-time updates
64
+ * Centralised credential/config store
65
+ * Mermaid diagrams for docs & SDK flow-charts
48
66
 
49
67
  ## Installation
50
68
 
@@ -74,31 +92,29 @@ Configure multiple adapters, optionally with named instances (default is `:prima
74
92
 
75
93
  ```ruby
76
94
  ActiveProject.configure do |config|
77
- # Primary Jira instance (default name :primary)
78
- config.add_adapter(:jira,
79
- site_url: ENV.fetch('JIRA_SITE_URL'),
80
- username: ENV.fetch('JIRA_USERNAME'),
81
- api_token: ENV.fetch('JIRA_API_TOKEN')
82
- )
83
-
84
- # Secondary Jira instance
85
- config.add_adapter(:jira, :secondary,
86
- site_url: ENV.fetch('JIRA_SECOND_SITE_URL'),
87
- username: ENV.fetch('JIRA_SECOND_USERNAME'),
88
- api_token: ENV.fetch('JIRA_SECOND_API_TOKEN')
89
- )
90
-
91
- # Basecamp primary instance
92
- config.add_adapter(:basecamp,
93
- account_id: ENV.fetch('BASECAMP_ACCOUNT_ID'),
94
- access_token: ENV.fetch('BASECAMP_ACCESS_TOKEN')
95
- )
96
-
97
- # Trello primary instance
98
- config.add_adapter(:trello,
99
- key: ENV.fetch('TRELLO_KEY'),
100
- token: ENV.fetch('TRELLO_TOKEN')
101
- )
95
+ config.add_adapter :jira,
96
+ site_url: ENV["JIRA_SITE_URL"],
97
+ username: ENV["JIRA_USERNAME"],
98
+ api_token: ENV["JIRA_API_TOKEN"]
99
+
100
+ config.add_adapter :basecamp,
101
+ account_id: ENV["BASECAMP_ACCOUNT_ID"],
102
+ access_token: ENV["BASECAMP_ACCESS_TOKEN"]
103
+
104
+ config.add_adapter :trello,
105
+ key: ENV["TRELLO_KEY"],
106
+ token: ENV["TRELLO_TOKEN"]
107
+
108
+ # GitHub Projects – real Issues/PRs only
109
+ config.add_adapter :github_project,
110
+ access_token: ENV["GITHUB_TOKEN"]
111
+
112
+ # GitHub primary instance
113
+ config.add_adapter :github,
114
+ owner: ENV["GITHUB_OWNER"],
115
+ repo: ENV["GITHUB_REPO"],
116
+ access_token: ENV["GITHUB_ACCESS_TOKEN"],
117
+ webhook_secret: ENV["GITHUB_WEBHOOK_SECRET"]
102
118
  end
103
119
  ```
104
120
 
@@ -111,6 +127,8 @@ jira_primary = ActiveProject.adapter(:jira) # defaults to :primary
111
127
  jira_secondary = ActiveProject.adapter(:jira, :secondary)
112
128
  basecamp = ActiveProject.adapter(:basecamp) # defaults to :primary
113
129
  trello = ActiveProject.adapter(:trello) # defaults to :primary
130
+ github = ActiveProject.adapter(:github) # defaults to :primary
131
+ github_project = ActiveProject.adapter(:github_project) # defaults to :primary
114
132
  ```
115
133
 
116
134
  ### Basic Usage (Jira Example)
@@ -175,8 +193,7 @@ end
175
193
 
176
194
  ```ruby
177
195
  # Get the configured Basecamp adapter instance
178
- basecamp_config = ActiveProject.configuration.adapter_config(:basecamp)
179
- basecamp_adapter = ActiveProject::Adapters::BasecampAdapter.new(**basecamp_config)
196
+ basecamp_adapter = ActiveProject.adapter(:basecamp)
180
197
 
181
198
  begin
182
199
  # List projects
@@ -238,15 +255,13 @@ end
238
255
 
239
256
  ## Asynchronous I/O
240
257
 
241
- ActiveProject ships with `async-http` under the hood.
258
+ ActiveProject ships with `async-http` under the hood.
242
259
  Enable the non-blocking adapter by setting an ENV var **before** your process boots:
243
260
 
244
261
  ```bash
245
262
  AP_DEFAULT_ADAPTER=async_http
246
263
  ```
247
264
 
248
- ### Parallel fan-out example
249
-
250
265
  ```ruby
251
266
  ActiveProject::Async.run do |task|
252
267
  jira = ActiveProject.adapter(:jira)
@@ -286,6 +301,137 @@ If another gem (e.g. Falcon) already set a scheduler, ActiveProject detects it a
286
301
 
287
302
  ---
288
303
 
304
+ ### Basic Usage (GitHub Example)
305
+
306
+ ```ruby
307
+ # Get the configured GitHub adapter instance
308
+ github_adapter = ActiveProject.adapter(:github)
309
+
310
+ begin
311
+ # With GitHub adapter, a repository is treated as a project.
312
+ # The adapter is configured for a specific repository.
313
+
314
+ # List projects (will return the configured repository)
315
+ projects = github_adapter.list_projects
316
+ puts "Found #{projects.count} GitHub repository."
317
+ repo = projects.first
318
+
319
+ if repo
320
+ puts "Working with repository: #{repo.key} (#{repo.name})"
321
+
322
+ # List issues in the repository
323
+ issues = github_adapter.list_issues(repo.key)
324
+ puts "- Found #{issues.count} issues."
325
+
326
+ # Find a specific issue (replace '1' with a valid issue number)
327
+ # issue_number = 1
328
+ # issue = github_adapter.find_issue(issue_number.to_s)
329
+ # puts "- Found issue: ##{issue.key} - #{issue.title}"
330
+
331
+ # Create a new issue
332
+ puts "Creating a new issue..."
333
+ new_issue_attributes = {
334
+ title: "New issue from ActiveProject Gem #{Time.now}",
335
+ description: "This issue was created via the ActiveProject gem.",
336
+ assignees: [{ name: "username" }] # Optional: Provide GitHub usernames for assignees
337
+ }
338
+ created_issue = github_adapter.create_issue(repo.key, new_issue_attributes)
339
+ puts "- Created issue: ##{created_issue.key} - #{created_issue.title}"
340
+
341
+ # Update the issue
342
+ puts "Updating issue ##{created_issue.key}..."
343
+ updated_issue = github_adapter.update_issue(created_issue.key, { title: "[Updated] #{created_issue.title}" })
344
+ puts "- Updated title: #{updated_issue.title}"
345
+
346
+ # Add a comment
347
+ puts "Adding comment to issue ##{updated_issue.key}..."
348
+ comment = github_adapter.add_comment(updated_issue.key, "This is a comment added via the ActiveProject gem.")
349
+ puts "- Comment added with ID: #{comment.id}"
350
+
351
+ # Close the issue
352
+ puts "Closing issue ##{updated_issue.key}..."
353
+ closed_issue = github_adapter.update_issue(updated_issue.key, { state: "closed" })
354
+ puts "- Issue closed, status: #{closed_issue.status}"
355
+ end
356
+
357
+ rescue ActiveProject::AuthenticationError => e
358
+ puts "Error: GitHub Authentication Failed - #{e.message}"
359
+ rescue ActiveProject::NotFoundError => e
360
+ puts "Error: Resource Not Found - #{e.message}"
361
+ rescue ActiveProject::ValidationError => e
362
+ puts "Error: Validation Failed - #{e.message}"
363
+ rescue ActiveProject::RateLimitError => e
364
+ puts "Error: GitHub Rate Limit Exceeded - #{e.message}"
365
+ rescue ActiveProject::ApiError => e
366
+ puts "Error: GitHub API Error (#{e.status_code}) - #{e.message}"
367
+ rescue => e
368
+ puts "An unexpected error occurred: #{e.message}"
369
+ end
370
+ ```
371
+
372
+ ### Webhook Support (GitHub Example)
373
+
374
+ The GitHub adapter includes support for processing GitHub webhooks, which allows you to receive real-time events when issues are created, updated, commented on, etc.
375
+
376
+ ```ruby
377
+ # Configure the adapter with a webhook secret (same secret used in GitHub webhook settings)
378
+ ActiveProject.configure do |config|
379
+ config.add_adapter(:github,
380
+ owner: ENV.fetch('GITHUB_OWNER'),
381
+ repo: ENV.fetch('GITHUB_REPO'),
382
+ access_token: ENV.fetch('GITHUB_ACCESS_TOKEN'),
383
+ webhook_secret: ENV.fetch('GITHUB_WEBHOOK_SECRET', nil)
384
+ )
385
+ end
386
+
387
+ # In your webhook endpoint (e.g., in a Rails controller)
388
+ def github_webhook
389
+ adapter = ActiveProject.adapter(:github)
390
+
391
+ # Get the raw request body and signature header
392
+ request_body = request.raw_post
393
+ signature = request.headers['X-Hub-Signature-256']
394
+
395
+ # Verify the webhook signature (if webhook_secret is configured)
396
+ if adapter.verify_webhook_signature(request_body, signature)
397
+ # Parse the webhook payload into a standardized WebhookEvent
398
+ event_headers = {
399
+ 'X-GitHub-Event' => request.headers['X-GitHub-Event'],
400
+ 'X-GitHub-Delivery' => request.headers['X-GitHub-Delivery']
401
+ }
402
+
403
+ webhook_event = adapter.parse_webhook(request_body, event_headers)
404
+
405
+ if webhook_event
406
+ # Process different event types
407
+ case webhook_event.type
408
+ when :issue_created
409
+ issue = webhook_event.data[:issue]
410
+ puts "New issue created: ##{issue.key} - #{issue.title}"
411
+
412
+ when :issue_updated, :issue_closed, :issue_reopened
413
+ issue = webhook_event.data[:issue]
414
+ puts "Issue ##{issue.key} was #{webhook_event.type.to_s.sub('issue_', '')}"
415
+
416
+ when :comment_created
417
+ comment = webhook_event.data[:comment]
418
+ issue = webhook_event.data[:issue]
419
+ puts "New comment on issue ##{issue.key}: #{comment.body.truncate(50)}"
420
+ end
421
+
422
+ # The webhook was successfully processed
423
+ head :ok
424
+ else
425
+ # The webhook payload couldn't be parsed
426
+ head :unprocessable_entity
427
+ end
428
+ else
429
+ # The webhook signature verification failed
430
+ head :forbidden
431
+ end
432
+ end
433
+ ```
434
+
289
435
  ## Development
290
436
 
291
437
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -1,10 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../status_mapper"
4
+
3
5
  module ActiveProject
4
6
  module Adapters
5
7
  # Base abstract class defining the interface for all adapters.
6
8
  # Concrete adapters should inherit from this class and implement its abstract methods.
7
9
  class Base
10
+ include ErrorMapper
11
+
12
+ # ─────────────────── Central HTTP-status → exception map ────────────
13
+ rescue_status 401..403, with: ActiveProject::AuthenticationError
14
+ rescue_status 404, with: ActiveProject::NotFoundError
15
+ rescue_status 429, with: ActiveProject::RateLimitError
16
+ rescue_status 400, 422, with: ActiveProject::ValidationError
17
+
18
+ attr_reader :config
19
+
20
+ def initialize(config:)
21
+ @config = config
22
+ @status_mapper = StatusMapper.from_config(adapter_type, config)
23
+ end
24
+
8
25
  # Lists projects accessible by the configured credentials.
9
26
  # @return [Array<ActiveProject::Project>]
10
27
  def list_projects
@@ -52,8 +69,13 @@ module ActiveProject
52
69
 
53
70
  # Finds a specific issue by its ID or key.
54
71
  # @param id [String, Integer] The ID or key of the issue.
55
- # @param context [Hash] Optional context hash (e.g., { project_id: '...' } for Basecamp).
72
+ # @param context [Hash] Optional context hash. Platform-specific requirements:
73
+ # - Basecamp: REQUIRES { project_id: '...' }
74
+ # - Jira: Optional { fields: '...' }
75
+ # - Trello: Optional { fields: '...' }
76
+ # - GitHub: Ignored
56
77
  # @return [ActiveProject::Issue, nil] The issue object or nil if not found.
78
+ # @raise [ArgumentError] if required context is missing (platform-specific).
57
79
  def find_issue(id, context = {})
58
80
  raise NotImplementedError, "#{self.class.name} must implement #find_issue"
59
81
  end
@@ -69,14 +91,30 @@ module ActiveProject
69
91
  # Updates an existing issue.
70
92
  # @param id [String, Integer] The ID or key of the issue to update.
71
93
  # @param attributes [Hash] Issue attributes to update.
72
- # @param context [Hash] Optional context hash (e.g., { project_id: '...' } for Basecamp).
94
+ # @param context [Hash] Optional context hash. Platform-specific requirements:
95
+ # - Basecamp: REQUIRES { project_id: '...' }
96
+ # - Jira: Optional { fields: '...' }
97
+ # - Trello: Optional { fields: '...' }
98
+ # - GitHub: Uses different signature: update_issue(project_id, item_id, attrs)
73
99
  # @return [ActiveProject::Issue] The updated issue object.
100
+ # @raise [ArgumentError] if required context is missing (platform-specific).
101
+ # @note GitHub adapter overrides this with update_issue(project_id, item_id, attrs)
102
+ # due to GraphQL API requirements for project-specific field operations.
74
103
  def update_issue(id, attributes, context = {})
75
104
  raise NotImplementedError, "#{self.class.name} must implement #update_issue"
76
105
  end
77
106
 
78
- # Base implementation of delete_issue that raises NotImplementedError
79
- # This will be included in the base adapter class and overridden by specific adapters
107
+ # Deletes an issue from a project.
108
+ # @param id [String, Integer] The ID or key of the issue to delete.
109
+ # @param context [Hash] Optional context hash. Platform-specific requirements:
110
+ # - Basecamp: REQUIRES { project_id: '...' }
111
+ # - Jira: Optional { delete_subtasks: true/false }
112
+ # - Trello: Ignored
113
+ # - GitHub: Uses different signature: delete_issue(project_id, item_id)
114
+ # @return [Boolean] true if deletion was successful.
115
+ # @raise [ArgumentError] if required context is missing (platform-specific).
116
+ # @note GitHub adapter overrides this with delete_issue(project_id, item_id)
117
+ # due to GraphQL API requirements.
80
118
  def delete_issue(id, context = {})
81
119
  raise NotImplementedError, "The #{self.class.name} adapter does not implement delete_issue"
82
120
  end
@@ -84,21 +122,79 @@ module ActiveProject
84
122
  # Adds a comment to an issue.
85
123
  # @param issue_id [String, Integer] The ID or key of the issue.
86
124
  # @param comment_body [String] The text of the comment.
87
- # @param context [Hash] Optional context hash (e.g., { project_id: '...' } for Basecamp).
125
+ # @param context [Hash] Optional context hash. Platform-specific requirements:
126
+ # - Basecamp: REQUIRES { project_id: '...' }
127
+ # - Jira: Ignored
128
+ # - Trello: Ignored
129
+ # - GitHub: Optional { content_node_id: '...' } for optimization
88
130
  # @return [ActiveProject::Comment] The created comment object.
131
+ # @raise [ArgumentError] if required context is missing (platform-specific).
89
132
  def add_comment(issue_id, comment_body, context = {})
90
133
  raise NotImplementedError, "#{self.class.name} must implement #add_comment"
91
134
  end
92
135
 
136
+ # Updates an existing comment.
137
+ # @param comment_id [String, Integer] The ID of the comment to update.
138
+ # @param body [String] The new comment body text.
139
+ # @param context [Hash] Optional context hash. Platform-specific requirements:
140
+ # - Basecamp: REQUIRES { project_id: '...' }
141
+ # - Jira: REQUIRES { issue_id: '...' }
142
+ # - Trello: Ignored
143
+ # - GitHub: Ignored
144
+ # @return [ActiveProject::Comment] The updated comment object.
145
+ # @raise [NotImplementedError] if the adapter does not support comment updates.
146
+ def update_comment(comment_id, body, context = {})
147
+ raise NotImplementedError, "#{self.class.name} does not support #update_comment"
148
+ end
149
+
150
+ # Deletes a comment.
151
+ # @param comment_id [String, Integer] The ID of the comment to delete.
152
+ # @param context [Hash] Optional context hash. Platform-specific requirements:
153
+ # - Basecamp: REQUIRES { project_id: '...' }
154
+ # - Jira: REQUIRES { issue_id: '...' }
155
+ # - Trello: Ignored
156
+ # - GitHub: Ignored
157
+ # @return [Boolean] true if deletion was successful.
158
+ # @raise [NotImplementedError] if the adapter does not support comment deletion.
159
+ def delete_comment(comment_id, context = {})
160
+ raise NotImplementedError, "#{self.class.name} does not support #delete_comment"
161
+ end
162
+
163
+ # Checks if the adapter supports webhook processing.
164
+ # @return [Boolean] true if the adapter can process webhooks
165
+ def supports_webhooks?
166
+ respond_to?(:parse_webhook, true) &&
167
+ !method(:parse_webhook).source_location.nil? &&
168
+ method(:parse_webhook).source_location[0] != __FILE__
169
+ end
170
+
171
+ # Returns the webhook type identifier for this adapter class.
172
+ # This distinguishes between different adapter types for the same platform.
173
+ # @return [Symbol] The webhook type (e.g., :github_repo, :github_project, :jira, :basecamp)
174
+ def self.webhook_type
175
+ # Default implementation extracts from class name
176
+ # GithubRepoAdapter -> :github_repo, BasecampAdapter -> :basecamp
177
+ class_name = name.split("::").last
178
+ class_name.gsub(/Adapter$/, "").gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym
179
+ end
180
+
181
+ # Instance method that delegates to class method
182
+ # @return [Symbol] The webhook type
183
+ def webhook_type
184
+ self.class.webhook_type
185
+ end
186
+
93
187
  # Verifies the signature of an incoming webhook request, if supported by the platform.
94
- # @param _request_body [String] The raw request body.
95
- # @param _signature_header [String] The value of the platform-specific signature header (e.g., 'X-Trello-Webhook').
96
- # @return [Boolean] true if the signature is valid or verification is not supported/needed, false otherwise.
97
- # @raise [NotImplementedError] if verification is applicable but not implemented by a subclass.
98
- def verify_webhook_signature(_request_body, _signature_header)
99
- # Default implementation assumes no verification needed or supported.
100
- # Adapters supporting verification should override this.
101
- true
188
+ # @param request_body [String] The raw request body.
189
+ # @param signature_header [String] The value of the platform-specific signature header.
190
+ # @param webhook_secret [String] Optional webhook secret for verification.
191
+ # @return [Boolean] true if the signature is valid or verification is not supported, false otherwise.
192
+ # @note Override this method in adapter subclasses to implement platform-specific verification.
193
+ def verify_webhook_signature(request_body, signature_header, webhook_secret: nil)
194
+ # Default implementation assumes no verification needed.
195
+ # Adapters supporting verification should override this method.
196
+ return true unless supports_webhooks? # Allow non-webhook flows by default
197
+ false # Adapters must override this method to implement verification
102
198
  end
103
199
 
104
200
  # Parses an incoming webhook payload into a standardized WebhookEvent struct.
@@ -107,7 +203,9 @@ module ActiveProject
107
203
  # @return [ActiveProject::WebhookEvent, nil] The parsed event object or nil if the payload is irrelevant/unparseable.
108
204
  # @raise [NotImplementedError] if webhook parsing is not implemented for the adapter.
109
205
  def parse_webhook(request_body, headers = {})
110
- raise NotImplementedError, "#{self.class.name} must implement #parse_webhook"
206
+ raise NotImplementedError,
207
+ "#{self.class.name} does not support webhook parsing. " \
208
+ "Webhook support is optional. Check #supports_webhooks? before calling this method."
111
209
  end
112
210
 
113
211
  # Retrieves details for the currently authenticated user.
@@ -124,6 +222,48 @@ module ActiveProject
124
222
  def connected?
125
223
  raise NotImplementedError, "#{self.class.name} must implement #connected?"
126
224
  end
225
+
226
+ # Adapters that do **not** support a custom “status” field can simply rely
227
+ # on this default implementation. Adapters that _do_ care (e.g. the
228
+ # GitHub project adapter which knows its single-select options) already
229
+ # override it.
230
+ #
231
+ # @return [Boolean] _true_ if the symbol is safe to pass through.
232
+ def status_known?(project_id, status_sym)
233
+ @status_mapper.status_known?(status_sym, project_id: project_id)
234
+ end
235
+
236
+ # Returns all valid statuses for the given project context.
237
+ # @param project_id [String, Integer] The project context
238
+ # @return [Array<Symbol>] Array of valid status symbols
239
+ def valid_statuses(project_id = nil)
240
+ @status_mapper.valid_statuses(project_id: project_id)
241
+ end
242
+
243
+ # Normalizes a platform-specific status to a standard symbol.
244
+ # @param platform_status [String, Symbol] Platform-specific status
245
+ # @param project_id [String, Integer] Optional project context
246
+ # @return [Symbol] Normalized status symbol
247
+ def normalize_status(platform_status, project_id: nil)
248
+ @status_mapper.normalize_status(platform_status, project_id: project_id)
249
+ end
250
+
251
+ # Converts a normalized status back to platform-specific format.
252
+ # @param normalized_status [Symbol] Normalized status symbol
253
+ # @param project_id [String, Integer] Optional project context
254
+ # @return [String, Symbol] Platform-specific status
255
+ def denormalize_status(normalized_status, project_id: nil)
256
+ @status_mapper.denormalize_status(normalized_status, project_id: project_id)
257
+ end
258
+
259
+ protected
260
+
261
+ # Returns the adapter type symbol for status mapping.
262
+ # Override in subclasses if the adapter type differs from class name pattern.
263
+ # @return [Symbol] The adapter type
264
+ def adapter_type
265
+ self.class.name.split("::").last.gsub("Adapter", "").downcase.to_sym
266
+ end
127
267
  end
128
268
  end
129
269
  end
@@ -21,6 +21,40 @@ module ActiveProject
21
21
  comment_data = make_request(:post, path, payload)
22
22
  map_comment_data(comment_data, todo_id.to_i)
23
23
  end
24
+
25
+ # Updates a comment on a To-do in Basecamp.
26
+ # @param comment_id [String, Integer] The ID of the comment.
27
+ # @param body [String] The new comment text (HTML).
28
+ # @param context [Hash] Required context: { project_id: '...' }.
29
+ # @return [ActiveProject::Resources::Comment] The updated comment resource.
30
+ def update_comment(comment_id, body, context = {})
31
+ project_id = context[:project_id]
32
+ unless project_id
33
+ raise ArgumentError,
34
+ "Missing required context: :project_id must be provided for BasecampAdapter#update_comment"
35
+ end
36
+
37
+ path = "buckets/#{project_id}/comments/#{comment_id}.json"
38
+ payload = { content: body }.to_json
39
+ comment_data = make_request(:put, path, payload)
40
+ map_comment_data(comment_data, comment_data["parent"]&.dig("id"))
41
+ end
42
+
43
+ # Deletes a comment from a To-do in Basecamp.
44
+ # @param comment_id [String, Integer] The ID of the comment to delete.
45
+ # @param context [Hash] Required context: { project_id: '...' }.
46
+ # @return [Boolean] True if successfully deleted.
47
+ def delete_comment(comment_id, context = {})
48
+ project_id = context[:project_id]
49
+ unless project_id
50
+ raise ArgumentError,
51
+ "Missing required context: :project_id must be provided for BasecampAdapter#delete_comment"
52
+ end
53
+
54
+ path = "buckets/#{project_id}/recordings/#{comment_id}/status/trashed.json"
55
+ make_request(:put, path)
56
+ true
57
+ end
24
58
  end
25
59
  end
26
60
  end
@@ -4,7 +4,7 @@ module ActiveProject
4
4
  module Adapters
5
5
  module Basecamp
6
6
  module Connection
7
- include ActiveProject::Adapters::HttpClient
7
+ include Connections::Rest
8
8
  BASE_URL_TEMPLATE = "https://3.basecampapi.com/%<account_id>s/"
9
9
  # Initializes the Basecamp Adapter.
10
10
  # @param config [Configurations::BaseAdapterConfiguration] The configuration object for Basecamp.
@@ -15,38 +15,20 @@ module ActiveProject
15
15
  unless config.is_a?(ActiveProject::Configurations::BaseAdapterConfiguration)
16
16
  raise ArgumentError, "BasecampAdapter requires a BaseAdapterConfiguration object"
17
17
  end
18
- @config = config
18
+
19
+ super(config: config)
19
20
 
20
21
  account_id = @config.options.fetch(:account_id)
21
22
  access_token = @config.options.fetch(:access_token)
22
23
 
23
- build_connection(
24
+ init_rest(
24
25
  base_url: format(BASE_URL_TEMPLATE, account_id: account_id),
25
26
  auth_middleware: ->(conn) { conn.request :authorization, :bearer, access_token }
26
27
  )
27
28
 
28
- unless account_id && !account_id.empty? && access_token && !access_token.empty?
29
- raise ArgumentError, "BasecampAdapter configuration requires :account_id and :access_token"
30
- end
31
-
32
- @base_url = format(BASE_URL_TEMPLATE, account_id: account_id)
33
- @connection = initialize_connection
34
- end
29
+ return if account_id && !account_id.empty? && access_token && !access_token.empty?
35
30
 
36
- private
37
-
38
- # Initializes the Faraday connection object.
39
- def initialize_connection
40
- access_token = @config.options[:access_token]
41
-
42
- Faraday.new(url: @base_url) do |conn|
43
- conn.request :authorization, :bearer, access_token
44
- conn.request :retry
45
- conn.response :raise_error
46
- conn.headers["Content-Type"] = "application/json"
47
- conn.headers["Accept"] = "application/json"
48
- conn.headers["User-Agent"] = ActiveProject.user_agent
49
- end
31
+ raise ArgumentError, "BasecampAdapter configuration requires :account_id and :access_token"
50
32
  end
51
33
  end
52
34
  end