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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +28 -82
  3. data/Rakefile +4 -2
  4. data/lib/active_project/adapters/base.rb +3 -14
  5. data/lib/active_project/adapters/basecamp/comments.rb +27 -0
  6. data/lib/active_project/adapters/basecamp/connection.rb +49 -0
  7. data/lib/active_project/adapters/basecamp/issues.rb +139 -0
  8. data/lib/active_project/adapters/basecamp/lists.rb +54 -0
  9. data/lib/active_project/adapters/basecamp/projects.rb +110 -0
  10. data/lib/active_project/adapters/basecamp/webhooks.rb +73 -0
  11. data/lib/active_project/adapters/basecamp_adapter.rb +46 -449
  12. data/lib/active_project/adapters/jira/comments.rb +28 -0
  13. data/lib/active_project/adapters/jira/connection.rb +47 -0
  14. data/lib/active_project/adapters/jira/issues.rb +132 -0
  15. data/lib/active_project/adapters/jira/projects.rb +100 -0
  16. data/lib/active_project/adapters/jira/transitions.rb +68 -0
  17. data/lib/active_project/adapters/jira/webhooks.rb +89 -0
  18. data/lib/active_project/adapters/jira_adapter.rb +59 -486
  19. data/lib/active_project/adapters/trello/comments.rb +21 -0
  20. data/lib/active_project/adapters/trello/connection.rb +37 -0
  21. data/lib/active_project/adapters/trello/issues.rb +117 -0
  22. data/lib/active_project/adapters/trello/lists.rb +27 -0
  23. data/lib/active_project/adapters/trello/projects.rb +82 -0
  24. data/lib/active_project/adapters/trello/webhooks.rb +91 -0
  25. data/lib/active_project/adapters/trello_adapter.rb +54 -377
  26. data/lib/active_project/association_proxy.rb +10 -3
  27. data/lib/active_project/configuration.rb +23 -17
  28. data/lib/active_project/configurations/trello_configuration.rb +1 -3
  29. data/lib/active_project/resource_factory.rb +20 -10
  30. data/lib/active_project/resources/comment.rb +0 -5
  31. data/lib/active_project/resources/issue.rb +0 -5
  32. data/lib/active_project/resources/project.rb +0 -3
  33. data/lib/active_project/resources/user.rb +0 -1
  34. data/lib/active_project/version.rb +3 -1
  35. data/lib/activeproject.rb +67 -15
  36. metadata +26 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f3323a59c15ce296050ec1c7b231416d2cac1aa6f78fe133c8d7d2fd3ebb639
4
- data.tar.gz: 0f60e5b29d28e3adca3ce7e26df63929ed3c0d4b898887213823cd284efa365a
3
+ metadata.gz: 46d0e95fea9e696d7f35f2f8ad2cc32a82f9c068aaf834a9daa2650824650620
4
+ data.tar.gz: 709c98e0c3bab903db0447c7d53559c55e5c7c98cffc511a771c3b9f08cfa2fc
5
5
  SHA512:
6
- metadata.gz: 756f9f83b0f443ceb6d426514d79c7c7cca5c8f6b410ab6fbad9469426354735d706d0b00be102a1f36ec4dcb4a7ee04d8b110c6aaaeda3ac8ccce2b61695623
7
- data.tar.gz: 5397faf8bae3c5fe4ab86a98360de55c92237270806ac3372cdf7cff08742fa857ccff9f4a139998116ce366cd321df5afab6d81368a78970822de853d43c68c
6
+ metadata.gz: 7fb8db4e4f952b10364e91727ebec93e6f0e65f6ebf7d5bd331dab77013bd3fc34d100f522a76d5f8d7014d947081a16fdee34535a6327cbabfe6bb74cbfc00b
7
+ data.tar.gz: 15231c9e5b94a103f21b643f219ffebf4747223f2662a410389ecbf142cc1f618c96384dd5314ba56d00afa6bdf4d0f81cfc0ba33bd580f2fa4999dae8a954eb
data/README.md CHANGED
@@ -18,7 +18,7 @@ The ActiveProject gem aims to solve this by providing a unified, opinionated int
18
18
 
