basecamp-sdk 0.5.0 → 0.6.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/basecamp/ambiguous_error.rb +23 -0
  4. data/lib/basecamp/api_error.rb +27 -0
  5. data/lib/basecamp/auth_error.rb +16 -0
  6. data/lib/basecamp/auth_strategy.rb +0 -18
  7. data/lib/basecamp/bearer_auth.rb +21 -0
  8. data/lib/basecamp/client.rb +126 -0
  9. data/lib/basecamp/download_result.rb +10 -0
  10. data/lib/basecamp/error.rb +86 -0
  11. data/lib/basecamp/error_code.rb +16 -0
  12. data/lib/basecamp/exit_code.rb +17 -0
  13. data/lib/basecamp/forbidden_error.rb +20 -0
  14. data/lib/basecamp/generated/metadata.json +12 -1
  15. data/lib/basecamp/{services → generated/services}/authorization_service.rb +1 -1
  16. data/lib/basecamp/generated/services/automation_service.rb +19 -0
  17. data/lib/basecamp/{services → generated/services}/base_service.rb +0 -1
  18. data/lib/basecamp/generated/services/card_steps_service.rb +9 -0
  19. data/lib/basecamp/generated/services/search_service.rb +1 -1
  20. data/lib/basecamp/generated/services/tools_service.rb +3 -2
  21. data/lib/basecamp/generated/types.rb +1 -1
  22. data/lib/basecamp/http.rb +22 -13
  23. data/lib/basecamp/network_error.rb +16 -0
  24. data/lib/basecamp/not_found_error.rb +20 -0
  25. data/lib/basecamp/oauth/config.rb +30 -0
  26. data/lib/basecamp/oauth/discovery.rb +9 -41
  27. data/lib/basecamp/oauth/exchange.rb +20 -114
  28. data/lib/basecamp/oauth/exchange_request.rb +36 -0
  29. data/lib/basecamp/oauth/{errors.rb → oauth_error.rb} +1 -1
  30. data/lib/basecamp/oauth/refresh_request.rb +30 -0
  31. data/lib/basecamp/oauth/token.rb +52 -0
  32. data/lib/basecamp/oauth.rb +40 -8
  33. data/lib/basecamp/operation_info.rb +0 -7
  34. data/lib/basecamp/operation_result.rb +10 -0
  35. data/lib/basecamp/rate_limit_error.rb +19 -0
  36. data/lib/basecamp/security.rb +1 -1
  37. data/lib/basecamp/usage_error.rb +10 -0
  38. data/lib/basecamp/validation_error.rb +15 -0
  39. data/lib/basecamp/version.rb +1 -1
  40. data/lib/basecamp/webhooks/rack_middleware.rb +0 -2
  41. data/lib/basecamp/webhooks/receiver.rb +0 -4
  42. data/lib/basecamp/webhooks/verification_error.rb +7 -0
  43. data/lib/basecamp.rb +62 -22
  44. data/scripts/generate-services.rb +3 -3
  45. metadata +26 -43
  46. data/lib/basecamp/errors.rb +0 -294
  47. data/lib/basecamp/oauth/types.rb +0 -133
  48. data/lib/basecamp/services/attachments_service.rb +0 -33
  49. data/lib/basecamp/services/campfires_service.rb +0 -141
  50. data/lib/basecamp/services/card_columns_service.rb +0 -106
  51. data/lib/basecamp/services/card_steps_service.rb +0 -86
  52. data/lib/basecamp/services/card_tables_service.rb +0 -23
  53. data/lib/basecamp/services/cards_service.rb +0 -93
  54. data/lib/basecamp/services/checkins_service.rb +0 -127
  55. data/lib/basecamp/services/client_approvals_service.rb +0 -33
  56. data/lib/basecamp/services/client_correspondences_service.rb +0 -33
  57. data/lib/basecamp/services/client_replies_service.rb +0 -35
  58. data/lib/basecamp/services/comments_service.rb +0 -63
  59. data/lib/basecamp/services/documents_service.rb +0 -74
  60. data/lib/basecamp/services/events_service.rb +0 -27
  61. data/lib/basecamp/services/forwards_service.rb +0 -80
  62. data/lib/basecamp/services/lineup_service.rb +0 -67
  63. data/lib/basecamp/services/message_boards_service.rb +0 -24
  64. data/lib/basecamp/services/message_types_service.rb +0 -79
  65. data/lib/basecamp/services/messages_service.rb +0 -133
  66. data/lib/basecamp/services/people_service.rb +0 -73
  67. data/lib/basecamp/services/projects_service.rb +0 -67
  68. data/lib/basecamp/services/recordings_service.rb +0 -127
  69. data/lib/basecamp/services/reports_service.rb +0 -80
  70. data/lib/basecamp/services/schedules_service.rb +0 -156
  71. data/lib/basecamp/services/search_service.rb +0 -36
  72. data/lib/basecamp/services/subscriptions_service.rb +0 -67
  73. data/lib/basecamp/services/templates_service.rb +0 -96
  74. data/lib/basecamp/services/timeline_service.rb +0 -62
  75. data/lib/basecamp/services/timesheet_service.rb +0 -68
  76. data/lib/basecamp/services/todolist_groups_service.rb +0 -100
  77. data/lib/basecamp/services/todolists_service.rb +0 -104
  78. data/lib/basecamp/services/todos_service.rb +0 -156
  79. data/lib/basecamp/services/todosets_service.rb +0 -23
  80. data/lib/basecamp/services/tools_service.rb +0 -89
  81. data/lib/basecamp/services/uploads_service.rb +0 -84
  82. data/lib/basecamp/services/vaults_service.rb +0 -84
  83. data/lib/basecamp/services/webhooks_service.rb +0 -88
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39a8cc069aaa9ee04e3d91d46e8c06932aaac5947ccbb98da863ebca3a46eadd
4
- data.tar.gz: c5d16c70981ddb8a784636578a211f3ae2e58636b664fe8d2be008604e95fa61
3
+ metadata.gz: da0e2d03c80b5801b886da56a59535f99cb521bd8233a146720ee117f76954b8
4
+ data.tar.gz: 4a3ea1075d88cac04a7dcc796f3c21d235aaffbb9d036be088321746c435c012
5
5
  SHA512:
