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