From 79153a1792301db7d53443bf5ce8d309ebbbf5cb Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 24 Jul 2026 23:24:48 -0700 Subject: [PATCH 1/8] TypeScript: treat bucketId as project scope in operation metadata Match projectId or bucketId for the operation's project scope and emit the matched path param, and exclude bucketId (as well as projectId) from resourceId selection. The nine bucketId-labeled ops now report projectId; the three collection ops (ListWebhooks, CreateWebhook, CreateTool) drop the bucket id from resourceId. Preserve the projectId shorthand for projectId-labeled ops so their generated output is byte-identical. --- typescript/scripts/generate-services.ts | 6 ++-- .../src/generated/services/card-columns.ts | 3 ++ typescript/src/generated/services/tools.ts | 2 +- typescript/src/generated/services/webhooks.ts | 4 +-- .../src/generated/services/wormholes.ts | 3 ++ typescript/tests/services/webhooks.test.ts | 28 ++++++++++++++++ typescript/tests/services/wormholes.test.ts | 33 +++++++++++++++++++ 7 files changed, 73 insertions(+), 6 deletions(-) diff --git a/typescript/scripts/generate-services.ts b/typescript/scripts/generate-services.ts index 76ce5a8e0..50f224419 100644 --- a/typescript/scripts/generate-services.ts +++ b/typescript/scripts/generate-services.ts @@ -1291,12 +1291,12 @@ function generateMethod(op: ParsedOperation, serviceName: string): string[] { lines.push(` resourceType: "${op.resourceType}",`); lines.push(` isMutation: ${op.isMutation},`); - const projectParam = op.pathParams.find((p) => p.name === "projectId"); + const projectParam = op.pathParams.find((p) => p.name === "projectId" || p.name === "bucketId"); if (projectParam) { - lines.push(` projectId,`); + lines.push(projectParam.name === "projectId" ? ` projectId,` : ` projectId: ${projectParam.name},`); } - const resourceParam = op.pathParams.findLast((p) => p.name !== "projectId" && p.name.endsWith("Id")); + const resourceParam = op.pathParams.findLast((p) => p.name !== "projectId" && p.name !== "bucketId" && p.name.endsWith("Id")); if (resourceParam) { lines.push(` resourceId: ${resourceParam.name},`); } diff --git a/typescript/src/generated/services/card-columns.ts b/typescript/src/generated/services/card-columns.ts index c67018ebe..c55eb2822 100644 --- a/typescript/src/generated/services/card-columns.ts +++ b/typescript/src/generated/services/card-columns.ts @@ -88,6 +88,7 @@ export class CardColumnsService extends BaseService { operation: "SetCardColumnColor", resourceType: "card_column_color", isMutation: true, + projectId: bucketId, resourceId: columnId, }, () => @@ -122,6 +123,7 @@ export class CardColumnsService extends BaseService { operation: "EnableCardColumnOnHold", resourceType: "card_column_on_hold", isMutation: true, + projectId: bucketId, resourceId: columnId, }, () => @@ -153,6 +155,7 @@ export class CardColumnsService extends BaseService { operation: "DisableCardColumnOnHold", resourceType: "card_column_on_hold", isMutation: true, + projectId: bucketId, resourceId: columnId, }, () => diff --git a/typescript/src/generated/services/tools.ts b/typescript/src/generated/services/tools.ts index 99431dc8e..1ff93762e 100644 --- a/typescript/src/generated/services/tools.ts +++ b/typescript/src/generated/services/tools.ts @@ -73,7 +73,7 @@ export class ToolsService extends BaseService { operation: "CreateTool", resourceType: "tool", isMutation: true, - resourceId: bucketId, + projectId: bucketId, }, () => this.client.POST("/buckets/{bucketId}/dock/tools.json", { diff --git a/typescript/src/generated/services/webhooks.ts b/typescript/src/generated/services/webhooks.ts index 025a91cd0..837c0afcb 100644 --- a/typescript/src/generated/services/webhooks.ts +++ b/typescript/src/generated/services/webhooks.ts @@ -75,7 +75,7 @@ export class WebhooksService extends BaseService { operation: "ListWebhooks", resourceType: "webhook", isMutation: false, - resourceId: bucketId, + projectId: bucketId, }, () => this.client.GET("/buckets/{bucketId}/webhooks.json", { @@ -112,7 +112,7 @@ export class WebhooksService extends BaseService { operation: "CreateWebhook", resourceType: "webhook", isMutation: true, - resourceId: bucketId, + projectId: bucketId, }, () => this.client.POST("/buckets/{bucketId}/webhooks.json", { diff --git a/typescript/src/generated/services/wormholes.ts b/typescript/src/generated/services/wormholes.ts index 82caa758f..830742ab7 100644 --- a/typescript/src/generated/services/wormholes.ts +++ b/typescript/src/generated/services/wormholes.ts @@ -60,6 +60,7 @@ export class WormholesService extends BaseService { operation: "UpdateWormhole", resourceType: "wormhole", isMutation: true, + projectId: bucketId, resourceId: wormholeId, }, () => @@ -94,6 +95,7 @@ export class WormholesService extends BaseService { operation: "DeleteWormhole", resourceType: "wormhole", isMutation: true, + projectId: bucketId, resourceId: wormholeId, }, () => @@ -125,6 +127,7 @@ export class WormholesService extends BaseService { operation: "CreateWormhole", resourceType: "wormhole", isMutation: true, + projectId: bucketId, resourceId: cardTableId, }, () => diff --git a/typescript/tests/services/webhooks.test.ts b/typescript/tests/services/webhooks.test.ts index 1a8eefe1e..32371fbd5 100644 --- a/typescript/tests/services/webhooks.test.ts +++ b/typescript/tests/services/webhooks.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { http, HttpResponse } from "msw"; import { server } from "../setup.js"; import { createBasecampClient, type BasecampClient } from "../../src/client.js"; +import type { OperationInfo } from "../../src/hooks.js"; import { BasecampError } from "../../src/errors.js"; const BASE_URL = "https://3.basecampapi.com/12345"; @@ -72,6 +73,33 @@ describe("WebhooksService", () => { const webhooks = await client.webhooks.list(1); expect(webhooks).toHaveLength(0); }); + + it("scopes the operation to the bucket as project with no resource id", async () => { + const bucketId = 2085958499; + let captured: OperationInfo | undefined; + + const hookedClient = createBasecampClient({ + accountId: "12345", + accessToken: "test-token", + hooks: { + onOperationStart: (info) => { + captured = info; + }, + }, + }); + + server.use( + http.get(`${BASE_URL}/buckets/${bucketId}/webhooks.json`, () => + HttpResponse.json([]) + ) + ); + + await hookedClient.webhooks.list(bucketId); + + expect(captured?.operation).toBe("ListWebhooks"); + expect(captured?.projectId).toBe(bucketId); + expect(captured?.resourceId).toBeUndefined(); + }); }); describe("get", () => { diff --git a/typescript/tests/services/wormholes.test.ts b/typescript/tests/services/wormholes.test.ts index e5362ab87..9d11e45a8 100644 --- a/typescript/tests/services/wormholes.test.ts +++ b/typescript/tests/services/wormholes.test.ts @@ -6,6 +6,7 @@ import { http, HttpResponse } from "msw"; import { server } from "../setup.js"; import { createBasecampClient } from "../../src/client.js"; import type { BasecampClient } from "../../src/client.js"; +import type { OperationInfo } from "../../src/hooks.js"; import { BasecampError } from "../../src/errors.js"; const BASE_URL = "https://3.basecampapi.com/12345"; @@ -97,6 +98,38 @@ describe("WormholesService", () => { client.wormholes.create(bucketId, cardTableId, { destinationRecordingId: 999 }) ).rejects.toThrow(BasecampError); }); + + it("scopes the operation to the bucket as project, card table as resource", async () => { + const bucketId = 2085958499; + const cardTableId = 1069479345; + let captured: OperationInfo | undefined; + + const hookedClient = createBasecampClient({ + accountId: "12345", + accessToken: "test-token", + enableRetry: false, + hooks: { + onOperationStart: (info) => { + captured = info; + }, + }, + }); + + server.use( + http.post( + `${BASE_URL}/buckets/${bucketId}/card_tables/${cardTableId}/wormholes.json`, + () => HttpResponse.json(sampleWormhole(99), { status: 201 }) + ) + ); + + await hookedClient.wormholes.create(bucketId, cardTableId, { + destinationRecordingId: 1069479500, + }); + + expect(captured?.operation).toBe("CreateWormhole"); + expect(captured?.projectId).toBe(bucketId); + expect(captured?.resourceId).toBe(cardTableId); + }); }); describe("update", () => { From e234c16374566a8b11d177ad0e4bac8767881a25 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 24 Jul 2026 23:25:00 -0700 Subject: [PATCH 2/8] Ruby: treat bucketId as project scope in operation metadata Match projectId or bucketId for project scope and emit the matched param's snake_case name, and exclude bucketId (as well as projectId) from resource_id selection. The nine bucketId-labeled ops now report project_id; the three collection ops (ListWebhooks, CreateWebhook, CreateTool) drop the bucket id from resource_id. --- .../services/card_columns_service.rb | 6 ++-- .../generated/services/tools_service.rb | 2 +- .../generated/services/webhooks_service.rb | 4 +-- .../generated/services/wormholes_service.rb | 6 ++-- ruby/scripts/generate-services.rb | 6 ++-- .../services/webhooks_service_test.rb | 34 ++++++++++++++++++ .../services/wormholes_service_test.rb | 36 +++++++++++++++++++ 7 files changed, 82 insertions(+), 12 deletions(-) diff --git a/ruby/lib/basecamp/generated/services/card_columns_service.rb b/ruby/lib/basecamp/generated/services/card_columns_service.rb index 2a54bfba1..5d32dfd36 100644 --- a/ruby/lib/basecamp/generated/services/card_columns_service.rb +++ b/ruby/lib/basecamp/generated/services/card_columns_service.rb @@ -13,7 +13,7 @@ class CardColumnsService < BaseService # @param color [String] Valid colors: white, red, orange, yellow, green, blue, aqua, purple, gray, pink, brown # @return [Hash] response data def set_color(bucket_id:, column_id:, color:) - with_operation(service: "cardcolumns", operation: "set_color", is_mutation: true, resource_id: column_id) do + with_operation(service: "cardcolumns", operation: "set_color", is_mutation: true, project_id: bucket_id, resource_id: column_id) do http_put("/buckets/#{bucket_id}/card_tables/columns/#{column_id}/color.json", body: compact_params(color: color)).json end end @@ -23,7 +23,7 @@ def set_color(bucket_id:, column_id:, color:) # @param column_id [Integer] column id ID # @return [Hash] response data def enable_on_hold(bucket_id:, column_id:) - with_operation(service: "cardcolumns", operation: "enable_on_hold", is_mutation: true, resource_id: column_id) do + with_operation(service: "cardcolumns", operation: "enable_on_hold", is_mutation: true, project_id: bucket_id, resource_id: column_id) do http_post("/buckets/#{bucket_id}/card_tables/columns/#{column_id}/on_hold.json").json end end @@ -33,7 +33,7 @@ def enable_on_hold(bucket_id:, column_id:) # @param column_id [Integer] column id ID # @return [Hash] response data def disable_on_hold(bucket_id:, column_id:) - with_operation(service: "cardcolumns", operation: "disable_on_hold", is_mutation: true, resource_id: column_id) do + with_operation(service: "cardcolumns", operation: "disable_on_hold", is_mutation: true, project_id: bucket_id, resource_id: column_id) do http_delete("/buckets/#{bucket_id}/card_tables/columns/#{column_id}/on_hold.json").json end end diff --git a/ruby/lib/basecamp/generated/services/tools_service.rb b/ruby/lib/basecamp/generated/services/tools_service.rb index 844f81e9f..0944dd088 100644 --- a/ruby/lib/basecamp/generated/services/tools_service.rb +++ b/ruby/lib/basecamp/generated/services/tools_service.rb @@ -13,7 +13,7 @@ class ToolsService < BaseService # @param title [String, nil] Title for the new tool. When omitted, Basecamp assigns the next available default title for the tool type. # @return [Hash] response data def create(bucket_id:, tool_type:, title: nil) - with_operation(service: "tools", operation: "create", is_mutation: true, resource_id: bucket_id) do + with_operation(service: "tools", operation: "create", is_mutation: true, project_id: bucket_id) do http_post("/buckets/#{bucket_id}/dock/tools.json", body: compact_params(tool_type: tool_type, title: title)).json end end diff --git a/ruby/lib/basecamp/generated/services/webhooks_service.rb b/ruby/lib/basecamp/generated/services/webhooks_service.rb index 905f36049..ba5c2d2f9 100644 --- a/ruby/lib/basecamp/generated/services/webhooks_service.rb +++ b/ruby/lib/basecamp/generated/services/webhooks_service.rb @@ -11,7 +11,7 @@ class WebhooksService < BaseService # @param bucket_id [Integer] bucket id ID # @return [Enumerator] paginated results def list(bucket_id:) - wrap_paginated(service: "webhooks", operation: "list", is_mutation: false, resource_id: bucket_id) do + wrap_paginated(service: "webhooks", operation: "list", is_mutation: false, project_id: bucket_id) do paginate("/buckets/#{bucket_id}/webhooks.json") end end @@ -23,7 +23,7 @@ def list(bucket_id:) # @param active [Boolean, nil] active # @return [Hash] response data def create(bucket_id:, payload_url:, types:, active: nil) - with_operation(service: "webhooks", operation: "create", is_mutation: true, resource_id: bucket_id) do + with_operation(service: "webhooks", operation: "create", is_mutation: true, project_id: bucket_id) do http_post("/buckets/#{bucket_id}/webhooks.json", body: compact_params(payload_url: payload_url, types: types, active: active)).json end end diff --git a/ruby/lib/basecamp/generated/services/wormholes_service.rb b/ruby/lib/basecamp/generated/services/wormholes_service.rb index f9c3094c5..b478595ce 100644 --- a/ruby/lib/basecamp/generated/services/wormholes_service.rb +++ b/ruby/lib/basecamp/generated/services/wormholes_service.rb @@ -13,7 +13,7 @@ class WormholesService < BaseService # @param destination_recording_id [Integer] Id of the new destination column (on another accessible card table). # @return [Hash] response data def update(bucket_id:, wormhole_id:, destination_recording_id:) - with_operation(service: "wormholes", operation: "update", is_mutation: true, resource_id: wormhole_id) do + with_operation(service: "wormholes", operation: "update", is_mutation: true, project_id: bucket_id, resource_id: wormhole_id) do http_put("/buckets/#{bucket_id}/card_tables/wormholes/#{wormhole_id}", body: compact_params(destination_recording_id: destination_recording_id)).json end end @@ -23,7 +23,7 @@ def update(bucket_id:, wormhole_id:, destination_recording_id:) # @param wormhole_id [Integer] wormhole id ID # @return [void] def delete(bucket_id:, wormhole_id:) - with_operation(service: "wormholes", operation: "delete", is_mutation: true, resource_id: wormhole_id) do + with_operation(service: "wormholes", operation: "delete", is_mutation: true, project_id: bucket_id, resource_id: wormhole_id) do http_delete("/buckets/#{bucket_id}/card_tables/wormholes/#{wormhole_id}") nil end @@ -35,7 +35,7 @@ def delete(bucket_id:, wormhole_id:) # @param destination_recording_id [Integer] Id of the destination column (on another accessible card table) to link to. # @return [Hash] response data def create(bucket_id:, card_table_id:, destination_recording_id:) - with_operation(service: "wormholes", operation: "create", is_mutation: true, resource_id: card_table_id) do + with_operation(service: "wormholes", operation: "create", is_mutation: true, project_id: bucket_id, resource_id: card_table_id) do http_post("/buckets/#{bucket_id}/card_tables/#{card_table_id}/wormholes.json", body: compact_params(destination_recording_id: destination_recording_id)).json end end diff --git a/ruby/scripts/generate-services.rb b/ruby/scripts/generate-services.rb index 749f58ced..11d0140a7 100644 --- a/ruby/scripts/generate-services.rb +++ b/ruby/scripts/generate-services.rb @@ -704,10 +704,10 @@ def build_hook_kwargs(op, service_name) kwargs << "operation: \"#{op[:method_name]}\"" kwargs << "is_mutation: #{op[:is_mutation]}" - project_param = op[:path_params].find { |p| p[:name] == 'projectId' } - resource_param = op[:path_params].reject { |p| p[:name] == 'projectId' }.last + project_param = op[:path_params].find { |p| %w[projectId bucketId].include?(p[:name]) } + resource_param = op[:path_params].reject { |p| %w[projectId bucketId].include?(p[:name]) }.last - kwargs << "project_id: project_id" if project_param + kwargs << "project_id: #{to_snake_case(project_param[:name])}" if project_param kwargs << "resource_id: #{to_snake_case(resource_param[:name])}" if resource_param kwargs.join(', ') diff --git a/ruby/test/basecamp/services/webhooks_service_test.rb b/ruby/test/basecamp/services/webhooks_service_test.rb index 7a77b96ba..3586bc7e4 100644 --- a/ruby/test/basecamp/services/webhooks_service_test.rb +++ b/ruby/test/basecamp/services/webhooks_service_test.rb @@ -20,6 +20,24 @@ def test_list assert_equal "https://example.com/webhook", result.first["payload_url"] end + def test_list_operation_metadata + events = [] + account = create_account_client(account_id: "12345", hooks: CapturingHooks.new(events)) + + response = [ { "id" => 1, "payload_url" => "https://example.com/webhook", "active" => true } ] + stub_request(:get, %r{https://3\.basecampapi\.com/12345/buckets/\d+/webhooks\.json}) + .to_return(status: 200, body: response.to_json, headers: { "Content-Type" => "application/json" }) + + # Paginated operations fire hooks on iteration, so drain the enumerator. + account.webhooks.list(bucket_id: 1).to_a + + info = events.find { |e| e[:event] == :on_operation_start }[:info] + assert_equal "webhooks", info.service + assert_equal "list", info.operation + assert_equal 1, info.project_id + assert_nil info.resource_id + end + def test_get response = { "id" => 1, "payload_url" => "https://example.com/webhook" } @@ -76,4 +94,20 @@ def test_get_with_recent_deliveries assert_equal "todo_created", delivery["request"]["body"]["kind"] assert_equal 200, delivery["response"]["code"] end + + private + + # Captures the OperationInfo passed to on_operation_start so tests can + # assert the metadata emitted by the real generated service call. + class CapturingHooks + include Basecamp::Hooks + + def initialize(events) + @events = events + end + + def on_operation_start(info) + @events << { event: :on_operation_start, info: info } + end + end end diff --git a/ruby/test/basecamp/services/wormholes_service_test.rb b/ruby/test/basecamp/services/wormholes_service_test.rb index ac9129504..f9b549ecf 100644 --- a/ruby/test/basecamp/services/wormholes_service_test.rb +++ b/ruby/test/basecamp/services/wormholes_service_test.rb @@ -43,6 +43,26 @@ def test_create_wormhole assert_not_nil wormhole["destination_url"] end + def test_create_wormhole_operation_metadata + events = [] + account = create_account_client(account_id: "12345", hooks: CapturingHooks.new(events)) + + stub_post("/12345/buckets/2085958499/card_tables/1069479345/wormholes.json", + response_body: sample_wormhole(id: 99)) + + account.wormholes.create( + bucket_id: 2085958499, + card_table_id: 1069479345, + destination_recording_id: 1069479500 + ) + + info = events.find { |e| e[:event] == :on_operation_start }[:info] + assert_equal "wormholes", info.service + assert_equal "create", info.operation + assert_equal 2085958499, info.project_id + assert_equal 1069479345, info.resource_id + end + def test_create_wormhole_raises_validation_error_at_limit stub_post("/12345/buckets/2085958499/card_tables/1069479345/wormholes.json", response_body: { "error" => "Limit reached" }, status: 422) @@ -118,4 +138,20 @@ def test_delete_wormhole_not_found @account.wormholes.delete(bucket_id: 2085958499, wormhole_id: 999) end end + + private + + # Captures the OperationInfo passed to on_operation_start so tests can + # assert the metadata emitted by the real generated service call. + class CapturingHooks + include Basecamp::Hooks + + def initialize(events) + @events = events + end + + def on_operation_start(info) + @events << { event: :on_operation_start, info: info } + end + end end From 7494e866e1b4cdc843b3dfaf83a1be23048a79ea Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 24 Jul 2026 23:25:00 -0700 Subject: [PATCH 3/8] Python: treat bucketId as project scope in operation metadata Match projectId or bucketId for project scope (emit is already dynamic), and exclude bucketId (as well as projectId) from resource_id selection. The nine bucketId-labeled ops now report project_id; the three collection ops (ListWebhooks, CreateWebhook, CreateTool) drop the bucket id from resource_id. --- python/scripts/generate_services.py | 4 +- .../generated/services/card_columns.py | 48 ++++++++++++++++--- .../src/basecamp/generated/services/tools.py | 4 +- .../generated/services/webhooks_service.py | 8 ++-- .../basecamp/generated/services/wormholes.py | 32 ++++++++++--- python/tests/services/test_tools.py | 44 +++++++++++++++++ .../tests/services/test_wormholes_service.py | 46 ++++++++++++++++++ 7 files changed, 166 insertions(+), 20 deletions(-) diff --git a/python/scripts/generate_services.py b/python/scripts/generate_services.py index 203c2ead1..6c77c8cf5 100644 --- a/python/scripts/generate_services.py +++ b/python/scripts/generate_services.py @@ -585,9 +585,9 @@ def build_info_kwargs(op: dict, service_name: str) -> str: f'is_mutation={op["is_mutation"]}', ] - project_param = next((p for p in op["path_params"] if p["name"] == "projectId"), None) + project_param = next((p for p in op["path_params"] if p["name"] in ("projectId", "bucketId")), None) resource_param = next( - (p for p in reversed(op["path_params"]) if p["name"] != "projectId"), + (p for p in reversed(op["path_params"]) if p["name"] not in ("projectId", "bucketId")), None, ) diff --git a/python/src/basecamp/generated/services/card_columns.py b/python/src/basecamp/generated/services/card_columns.py index cece5c47a..c0f476f49 100644 --- a/python/src/basecamp/generated/services/card_columns.py +++ b/python/src/basecamp/generated/services/card_columns.py @@ -13,7 +13,13 @@ class CardColumnsService(BaseService): def set_color(self, *, bucket_id: int, column_id: int, color: str) -> dict[str, Any]: return self._request( - OperationInfo(service="cardcolumns", operation="set_color", is_mutation=True, resource_id=column_id), + OperationInfo( + service="cardcolumns", + operation="set_color", + is_mutation=True, + project_id=bucket_id, + resource_id=column_id, + ), "PUT", f"/buckets/{bucket_id}/card_tables/columns/{column_id}/color.json", json_body=self._compact(color=color), @@ -22,7 +28,13 @@ def set_color(self, *, bucket_id: int, column_id: int, color: str) -> dict[str, def enable_on_hold(self, *, bucket_id: int, column_id: int) -> dict[str, Any]: return self._request( - OperationInfo(service="cardcolumns", operation="enable_on_hold", is_mutation=True, resource_id=column_id), + OperationInfo( + service="cardcolumns", + operation="enable_on_hold", + is_mutation=True, + project_id=bucket_id, + resource_id=column_id, + ), "POST", f"/buckets/{bucket_id}/card_tables/columns/{column_id}/on_hold.json", operation="EnableCardColumnOnHold", @@ -30,7 +42,13 @@ def enable_on_hold(self, *, bucket_id: int, column_id: int) -> dict[str, Any]: def disable_on_hold(self, *, bucket_id: int, column_id: int) -> dict[str, Any]: return self._request( - OperationInfo(service="cardcolumns", operation="disable_on_hold", is_mutation=True, resource_id=column_id), + OperationInfo( + service="cardcolumns", + operation="disable_on_hold", + is_mutation=True, + project_id=bucket_id, + resource_id=column_id, + ), "DELETE", f"/buckets/{bucket_id}/card_tables/columns/{column_id}/on_hold.json", operation="DisableCardColumnOnHold", @@ -94,7 +112,13 @@ def move(self, *, card_table_id: int, source_id: int, target_id: int, position: class AsyncCardColumnsService(AsyncBaseService): async def set_color(self, *, bucket_id: int, column_id: int, color: str) -> dict[str, Any]: return await self._request( - OperationInfo(service="cardcolumns", operation="set_color", is_mutation=True, resource_id=column_id), + OperationInfo( + service="cardcolumns", + operation="set_color", + is_mutation=True, + project_id=bucket_id, + resource_id=column_id, + ), "PUT", f"/buckets/{bucket_id}/card_tables/columns/{column_id}/color.json", json_body=self._compact(color=color), @@ -103,7 +127,13 @@ async def set_color(self, *, bucket_id: int, column_id: int, color: str) -> dict async def enable_on_hold(self, *, bucket_id: int, column_id: int) -> dict[str, Any]: return await self._request( - OperationInfo(service="cardcolumns", operation="enable_on_hold", is_mutation=True, resource_id=column_id), + OperationInfo( + service="cardcolumns", + operation="enable_on_hold", + is_mutation=True, + project_id=bucket_id, + resource_id=column_id, + ), "POST", f"/buckets/{bucket_id}/card_tables/columns/{column_id}/on_hold.json", operation="EnableCardColumnOnHold", @@ -111,7 +141,13 @@ async def enable_on_hold(self, *, bucket_id: int, column_id: int) -> dict[str, A async def disable_on_hold(self, *, bucket_id: int, column_id: int) -> dict[str, Any]: return await self._request( - OperationInfo(service="cardcolumns", operation="disable_on_hold", is_mutation=True, resource_id=column_id), + OperationInfo( + service="cardcolumns", + operation="disable_on_hold", + is_mutation=True, + project_id=bucket_id, + resource_id=column_id, + ), "DELETE", f"/buckets/{bucket_id}/card_tables/columns/{column_id}/on_hold.json", operation="DisableCardColumnOnHold", diff --git a/python/src/basecamp/generated/services/tools.py b/python/src/basecamp/generated/services/tools.py index 043776305..4c60e2e72 100644 --- a/python/src/basecamp/generated/services/tools.py +++ b/python/src/basecamp/generated/services/tools.py @@ -13,7 +13,7 @@ class ToolsService(BaseService): def create(self, *, bucket_id: int, tool_type: str, title: str | None = None) -> dict[str, Any]: return self._request( - OperationInfo(service="tools", operation="create", is_mutation=True, resource_id=bucket_id), + OperationInfo(service="tools", operation="create", is_mutation=True, project_id=bucket_id), "POST", f"/buckets/{bucket_id}/dock/tools.json", json_body=self._compact(tool_type=tool_type, title=title), @@ -73,7 +73,7 @@ def disable(self, *, tool_id: int) -> None: class AsyncToolsService(AsyncBaseService): async def create(self, *, bucket_id: int, tool_type: str, title: str | None = None) -> dict[str, Any]: return await self._request( - OperationInfo(service="tools", operation="create", is_mutation=True, resource_id=bucket_id), + OperationInfo(service="tools", operation="create", is_mutation=True, project_id=bucket_id), "POST", f"/buckets/{bucket_id}/dock/tools.json", json_body=self._compact(tool_type=tool_type, title=title), diff --git a/python/src/basecamp/generated/services/webhooks_service.py b/python/src/basecamp/generated/services/webhooks_service.py index 73ab4f86f..7f846ea8b 100644 --- a/python/src/basecamp/generated/services/webhooks_service.py +++ b/python/src/basecamp/generated/services/webhooks_service.py @@ -13,7 +13,7 @@ class WebhooksService(BaseService): def list(self, *, bucket_id: int) -> ListResult: return self._request_paginated( - OperationInfo(service="webhooks", operation="list", is_mutation=False, resource_id=bucket_id), + OperationInfo(service="webhooks", operation="list", is_mutation=False, project_id=bucket_id), f"/buckets/{bucket_id}/webhooks.json", ) @@ -21,7 +21,7 @@ def create( self, *, bucket_id: int, payload_url: str, types: list[str], active: bool | None = None ) -> dict[str, Any]: return self._request( - OperationInfo(service="webhooks", operation="create", is_mutation=True, resource_id=bucket_id), + OperationInfo(service="webhooks", operation="create", is_mutation=True, project_id=bucket_id), "POST", f"/buckets/{bucket_id}/webhooks.json", json_body=self._compact(payload_url=payload_url, types=types, active=active), @@ -63,7 +63,7 @@ def delete(self, *, webhook_id: int) -> None: class AsyncWebhooksService(AsyncBaseService): async def list(self, *, bucket_id: int) -> ListResult: return await self._request_paginated( - OperationInfo(service="webhooks", operation="list", is_mutation=False, resource_id=bucket_id), + OperationInfo(service="webhooks", operation="list", is_mutation=False, project_id=bucket_id), f"/buckets/{bucket_id}/webhooks.json", ) @@ -71,7 +71,7 @@ async def create( self, *, bucket_id: int, payload_url: str, types: list[str], active: bool | None = None ) -> dict[str, Any]: return await self._request( - OperationInfo(service="webhooks", operation="create", is_mutation=True, resource_id=bucket_id), + OperationInfo(service="webhooks", operation="create", is_mutation=True, project_id=bucket_id), "POST", f"/buckets/{bucket_id}/webhooks.json", json_body=self._compact(payload_url=payload_url, types=types, active=active), diff --git a/python/src/basecamp/generated/services/wormholes.py b/python/src/basecamp/generated/services/wormholes.py index 0ab7daa71..80c48d305 100644 --- a/python/src/basecamp/generated/services/wormholes.py +++ b/python/src/basecamp/generated/services/wormholes.py @@ -13,7 +13,9 @@ class WormholesService(BaseService): def update(self, *, bucket_id: int, wormhole_id: int, destination_recording_id: int) -> dict[str, Any]: return self._request( - OperationInfo(service="wormholes", operation="update", is_mutation=True, resource_id=wormhole_id), + OperationInfo( + service="wormholes", operation="update", is_mutation=True, project_id=bucket_id, resource_id=wormhole_id + ), "PUT", f"/buckets/{bucket_id}/card_tables/wormholes/{wormhole_id}", json_body=self._compact(destination_recording_id=destination_recording_id), @@ -22,7 +24,9 @@ def update(self, *, bucket_id: int, wormhole_id: int, destination_recording_id: def delete(self, *, bucket_id: int, wormhole_id: int) -> None: self._request_void( - OperationInfo(service="wormholes", operation="delete", is_mutation=True, resource_id=wormhole_id), + OperationInfo( + service="wormholes", operation="delete", is_mutation=True, project_id=bucket_id, resource_id=wormhole_id + ), "DELETE", f"/buckets/{bucket_id}/card_tables/wormholes/{wormhole_id}", operation="DeleteWormhole", @@ -30,7 +34,13 @@ def delete(self, *, bucket_id: int, wormhole_id: int) -> None: def create(self, *, bucket_id: int, card_table_id: int, destination_recording_id: int) -> dict[str, Any]: return self._request( - OperationInfo(service="wormholes", operation="create", is_mutation=True, resource_id=card_table_id), + OperationInfo( + service="wormholes", + operation="create", + is_mutation=True, + project_id=bucket_id, + resource_id=card_table_id, + ), "POST", f"/buckets/{bucket_id}/card_tables/{card_table_id}/wormholes.json", json_body=self._compact(destination_recording_id=destination_recording_id), @@ -41,7 +51,9 @@ def create(self, *, bucket_id: int, card_table_id: int, destination_recording_id class AsyncWormholesService(AsyncBaseService): async def update(self, *, bucket_id: int, wormhole_id: int, destination_recording_id: int) -> dict[str, Any]: return await self._request( - OperationInfo(service="wormholes", operation="update", is_mutation=True, resource_id=wormhole_id), + OperationInfo( + service="wormholes", operation="update", is_mutation=True, project_id=bucket_id, resource_id=wormhole_id + ), "PUT", f"/buckets/{bucket_id}/card_tables/wormholes/{wormhole_id}", json_body=self._compact(destination_recording_id=destination_recording_id), @@ -50,7 +62,9 @@ async def update(self, *, bucket_id: int, wormhole_id: int, destination_recordin async def delete(self, *, bucket_id: int, wormhole_id: int) -> None: await self._request_void( - OperationInfo(service="wormholes", operation="delete", is_mutation=True, resource_id=wormhole_id), + OperationInfo( + service="wormholes", operation="delete", is_mutation=True, project_id=bucket_id, resource_id=wormhole_id + ), "DELETE", f"/buckets/{bucket_id}/card_tables/wormholes/{wormhole_id}", operation="DeleteWormhole", @@ -58,7 +72,13 @@ async def delete(self, *, bucket_id: int, wormhole_id: int) -> None: async def create(self, *, bucket_id: int, card_table_id: int, destination_recording_id: int) -> dict[str, Any]: return await self._request( - OperationInfo(service="wormholes", operation="create", is_mutation=True, resource_id=card_table_id), + OperationInfo( + service="wormholes", + operation="create", + is_mutation=True, + project_id=bucket_id, + resource_id=card_table_id, + ), "POST", f"/buckets/{bucket_id}/card_tables/{card_table_id}/wormholes.json", json_body=self._compact(destination_recording_id=destination_recording_id), diff --git a/python/tests/services/test_tools.py b/python/tests/services/test_tools.py index 46c99f885..7ae4922ff 100644 --- a/python/tests/services/test_tools.py +++ b/python/tests/services/test_tools.py @@ -9,6 +9,15 @@ import respx from basecamp import AsyncClient, Client, ValidationError +from basecamp.hooks import BasecampHooks, OperationInfo + + +class _RecordingHooks(BasecampHooks): + def __init__(self) -> None: + self.operations: list[OperationInfo] = [] + + def on_operation_start(self, info: OperationInfo) -> None: + self.operations.append(info) def _tool(tool_id: int = 800, *, title: str = "Message Board") -> dict: @@ -57,6 +66,23 @@ def test_create_omits_title_when_not_provided(self): assert route.called assert json.loads(route.calls[0].request.content) == {"tool_type": "Message::Board"} + @respx.mock + def test_create_operation_metadata_scopes_project_with_null_resource(self): + respx.post("https://3.basecampapi.com/12345/buckets/456/dock/tools.json").mock( + return_value=httpx.Response(201, json=_tool()) + ) + + hooks = _RecordingHooks() + account = Client(access_token="test-token", hooks=hooks).for_account("12345") + account.tools.create(bucket_id=456, tool_type="Message::Board") + + assert len(hooks.operations) == 1 + info = hooks.operations[0] + assert info.service == "tools" + assert info.operation == "create" + assert info.project_id == 456 + assert info.resource_id is None + @respx.mock def test_create_raises_validation_error_on_422(self): route = respx.post("https://3.basecampapi.com/12345/buckets/456/dock/tools.json").mock( @@ -107,6 +133,24 @@ async def test_create_omits_title_when_not_provided(self): assert route.called assert json.loads(route.calls[0].request.content) == {"tool_type": "Message::Board"} + @pytest.mark.asyncio + @respx.mock + async def test_create_operation_metadata_scopes_project_with_null_resource(self): + respx.post("https://3.basecampapi.com/12345/buckets/456/dock/tools.json").mock( + return_value=httpx.Response(201, json=_tool()) + ) + + hooks = _RecordingHooks() + account = AsyncClient(access_token="test-token", hooks=hooks).for_account("12345") + await account.tools.create(bucket_id=456, tool_type="Message::Board") + + assert len(hooks.operations) == 1 + info = hooks.operations[0] + assert info.service == "tools" + assert info.operation == "create" + assert info.project_id == 456 + assert info.resource_id is None + @pytest.mark.asyncio @respx.mock async def test_create_raises_validation_error_on_422(self): diff --git a/python/tests/services/test_wormholes_service.py b/python/tests/services/test_wormholes_service.py index 7963ba622..564705ccf 100644 --- a/python/tests/services/test_wormholes_service.py +++ b/python/tests/services/test_wormholes_service.py @@ -10,12 +10,21 @@ from basecamp import AsyncClient, Client from basecamp.errors import ForbiddenError, NotFoundError, ValidationError +from basecamp.hooks import BasecampHooks, OperationInfo CREATE_URL = "https://3.basecampapi.com/12345/buckets/2085958499/card_tables/1069479345/wormholes.json" WORMHOLE_URL = "https://3.basecampapi.com/12345/buckets/2085958499/card_tables/wormholes/1069479400" MISSING_URL = "https://3.basecampapi.com/12345/buckets/2085958499/card_tables/wormholes/999" +class _RecordingHooks(BasecampHooks): + def __init__(self) -> None: + self.operations: list[OperationInfo] = [] + + def on_operation_start(self, info: OperationInfo) -> None: + self.operations.append(info) + + def _wormhole(wormhole_id: int = 1069479400, linked: bool = True) -> dict: return { "id": wormhole_id, @@ -49,6 +58,24 @@ def test_create(self): body = json.loads(route.calls.last.request.content) assert body == {"destination_recording_id": 1069479500} + @respx.mock + def test_create_operation_metadata_scopes_project_and_resource(self): + respx.post(CREATE_URL).mock(return_value=httpx.Response(201, json=_wormhole(99))) + + hooks = _RecordingHooks() + c = Client(access_token="test-token", hooks=hooks) + c.for_account("12345").wormholes.create( + bucket_id=2085958499, card_table_id=1069479345, destination_recording_id=1069479500 + ) + c.close() + + assert len(hooks.operations) == 1 + info = hooks.operations[0] + assert info.service == "wormholes" + assert info.operation == "create" + assert info.project_id == 2085958499 + assert info.resource_id == 1069479345 + @respx.mock def test_create_validation_error_at_limit(self): respx.post(CREATE_URL).mock(return_value=httpx.Response(422, json={"error": "Limit reached"})) @@ -144,6 +171,25 @@ async def test_create(self): body = json.loads(route.calls.last.request.content) assert body == {"destination_recording_id": 1069479500} + @pytest.mark.asyncio + @respx.mock + async def test_create_operation_metadata_scopes_project_and_resource(self): + respx.post(CREATE_URL).mock(return_value=httpx.Response(201, json=_wormhole(99))) + + hooks = _RecordingHooks() + c = AsyncClient(access_token="test-token", hooks=hooks) + await c.for_account("12345").wormholes.create( + bucket_id=2085958499, card_table_id=1069479345, destination_recording_id=1069479500 + ) + await c.close() + + assert len(hooks.operations) == 1 + info = hooks.operations[0] + assert info.service == "wormholes" + assert info.operation == "create" + assert info.project_id == 2085958499 + assert info.resource_id == 1069479345 + @pytest.mark.asyncio @respx.mock async def test_create_validation_error_at_limit(self): From e4bc5b40481a83bb33da366d223af82bdd1d2d37 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 24 Jul 2026 23:25:11 -0700 Subject: [PATCH 4/8] Kotlin: treat bucketId as project scope in operation metadata Match projectId or bucketId for project scope and emit the matched param's camelCase name (previously a hardcoded projectId, which would reference an undefined local for a bucketId op), and exclude bucketId (as well as projectId) from resourceId selection. The nine bucketId-labeled ops now report projectId; the three collection ops (ListWebhooks, CreateWebhook, CreateTool) drop the bucket id from resourceId. --- .../basecamp/sdk/generator/ServiceEmitter.kt | 6 ++-- .../sdk/generated/services/card-columns.kt | 6 ++-- .../basecamp/sdk/generated/services/tools.kt | 4 +-- .../sdk/generated/services/webhooks.kt | 8 ++--- .../sdk/generated/services/wormholes.kt | 6 ++-- .../com/basecamp/sdk/WebhooksServiceTest.kt | 35 +++++++++++++++++++ .../com/basecamp/sdk/WormholesServiceTest.kt | 34 ++++++++++++++++++ 7 files changed, 84 insertions(+), 15 deletions(-) diff --git a/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/ServiceEmitter.kt b/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/ServiceEmitter.kt index 3c9942b3f..44913d66f 100644 --- a/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/ServiceEmitter.kt +++ b/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/ServiceEmitter.kt @@ -106,9 +106,9 @@ class ServiceEmitter(private val api: OpenApiParser) { sb.appendLine(" suspend fun ${op.methodName}($params): $returnType {") // Build OperationInfo - val projectParam = op.pathParams.find { it.name == "projectId" } - val resourceParam = op.pathParams.findLast { it.name != "projectId" && it.name.endsWith("Id") } - val projectArg = if (projectParam != null) "projectId" else "null" + val projectParam = op.pathParams.find { it.name == "projectId" || it.name == "bucketId" } + val resourceParam = op.pathParams.findLast { it.name != "projectId" && it.name != "bucketId" && it.name.endsWith("Id") } + val projectArg = if (projectParam != null) projectParam.name.snakeToCamelCase() else "null" val resourceArg = if (resourceParam != null) resourceParam.name.snakeToCamelCase() else "null" sb.appendLine(" val info = OperationInfo(") diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/card-columns.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/card-columns.kt index 6af1a060f..6522908ab 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/card-columns.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/card-columns.kt @@ -24,7 +24,7 @@ class CardColumnsService(client: AccountClient) : BaseService(client) { operation = "SetCardColumnColor", resourceType = "card_column_color", isMutation = true, - projectId = null, + projectId = bucketId, resourceId = columnId, ) return request(info, { @@ -47,7 +47,7 @@ class CardColumnsService(client: AccountClient) : BaseService(client) { operation = "EnableCardColumnOnHold", resourceType = "card_column_on_hold", isMutation = true, - projectId = null, + projectId = bucketId, resourceId = columnId, ) return request(info, { @@ -68,7 +68,7 @@ class CardColumnsService(client: AccountClient) : BaseService(client) { operation = "DisableCardColumnOnHold", resourceType = "card_column_on_hold", isMutation = true, - projectId = null, + projectId = bucketId, resourceId = columnId, ) return request(info, { diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/tools.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/tools.kt index 4915b49ad..f3663956a 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/tools.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/tools.kt @@ -23,8 +23,8 @@ class ToolsService(client: AccountClient) : BaseService(client) { operation = "CreateTool", resourceType = "tool", isMutation = true, - projectId = null, - resourceId = bucketId, + projectId = bucketId, + resourceId = null, ) return request(info, { httpPost("/buckets/${bucketId}/dock/tools.json", json.encodeToString(kotlinx.serialization.json.buildJsonObject { diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/webhooks.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/webhooks.kt index 315b96970..ae698c14f 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/webhooks.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/webhooks.kt @@ -23,8 +23,8 @@ class WebhooksService(client: AccountClient) : BaseService(client) { operation = "ListWebhooks", resourceType = "webhook", isMutation = false, - projectId = null, - resourceId = bucketId, + projectId = bucketId, + resourceId = null, ) return requestPaginated(info, options, { httpGet("/buckets/${bucketId}/webhooks.json", operationName = info.operation) @@ -44,8 +44,8 @@ class WebhooksService(client: AccountClient) : BaseService(client) { operation = "CreateWebhook", resourceType = "webhook", isMutation = true, - projectId = null, - resourceId = bucketId, + projectId = bucketId, + resourceId = null, ) return request(info, { httpPost("/buckets/${bucketId}/webhooks.json", json.encodeToString(kotlinx.serialization.json.buildJsonObject { diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/wormholes.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/wormholes.kt index 7e49e4345..c69fa43a7 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/wormholes.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/wormholes.kt @@ -24,7 +24,7 @@ class WormholesService(client: AccountClient) : BaseService(client) { operation = "UpdateWormhole", resourceType = "wormhole", isMutation = true, - projectId = null, + projectId = bucketId, resourceId = wormholeId, ) return request(info, { @@ -47,7 +47,7 @@ class WormholesService(client: AccountClient) : BaseService(client) { operation = "DeleteWormhole", resourceType = "wormhole", isMutation = true, - projectId = null, + projectId = bucketId, resourceId = wormholeId, ) request(info, { @@ -67,7 +67,7 @@ class WormholesService(client: AccountClient) : BaseService(client) { operation = "CreateWormhole", resourceType = "wormhole", isMutation = true, - projectId = null, + projectId = bucketId, resourceId = cardTableId, ) return request(info, { diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WebhooksServiceTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WebhooksServiceTest.kt index 33373d5a0..d545f4e20 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WebhooksServiceTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WebhooksServiceTest.kt @@ -12,6 +12,8 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class WebhooksServiceTest { @@ -70,6 +72,39 @@ class WebhooksServiceTest { client.close() } + @Test + fun listEmitsBucketProjectScopeWithNullResourceId() = runTest { + var capturedInfo: OperationInfo? = null + val hooks = object : BasecampHooks { + override fun onOperationStart(info: OperationInfo) { + if (info.operation == "ListWebhooks") capturedInfo = info + } + } + val engine = MockEngine { _ -> + respond( + content = """[${webhookJson(10, "https://example.com/hook1")}]""", + status = HttpStatusCode.OK, + headers = headersOf( + HttpHeaders.ContentType to listOf(ContentType.Application.Json.toString()), + "X-Total-Count" to listOf("1"), + ), + ) + } + val client = testBasecampClient { + accessToken("test-token") + this.engine = engine + this.hooks = hooks + } + + client.forAccount("12345").webhooks.list(bucketId = 1) + + assertNotNull(capturedInfo) + assertEquals(1L, capturedInfo!!.projectId) + assertNull(capturedInfo!!.resourceId) + + client.close() + } + @Test fun getWebhook() = runTest { val client = mockClient { request -> diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WormholesServiceTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WormholesServiceTest.kt index b9e589f23..18d6c1e4f 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WormholesServiceTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WormholesServiceTest.kt @@ -82,6 +82,40 @@ class WormholesServiceTest { client.close() } + @Test + fun createEmitsBucketProjectScopeWithCardTableResourceId() = runTest { + var capturedInfo: OperationInfo? = null + val hooks = object : BasecampHooks { + override fun onOperationStart(info: OperationInfo) { + if (info.operation == "CreateWormhole") capturedInfo = info + } + } + val engine = MockEngine { _ -> + respond( + content = wormholeJson(99), + status = HttpStatusCode.Created, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) + } + val client = testBasecampClient { + accessToken("test-token") + this.engine = engine + this.hooks = hooks + } + + client.forAccount("12345").wormholes.create( + bucketId = 2085958499, + cardTableId = 1069479345, + body = CreateWormholeBody(destinationRecordingId = 1069479500), + ) + + assertNotNull(capturedInfo) + assertEquals(2085958499L, capturedInfo!!.projectId) + assertEquals(1069479345L, capturedInfo!!.resourceId) + + client.close() + } + @Test fun createValidationErrorAtLimitThrows() = runTest { val client = mockClient { _ -> From 1ecf68ab6d684c8c923b40b6ee0708e50a4e63b8 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 24 Jul 2026 23:25:11 -0700 Subject: [PATCH 5/8] Swift: treat bucketId as project scope in operation metadata Match projectId or bucketId for project scope and emit the matched param's name (previously a hardcoded projectId, which would reference an undefined local for a bucketId op), and exclude bucketId (as well as projectId) from resourceId selection. The nine bucketId-labeled ops now report projectId; the three collection ops (ListWebhooks, CreateWebhook, CreateTool) drop the bucket id from resourceId. --- .../Services/CardColumnsService.swift | 6 ++-- .../Generated/Services/ToolsService.swift | 2 +- .../Generated/Services/WebhooksService.swift | 4 +-- .../Generated/Services/WormholesService.swift | 6 ++-- .../BasecampGenerator/ServiceEmitter.swift | 8 ++--- .../BasecampTests/GeneratedServiceTests.swift | 31 +++++++++++++++++++ .../BasecampTests/WormholesServiceTests.swift | 31 +++++++++++++++++++ 7 files changed, 75 insertions(+), 13 deletions(-) diff --git a/swift/Sources/Basecamp/Generated/Services/CardColumnsService.swift b/swift/Sources/Basecamp/Generated/Services/CardColumnsService.swift index db4024b4f..f5fd4885d 100644 --- a/swift/Sources/Basecamp/Generated/Services/CardColumnsService.swift +++ b/swift/Sources/Basecamp/Generated/Services/CardColumnsService.swift @@ -14,7 +14,7 @@ public final class CardColumnsService: BaseService, @unchecked Sendable { public func disableOnHold(bucketId: Int, columnId: Int) async throws -> CardColumn { return try await request( - OperationInfo(service: "CardColumns", operation: "DisableCardColumnOnHold", resourceType: "card_column_on_hold", isMutation: true, resourceId: columnId), + OperationInfo(service: "CardColumns", operation: "DisableCardColumnOnHold", resourceType: "card_column_on_hold", isMutation: true, projectId: bucketId, resourceId: columnId), method: "DELETE", path: "/buckets/\(bucketId)/card_tables/columns/\(columnId)/on_hold.json", retryConfig: Metadata.retryConfig(for: "DisableCardColumnOnHold") @@ -23,7 +23,7 @@ public final class CardColumnsService: BaseService, @unchecked Sendable { public func enableOnHold(bucketId: Int, columnId: Int) async throws -> CardColumn { return try await request( - OperationInfo(service: "CardColumns", operation: "EnableCardColumnOnHold", resourceType: "card_column_on_hold", isMutation: true, resourceId: columnId), + OperationInfo(service: "CardColumns", operation: "EnableCardColumnOnHold", resourceType: "card_column_on_hold", isMutation: true, projectId: bucketId, resourceId: columnId), method: "POST", path: "/buckets/\(bucketId)/card_tables/columns/\(columnId)/on_hold.json", retryConfig: Metadata.retryConfig(for: "EnableCardColumnOnHold") @@ -51,7 +51,7 @@ public final class CardColumnsService: BaseService, @unchecked Sendable { public func setColor(bucketId: Int, columnId: Int, req: SetCardColumnColorRequest) async throws -> CardColumn { return try await request( - OperationInfo(service: "CardColumns", operation: "SetCardColumnColor", resourceType: "card_column_color", isMutation: true, resourceId: columnId), + OperationInfo(service: "CardColumns", operation: "SetCardColumnColor", resourceType: "card_column_color", isMutation: true, projectId: bucketId, resourceId: columnId), method: "PUT", path: "/buckets/\(bucketId)/card_tables/columns/\(columnId)/color.json", body: req, diff --git a/swift/Sources/Basecamp/Generated/Services/ToolsService.swift b/swift/Sources/Basecamp/Generated/Services/ToolsService.swift index dc4879462..f2352ef36 100644 --- a/swift/Sources/Basecamp/Generated/Services/ToolsService.swift +++ b/swift/Sources/Basecamp/Generated/Services/ToolsService.swift @@ -4,7 +4,7 @@ import Foundation public final class ToolsService: BaseService, @unchecked Sendable { public func create(bucketId: Int, req: CreateToolRequest) async throws -> Tool { return try await request( - OperationInfo(service: "Tools", operation: "CreateTool", resourceType: "tool", isMutation: true, resourceId: bucketId), + OperationInfo(service: "Tools", operation: "CreateTool", resourceType: "tool", isMutation: true, projectId: bucketId), method: "POST", path: "/buckets/\(bucketId)/dock/tools.json", body: req, diff --git a/swift/Sources/Basecamp/Generated/Services/WebhooksService.swift b/swift/Sources/Basecamp/Generated/Services/WebhooksService.swift index dfcecd901..926cf64a1 100644 --- a/swift/Sources/Basecamp/Generated/Services/WebhooksService.swift +++ b/swift/Sources/Basecamp/Generated/Services/WebhooksService.swift @@ -13,7 +13,7 @@ public struct ListWebhookOptions: Sendable { public final class WebhooksService: BaseService, @unchecked Sendable { public func create(bucketId: Int, req: CreateWebhookRequest) async throws -> Webhook { return try await request( - OperationInfo(service: "Webhooks", operation: "CreateWebhook", resourceType: "webhook", isMutation: true, resourceId: bucketId), + OperationInfo(service: "Webhooks", operation: "CreateWebhook", resourceType: "webhook", isMutation: true, projectId: bucketId), method: "POST", path: "/buckets/\(bucketId)/webhooks.json", body: req, @@ -41,7 +41,7 @@ public final class WebhooksService: BaseService, @unchecked Sendable { public func list(bucketId: Int, options: ListWebhookOptions? = nil) async throws -> ListResult { return try await requestPaginated( - OperationInfo(service: "Webhooks", operation: "ListWebhooks", resourceType: "webhook", isMutation: false, resourceId: bucketId), + OperationInfo(service: "Webhooks", operation: "ListWebhooks", resourceType: "webhook", isMutation: false, projectId: bucketId), path: "/buckets/\(bucketId)/webhooks.json", paginationOpts: options.flatMap { PaginationOptions(maxItems: $0.maxItems) }, retryConfig: Metadata.retryConfig(for: "ListWebhooks") diff --git a/swift/Sources/Basecamp/Generated/Services/WormholesService.swift b/swift/Sources/Basecamp/Generated/Services/WormholesService.swift index 8bf41b879..b7bfd3b44 100644 --- a/swift/Sources/Basecamp/Generated/Services/WormholesService.swift +++ b/swift/Sources/Basecamp/Generated/Services/WormholesService.swift @@ -4,7 +4,7 @@ import Foundation public final class WormholesService: BaseService, @unchecked Sendable { public func create(bucketId: Int, cardTableId: Int, req: CreateWormholeRequest) async throws -> Wormhole { return try await request( - OperationInfo(service: "Wormholes", operation: "CreateWormhole", resourceType: "wormhole", isMutation: true, resourceId: cardTableId), + OperationInfo(service: "Wormholes", operation: "CreateWormhole", resourceType: "wormhole", isMutation: true, projectId: bucketId, resourceId: cardTableId), method: "POST", path: "/buckets/\(bucketId)/card_tables/\(cardTableId)/wormholes.json", body: req, @@ -14,7 +14,7 @@ public final class WormholesService: BaseService, @unchecked Sendable { public func delete(bucketId: Int, wormholeId: Int) async throws { try await requestVoid( - OperationInfo(service: "Wormholes", operation: "DeleteWormhole", resourceType: "wormhole", isMutation: true, resourceId: wormholeId), + OperationInfo(service: "Wormholes", operation: "DeleteWormhole", resourceType: "wormhole", isMutation: true, projectId: bucketId, resourceId: wormholeId), method: "DELETE", path: "/buckets/\(bucketId)/card_tables/wormholes/\(wormholeId)", retryConfig: Metadata.retryConfig(for: "DeleteWormhole") @@ -23,7 +23,7 @@ public final class WormholesService: BaseService, @unchecked Sendable { public func update(bucketId: Int, wormholeId: Int, req: UpdateWormholeRequest) async throws -> Wormhole { return try await request( - OperationInfo(service: "Wormholes", operation: "UpdateWormhole", resourceType: "wormhole", isMutation: true, resourceId: wormholeId), + OperationInfo(service: "Wormholes", operation: "UpdateWormhole", resourceType: "wormhole", isMutation: true, projectId: bucketId, resourceId: wormholeId), method: "PUT", path: "/buckets/\(bucketId)/card_tables/wormholes/\(wormholeId)", body: req, diff --git a/swift/Sources/BasecampGenerator/ServiceEmitter.swift b/swift/Sources/BasecampGenerator/ServiceEmitter.swift index ad52a7591..307128657 100644 --- a/swift/Sources/BasecampGenerator/ServiceEmitter.swift +++ b/swift/Sources/BasecampGenerator/ServiceEmitter.swift @@ -178,16 +178,16 @@ private func emitMethod(_ op: ParsedOperation, serviceName: String, schemas: [St // Build OperationInfo let swiftPath = pathToSwiftInterpolation(op.path) - let projectParam = op.pathParams.first { $0.name == "projectId" } - let resourceParam = op.pathParams.last { $0.name != "projectId" && $0.name.hasSuffix("Id") } + let projectParam = op.pathParams.first { $0.name == "projectId" || $0.name == "bucketId" } + let resourceParam = op.pathParams.last { $0.name != "projectId" && $0.name != "bucketId" && $0.name.hasSuffix("Id") } var opInfoParts: [String] = [] opInfoParts.append("service: \"\(serviceName)\"") opInfoParts.append("operation: \"\(op.operationId)\"") opInfoParts.append("resourceType: \"\(op.resourceType)\"") opInfoParts.append("isMutation: \(op.isMutation)") - if projectParam != nil { - opInfoParts.append("projectId: projectId") + if let pp = projectParam { + opInfoParts.append("projectId: \(pp.name)") } if let rp = resourceParam { opInfoParts.append("resourceId: \(rp.name)") diff --git a/swift/Tests/BasecampTests/GeneratedServiceTests.swift b/swift/Tests/BasecampTests/GeneratedServiceTests.swift index 93bbe48de..94153f07b 100644 --- a/swift/Tests/BasecampTests/GeneratedServiceTests.swift +++ b/swift/Tests/BasecampTests/GeneratedServiceTests.swift @@ -335,6 +335,23 @@ final class GeneratedServiceTests: XCTestCase { XCTAssertEqual(transport.lastRequest!.request.httpMethod, "POST") } + // ListWebhooks is a bucket-scoped collection op: operation metadata carries + // projectId == bucket id and NO resourceId (there's no deeper target id). + func testListWebhooksEmitsProjectScopeWithoutResourceId() async throws { + let spy = SpyHooks() + let data = try JSONSerialization.data(withJSONObject: [] as [Any]) + let transport = MockTransport(statusCode: 200, data: data, headers: ["X-Total-Count": "0"]) + let account = makeTestAccountClient(transport: transport, hooks: spy) + + _ = try await account.webhooks.list(bucketId: 7) + + XCTAssertEqual(spy.operationStarts.count, 1) + let info = spy.operationStarts.first! + XCTAssertEqual(info.operation, "ListWebhooks") + XCTAssertEqual(info.projectId, 7, "projectId should be the bucket id") + XCTAssertNil(info.resourceId, "collection op has no deeper resource id") + } + func testCommentsServiceGet() async throws { let responseJSON: [String: Any] = [ "id": 7, "content": "Great idea!", "content_attachments": [], @@ -742,3 +759,17 @@ final class GeneratedServiceTests: XCTestCase { XCTAssertEqual(sentJSON["visible_to_clients"] as? Bool, false) } } + +/// Records operation-start callbacks so tests can assert emitted metadata. +private final class SpyHooks: BasecampHooks, @unchecked Sendable { + private let lock = NSLock() + private var _operationStarts: [OperationInfo] = [] + + var operationStarts: [OperationInfo] { + lock.withLock { _operationStarts } + } + + func onOperationStart(_ info: OperationInfo) { + lock.withLock { _operationStarts.append(info) } + } +} diff --git a/swift/Tests/BasecampTests/WormholesServiceTests.swift b/swift/Tests/BasecampTests/WormholesServiceTests.swift index 2da3c01b5..b39ced90b 100644 --- a/swift/Tests/BasecampTests/WormholesServiceTests.swift +++ b/swift/Tests/BasecampTests/WormholesServiceTests.swift @@ -137,6 +137,23 @@ final class WormholesServiceTests: XCTestCase { } } + // CreateWormhole is bucket-scoped with a deeper card-table id: operation + // metadata carries projectId == bucket id AND resourceId == card table id. + func testCreateEmitsProjectAndResourceMetadata() async throws { + let spy = SpyHooks() + let responseData = try JSONSerialization.data(withJSONObject: wormholeJSON(id: 99)) + let transport = MockTransport(statusCode: 201, data: responseData) + let account = makeTestAccountClient(transport: transport, hooks: spy) + + _ = try await account.wormholes.create(bucketId: 7, cardTableId: 42, req: CreateWormholeRequest(destinationRecordingId: 500)) + + XCTAssertEqual(spy.operationStarts.count, 1) + let info = spy.operationStarts.first! + XCTAssertEqual(info.operation, "CreateWormhole") + XCTAssertEqual(info.projectId, 7, "projectId should be the bucket id") + XCTAssertEqual(info.resourceId, 42, "resourceId should be the card table id") + } + func testCardTableDecodesLinkedAndUnlinkedWormholes() async throws { let json: [String: Any] = [ "id": 1069479345, @@ -168,3 +185,17 @@ final class WormholesServiceTests: XCTestCase { XCTAssertNil(cardTable.wormholes?[1].color) } } + +/// Records operation-start callbacks so tests can assert emitted metadata. +private final class SpyHooks: BasecampHooks, @unchecked Sendable { + private let lock = NSLock() + private var _operationStarts: [OperationInfo] = [] + + var operationStarts: [OperationInfo] { + lock.withLock { _operationStarts } + } + + func onOperationStart(_ info: OperationInfo) { + lock.withLock { _operationStarts.append(info) } + } +} From 8af668ce25b27738ff9a537f21953bb80a15c52e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 24 Jul 2026 23:25:21 -0700 Subject: [PATCH 6/8] Go: add OperationInfo.ProjectID and populate project/bucket scope Bring the hand-written Go observability layer to parity with the generated SDKs. Add an exported OperationInfo.ProjectID field and populate it at all 19 sites carrying a project/bucket id, with the rule: project/bucket id -> ProjectID, deepest non-project id -> ResourceID. Emit a basecamp.project_id span attribute in the otel hook. Relocate the mis-assigned bucket/project id out of ResourceID for Tools.Create and Projects.Get/Update/Trash so they match the generated SDKs. A table-driven test drives a real service call per op through a capturing hook and asserts the {ProjectID, ResourceID} split, guaranteeing no forgotten assignment. SPEC.md drops the '(Go omits this field)' clause on project_id. Public Go observability API expansion (additive): OperationInfo gains an exported ProjectID field. This can only break unkeyed struct literals of OperationInfo, which consumers never construct (the struct is received via the hook callback); apidiff ignores this case by design. --- SPEC.md | 2 +- go/pkg/basecamp/cards.go | 3 + go/pkg/basecamp/gauges.go | 3 + go/pkg/basecamp/observability.go | 2 + go/pkg/basecamp/operation_scope_test.go | 131 ++++++++++++++++++++++++ go/pkg/basecamp/otel/otel.go | 4 + go/pkg/basecamp/otel/otel_test.go | 4 + go/pkg/basecamp/people.go | 2 + go/pkg/basecamp/projects.go | 6 +- go/pkg/basecamp/timeline.go | 1 + go/pkg/basecamp/timesheet.go | 1 + go/pkg/basecamp/tools.go | 2 +- go/pkg/basecamp/tools_test.go | 7 +- go/pkg/basecamp/webhooks.go | 2 + go/pkg/basecamp/wormholes.go | 3 + 15 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 go/pkg/basecamp/operation_scope_test.go diff --git a/SPEC.md b/SPEC.md index eb3983992..5da385146 100644 --- a/SPEC.md +++ b/SPEC.md @@ -782,7 +782,7 @@ RECORD OperationInfo operation : String -- full operationId, e.g., "ListProjects", "GetTodo", "CreateProject" resource_type : String -- e.g., "todo", "project" is_mutation : Boolean -- true for POST, PUT, DELETE - project_id : Integer? -- if operation is project-scoped (Go omits this field) + project_id : Integer? -- if operation is project-scoped resource_id : Integer? -- if operation targets a specific resource END ``` diff --git a/go/pkg/basecamp/cards.go b/go/pkg/basecamp/cards.go index 4d519d9ea..01cbfd7fa 100644 --- a/go/pkg/basecamp/cards.go +++ b/go/pkg/basecamp/cards.go @@ -760,6 +760,7 @@ func (s *CardColumnsService) SetColor(ctx context.Context, bucketID, columnID in op := OperationInfo{ Service: "CardColumns", Operation: "SetColor", ResourceType: "card_column", IsMutation: true, + ProjectID: bucketID, ResourceID: columnID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { @@ -802,6 +803,7 @@ func (s *CardColumnsService) EnableOnHold(ctx context.Context, bucketID, columnI op := OperationInfo{ Service: "CardColumns", Operation: "EnableOnHold", ResourceType: "card_column", IsMutation: true, + ProjectID: bucketID, ResourceID: columnID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { @@ -835,6 +837,7 @@ func (s *CardColumnsService) DisableOnHold(ctx context.Context, bucketID, column op := OperationInfo{ Service: "CardColumns", Operation: "DisableOnHold", ResourceType: "card_column", IsMutation: true, + ProjectID: bucketID, ResourceID: columnID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { diff --git a/go/pkg/basecamp/gauges.go b/go/pkg/basecamp/gauges.go index 3171cfb74..5888f3caf 100644 --- a/go/pkg/basecamp/gauges.go +++ b/go/pkg/basecamp/gauges.go @@ -167,6 +167,7 @@ func (s *GaugesService) ListNeedles(ctx context.Context, projectID int64) (resul op := OperationInfo{ Service: "Gauges", Operation: "ListNeedles", ResourceType: "gauge_needle", IsMutation: false, + ProjectID: projectID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { @@ -243,6 +244,7 @@ func (s *GaugesService) CreateNeedle(ctx context.Context, projectID int64, req * op := OperationInfo{ Service: "Gauges", Operation: "CreateNeedle", ResourceType: "gauge_needle", IsMutation: true, + ProjectID: projectID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { @@ -357,6 +359,7 @@ func (s *GaugesService) Toggle(ctx context.Context, projectID int64, enabled boo op := OperationInfo{ Service: "Gauges", Operation: "Toggle", ResourceType: "gauge", IsMutation: true, + ProjectID: projectID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { diff --git a/go/pkg/basecamp/observability.go b/go/pkg/basecamp/observability.go index b80da0acc..2860b7393 100644 --- a/go/pkg/basecamp/observability.go +++ b/go/pkg/basecamp/observability.go @@ -70,6 +70,8 @@ type OperationInfo struct { ResourceType string // IsMutation indicates if this operation modifies state. IsMutation bool + // ProjectID is the project/bucket ID if applicable. + ProjectID int64 // ResourceID is the specific resource ID if applicable. ResourceID int64 } diff --git a/go/pkg/basecamp/operation_scope_test.go b/go/pkg/basecamp/operation_scope_test.go new file mode 100644 index 000000000..c9737e979 --- /dev/null +++ b/go/pkg/basecamp/operation_scope_test.go @@ -0,0 +1,131 @@ +package basecamp + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// TestOperationProjectResourceScope pins the {ProjectID, ResourceID} split that +// OnOperationStart observes for every operation whose path is project- or +// bucket-scoped. It drives real service calls through a capturing hook so a +// forgotten ProjectID assignment (or a stale ResourceID) is caught mechanically +// — `make check` alone cannot guarantee this. The server returns a trivial body +// because the assertions only inspect the operation metadata captured before the +// request is issued, not the decoded response. +func TestOperationProjectResourceScope(t *testing.T) { + const ( + accountID = "5245563" + bucketID = int64(2085958499) + columnID = int64(1101) + cardTableID = int64(2202) + wormholeID = int64(3303) + projectID = int64(4404) + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{}")) + })) + defer server.Close() + + tests := []struct { + name string + invoke func(ctx context.Context, ac *AccountClient) + wantProjectID int64 + wantResourceID int64 + }{ + // Group 1 — project scope (bucket) plus a deeper resource id that is kept. + {"CardColumns.SetColor", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.CardColumns().SetColor(ctx, bucketID, columnID, "white") + }, bucketID, columnID}, + {"CardColumns.EnableOnHold", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.CardColumns().EnableOnHold(ctx, bucketID, columnID) + }, bucketID, columnID}, + {"CardColumns.DisableOnHold", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.CardColumns().DisableOnHold(ctx, bucketID, columnID) + }, bucketID, columnID}, + {"Wormholes.Create", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Wormholes().Create(ctx, bucketID, cardTableID, 9001) + }, bucketID, cardTableID}, + {"Wormholes.Update", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Wormholes().Update(ctx, bucketID, wormholeID, 9001) + }, bucketID, wormholeID}, + {"Wormholes.Delete", func(ctx context.Context, ac *AccountClient) { + _ = ac.Wormholes().Delete(ctx, bucketID, wormholeID) + }, bucketID, wormholeID}, + + // Group 2 — project scope only; no deeper resource id. + {"Gauges.ListNeedles", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Gauges().ListNeedles(ctx, projectID) + }, projectID, 0}, + {"Gauges.CreateNeedle", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Gauges().CreateNeedle(ctx, projectID, &CreateGaugeNeedleRequest{}) + }, projectID, 0}, + {"Gauges.Toggle", func(ctx context.Context, ac *AccountClient) { + _ = ac.Gauges().Toggle(ctx, projectID, true) + }, projectID, 0}, + {"People.ListProjectPeople", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.People().ListProjectPeople(ctx, projectID, nil) + }, projectID, 0}, + {"People.UpdateProjectAccess", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.People().UpdateProjectAccess(ctx, projectID, &UpdateProjectAccessRequest{}) + }, projectID, 0}, + {"Timesheet.ProjectReport", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Timesheet().ProjectReport(ctx, projectID, nil) + }, projectID, 0}, + {"Timeline.ProjectTimeline", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Timeline().ProjectTimeline(ctx, projectID, nil) + }, projectID, 0}, + {"Tools.Create", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Tools().Create(ctx, bucketID, "Message::Board", nil) + }, bucketID, 0}, + {"Webhooks.List", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Webhooks().List(ctx, bucketID, &WebhookListOptions{Page: 1}) + }, bucketID, 0}, + {"Webhooks.Create", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Webhooks().Create(ctx, bucketID, &CreateWebhookRequest{}) + }, bucketID, 0}, + {"Projects.Get", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Projects().Get(ctx, projectID) + }, projectID, 0}, + {"Projects.Update", func(ctx context.Context, ac *AccountClient) { + _, _ = ac.Projects().Update(ctx, projectID, &UpdateProjectRequest{}) + }, projectID, 0}, + {"Projects.Trash", func(ctx context.Context, ac *AccountClient) { + _ = ac.Projects().Trash(ctx, projectID) + }, projectID, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := DefaultConfig() + cfg.BaseURL = server.URL + + var capturedOp OperationInfo + var captured bool + hooks := &testHooks{ + onOperationStart: func(ctx context.Context, op OperationInfo) context.Context { + capturedOp = op + captured = true + return ctx + }, + } + client := NewClient(cfg, &StaticTokenProvider{Token: "test-token"}, WithHooks(hooks)) + + tt.invoke(context.Background(), client.ForAccount(accountID)) + + if !captured { + t.Fatalf("%s: OnOperationStart never fired", tt.name) + } + if capturedOp.ProjectID != tt.wantProjectID { + t.Errorf("%s: ProjectID = %d, want %d", tt.name, capturedOp.ProjectID, tt.wantProjectID) + } + if capturedOp.ResourceID != tt.wantResourceID { + t.Errorf("%s: ResourceID = %d, want %d", tt.name, capturedOp.ResourceID, tt.wantResourceID) + } + }) + } +} diff --git a/go/pkg/basecamp/otel/otel.go b/go/pkg/basecamp/otel/otel.go index d82bbea65..59e97b952 100644 --- a/go/pkg/basecamp/otel/otel.go +++ b/go/pkg/basecamp/otel/otel.go @@ -149,6 +149,10 @@ func (h *Hooks) OnOperationStart(ctx context.Context, op basecamp.OperationInfo) ), ) + if op.ProjectID != 0 { + span.SetAttributes(attribute.Int64("basecamp.project_id", op.ProjectID)) + } + if op.ResourceID != 0 { span.SetAttributes(attribute.Int64("basecamp.resource_id", op.ResourceID)) } diff --git a/go/pkg/basecamp/otel/otel_test.go b/go/pkg/basecamp/otel/otel_test.go index aa7348811..a1856f7b5 100644 --- a/go/pkg/basecamp/otel/otel_test.go +++ b/go/pkg/basecamp/otel/otel_test.go @@ -55,6 +55,7 @@ func TestOnOperationStartEnd(t *testing.T) { Operation: "Complete", ResourceType: "todo", IsMutation: true, + ProjectID: 789, ResourceID: 456, } @@ -93,6 +94,9 @@ func TestOnOperationStartEnd(t *testing.T) { if attrs["basecamp.is_mutation"] != true { t.Errorf("expected basecamp.is_mutation=true, got %v", attrs["basecamp.is_mutation"]) } + if attrs["basecamp.project_id"] != int64(789) { + t.Errorf("expected basecamp.project_id=789, got %v", attrs["basecamp.project_id"]) + } if attrs["basecamp.resource_id"] != int64(456) { t.Errorf("expected basecamp.resource_id=456, got %v", attrs["basecamp.resource_id"]) } diff --git a/go/pkg/basecamp/people.go b/go/pkg/basecamp/people.go index 4cb2ddf68..0b99629af 100644 --- a/go/pkg/basecamp/people.go +++ b/go/pkg/basecamp/people.go @@ -313,6 +313,7 @@ func (s *PeopleService) ListProjectPeople(ctx context.Context, projectID int64, op := OperationInfo{ Service: "People", Operation: "ListProjectPeople", ResourceType: "person", IsMutation: false, + ProjectID: projectID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { @@ -459,6 +460,7 @@ func (s *PeopleService) UpdateProjectAccess(ctx context.Context, projectID int64 op := OperationInfo{ Service: "People", Operation: "UpdateProjectAccess", ResourceType: "person", IsMutation: true, + ProjectID: projectID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { diff --git a/go/pkg/basecamp/projects.go b/go/pkg/basecamp/projects.go index b195fc2a7..bcec161dd 100644 --- a/go/pkg/basecamp/projects.go +++ b/go/pkg/basecamp/projects.go @@ -212,7 +212,7 @@ func (s *ProjectsService) Get(ctx context.Context, id int64) (result *Project, e op := OperationInfo{ Service: "Projects", Operation: "Get", ResourceType: "project", IsMutation: false, - ResourceID: id, + ProjectID: id, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { @@ -287,7 +287,7 @@ func (s *ProjectsService) Update(ctx context.Context, id int64, req *UpdateProje op := OperationInfo{ Service: "Projects", Operation: "Update", ResourceType: "project", IsMutation: true, - ResourceID: id, + ProjectID: id, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { @@ -351,7 +351,7 @@ func (s *ProjectsService) Trash(ctx context.Context, id int64) (err error) { op := OperationInfo{ Service: "Projects", Operation: "Trash", ResourceType: "project", IsMutation: true, - ResourceID: id, + ProjectID: id, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { diff --git a/go/pkg/basecamp/timeline.go b/go/pkg/basecamp/timeline.go index e79d39673..4426502eb 100644 --- a/go/pkg/basecamp/timeline.go +++ b/go/pkg/basecamp/timeline.go @@ -152,6 +152,7 @@ func (s *TimelineService) ProjectTimeline(ctx context.Context, projectID int64, op := OperationInfo{ Service: "Timeline", Operation: "ProjectTimeline", ResourceType: "timeline_event", IsMutation: false, + ProjectID: projectID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { diff --git a/go/pkg/basecamp/timesheet.go b/go/pkg/basecamp/timesheet.go index 9bfaa1661..2e7f218f6 100644 --- a/go/pkg/basecamp/timesheet.go +++ b/go/pkg/basecamp/timesheet.go @@ -152,6 +152,7 @@ func (s *TimesheetService) ProjectReport(ctx context.Context, projectID int64, o op := OperationInfo{ Service: "Timesheet", Operation: "ProjectReport", ResourceType: "timesheet_entry", IsMutation: false, + ProjectID: projectID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { diff --git a/go/pkg/basecamp/tools.go b/go/pkg/basecamp/tools.go index 5ccd82599..916babf52 100644 --- a/go/pkg/basecamp/tools.go +++ b/go/pkg/basecamp/tools.go @@ -87,7 +87,7 @@ func (s *ToolsService) Create(ctx context.Context, bucketID int64, toolType stri op := OperationInfo{ Service: "Tools", Operation: "Create", ResourceType: "tool", IsMutation: true, - ResourceID: bucketID, + ProjectID: bucketID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { diff --git a/go/pkg/basecamp/tools_test.go b/go/pkg/basecamp/tools_test.go index c8feb0146..113fb7c00 100644 --- a/go/pkg/basecamp/tools_test.go +++ b/go/pkg/basecamp/tools_test.go @@ -135,8 +135,11 @@ func TestToolsServiceCreatePostsToBucketDock(t *testing.T) { if err != nil { t.Fatalf("Create() error = %v; request path = %s; want bucket %d dock tools endpoint", err, capturedPath, bucketID) } - if capturedOp.ResourceID != bucketID { - t.Fatalf("Create() operation ResourceID = %d, want destination bucket %d", capturedOp.ResourceID, bucketID) + if capturedOp.ProjectID != bucketID { + t.Fatalf("Create() operation ProjectID = %d, want destination bucket %d", capturedOp.ProjectID, bucketID) + } + if capturedOp.ResourceID != 0 { + t.Fatalf("Create() operation ResourceID = %d, want 0 (bucket is project scope, not a resource)", capturedOp.ResourceID) } } diff --git a/go/pkg/basecamp/webhooks.go b/go/pkg/basecamp/webhooks.go index 56fa0ea69..8bfa80ed2 100644 --- a/go/pkg/basecamp/webhooks.go +++ b/go/pkg/basecamp/webhooks.go @@ -104,6 +104,7 @@ func (s *WebhooksService) List(ctx context.Context, bucketID int64, opts *Webhoo op := OperationInfo{ Service: "Webhooks", Operation: "List", ResourceType: "webhook", IsMutation: false, + ProjectID: bucketID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { @@ -209,6 +210,7 @@ func (s *WebhooksService) Create(ctx context.Context, bucketID int64, req *Creat op := OperationInfo{ Service: "Webhooks", Operation: "Create", ResourceType: "webhook", IsMutation: true, + ProjectID: bucketID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { diff --git a/go/pkg/basecamp/wormholes.go b/go/pkg/basecamp/wormholes.go index 02c4e7676..19e1a4502 100644 --- a/go/pkg/basecamp/wormholes.go +++ b/go/pkg/basecamp/wormholes.go @@ -62,6 +62,7 @@ func (s *WormholesService) Create(ctx context.Context, bucketID, cardTableID, de op := OperationInfo{ Service: "Wormholes", Operation: "Create", ResourceType: "wormhole", IsMutation: true, + ProjectID: bucketID, ResourceID: cardTableID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { @@ -114,6 +115,7 @@ func (s *WormholesService) Update(ctx context.Context, bucketID, wormholeID, des op := OperationInfo{ Service: "Wormholes", Operation: "Update", ResourceType: "wormhole", IsMutation: true, + ProjectID: bucketID, ResourceID: wormholeID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { @@ -163,6 +165,7 @@ func (s *WormholesService) Delete(ctx context.Context, bucketID, wormholeID int6 op := OperationInfo{ Service: "Wormholes", Operation: "Delete", ResourceType: "wormhole", IsMutation: true, + ProjectID: bucketID, ResourceID: wormholeID, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { From 45819c6be08a895b1214c401f0a4955d05297e17 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 24 Jul 2026 23:52:57 -0700 Subject: [PATCH 7/8] Ruby: guard hook-capture assertions against a non-firing hook If on_operation_start never fires, events.find returns nil and nil[:info] raises an opaque NoMethodError. Assert the event was found first so a wiring regression points straight at the missing hook. Applied to both the webhooks and wormholes metadata tests. (review: cubic-dev-ai) --- ruby/test/basecamp/services/webhooks_service_test.rb | 4 +++- ruby/test/basecamp/services/wormholes_service_test.rb | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ruby/test/basecamp/services/webhooks_service_test.rb b/ruby/test/basecamp/services/webhooks_service_test.rb index 3586bc7e4..e1469a50d 100644 --- a/ruby/test/basecamp/services/webhooks_service_test.rb +++ b/ruby/test/basecamp/services/webhooks_service_test.rb @@ -31,7 +31,9 @@ def test_list_operation_metadata # Paginated operations fire hooks on iteration, so drain the enumerator. account.webhooks.list(bucket_id: 1).to_a - info = events.find { |e| e[:event] == :on_operation_start }[:info] + event = events.find { |e| e[:event] == :on_operation_start } + assert event, "Expected on_operation_start to fire" + info = event[:info] assert_equal "webhooks", info.service assert_equal "list", info.operation assert_equal 1, info.project_id diff --git a/ruby/test/basecamp/services/wormholes_service_test.rb b/ruby/test/basecamp/services/wormholes_service_test.rb index f9b549ecf..681b3a77f 100644 --- a/ruby/test/basecamp/services/wormholes_service_test.rb +++ b/ruby/test/basecamp/services/wormholes_service_test.rb @@ -56,7 +56,9 @@ def test_create_wormhole_operation_metadata destination_recording_id: 1069479500 ) - info = events.find { |e| e[:event] == :on_operation_start }[:info] + event = events.find { |e| e[:event] == :on_operation_start } + assert event, "Expected on_operation_start to fire" + info = event[:info] assert_equal "wormholes", info.service assert_equal "create", info.operation assert_equal 2085958499, info.project_id From 7a214301311b96c0d6c64539dc2ed0bb08e92c54 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 24 Jul 2026 23:52:57 -0700 Subject: [PATCH 8/8] Swift: absorb pre-existing Metadata.swift idempotency regen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make swift-generate adds DeleteWormhole and UpdateWormhole to the idempotent operation list — pre-existing drift left by #402 (the committed Metadata.swift predates those ops landing in the idempotent set), unrelated to project scope and uncaught because there is no Swift drift gate. Absorb it here so the branch is reproducible: make generate now leaves the tree clean rather than producing this diff. --- swift/Sources/Basecamp/Generated/Metadata.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/swift/Sources/Basecamp/Generated/Metadata.swift b/swift/Sources/Basecamp/Generated/Metadata.swift index 55620d467..1eacefbfb 100644 --- a/swift/Sources/Basecamp/Generated/Metadata.swift +++ b/swift/Sources/Basecamp/Generated/Metadata.swift @@ -228,6 +228,7 @@ enum Metadata { "DeleteTemplate", "DeleteTool", "DeleteWebhook", + "DeleteWormhole", "DestroyGaugeNeedle", "DisableCardColumnOnHold", "DisableOutOfOffice", @@ -285,6 +286,7 @@ enum Metadata { "UpdateUpload", "UpdateVault", "UpdateWebhook", + "UpdateWormhole", ] static func isIdempotent(for operationId: String) -> Bool {