6
- metadata.gz: 8cde71ee5f4b3957b62b281302e66ec5cf2eefd8a5ce91bcc658388933a2b3250b913ccb6081c1f6d9adb47c5e874f0341c5b2df1420d4e0550594d7ad5656e2
7
- data.tar.gz: 486db1ccd6dfb83d41277f5c742306e7f7bfd13d3987a6fef5184a2b154c1025c5c5b5f94826dbb188c5c71c02390e18fa66156d31761f6fc829dd20e9482bf3
6
+ metadata.gz: e5d5298402d8239d525cff7aeac7815254cb7a1a3a967ed6f29fe9fea581432f0c2e7b272edc52a866b457ec988afc11f3ff6dbb48977b5e2ac716af8bbdb46a
7
+ data.tar.gz: f37ba99c63d4fb9836fe9fc5e6e717e0166f97892069da141f2be746ac95c8e15629319fb1deb4c23cdd4a7ee30d1ac84a83bf183e19930c9d394e47995704e6
data/README.md CHANGED
@@ -215,7 +215,7 @@ rescue Basecamp::RateLimitError => e
215
215
  puts "Rate limited, retry after: #{e.retry_after} seconds"
216
216
  rescue Basecamp::AuthError => e
217
217
  puts "Authentication failed: #{e.message}"
218
- rescue Basecamp::APIError => e
218
+ rescue Basecamp::ApiError => e
219
219
  puts "API error (#{e.http_status}): #{e.message}"
220
220
  end