19
19
  The initial focus is on integrating with platforms primarily via their **REST APIs**:
20
20
 
21
- * **Jira (Cloud & Server):** REST API (v2/v3)
21
+ * **Jira (Cloud & Server):** REST API (v3)
22
22
  * **Basecamp (v3+):** REST API
23
23
  * **Trello:** REST API
24
24
 
@@ -69,29 +69,49 @@ $ gem install activeproject
69
69
 
70
70
  ### Configuration
71
71
 
72
- Configure the adapters in an initializer (e.g., `config/initializers/active_project.rb`):
72
+ Configure multiple adapters, optionally with named instances (default is `:primary`):
73
73
 
74
74
  ```ruby
75
75
  ActiveProject.configure do |config|
76
- # Configure Jira Adapter
76
+ # Primary Jira instance (default name :primary)
77
77
  config.add_adapter(:jira,
78
78
  site_url: ENV.fetch('JIRA_SITE_URL'),
79
- username: ENV.fetch('JIRA_USERNAME'), # Your Jira email
79
+ username: ENV.fetch('JIRA_USERNAME'),
80
80
  api_token: ENV.fetch('JIRA_API_TOKEN')
81
81
  )
82
82
 
83
+ # Secondary Jira instance
84
+ config.add_adapter(:jira, :secondary,
85
+ site_url: ENV.fetch('JIRA_SECOND_SITE_URL'),
86
+ username: ENV.fetch('JIRA_SECOND_USERNAME'),
87
+ api_token: ENV.fetch('JIRA_SECOND_API_TOKEN')
88
+ )
83
89
 
84
- # Configure Basecamp Adapter
90
+ # Basecamp primary instance
85
91
  config.add_adapter(:basecamp,
86
92
  account_id: ENV.fetch('BASECAMP_ACCOUNT_ID'),
87
93
  access_token: ENV.fetch('BASECAMP_ACCESS_TOKEN')
88
94
  )
89
95
 
90
- # Configure other adapters later (e.g., :trello, :basecamp)
91
- # config.add_adapter(:trello, key: '...', token: '...')
96
+ # Trello primary instance
97
+ config.add_adapter(:trello,
98
+ key: ENV.fetch('TRELLO_KEY'),
99
+ token: ENV.fetch('TRELLO_TOKEN')
100
+ )
92
101
  end
93
102
  ```
94
103
 
104
+ ### Accessing adapters
105
+
106
+ Fetch a specific adapter instance:
107
+
108
+ ```ruby
109
+ jira_primary = ActiveProject.adapter(:jira) # defaults to :primary
110
+ jira_secondary = ActiveProject.adapter(:jira, :secondary)
111
+ basecamp = ActiveProject.adapter(:basecamp) # defaults to :primary
112
+ trello = ActiveProject.adapter(:trello) # defaults to :primary
113
+ ```
114
+
95
115
  ### Basic Usage (Jira Example)
96
116
 
97
117
  ```ruby
@@ -215,80 +235,6 @@ rescue => e
215
235
  end
216
236
  ```
217
237
 
218
-
219
-
220
- ### Webhook Handling
221
-
222
- The gem provides helpers for parsing webhook payloads and verifying signatures (where applicable), but you need to implement your own webhook receiver endpoint (e.g., a Rails controller action).
223
-
224
- ```ruby
225
- # Example Rails Controller Action
226
- class WebhooksController < ApplicationController
227
- # Disable CSRF protection for webhook endpoints
228
- skip_before_action :verify_authenticity_token
229
-
230
- def jira_webhook
231
- adapter = ActiveProject.adapter(:jira)
232
- request_body = request.body.read
233
-
234
- # Verification (if applicable and implemented for your Jira setup)
235
- # signature = request.headers['X-Jira-Signature'] # Example header
236
- # unless adapter.verify_webhook_signature(request_body, signature)
237
- # render plain: 'Invalid signature', status: :unauthorized
238
- # return
239
- # end
240
-
241
- # Parse the event
242
- event = adapter.parse_webhook(request_body, request.headers)
243
-
244
- if event
245
- puts "Received Jira Event: #{event.event_type} for #{event.object_kind} #{event.object_key || event.object_id}"
246
- # Process the event (e.g., queue a background job)
247
- # handle_event(event)
248
- else
249
- puts "Received unhandled or unparseable Jira webhook"
250
- end
251
-
252
- head :ok # Respond to Jira quickly
253
- end
254
-
255
- def trello_webhook
256
- adapter = ActiveProject.adapter(:trello)
257
- request_body = request.body.read
258
- signature = request.headers['X-Trello-Webhook'] # Trello signature header
259
- callback_url = request.original_url # The URL Trello sent the webhook to
260
- trello_api_secret = ENV.fetch('TRELLO_API_SECRET') # You need your secret
261
-
262
- # Verification (Manual comparison using the helper)
263
- expected_signature = ActiveProject::Adapters::TrelloAdapter.compute_webhook_signature(
264
- callback_url,
265
- request_body,
266
- trello_api_secret
267
- )
268
-
269
- unless ActiveSupport::SecurityUtils.secure_compare(signature, expected_signature)
270
- render plain: 'Invalid Trello signature', status: :unauthorized
271
- return
272
- end
273
-
274
- # Parse the event
275
- event = adapter.parse_webhook(request_body)
276
-
277
- if event
278
- puts "Received Trello Event: #{event.event_type} for #{event.object_kind} #{event.object_id}"
279
- # Process the event
280
- else
281
- puts "Received unhandled or unparseable Trello webhook"
282
- end
283
-
284
- head :ok
285
- end
286
-
287
- # Add similar actions for other adapters like Basecamp
288
-
289
- end
290
- ```
291
-
292
238
  ## Development
293
239
 
294
240
  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.
@@ -297,7 +243,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
297
243
 
298
244
  ## Contributing
299
245
 
300
- Bug reports and pull requests are welcome on GitHub at https://github.com/seuros/activeproject.
246
+ Bug reports and pull requests are welcome on GitHub at https://github.com/seuros/active_project.
301
247
 
302
248
  ## License
303
249
 
data/Rakefile CHANGED
@@ -1,3 +1,5 @@
1
- require "bundler/setup"
1
+ # frozen_string_literal: true
2
2
 
3
- require "bundler/gem_tasks"
3
+ require 'bundler/setup'
4
+
5
+ require 'bundler/gem_tasks'
@@ -18,7 +18,6 @@ module ActiveProject
18
18
  raise NotImplementedError, "#{self.class.name} must implement #find_project"
19
19
  end
20
20
 
21
-
22
21
  # Creates a new project.
23
22
  # @param attributes [Hash] Project attributes (platform-specific).
24
23
  # @return [ActiveProject::Project] The created project object.
@@ -35,7 +34,6 @@ module ActiveProject
35
34
  raise NotImplementedError, "#{self.class.name} does not support #create_list or must implement #create_list"
36
35
  end
37
36
 
38
-
39
37
  # Deletes a project. Use with caution.
40
38
  # @param project_id [String, Integer] The ID or key of the project to delete.
41
39
  # @return [Boolean] true if deletion was successful (or accepted), false otherwise.
@@ -44,7 +42,6 @@ module ActiveProject
44
42
  raise NotImplementedError, "#{self.class.name} does not support #delete_project or must implement it"
45
43
  end
46
44
 
47
-
48
45
  # Lists issues within a specific project.
49
46
  # @param project_id [String, Integer] The ID or key of the project.
50
47
  # @param options [Hash] Optional filtering/pagination options.
@@ -88,11 +85,11 @@ module ActiveProject
88
85
  end
89
86
 
90
87
  # Verifies the signature of an incoming webhook request, if supported by the platform.
91
- # @param request_body [String] The raw request body.
92
- # @param signature_header [String] The value of the platform-specific signature header (e.g., 'X-Trello-Webhook').
88
+ # @param _request_body [String] The raw request body.
89
+ # @param _signature_header [String] The value of the platform-specific signature header (e.g., 'X-Trello-Webhook').
93
90
  # @return [Boolean] true if the signature is valid or verification is not supported/needed, false otherwise.