221
221
  ```
@@ -224,7 +224,7 @@ end
224
224
 
225
225
  | Error | Description |
226
226
  |-------|-------------|
227
- | `APIError` | Base error class for all API errors |
227
+ | `ApiError` | Base error class for all API errors |
228
228
  | `AuthError` | Authentication failures (401) |
229
229
  | `ForbiddenError` | Access denied (403) |
230
230
  | `NotFoundError` | Resource not found (404) |
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised when a name/identifier matches multiple resources.
5
+ class AmbiguousError < Error
6
+ # @return [Array<String>] list of matching resources
7
+ attr_reader :matches
8
+
9
+ def initialize(resource, matches: [])
10
+ @matches = matches
11
+ hint = if matches.any? && matches.length <= 5
12
+ "Did you mean: #{matches.join(", ")}"
13
+ else
14
+ "Be more specific"
15
+ end
16
+ super(
17
+ code: ErrorCode::AMBIGUOUS,
18
+ message: "Ambiguous #{resource}",
19
+ hint: hint
20
+ )
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised for generic API errors.
5
+ class ApiError < Error
6
+ def initialize(message, http_status: nil, hint: nil, retryable: false, cause: nil)
7
+ super(
8
+ code: ErrorCode::API,
9
+ message: message,
10
+ hint: hint,
11
+ http_status: http_status,
12
+ retryable: retryable,
13
+ cause: cause
14
+ )
15
+ end
16
+
17
+ # Creates an ApiError from an HTTP status code.
18
+ # @param status [Integer] HTTP status code
19
+ # @param message [String, nil] optional error message
20
+ # @return [ApiError]
21
+ def self.from_status(status, message = nil)
22
+ message ||= "Request failed (HTTP #{status})"
23
+ retryable = status >= 500 && status < 600
24
+ new(message, http_status: status, retryable: retryable)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised when authentication fails (401).
5
+ class AuthError < Error
6
+ def initialize(message = "Authentication required", hint: nil, cause: nil)
7
+ super(
8
+ code: ErrorCode::AUTH,
9
+ message: message,
10
+ hint: hint || "Check your access token or refresh it if expired",
11
+ http_status: 401,
12
+ cause: cause
13
+ )
14
+ end
15
+ end
16
+ end
@@ -17,22 +17,4 @@ module Basecamp
17
17
  raise NotImplementedError, "#{self.class} must implement #authenticate"
18
18
  end
19
19
  end
20
-
21
- # Bearer token authentication strategy (default).
22
- # Sets the Authorization header with "Bearer {token}".
23
- class BearerAuth
24
- include AuthStrategy
25
-
26
- # @param token_provider [TokenProvider] provides access tokens
27
- def initialize(token_provider)
28
- @token_provider = token_provider
29
- end
30
-
31
- # @return [TokenProvider] the underlying token provider
32
- attr_reader :token_provider
33
-
34
- def authenticate(headers)
35
- headers["Authorization"] = "Bearer #{@token_provider.access_token}"
36
- end
37
- end
38
20
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Bearer token authentication strategy (default).
5
+ # Sets the Authorization header with "Bearer {token}".
6
+ class BearerAuth
7
+ include AuthStrategy
8
+
9
+ # @param token_provider [TokenProvider] provides access tokens
10
+ def initialize(token_provider)
11
+ @token_provider = token_provider
12
+ end
13
+
14
+ # @return [TokenProvider] the underlying token provider
15
+ attr_reader :token_provider
16
+
17
+ def authenticate(headers)
18
+ headers["Authorization"] = "Bearer #{@token_provider.access_token}"
19
+ end
20
+ end
21
+ end
@@ -217,6 +217,92 @@ module Basecamp
217
217
  @parent.http.paginate_wrapped(account_path(path), key: key, params: params)
218
218
  end
219
219
 
220
+ # Downloads file content from any API-routable download URL.
221
+ #
222
+ # Handles the full download flow: URL rewriting to the configured API host,
223
+ # authenticated first hop (which typically 302s to a signed download URL),
224
+ # and unauthenticated second hop to fetch the actual file content.
225
+ #
226
+ # @param raw_url [String] absolute download URL (e.g., from bc-attachment elements)
227
+ # @return [DownloadResult] the download result with body, content_type, content_length, filename
228
+ # @raise [UsageError] if raw_url is empty or not absolute
229
+ # @raise [NetworkError] if a network error occurs
230
+ # @raise [ApiError] if the API or download returns an error
231
+ def download_url(raw_url)
232
+ # Validation
233
+ raise UsageError.new("download URL is required") if raw_url.nil? || raw_url.to_s.empty?
234
+
235
+ begin
236
+ parsed = URI.parse(raw_url)
237
+ rescue URI::InvalidURIError
238
+ raise UsageError.new("download URL must be an absolute URL")
239
+ end
240
+ raise UsageError.new("download URL must be an absolute URL") unless parsed.is_a?(URI::HTTP)
241
+
242
+ # Operation hooks
243
+ op = OperationInfo.new(
244
+ service: "Account", operation: "DownloadURL",
245
+ resource_type: "download", is_mutation: false
246
+ )
247
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
248
+ safe_hook { hooks.on_operation_start(op) }
249
+
250
+ begin
251
+ # URL rewriting: replace scheme+host with config.base_url origin, preserve path+query+fragment
252
+ base = URI.parse(config.base_url)
253
+ rewritten = parsed.dup
254
+ rewritten.scheme = base.scheme
255
+ rewritten.host = base.host
256
+ rewritten.port = base.port
257
+ rewritten_url = rewritten.to_s
258
+
259
+ # Hop 1: Authenticated API request (no retry, captures redirect)
260
+ response = http.get_no_retry(rewritten_url)
261
+
262
+ result = case response.status
263
+ when 301, 302, 303, 307, 308
264
+ # Redirect — extract Location, proceed to hop 2
265
+ location = response.headers["Location"] || response.headers["location"]
266
+ raise ApiError.new("redirect #{response.status} with no Location header") if location.nil? || location.empty?
267
+
268
+ # Resolve relative Location against the rewritten API URL
269
+ resolved_url = Security.resolve_url(rewritten_url, location)
270
+
271
+ # Hop 2: fetch from signed URL (no auth, no hooks)
272
+ signed_response = fetch_signed_download(resolved_url)
273
+
274
+ DownloadResult.new(
275
+ body: signed_response.body,
276
+ content_type: signed_response["Content-Type"] || "",
277
+ content_length: parse_content_length(signed_response["Content-Length"]),
278
+ filename: Basecamp.filename_from_url(raw_url)
279
+ )
280
+
281
+ when 200..299
282
+ # Direct download — no second hop
283
+ DownloadResult.new(
284
+ body: response.body,
285
+ content_type: response.headers["Content-Type"] || response.headers["content-type"] || "",
286
+ content_length: parse_content_length(response.headers["Content-Length"] || response.headers["content-length"]),
287
+ filename: Basecamp.filename_from_url(raw_url)
288
+ )
289
+
290
+ else
291
+ # This shouldn't happen because Faraday's raise_error middleware
292
+ # handles 4xx/5xx, but handle it defensively
293
+ raise Basecamp.error_from_response(response.status, response.body)
294
+ end
295
+ rescue => e
296
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
297
+ safe_hook { hooks.on_operation_end(op, OperationResult.new(duration_ms: duration, error: e)) }
298
+ raise
299
+ else
300
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
301
+ safe_hook { hooks.on_operation_end(op, OperationResult.new(duration_ms: duration, error: nil)) }
302
+ result
303
+ end
304
+ end
305
+
220
306
  # @!group Services
221
307
 
222
308
  # @return [Services::ProjectsService]
@@ -359,6 +445,11 @@ module Basecamp
359
445
  service(:lineup) { Services::LineupService.new(self) }
360
446
  end
361
447
 
448
+ # @return [Services::AutomationService]
449
+ def automation
450
+ service(:automation) { Services::AutomationService.new(self) }
451
+ end
452
+
362
453
  # @return [Services::MessageTypesService]
363
454
  def message_types
364
455
  service(:message_types) { Services::MessageTypesService.new(self) }
@@ -433,5 +524,40 @@ module Basecamp
433
524
  @services[name] ||= yield
434
525
  end
435
526
  end
527
+
528
+ def fetch_signed_download(url)
529
+ uri = URI.parse(url)
530
+ http_client = Net::HTTP.new(uri.host, uri.port)
531
+ http_client.use_ssl = (uri.scheme == "https")
532
+ http_client.open_timeout = config.timeout
533
+ http_client.read_timeout = config.timeout
534
+
535
+ request = Net::HTTP::Get.new(uri)
536
+
537
+ begin
538
+ response = http_client.request(request)
539
+ rescue StandardError => e
540
+ raise NetworkError.new("Download failed: #{e.message}", cause: e)
541
+ end
542
+
543
+ unless response.is_a?(Net::HTTPSuccess)
544
+ raise ApiError.new("download failed with status #{response.code}", http_status: response.code.to_i)
545
+ end
546
+
547
+ response
548
+ end
549
+
550
+ def safe_hook
551
+ yield
552
+ rescue => e
553
+ warn "Basecamp hook error: #{e.class}: #{e.message}"
554
+ end
555
+
556
+ def parse_content_length(value)
557
+ return -1 if value.nil? || value.to_s.empty?
558
+
559
+ parsed = value.to_i
560
+ parsed >= 0 ? parsed : -1
561
+ end
436
562
  end
437
563
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Result of downloading file content from a URL.
5
+ DownloadResult = Data.define(:body, :content_type, :content_length, :filename) do
6
+ def initialize(body:, content_type: "", content_length: -1, filename: "download")
7
+ super
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Base error class for all Basecamp SDK errors.
5
+ # Provides structured error handling with codes, hints, and CLI exit codes.
6
+ #
7
+ # @example Catching errors
8
+ # begin
9
+ # client.projects.list
10
+ # rescue Basecamp::Error => e
11
+ # puts "#{e.code}: #{e.message}"
12
+ # puts "Hint: #{e.hint}" if e.hint
13
+ # exit e.exit_code
14
+ # end
15
+ class Error < StandardError
16
+ # @return [String] error category code
17
+ attr_reader :code
18
+
19
+ # @return [String, nil] user-friendly hint for resolving the error
20
+ attr_reader :hint
21
+
22
+ # @return [Integer, nil] HTTP status code that caused the error
23
+ attr_reader :http_status
24
+
25
+ # @return [Boolean] whether the operation can be retried
26
+ attr_reader :retryable
27
+
28
+ # @return [Integer, nil] seconds to wait before retrying (for rate limits)
29
+ attr_reader :retry_after
30
+
31
+ # @return [String, nil] X-Request-Id from the response
32
+ attr_reader :request_id
33
+
34
+ # @return [Exception, nil] original error that caused this error
35
+ attr_reader :cause
36
+
37
+ # @param code [String] error category code
38
+ # @param message [String] error message
39
+ # @param hint [String, nil] user-friendly hint
40
+ # @param http_status [Integer, nil] HTTP status code
41
+ # @param retryable [Boolean] whether operation can be retried
42
+ # @param retry_after [Integer, nil] seconds to wait before retry
43
+ # @param request_id [String, nil] X-Request-Id from response
44
+ # @param cause [Exception, nil] underlying cause
45
+ def initialize(code:, message:, hint: nil, http_status: nil, retryable: false, retry_after: nil, request_id: nil, cause: nil)
46
+ super(message)
47
+ @code = code
48
+ @hint = hint
49
+ @http_status = http_status
50
+ @retryable = retryable
51
+ @retry_after = retry_after
52
+ @request_id = request_id
53
+ @cause = cause
54
+ end
55
+
56
+ # Returns the exit code for CLI applications.
57
+ # @return [Integer]
58
+ def exit_code
59
+ self.class.exit_code_for(@code)
60
+ end
61
+
62
+ # Returns whether this error can be retried.
63
+ # @return [Boolean]
64
+ def retryable?
65
+ @retryable
66
+ end
67
+
68
+ # Maps error codes to exit codes.
69
+ # @param code [String]
70
+ # @return [Integer]
71
+ def self.exit_code_for(code)
72
+ case code
73
+ when ErrorCode::USAGE then ExitCode::USAGE
74
+ when ErrorCode::NOT_FOUND then ExitCode::NOT_FOUND
75
+ when ErrorCode::AUTH then ExitCode::AUTH
76
+ when ErrorCode::FORBIDDEN then ExitCode::FORBIDDEN
77
+ when ErrorCode::RATE_LIMIT then ExitCode::RATE_LIMIT
78
+ when ErrorCode::NETWORK then ExitCode::NETWORK
79
+ when ErrorCode::API then ExitCode::API
80
+ when ErrorCode::AMBIGUOUS then ExitCode::AMBIGUOUS
81
+ when ErrorCode::VALIDATION then ExitCode::VALIDATION
82
+ else ExitCode::API
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Error codes for API responses
5
+ module ErrorCode
6
+ USAGE = "usage"
7
+ NOT_FOUND = "not_found"
8
+ AUTH = "auth_required"
9
+ FORBIDDEN = "forbidden"
10
+ RATE_LIMIT = "rate_limit"
11
+ NETWORK = "network"
12
+ API = "api_error"
13
+ AMBIGUOUS = "ambiguous"
14
+ VALIDATION = "validation"
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Exit codes for CLI tools
5
+ module ExitCode
6
+ OK = 0
7
+ USAGE = 1
8
+ NOT_FOUND = 2
9
+ AUTH = 3
10
+ FORBIDDEN = 4
11
+ RATE_LIMIT = 5
12
+ NETWORK = 6
13
+ API = 7
14
+ AMBIGUOUS = 8
15
+ VALIDATION = 9
16
+ end
17
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised when access is denied (403).
5
+ class ForbiddenError < Error
6
+ def initialize(message = "Access denied", hint: nil)
7
+ super(
8
+ code: ErrorCode::FORBIDDEN,
9
+ message: message,
10
+ hint: hint || "You do not have permission to access this resource",
11
+ http_status: 403
12
+ )
13
+ end
14
+
15
+ # Creates a forbidden error due to insufficient OAuth scope.
16
+ def self.insufficient_scope
17
+ new("Access denied: insufficient scope", hint: "Re-authenticate with full scope")
18
+ end
19
+ end
20
+ end
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://basecamp.com/schemas/sdk-metadata.json",
3
3
  "version": "1.0.0",
4
- "generated": "2026-03-12T01:04:29Z",
4
+ "generated": "2026-03-16T01:24:22Z",
5
5
  "operations": {
6
6
  "CreateAttachment": {
7
7
  "retry": {
@@ -243,6 +243,17 @@
243
243
  "natural": true
244
244
  }
245
245
  },
246
+ "GetCardStep": {
247
+ "retry": {
248
+ "maxAttempts": 3,
249
+ "baseDelayMs": 1000,
250
+ "backoff": "exponential",
251
+ "retryOn": [
252
+ 429,
253
+ 503
254
+ ]
255
+ }
256
+ },
246
257
  "UpdateCardStep": {
247
258
  "retry": {
248
259
  "maxAttempts": 3,
@@ -38,7 +38,7 @@ module Basecamp
38
38
  config = Oauth.discover(http.base_url)
39
39
  # Use issuer as base for authorization.json
40
40
  "#{config.issuer.chomp("/")}/authorization.json"
41
- rescue Oauth::OAuthError
41
+ rescue Oauth::OauthError
42
42
  # Fall back to Launchpad
43
43
  LAUNCHPAD_AUTHORIZATION_URL
44
44
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Services
5
+ # Service for Automation operations
6
+ #
7
+ # @generated from OpenAPI spec
8
+ class AutomationService < BaseService
9
+
10
+ # List all lineup markers for the account
11
+ # @return [Hash] response data
12
+ def list_lineup_markers()
13
+ with_operation(service: "automation", operation: "list_lineup_markers", is_mutation: false) do
14
+ http_get("/lineup/markers.json").json
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "cgi/escape"
4
- require_relative "../errors"
5
4
 
6
5
  module Basecamp
7
6
  module Services
@@ -31,6 +31,15 @@ module Basecamp
31
31
  end
32
32
  end
33
33
 
34
+ # Get a step by ID
35
+ # @param step_id [Integer] step id ID
36
+ # @return [Hash] response data
37
+ def get(step_id:)
38
+ with_operation(service: "cardsteps", operation: "get", is_mutation: false, resource_id: step_id) do
39
+ http_get("/card_tables/steps/#{step_id}").json
40
+ end
41
+ end
42
+
34
43
  # Update an existing step
35
44
  # @param step_id [Integer] step id ID
36
45
  # @param title [String, nil] title
@@ -9,7 +9,7 @@ module Basecamp
9
9
 
10
10
  # Search for content across the account
11
11
  # @param q [String] q
12
- # @param sort [String, nil] created_at|updated_at
12
+ # @param sort [String, nil] best_match|created_at
13
13
  # @return [Enumerator<Hash>] paginated results
14
14
  def search(q:, sort: nil)
15
15
  wrap_paginated(service: "search", operation: "search", is_mutation: false) do
@@ -9,10 +9,11 @@ module Basecamp
9
9
 
10
10
  # Clone an existing tool to create a new one
11
11
  # @param source_recording_id [Integer] source recording id
12
+ # @param title [String, nil] title
12
13
  # @return [Hash] response data
13
- def clone(source_recording_id:)
14
+ def clone(source_recording_id:, title: nil)
14
15
  with_operation(service: "tools", operation: "clone", is_mutation: true) do
15
- http_post("/dock/tools.json", body: compact_params(source_recording_id: source_recording_id)).json
16
+ http_post("/dock/tools.json", body: compact_params(source_recording_id: source_recording_id, title: title)).json
16
17
  end
17
18
  end
18
19
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Auto-generated from OpenAPI spec. Do not edit manually.
4
- # Generated: 2026-03-12T01:04:29Z
4
+ # Generated: 2026-03-16T01:24:22Z
5
5
 
6
6
  require "json"
7
7
  require "time"