94
91
  # @raise [NotImplementedError] if verification is applicable but not implemented by a subclass.
95
- def verify_webhook_signature(request_body, signature_header)
92
+ def verify_webhook_signature(_request_body, _signature_header)
96
93
  # Default implementation assumes no verification needed or supported.
97
94
  # Adapters supporting verification should override this.
98
95
  true
@@ -107,7 +104,6 @@ module ActiveProject
107
104
  raise NotImplementedError, "#{self.class.name} must implement #parse_webhook"
108
105
  end
109
106
 
110
-
111
107
  # Retrieves details for the currently authenticated user.
112
108
  # @return [ActiveProject::Resources::User] The user object.
113
109
  # @raise [ActiveProject::AuthenticationError] if authentication fails.
@@ -122,13 +118,6 @@ module ActiveProject
122
118
  def connected?
123
119
  raise NotImplementedError, "#{self.class.name} must implement #connected?"
124
120
  end
125
-
126
-
127
- # Placeholder comments for data structures (to be defined elsewhere)
128
- # Example:
129
- # Project = Struct.new(:id, :key, :name, :adapter_source, keyword_init: true)
130
- # Issue = Struct.new(:id, :key, :title, :description, :status, :assignee, :project_id, :adapter_source, keyword_init: true)
131
- # Comment = Struct.new(:id, :body, :author, :created_at, :issue_id, :adapter_source, keyword_init: true)
132
121
  end
133
122
  end
134
123
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Basecamp
6
+ module Comments
7
+ # Adds a comment to a To-do in Basecamp.
8
+ # @param todo_id [String, Integer] The ID of the Basecamp To-do.
9
+ # @param comment_body [String] The comment text (HTML).
10
+ # @param context [Hash] Required context: { project_id: '...' }.
11
+ # @return [ActiveProject::Resources::Comment] The created comment resource.
12
+ def add_comment(todo_id, comment_body, context = {})
13
+ project_id = context[:project_id]
14
+ unless project_id
15
+ raise ArgumentError,
16
+ "Missing required context: :project_id must be provided for BasecampAdapter#add_comment"
17
+ end
18
+
19
+ path = "buckets/#{project_id}/recordings/#{todo_id}/comments.json"
20
+ payload = { content: comment_body }.to_json
21
+ comment_data = make_request(:post, path, payload)
22
+ map_comment_data(comment_data, todo_id.to_i)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Basecamp
6
+ module Connection
7
+ BASE_URL_TEMPLATE = "https://3.basecampapi.com/%<account_id>s/"
8
+ # Initializes the Basecamp Adapter.
9
+ # @param config [Configurations::BaseAdapterConfiguration] The configuration object for Basecamp.
10
+ # @raise [ArgumentError] if required configuration options (:account_id, :access_token) are missing.
11
+ def initialize(config:)
12
+ # For now, Basecamp uses the base config. If specific Basecamp options are added,
13
+ # create BasecampConfiguration and check for that type.
14
+ unless config.is_a?(ActiveProject::Configurations::BaseAdapterConfiguration)
15
+ raise ArgumentError, "BasecampAdapter requires a BaseAdapterConfiguration object"
16
+ end
17
+
18
+ @config = config
19
+
20
+ account_id = @config.options[:account_id].to_s # Ensure it's a string
21
+ access_token = @config.options[:access_token]
22
+
23
+ unless account_id && !account_id.empty? && access_token && !access_token.empty?
24
+ raise ArgumentError, "BasecampAdapter configuration requires :account_id and :access_token"
25
+ end
26
+
27
+ @base_url = format(BASE_URL_TEMPLATE, account_id: account_id)
28
+ @connection = initialize_connection
29
+ end
30
+
31
+ private
32
+
33
+ # Initializes the Faraday connection object.
34
+ def initialize_connection
35
+ access_token = @config.options[:access_token]
36
+
37
+ Faraday.new(url: @base_url) do |conn|
38
+ conn.request :authorization, :bearer, access_token
39
+ conn.request :retry
40
+ conn.response :raise_error
41
+ conn.headers["Content-Type"] = "application/json"
42
+ conn.headers["Accept"] = "application/json"
43
+ conn.headers["User-Agent"] = ActiveProject.user_agent
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Basecamp
6
+ module Issues
7
+ # Lists To-dos within a specific project.
8
+ # @param project_id [String, Integer] The ID of the Basecamp project.
9
+ # @param options [Hash] Optional options. Accepts :todolist_id.
10
+ # @return [Array<ActiveProject::Resources::Issue>] An array of issue resources.
11
+ def list_issues(project_id, options = {})
12
+ all_todos = []
13
+ todolist_id = options[:todolist_id]
14
+
15
+ unless todolist_id
16
+ todolist_id = find_first_todolist_id(project_id)
17
+ return [] unless todolist_id
18
+ end
19
+
20
+ path = "buckets/#{project_id}/todolists/#{todolist_id}/todos.json"
21
+
22
+ loop do
23
+ response = @connection.get(path)
24
+ todos_data = begin
25
+ JSON.parse(response.body)
26
+ rescue StandardError
27
+ []
28
+ end
29
+ break if todos_data.empty?
30
+
31
+ todos_data.each do |todo_data|
32
+ all_todos << map_todo_data(todo_data, project_id)
33
+ end
34
+
35
+ link_header = response.headers["Link"]
36
+ next_url = parse_next_link(link_header)
37
+ break unless next_url
38
+
39
+ path = next_url.sub(@base_url, "").sub(%r{^/}, "")
40
+ end
41
+
42
+ all_todos
43
+ rescue Faraday::Error => e
44
+ handle_faraday_error(e)
45
+ end
46
+
47
+ # Finds a specific To-do by its ID.
48
+ # @param todo_id [String, Integer] The ID of the Basecamp To-do.
49
+ # @param context [Hash] Required context: { project_id: '...' }.
50
+ # @return [ActiveProject::Resources::Issue] The issue resource.
51
+ def find_issue(todo_id, context = {})
52
+ project_id = context[:project_id]
53
+ unless project_id
54
+ raise ArgumentError, "Missing required context: :project_id must be provided for BasecampAdapter#find_issue"
55
+ end
56
+
57
+ path = "buckets/#{project_id}/todos/#{todo_id}.json"
58
+ todo_data = make_request(:get, path)
59
+ map_todo_data(todo_data, project_id)
60
+ end
61
+
62
+ # Creates a new To-do in Basecamp.
63
+ # @param project_id [String, Integer] The ID of the Basecamp project.
64
+ # @param attributes [Hash] To-do attributes. Required: :todolist_id, :title. Optional: :description, :due_on, :assignee_ids.
65
+ # @return [ActiveProject::Resources::Issue] The created issue resource.
66
+ def create_issue(project_id, attributes)
67
+ todolist_id = attributes[:todolist_id]
68
+ title = attributes[:title]
69
+
70
+ unless todolist_id && title && !title.empty?
71
+ raise ArgumentError, "Missing required attributes for Basecamp to-do creation: :todolist_id, :title"
72
+ end
73
+
74
+ path = "buckets/#{project_id}/todolists/#{todolist_id}/todos.json"
75
+
76
+ payload = {
77
+ content: title,
78
+ description: attributes[:description],
79
+ due_on: attributes[:due_on].respond_to?(:strftime) ? attributes[:due_on].strftime("%Y-%m-%d") : attributes[:due_on],
80
+ assignee_ids: attributes[:assignee_ids]
81
+ }.compact
82
+
83
+ todo_data = make_request(:post, path, payload.to_json)
84
+ map_todo_data(todo_data, project_id)
85
+ end
86
+
87
+ # Updates an existing To-do in Basecamp.
88
+ # Handles updates to standard fields via PUT and status changes via POST/DELETE completion endpoints.
89
+ # @param todo_id [String, Integer] The ID of the Basecamp To-do.
90
+ # @param attributes [Hash] Attributes to update (e.g., :title, :description, :status, :assignee_ids, :due_on).
91
+ # @param context [Hash] Required context: { project_id: '...' }.
92
+ # @return [ActiveProject::Resources::Issue] The updated issue resource (fetched after updates).
93
+ def update_issue(todo_id, attributes, context = {})
94
+ project_id = context[:project_id]
95
+ unless project_id
96
+ raise ArgumentError,
97
+ "Missing required context: :project_id must be provided for BasecampAdapter#update_issue"
98
+ end
99
+
100
+ put_payload = {}
101
+ put_payload[:content] = attributes[:title] if attributes.key?(:title)
102
+ put_payload[:description] = attributes[:description] if attributes.key?(:description)
103
+ if attributes.key?(:due_on)
104
+ due_on_val = attributes[:due_on]
105
+ put_payload[:due_on] = due_on_val.respond_to?(:strftime) ? due_on_val.strftime("%Y-%m-%d") : due_on_val
106
+ end
107
+ put_payload[:assignee_ids] = attributes[:assignee_ids] if attributes.key?(:assignee_ids)
108
+
109
+ status_change_required = attributes.key?(:status)
110
+ target_status = attributes[:status] if status_change_required
111
+
112
+ unless !put_payload.empty? || status_change_required
113
+ raise ArgumentError, "No attributes provided to update for BasecampAdapter#update_issue"
114
+ end
115
+
116
+ unless put_payload.empty?
117
+ put_path = "buckets/#{project_id}/todos/#{todo_id}.json"
118
+ make_request(:put, put_path, put_payload.compact.to_json)
119
+ end
120
+
121
+ if status_change_required
122
+ completion_path = "buckets/#{project_id}/todos/#{todo_id}/completion.json"
123
+ begin
124
+ if target_status == :closed
125
+ make_request(:post, completion_path)
126
+ elsif target_status == :open
127
+ make_request(:delete, completion_path)
128
+ end
129
+ rescue NotFoundError
130
+ raise unless target_status == :open
131
+ end
132
+ end
133
+
134
+ find_issue(todo_id, context)
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Basecamp
6
+ module Lists
7
+ # Creates a new Todolist within a project.
8
+ # @param project_id [String, Integer] The ID of the Basecamp project (bucket).
9
+ # @param attributes [Hash] Todolist attributes. Required: :name. Optional: :description.
10
+ # @return [Hash] The raw data hash of the created todolist.
11
+ def create_list(project_id, attributes)
12
+ unless attributes[:name] && !attributes[:name].empty?
13
+ raise ArgumentError, "Missing required attribute for Basecamp todolist creation: :name"
14
+ end
15
+
16
+ project_data = make_request(:get, "projects/#{project_id}.json")
17
+ todoset_dock_entry = project_data&.dig("dock")&.find { |d| d["name"] == "todoset" }
18
+ todoset_url = todoset_dock_entry&.dig("url")
19
+ raise ApiError, "Could not find todoset URL for project #{project_id}" unless todoset_url
20
+
21
+ todoset_id = todoset_url.match(%r{todosets/(\d+)\.json$})&.captures&.first
22
+ raise ApiError, "Could not extract todoset ID from URL: #{todoset_url}" unless todoset_id
23
+
24
+ path = "buckets/#{project_id}/todosets/#{todoset_id}/todolists.json"
25
+ payload = {
26
+ name: attributes[:name],
27
+ description: attributes[:description]
28
+ }.compact
29
+
30
+ make_request(:post, path, payload.to_json)
31
+ end
32
+
33
+ # Finds the ID of the first todolist in a project.
34
+ # @param project_id [String, Integer]
35
+ # @return [String, nil]
36
+ def find_first_todolist_id(project_id)
37
+ project_data = make_request(:get, "projects/#{project_id}.json")
38
+ todoset_dock_entry = project_data&.dig("dock")&.find { |d| d["name"] == "todoset" }
39
+ todoset_url = todoset_dock_entry&.dig("url")
40
+ return nil unless todoset_url
41
+
42
+ todoset_id = todoset_url.match(%r{todosets/(\d+)\.json$})&.captures&.first
43
+ return nil unless todoset_id
44
+
45
+ todolists_url_path = "buckets/#{project_id}/todosets/#{todoset_id}/todolists.json"
46
+ todolists_data = make_request(:get, todolists_url_path)
47
+ todolists_data&.first&.dig("id")
48
+ rescue NotFoundError
49
+ nil
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveProject
4
+ module Adapters
5
+ module Basecamp
6
+ module Projects
7
+ # Lists projects accessible by the configured credentials.
8
+ # Handles pagination automatically using the Link header.
9
+ # @return [Array<ActiveProject::Resources::Project>] An array of project resources.
10
+ def list_projects
11
+ all_projects = []
12
+ path = "projects.json"
13
+
14
+ loop do
15
+ response = @connection.get(path)
16
+ projects_data = begin
17
+ JSON.parse(response.body)
18
+ rescue StandardError
19
+ []
20
+ end
21
+ break if projects_data.empty?
22
+
23
+ projects_data.each do |project_data|
24
+ all_projects << Resources::Project.new(self,
25
+ id: project_data["id"],
26
+ key: nil,
27
+ name: project_data["name"],
28
+ adapter_source: :basecamp,
29
+ raw_data: project_data)
30
+ end
31
+
32
+ link_header = response.headers["Link"]
33
+ next_url = parse_next_link(link_header)
34
+ break unless next_url
35
+
36
+ path = next_url.sub(@base_url, "").sub(%r{^/}, "")
37
+ end
38
+
39
+ all_projects
40
+ rescue Faraday::Error => e
41
+ handle_faraday_error(e)
42
+ end
43
+
44
+ # Finds a specific project by its ID.
45
+ # @param project_id [String, Integer] The ID of the Basecamp project.
46
+ # @return [ActiveProject::Resources::Project] The project resource.
47
+ def find_project(project_id)
48
+ path = "projects/#{project_id}.json"
49
+ project_data = make_request(:get, path)
50
+ return nil unless project_data
51
+
52
+ raise NotFoundError, "Basecamp project ID #{project_id} is trashed." if project_data["status"] == "trashed"
53
+
54
+ Resources::Project.new(self,
55
+ id: project_data["id"],
56
+ key: nil,
57
+ name: project_data["name"],
58
+ adapter_source: :basecamp,
59
+ raw_data: project_data)
60
+ end
61
+
62
+ # Creates a new project in Basecamp.
63
+ # @param attributes [Hash] Project attributes. Required: :name. Optional: :description.
64
+ # @return [ActiveProject::Resources::Project] The created project resource.
65
+ def create_project(attributes)
66
+ unless attributes[:name] && !attributes[:name].empty?
67
+ raise ArgumentError, "Missing required attribute for Basecamp project creation: :name"
68
+ end
69
+
70
+ path = "projects.json"
71
+ payload = {
72
+ name: attributes[:name],
73
+ description: attributes[:description]
74
+ }.compact
75
+
76
+ project_data = make_request(:post, path, payload.to_json)
77
+
78
+ Resources::Project.new(self,
79
+ id: project_data["id"],
80
+ key: nil,
81
+ name: project_data["name"],
82
+ adapter_source: :basecamp,
83
+ raw_data: project_data)
84
+ end
85
+
86
+ # Recovers a trashed project in Basecamp.
87
+ # @param project_id [String, Integer] The ID of the project to recover.
88
+ # @return [Boolean] true if recovery was successful (API returns 204).
89
+ def untrash_project(project_id)
90
+ path = "projects/#{project_id}.json"
91
+ make_request(:put, path, { "status": "active" }.to_json)
92
+ true
93
+ end
94
+
95
+ # Archives (trashes) a project in Basecamp.
96
+ # Note: Basecamp API doesn't offer permanent deletion via this endpoint.
97
+ # @param project_id [String, Integer] The ID of the project to trash.
98
+ # @return [Boolean] true if trashing was successful (API returns 204).
99
+ # @raise [NotFoundError] if the project is not found.
100
+ # @raise [AuthenticationError] if credentials lack permission.
101
+ # @raise [ApiError] for other errors.
102
+ def delete_project(project_id)
103
+ path = "projects/#{project_id}.json"
104
+ make_request(:delete, path)
105
+ true
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end