diff --git a/conformance/runner/typescript/runner.test.ts b/conformance/runner/typescript/runner.test.ts index 8a76c0b7d..b63cad8c6 100644 --- a/conformance/runner/typescript/runner.test.ts +++ b/conformance/runner/typescript/runner.test.ts @@ -63,6 +63,13 @@ interface TestCase { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const TESTS_DIR = path.resolve(__dirname, "../../tests"); + +/** + * Allowance for Node timers firing early. libuv rounds a timer's deadline down + * internally, so `setTimeout(2000)` can elapse in ~1999.9ms of wall clock. + * Small enough that no real backoff regression fits inside it. + */ +const TIMER_SLACK_MS = 2; const TEST_ACCOUNT_ID = "999"; /** @@ -468,7 +475,11 @@ function installMockHandlers(tc: TestCase): { ); const handler = http.all(originPattern, async ({ request }) => { count++; - times.push(Date.now()); + // performance.now(), not Date.now(): Date.now() is floored to whole + // milliseconds, so bracketing a 2000ms sleep can read 1999 purely from + // rounding. See TIMER_SLACK_MS for the second, separate reason this + // assertion needs care. + times.push(performance.now()); const url = new URL(request.url); paths.push(url.pathname); methods.push(request.method); @@ -707,10 +718,20 @@ function checkAssertions( if (times.length >= 2) { const delay = times[1]! - times[0]!; const minDelay = assertion.min ?? 0; + // Node's timers may fire marginally BEFORE the requested delay — + // libuv rounds the deadline down internally, so a 2000ms sleep can + // legitimately elapse in 1999.87ms. That is a runtime property, not + // an SDK behaviour: the SDK asked for the full interval. The Go + // runner never sees it because Go's timers do not fire early. + // + // A sub-millisecond allowance cannot mask a real regression, which + // would miss by hundreds of milliseconds (a dropped Retry-After + // means ~1000ms of backoff instead of 2000ms) or by the whole + // interval (no delay at all). expect( delay, - `[${tc.name}] expected delay >= ${minDelay}ms, got ${delay}ms`, - ).toBeGreaterThanOrEqual(minDelay); + `[${tc.name}] expected delay >= ${minDelay}ms (allowing ${TIMER_SLACK_MS}ms timer slack), got ${delay}ms`, + ).toBeGreaterThanOrEqual(minDelay - TIMER_SLACK_MS); } break; } diff --git a/go/pkg/basecamp/recordings.go b/go/pkg/basecamp/recordings.go index 5a35cc684..6bdaac14d 100644 --- a/go/pkg/basecamp/recordings.go +++ b/go/pkg/basecamp/recordings.go @@ -44,7 +44,13 @@ type Recording struct { URL string `json:"url"` AppURL string `json:"app_url"` BookmarkURL string `json:"bookmark_url"` - Content string `json:"content,omitempty"` + // BubbleUpURL is the URL of the Bubble Up record for this recording. Optional + // on this polymorphic projection: recordings/_recording.json.jbuilder emits + // the key only when the caller passes bubbleupable, and todolists/_todolist + // is the only partial that does — so a Todolist-shaped instance carries it + // and the other recording types do not. + BubbleUpURL string `json:"bubble_up_url,omitempty"` + Content string `json:"content,omitempty"` // ContentAttachments and DescriptionAttachments are the rich text companion // arrays carried through the generic recording projection. A given recording // is one type, so it carries only the array matching its rich text attribute @@ -436,6 +442,7 @@ func recordingFromGenerated(gr generated.Recording) Recording { URL: gr.Url, AppURL: gr.AppUrl, BookmarkURL: gr.BookmarkUrl, + BubbleUpURL: gr.BubbleUpUrl, Content: gr.Content, CommentsCount: int(gr.CommentsCount), CommentsURL: gr.CommentsUrl, diff --git a/go/pkg/basecamp/recordings_test.go b/go/pkg/basecamp/recordings_test.go index fe6fd21b1..4140ead0f 100644 --- a/go/pkg/basecamp/recordings_test.go +++ b/go/pkg/basecamp/recordings_test.go @@ -32,8 +32,21 @@ func TestRecording_UnmarshalList(t *testing.T) { t.Fatalf("failed to unmarshal list.json: %v", err) } - if len(recordings) != 2 { - t.Errorf("expected 2 recordings, got %d", len(recordings)) + if len(recordings) != 3 { + t.Errorf("expected 3 recordings, got %d", len(recordings)) + } + + // bubble_up_url is optional on the generic Recording projection: only the + // todolist partial passes bubbleupable, so the two Message recordings omit + // it and the Todolist one carries it. + for _, r := range recordings { + if r.Type == "Todolist" { + if r.BubbleUpURL == "" { + t.Errorf("Todolist recording %d: expected BubbleUpURL to be set", r.ID) + } + } else if r.BubbleUpURL != "" { + t.Errorf("%s recording %d: expected no BubbleUpURL, got %q", r.Type, r.ID, r.BubbleUpURL) + } } // Verify first recording diff --git a/go/pkg/basecamp/search.go b/go/pkg/basecamp/search.go index 3d19bea6f..dac6a992d 100644 --- a/go/pkg/basecamp/search.go +++ b/go/pkg/basecamp/search.go @@ -22,9 +22,15 @@ type SearchResult struct { URL string `json:"url"` AppURL string `json:"app_url"` BookmarkURL string `json:"bookmark_url"` - Parent *Parent `json:"parent,omitempty"` - Bucket *Bucket `json:"bucket,omitempty"` - Creator *Person `json:"creator,omitempty"` + // BubbleUpURL is the URL of the Bubble Up record for this recording. Optional + // on this polymorphic projection: recordings/_recording.json.jbuilder emits + // the key only when the caller passes bubbleupable, and todolists/_todolist + // is the only partial that does — so a Todolist-shaped instance carries it + // and the other recording types do not. + BubbleUpURL string `json:"bubble_up_url,omitempty"` + Parent *Parent `json:"parent,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + Creator *Person `json:"creator,omitempty"` // Content and Description are always present on the wire and always null: // api/searches/show.json.jbuilder renders the recording's own partial and // then unconditionally overwrites both with nil to keep the large HTML body @@ -341,6 +347,7 @@ func searchResultFromGenerated(gsr generated.SearchResult) SearchResult { URL: gsr.Url, AppURL: gsr.AppUrl, BookmarkURL: gsr.BookmarkUrl, + BubbleUpURL: gsr.BubbleUpUrl, Content: gsr.Content, Description: gsr.Description, PlainTextContent: gsr.PlainTextContent, diff --git a/go/pkg/basecamp/search_test.go b/go/pkg/basecamp/search_test.go index 7a2ad6769..2ceffa8f8 100644 --- a/go/pkg/basecamp/search_test.go +++ b/go/pkg/basecamp/search_test.go @@ -36,8 +36,22 @@ func TestSearchResult_UnmarshalResults(t *testing.T) { t.Fatalf("failed to unmarshal results.json: %v", err) } - if len(results) != 3 { - t.Errorf("expected 3 results, got %d", len(results)) + if len(results) != 4 { + t.Errorf("expected 4 results, got %d", len(results)) + } + + // bubble_up_url rides the polymorphic search projection: Todolists are + // searchable and api_search_result_template_path falls through to + // todolists/todolist, which passes bubbleupable: true. Every other result + // type omits the key. + for _, r := range results { + if r.Type == "Todolist" { + if r.BubbleUpURL == "" { + t.Errorf("Todolist result %d: expected BubbleUpURL to be set", r.ID) + } + } else if r.BubbleUpURL != "" { + t.Errorf("%s result %d: expected no BubbleUpURL, got %q", r.Type, r.ID, r.BubbleUpURL) + } } // Verify first result (Message) @@ -515,8 +529,8 @@ func TestSearchService_Search_BestMatchSort(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(result.Results) != 3 { - t.Errorf("expected 3 results, got %d", len(result.Results)) + if len(result.Results) != 4 { + t.Errorf("expected 4 results, got %d", len(result.Results)) } // End-to-end projection proof: the polymorphic search projection carries a diff --git a/go/pkg/basecamp/todolist_groups.go b/go/pkg/basecamp/todolist_groups.go index 0f10da1cc..dc6047cd3 100644 --- a/go/pkg/basecamp/todolist_groups.go +++ b/go/pkg/basecamp/todolist_groups.go @@ -33,17 +33,22 @@ type TodolistGroup struct { AppURL string `json:"app_url"` BookmarkURL string `json:"bookmark_url"` SubscriptionURL string `json:"subscription_url"` - CommentsCount int `json:"comments_count"` - CommentsURL string `json:"comments_url"` - Position int `json:"position"` - Parent *Parent `json:"parent,omitempty"` - Bucket *Bucket `json:"bucket,omitempty"` - Creator *Person `json:"creator,omitempty"` - Name string `json:"name"` - Completed bool `json:"completed"` - CompletedRatio string `json:"completed_ratio"` - TodosURL string `json:"todos_url"` - AppTodosURL string `json:"app_todos_url"` + // BubbleUpURL is the URL of the Bubble Up record for this recording. Always + // present: todolists/_todolist.json.jbuilder renders the shared recording + // partial with bubbleupable: true unconditionally, and every list, show, and + // group path renders that partial. + BubbleUpURL string `json:"bubble_up_url"` + CommentsCount int `json:"comments_count"` + CommentsURL string `json:"comments_url"` + Position int `json:"position"` + Parent *Parent `json:"parent,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + Creator *Person `json:"creator,omitempty"` + Name string `json:"name"` + Completed bool `json:"completed"` + CompletedRatio string `json:"completed_ratio"` + TodosURL string `json:"todos_url"` + AppTodosURL string `json:"app_todos_url"` } // CreateTodolistGroupRequest specifies the parameters for creating a todolist group. @@ -353,6 +358,7 @@ func todolistGroupFromGenerated(gg generated.TodolistGroup) TodolistGroup { AppURL: gg.AppUrl, BookmarkURL: gg.BookmarkUrl, SubscriptionURL: gg.SubscriptionUrl, + BubbleUpURL: gg.BubbleUpUrl, CommentsCount: int(gg.CommentsCount), CommentsURL: gg.CommentsUrl, Position: int(gg.Position), diff --git a/go/pkg/basecamp/todolists.go b/go/pkg/basecamp/todolists.go index a65aa4ef6..178aa901e 100644 --- a/go/pkg/basecamp/todolists.go +++ b/go/pkg/basecamp/todolists.go @@ -29,13 +29,18 @@ type Todolist struct { BoostsCount int `json:"boosts_count,omitempty"` BoostsURL string `json:"boosts_url,omitempty"` SubscriptionURL string `json:"subscription_url"` - CommentsCount int `json:"comments_count"` - CommentsURL string `json:"comments_url"` - Position int `json:"position"` - Parent *Parent `json:"parent,omitempty"` - Bucket *Bucket `json:"bucket,omitempty"` - Creator *Person `json:"creator,omitempty"` - Description string `json:"description"` + // BubbleUpURL is the URL of the Bubble Up record for this recording. Always + // present: todolists/_todolist.json.jbuilder renders the shared recording + // partial with bubbleupable: true unconditionally, and every list, show, and + // group path renders that partial. + BubbleUpURL string `json:"bubble_up_url"` + CommentsCount int `json:"comments_count"` + CommentsURL string `json:"comments_url"` + Position int `json:"position"` + Parent *Parent `json:"parent,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + Creator *Person `json:"creator,omitempty"` + Description string `json:"description"` // DescriptionAttachments holds structured metadata for the downloadable files // embedded in the rich text Description. @required — the API always sends this // array (empty when the description has no inline files). No omitempty, so on @@ -400,6 +405,7 @@ func todolistFromGenerated(gtl generated.Todolist) Todolist { BoostsCount: int(gtl.BoostsCount), BoostsURL: gtl.BoostsUrl, SubscriptionURL: gtl.SubscriptionUrl, + BubbleUpURL: gtl.BubbleUpUrl, CommentsCount: int(gtl.CommentsCount), CommentsURL: gtl.CommentsUrl, Position: int(gtl.Position), diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go index 069d10028..cef7b41cc 100644 --- a/go/pkg/generated/client.gen.go +++ b/go/pkg/generated/client.gen.go @@ -1966,8 +1966,16 @@ type Recording struct { // aggregate feeds (`/messages.json`, `/comments.json`), whose type-specific // partials render with `boostable: true`. Optional (absent on non-boostable // recordings and on the base/webhook partial). - BoostsCount *int32 `json:"boosts_count,omitempty"` - BoostsUrl *string `json:"boosts_url,omitempty"` + BoostsCount *int32 `json:"boosts_count,omitempty"` + BoostsUrl *string `json:"boosts_url,omitempty"` + + // BubbleUpUrl URL of the Bubble Up record for this recording (BC5 addition). Optional + // here because this is a polymorphic projection: + // `recordings/_recording.json.jbuilder` emits the key only when the caller + // passes `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the + // only partial that does. So a Todolist-shaped instance carries it and the + // other recording types do not. + BubbleUpUrl string `json:"bubble_up_url,omitempty"` Bucket RecordingBucket `json:"bucket"` // Category A message category (type) as rendered by the shared recordings/_category @@ -2221,8 +2229,16 @@ type SearchResponseContent = []SearchResult // SearchResult defines model for SearchResult. type SearchResult struct { - AppUrl string `json:"app_url"` - BookmarkUrl string `json:"bookmark_url,omitempty"` + AppUrl string `json:"app_url"` + BookmarkUrl string `json:"bookmark_url,omitempty"` + + // BubbleUpUrl URL of the Bubble Up record for this recording (BC5 addition). Optional + // here because this is a polymorphic projection: + // `recordings/_recording.json.jbuilder` emits the key only when the caller + // passes `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the + // only partial that does. So a Todolist-shaped instance carries it and the + // other recording types do not. + BubbleUpUrl string `json:"bubble_up_url,omitempty"` Bucket RecordingBucket `json:"bucket,omitempty"` // Content Always present, always null. `api/searches/show.json.jbuilder` renders the @@ -2603,11 +2619,18 @@ type TodoParent struct { // Todolist defines model for Todolist. type Todolist struct { - AppTodosUrl string `json:"app_todos_url,omitempty"` - AppUrl string `json:"app_url"` - BookmarkUrl string `json:"bookmark_url,omitempty"` - BoostsCount int32 `json:"boosts_count,omitempty"` - BoostsUrl string `json:"boosts_url,omitempty"` + AppTodosUrl string `json:"app_todos_url,omitempty"` + AppUrl string `json:"app_url"` + BookmarkUrl string `json:"bookmark_url,omitempty"` + BoostsCount int32 `json:"boosts_count,omitempty"` + BoostsUrl string `json:"boosts_url,omitempty"` + + // BubbleUpUrl URL of the Bubble Up record for this recording (BC5 addition). Required: + // `todolists/_todolist.json.jbuilder` renders the shared recording partial + // with `bubbleupable: true` unconditionally, and every list, show, and group + // path renders that partial — so the key is present on every projection of + // this shape. + BubbleUpUrl string `json:"bubble_up_url"` Bucket TodoBucket `json:"bucket"` CommentsCount int32 `json:"comments_count,omitempty"` CommentsUrl string `json:"comments_url,omitempty"` @@ -2637,9 +2660,16 @@ type Todolist struct { // TodolistGroup defines model for TodolistGroup. type TodolistGroup struct { - AppTodosUrl string `json:"app_todos_url,omitempty"` - AppUrl string `json:"app_url"` - BookmarkUrl string `json:"bookmark_url,omitempty"` + AppTodosUrl string `json:"app_todos_url,omitempty"` + AppUrl string `json:"app_url"` + BookmarkUrl string `json:"bookmark_url,omitempty"` + + // BubbleUpUrl URL of the Bubble Up record for this recording (BC5 addition). Required: + // `todolists/_todolist.json.jbuilder` renders the shared recording partial + // with `bubbleupable: true` unconditionally, and every list, show, and group + // path renders that partial — so the key is present on every projection of + // this shape. + BubbleUpUrl string `json:"bubble_up_url"` Bucket TodoBucket `json:"bucket"` CommentsCount int32 `json:"comments_count,omitempty"` CommentsUrl string `json:"comments_url,omitempty"` diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Recording.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Recording.kt index 32f15b041..dfa6e6a21 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Recording.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Recording.kt @@ -25,6 +25,7 @@ data class Recording( val bucket: RecordingBucket, val creator: Person, @SerialName("bookmark_url") val bookmarkUrl: String? = null, + @SerialName("bubble_up_url") val bubbleUpUrl: String? = null, val content: String? = null, @SerialName("content_attachments") val contentAttachments: List? = null, @SerialName("description_attachments") val descriptionAttachments: List? = null, diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Todolist.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Todolist.kt index 8530b3812..58bde4022 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Todolist.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Todolist.kt @@ -22,6 +22,7 @@ data class Todolist( val type: String, val url: String, @SerialName("app_url") val appUrl: String, + @SerialName("bubble_up_url") val bubbleUpUrl: String, val parent: TodoParent, val bucket: TodoBucket, val creator: Person, diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TodolistGroup.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TodolistGroup.kt index 30226340e..970d344b2 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TodolistGroup.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TodolistGroup.kt @@ -22,6 +22,7 @@ data class TodolistGroup( val type: String, val url: String, @SerialName("app_url") val appUrl: String, + @SerialName("bubble_up_url") val bubbleUpUrl: String, val parent: TodoParent, val bucket: TodoBucket, val creator: Person, diff --git a/openapi.json b/openapi.json index fac3028e6..37f71dc66 100644 --- a/openapi.json +++ b/openapi.json @@ -19457,6 +19457,7 @@ "description_attachments": [], "url": "https://3.basecampapi.com/999/buckets/12345678/todolists/987654.json", "app_url": "https://3.basecamp.com/999/buckets/12345678/todolists/987654", + "bubble_up_url": "https://3.basecampapi.com/999/buckets/12345678/recordings/987654/bubble_up.json", "creator": { "id": 1, "name": "Someone", @@ -19496,6 +19497,7 @@ "type": "TodolistGroup", "url": "https://3.basecampapi.com/999/buckets/12345678/todolists/111222.json", "app_url": "https://3.basecamp.com/999/buckets/12345678/todolists/111222", + "bubble_up_url": "https://3.basecampapi.com/999/buckets/12345678/recordings/111222/bubble_up.json", "creator": { "id": 1, "name": "Someone", @@ -28675,6 +28677,10 @@ "bookmark_url": { "type": "string" }, + "bubble_up_url": { + "type": "string", + "description": "URL of the Bubble Up record for this recording (BC5 addition). Optional\nhere because this is a polymorphic projection:\n`recordings/_recording.json.jbuilder` emits the key only when the caller\npasses `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the\nonly partial that does. So a Todolist-shaped instance carries it and the\nother recording types do not." + }, "content": { "type": "string" }, @@ -29378,6 +29384,10 @@ "bookmark_url": { "type": "string" }, + "bubble_up_url": { + "type": "string", + "description": "URL of the Bubble Up record for this recording (BC5 addition). Optional\nhere because this is a polymorphic projection:\n`recordings/_recording.json.jbuilder` emits the key only when the caller\npasses `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the\nonly partial that does. So a Todolist-shaped instance carries it and the\nother recording types do not." + }, "parent": { "$ref": "#/components/schemas/RecordingParent" }, @@ -30248,6 +30258,10 @@ "subscription_url": { "type": "string" }, + "bubble_up_url": { + "type": "string", + "description": "URL of the Bubble Up record for this recording (BC5 addition). Required:\n`todolists/_todolist.json.jbuilder` renders the shared recording partial\nwith `bubbleupable: true` unconditionally, and every list, show, and group\npath renders that partial — so the key is present on every projection of\nthis shape." + }, "comments_count": { "type": "integer", "format": "int32" @@ -30305,6 +30319,7 @@ }, "required": [ "app_url", + "bubble_up_url", "bucket", "created_at", "creator", @@ -30372,6 +30387,10 @@ "subscription_url": { "type": "string" }, + "bubble_up_url": { + "type": "string", + "description": "URL of the Bubble Up record for this recording (BC5 addition). Required:\n`todolists/_todolist.json.jbuilder` renders the shared recording partial\nwith `bubbleupable: true` unconditionally, and every list, show, and group\npath renders that partial — so the key is present on every projection of\nthis shape." + }, "comments_count": { "type": "integer", "format": "int32" @@ -30410,6 +30429,7 @@ }, "required": [ "app_url", + "bubble_up_url", "bucket", "created_at", "creator", diff --git a/python/src/basecamp/generated/types.py b/python/src/basecamp/generated/types.py index c18269c6f..291a83e8b 100644 --- a/python/src/basecamp/generated/types.py +++ b/python/src/basecamp/generated/types.py @@ -1236,6 +1236,7 @@ class RateLimitErrorResponseContent(TypedDict): "bookmark_url": NotRequired[str], "boosts_count": NotRequired[int], "boosts_url": NotRequired[str], + "bubble_up_url": NotRequired[str], "bucket": "RecordingBucket", "category": NotRequired["RecordingCategory"], "comments_count": NotRequired[int], @@ -1405,6 +1406,7 @@ class SearchMetadata(TypedDict): class SearchResult(TypedDict): app_url: str bookmark_url: NotRequired[str] + bubble_up_url: NotRequired[str] bucket: NotRequired[RecordingBucket] content: str | None content_attachments: NotRequired[list[RichTextAttachment]] @@ -1600,6 +1602,7 @@ class Todolist(TypedDict): bookmark_url: NotRequired[str] boosts_count: NotRequired[int] boosts_url: NotRequired[str] + bubble_up_url: str bucket: TodoBucket comments_count: NotRequired[int] comments_url: NotRequired[str] @@ -1629,6 +1632,7 @@ class TodolistGroup(TypedDict): app_todos_url: NotRequired[str] app_url: str bookmark_url: NotRequired[str] + bubble_up_url: str bucket: TodoBucket comments_count: NotRequired[int] comments_url: NotRequired[str] diff --git a/python/tests/services/test_todolists_service.py b/python/tests/services/test_todolists_service.py index badfd6efb..40e4638c9 100644 --- a/python/tests/services/test_todolists_service.py +++ b/python/tests/services/test_todolists_service.py @@ -15,6 +15,24 @@ BASE = "https://3.basecampapi.com/12345" +def _todolist(name: str) -> dict: + """Minimal Todolist payload for the metadata/hook tests. + + These tests are about operation identity and resource-id scoping, not + payload shape — but a stub must not contradict the contract either. + bubble_up_url is @required on Todolist (todolists/_todolist.json.jbuilder + always passes bubbleupable: true), so it belongs here even though nothing + below reads it. Full-shape coverage lives in spec/fixtures, which + `make check-fixture-coverage` validates. + """ + return { + "id": 2, + "name": name, + "description_attachments": [], + "bubble_up_url": "https://3.basecampapi.com/12345/buckets/1/recordings/2/bubble_up.json", + } + + class _RecordingHooks(BasecampHooks): def __init__(self) -> None: self.operations: list[OperationInfo] = [] @@ -85,9 +103,7 @@ async def test_reposition_not_found(self): class TestSyncTodolistMetadata: @respx.mock def test_get_scopes_resource_to_todolist_id(self): - respx.get(f"{BASE}/todolists/2").mock( - return_value=httpx.Response(200, json={"id": 2, "name": "Sprint Tasks", "description_attachments": []}) - ) + respx.get(f"{BASE}/todolists/2").mock(return_value=httpx.Response(200, json=_todolist("Sprint Tasks"))) hooks = _RecordingHooks() c = Client(access_token="test-token", hooks=hooks) @@ -102,9 +118,7 @@ def test_get_scopes_resource_to_todolist_id(self): @respx.mock def test_update_scopes_resource_to_todolist_id(self): - respx.put(f"{BASE}/todolists/2").mock( - return_value=httpx.Response(200, json={"id": 2, "name": "Updated List", "description_attachments": []}) - ) + respx.put(f"{BASE}/todolists/2").mock(return_value=httpx.Response(200, json=_todolist("Updated List"))) hooks = _RecordingHooks() c = Client(access_token="test-token", hooks=hooks) @@ -122,9 +136,7 @@ class TestAsyncTodolistMetadata: @pytest.mark.asyncio @respx.mock async def test_get_scopes_resource_to_todolist_id(self): - respx.get(f"{BASE}/todolists/2").mock( - return_value=httpx.Response(200, json={"id": 2, "name": "Sprint Tasks", "description_attachments": []}) - ) + respx.get(f"{BASE}/todolists/2").mock(return_value=httpx.Response(200, json=_todolist("Sprint Tasks"))) hooks = _RecordingHooks() c = AsyncClient(access_token="test-token", hooks=hooks) @@ -140,9 +152,7 @@ async def test_get_scopes_resource_to_todolist_id(self): @pytest.mark.asyncio @respx.mock async def test_update_scopes_resource_to_todolist_id(self): - respx.put(f"{BASE}/todolists/2").mock( - return_value=httpx.Response(200, json={"id": 2, "name": "Updated List", "description_attachments": []}) - ) + respx.put(f"{BASE}/todolists/2").mock(return_value=httpx.Response(200, json=_todolist("Updated List"))) hooks = _RecordingHooks() c = AsyncClient(access_token="test-token", hooks=hooks) diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index 7d1d23650..470b54131 100644 --- a/ruby/lib/basecamp/generated/metadata.json +++ b/ruby/lib/basecamp/generated/metadata.json @@ -1,7 +1,7 @@ { "$schema": "https://basecamp.com/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-07-28T06:50:08Z", + "generated": "2026-07-28T07:10:09Z", "operations": { "GetAccount": { "retry": { diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index 27539b763..03757bc3b 100644 --- a/ruby/lib/basecamp/generated/types.rb +++ b/ruby/lib/basecamp/generated/types.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Auto-generated from OpenAPI spec. Do not edit manually. -# Generated: 2026-07-28T06:50:08Z +# Generated: 2026-07-28T07:10:09Z require "json" require "time" @@ -3097,7 +3097,7 @@ def to_json(*args) # Recording class Recording include TypeHelpers - attr_accessor :app_url, :bucket, :created_at, :creator, :id, :inherits_status, :status, :title, :type, :updated_at, :url, :visible_to_clients, :bookmark_url, :boosts_count, :boosts_url, :category, :comments_count, :comments_url, :content, :content_attachments, :description, :description_attachments, :from, :group_on, :parent, :position, :replies_count, :replies_url, :service, :subject, :subscription_url + attr_accessor :app_url, :bucket, :created_at, :creator, :id, :inherits_status, :status, :title, :type, :updated_at, :url, :visible_to_clients, :bookmark_url, :boosts_count, :boosts_url, :bubble_up_url, :category, :comments_count, :comments_url, :content, :content_attachments, :description, :description_attachments, :from, :group_on, :parent, :position, :replies_count, :replies_url, :service, :subject, :subscription_url # @return [Array] def self.required_fields @@ -3120,6 +3120,7 @@ def initialize(data = {}) @bookmark_url = data["bookmark_url"] @boosts_count = parse_integer(data["boosts_count"]) @boosts_url = data["boosts_url"] + @bubble_up_url = data["bubble_up_url"] @category = parse_type(data["category"], "RecordingCategory") @comments_count = parse_integer(data["comments_count"]) @comments_url = data["comments_url"] @@ -3155,6 +3156,7 @@ def to_h "bookmark_url" => @bookmark_url, "boosts_count" => @boosts_count, "boosts_url" => @boosts_url, + "bubble_up_url" => @bubble_up_url, "category" => @category, "comments_count" => @comments_count, "comments_url" => @comments_url, @@ -3511,7 +3513,7 @@ def to_json(*args) # SearchResult class SearchResult include TypeHelpers - attr_accessor :app_url, :content, :description, :id, :title, :type, :url, :bookmark_url, :bucket, :content_attachments, :created_at, :creator, :description_attachments, :inherits_status, :parent, :plain_text_content, :plain_text_description, :status, :subject, :updated_at, :visible_to_clients + attr_accessor :app_url, :content, :description, :id, :title, :type, :url, :bookmark_url, :bubble_up_url, :bucket, :content_attachments, :created_at, :creator, :description_attachments, :inherits_status, :parent, :plain_text_content, :plain_text_description, :status, :subject, :updated_at, :visible_to_clients # @return [Array] def self.required_fields @@ -3527,6 +3529,7 @@ def initialize(data = {}) @type = data["type"] @url = data["url"] @bookmark_url = data["bookmark_url"] + @bubble_up_url = data["bubble_up_url"] @bucket = parse_type(data["bucket"], "RecordingBucket") @content_attachments = parse_array(data["content_attachments"], "RichTextAttachment") @created_at = parse_datetime(data["created_at"]) @@ -3552,6 +3555,7 @@ def to_h "type" => @type, "url" => @url, "bookmark_url" => @bookmark_url, + "bubble_up_url" => @bubble_up_url, "bucket" => @bucket, "content_attachments" => @content_attachments, "created_at" => @created_at, @@ -4048,15 +4052,16 @@ def to_json(*args) # Todolist class Todolist include TypeHelpers - attr_accessor :app_url, :bucket, :created_at, :creator, :description_attachments, :id, :inherits_status, :name, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :app_todos_url, :bookmark_url, :boosts_count, :boosts_url, :comments_count, :comments_url, :completed, :completed_ratio, :description, :groups_url, :position, :subscription_url, :todos_url + attr_accessor :app_url, :bubble_up_url, :bucket, :created_at, :creator, :description_attachments, :id, :inherits_status, :name, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :app_todos_url, :bookmark_url, :boosts_count, :boosts_url, :comments_count, :comments_url, :completed, :completed_ratio, :description, :groups_url, :position, :subscription_url, :todos_url # @return [Array] def self.required_fields - %i[app_url bucket created_at creator description_attachments id inherits_status name parent status title type updated_at url visible_to_clients].freeze + %i[app_url bubble_up_url bucket created_at creator description_attachments id inherits_status name parent status title type updated_at url visible_to_clients].freeze end def initialize(data = {}) @app_url = data["app_url"] + @bubble_up_url = data["bubble_up_url"] @bucket = parse_type(data["bucket"], "TodoBucket") @created_at = parse_datetime(data["created_at"]) @creator = parse_type(data["creator"], "Person") @@ -4089,6 +4094,7 @@ def initialize(data = {}) def to_h { "app_url" => @app_url, + "bubble_up_url" => @bubble_up_url, "bucket" => @bucket, "created_at" => @created_at, "creator" => @creator, @@ -4127,15 +4133,16 @@ def to_json(*args) # TodolistGroup class TodolistGroup include TypeHelpers - attr_accessor :app_url, :bucket, :created_at, :creator, :id, :inherits_status, :name, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :app_todos_url, :bookmark_url, :comments_count, :comments_url, :completed, :completed_ratio, :position, :subscription_url, :todos_url + attr_accessor :app_url, :bubble_up_url, :bucket, :created_at, :creator, :id, :inherits_status, :name, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :app_todos_url, :bookmark_url, :comments_count, :comments_url, :completed, :completed_ratio, :position, :subscription_url, :todos_url # @return [Array] def self.required_fields - %i[app_url bucket created_at creator id inherits_status name parent status title type updated_at url visible_to_clients].freeze + %i[app_url bubble_up_url bucket created_at creator id inherits_status name parent status title type updated_at url visible_to_clients].freeze end def initialize(data = {}) @app_url = data["app_url"] + @bubble_up_url = data["bubble_up_url"] @bucket = parse_type(data["bucket"], "TodoBucket") @created_at = parse_datetime(data["created_at"]) @creator = parse_type(data["creator"], "Person") @@ -4163,6 +4170,7 @@ def initialize(data = {}) def to_h { "app_url" => @app_url, + "bubble_up_url" => @bubble_up_url, "bucket" => @bucket, "created_at" => @created_at, "creator" => @creator, diff --git a/ruby/test/basecamp/services/todolists_service_test.rb b/ruby/test/basecamp/services/todolists_service_test.rb index 52dd580b8..a6e21010f 100644 --- a/ruby/test/basecamp/services/todolists_service_test.rb +++ b/ruby/test/basecamp/services/todolists_service_test.rb @@ -36,15 +36,23 @@ def test_list_with_status assert_equal "archived", result.first["status"] end + # Uses the canonical fixture rather than a hand-rolled hash: Todolist has a + # dozen required members (bubble_up_url among them) and a minimal stub is a + # payload BC3 cannot produce. spec/fixtures is validated by + # `make check-fixture-coverage`, so this stub cannot drift from the contract. def test_get - response = { "id" => 2, "name" => "Sprint Tasks", "description_attachments" => [] } + response = load_fixture("todolists/get.json") # Generated service uses /todolists/{id} without .json stub_request(:get, %r{https://3\.basecampapi\.com/12345/todolists/\d+$}) .to_return(status: 200, body: response.to_json, headers: { "Content-Type" => "application/json" }) result = @account.todolists.get(id: 2) - assert_equal "Sprint Tasks", result["name"] + assert_equal response["name"], result["name"] + # bubble_up_url is @required on Todolist: todolists/_todolist.json.jbuilder + # renders the shared recording partial with bubbleupable: true + # unconditionally, so every projection of this shape carries it. + assert result.key?("bubble_up_url"), "required bubble_up_url must be present" end def test_create diff --git a/spec/api-gaps/recording-bubbleupable-field.md b/spec/api-gaps/recording-bubbleupable-field.md index 16ed86655..98e781635 100644 --- a/spec/api-gaps/recording-bubbleupable-field.md +++ b/spec/api-gaps/recording-bubbleupable-field.md @@ -27,6 +27,17 @@ did not close this. `bubbleupable` appears **nowhere in `doc/api/`** on `master` — no documented wire contract exists. Status stays `no-json-contract`. +**Not closed by the `bubble_up_url` absorption (2026-07-28):** the SDK now +models `bubble_up_url` on Todolist, TodolistGroup, Recording, and +SearchResult, sourced from `recordings/_recording.json.jbuilder`'s +`local_assigns[:bubbleupable]` branch. That is a **URL**, and its presence is +decided by the *rendering call site* — `todolists/_todolist` passes +`bubbleupable: true` unconditionally — not by the current user's eligibility. +This gap tracks a distinct **boolean** `bubbleupable` field expressing +per-user eligibility, which still has no wire contract. Presence of +`bubble_up_url` is not a substitute: it is constant per shape, so it cannot +answer "can *this* user bubble this up". + ## Why it matters Without the field, SDK consumers can't pre-compute the eligibility of UI diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy index e7524b747..c55a68634 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -1536,6 +1536,14 @@ structure Todolist { app_url: String bookmark_url: String subscription_url: String + + /// URL of the Bubble Up record for this recording (BC5 addition). Required: + /// `todolists/_todolist.json.jbuilder` renders the shared recording partial + /// with `bubbleupable: true` unconditionally, and every list, show, and group + /// path renders that partial — so the key is present on every projection of + /// this shape. + @required + bubble_up_url: String comments_count: Integer comments_url: String position: Integer @@ -1591,6 +1599,14 @@ structure TodolistGroup { app_url: String bookmark_url: String subscription_url: String + + /// URL of the Bubble Up record for this recording (BC5 addition). Required: + /// `todolists/_todolist.json.jbuilder` renders the shared recording partial + /// with `bubbleupable: true` unconditionally, and every list, show, and group + /// path renders that partial — so the key is present on every projection of + /// this shape. + @required + bubble_up_url: String comments_count: Integer comments_url: String position: Integer @@ -6306,6 +6322,14 @@ structure Recording { @required app_url: String bookmark_url: String + + /// URL of the Bubble Up record for this recording (BC5 addition). Optional + /// here because this is a polymorphic projection: + /// `recordings/_recording.json.jbuilder` emits the key only when the caller + /// passes `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the + /// only partial that does. So a Todolist-shaped instance carries it and the + /// other recording types do not. + bubble_up_url: String content: String /// Rich-text companion arrays carried through the generic recording /// projection (`to_recordable_partial_path` renders the full type-specific @@ -7973,6 +7997,14 @@ structure SearchResult { @required app_url: String bookmark_url: String + + /// URL of the Bubble Up record for this recording (BC5 addition). Optional + /// here because this is a polymorphic projection: + /// `recordings/_recording.json.jbuilder` emits the key only when the caller + /// passes `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the + /// only partial that does. So a Todolist-shaped instance carries it and the + /// other recording types do not. + bubble_up_url: String parent: RecordingParent bucket: RecordingBucket creator: Person diff --git a/spec/fixtures/manifest.yaml b/spec/fixtures/manifest.yaml index b47438233..d69c73655 100644 --- a/spec/fixtures/manifest.yaml +++ b/spec/fixtures/manifest.yaml @@ -139,6 +139,14 @@ targets: fixture: recordings/get.json pointer: "" schema: Recording + # bubble_up_url is optional on Recording — recordings/_recording.json.jbuilder + # emits it only when the caller passes bubbleupable, and todolists/_todolist is + # the only partial that does. recordings/get.json is a Message and correctly + # omits it, so this Todolist-shaped element is the field-bearing representative. + - id: recording-list-todolist + fixture: recordings/list.json + pointer: "/2" + schema: Recording - id: todo-get fixture: todos/get.json pointer: "" @@ -150,6 +158,15 @@ targets: fixture: search/results.json pointer: "/0" schema: SearchResult + # Todolists are searchable (Search::Type::TYPE_FILTER_MAPPING maps + # "Todo" => %w[Todo Todolist]) and api_search_result_template_path falls through + # to to_recordable_partial_path → todolists/todolist, which passes + # bubbleupable: true. So a Todolist search result carries bubble_up_url while + # the other result types do not — this is that representative. + - id: search-result-todolist + fixture: search/results.json + pointer: "/3" + schema: SearchResult # Two concrete RichTextAttachment instances (populated elements, not empty # arrays) — the only way to cover RichTextAttachment per the concrete-instance @@ -193,9 +210,9 @@ covered_schemas: ClientCorrespondence: [client-correspondence-get] ClientReply: [client-reply-get] QuestionAnswer: [question-answer-get] - Recording: [recording-get] + Recording: [recording-get, recording-list-todolist] Todo: [todo-get] - SearchResult: [search-result-0] + SearchResult: [search-result-0, search-result-todolist] RichTextAttachment: [richtext-comment-0, richtext-upload-0] Gauge: [gauge-get] GaugeNeedle: [gauge-needle-get] diff --git a/spec/fixtures/recordings/list.json b/spec/fixtures/recordings/list.json index d3460c476..eaf179afc 100644 --- a/spec/fixtures/recordings/list.json +++ b/spec/fixtures/recordings/list.json @@ -122,5 +122,55 @@ "thumbnail_url": "https://3.basecampapi.com/195539477/buckets/2085958499/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvJTA51--pr0j0051/thumbnails/diagram-51.png" } ] + }, + { + "id": 1069479519, + "status": "active", + "visible_to_clients": false, + "created_at": "2022-10-28T15:21:46.169Z", + "updated_at": "2022-10-28T15:21:46.169Z", + "title": "Hardware", + "inherits_status": true, + "type": "Todolist", + "url": "https://3.basecampapi.com/195539477/buckets/2085958500/todolists/1069479519.json", + "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479519", + "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NTE5P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--ebab26d1c66e726e17dd785d0e46bfc4e5cba3f5.json", + "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/bubble_up.json", + "parent": { + "id": 1069479338, + "title": "To-dos", + "type": "Todoset", + "url": "https://3.basecampapi.com/195539477/buckets/2085958500/todosets/1069479338.json", + "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todosets/1069479338" + }, + "bucket": { + "id": 2085958500, + "name": "The Leto Locator", + "type": "Project" + }, + "creator": { + "id": 1049715914, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE0P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--aabbccdd", + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "personable_type": "User", + "title": "Chief Strategist", + "bio": "Don't let your dreams be dreams", + "location": "Chicago, IL", + "created_at": "2022-11-22T08:23:21.732Z", + "updated_at": "2022-11-22T08:23:21.732Z", + "admin": true, + "owner": true, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMpkkT4=--avatar", + "company": { + "id": 1033447817, + "name": "Honcho Design" + }, + "can_manage_projects": true, + "can_manage_people": true + } } ] diff --git a/spec/fixtures/search/results.json b/spec/fixtures/search/results.json index f69b67ce3..1716fdeb9 100644 --- a/spec/fixtures/search/results.json +++ b/spec/fixtures/search/results.json @@ -188,5 +188,54 @@ "thumbnail_url": "https://3.basecampapi.com/195539477/buckets/2085958499/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvJTA32--pr0j0032/thumbnails/diagram-32.png" } ] + }, + { + "id": 1069479519, + "status": "active", + "visible_to_clients": false, + "created_at": "2022-10-29T10:30:00.000Z", + "updated_at": "2022-10-29T10:30:00.000Z", + "title": "Hardware", + "inherits_status": true, + "type": "Todolist", + "url": "https://3.basecampapi.com/195539477/buckets/2085958500/todolists/1069479519.json", + "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479519", + "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NTE5P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--ebab26d1c66e726e17dd785d0e46bfc4e5cba3f5.json", + "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/bubble_up.json", + "content": null, + "description": null, + "plain_text_description": "Everything that has to ship before the Leto launch.", + "parent": { + "id": 1069479338, + "title": "To-dos", + "type": "Todoset", + "url": "https://3.basecampapi.com/195539477/buckets/2085958500/todosets/1069479338.json", + "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todosets/1069479338" + }, + "bucket": { + "id": 2085958500, + "name": "The Leto Locator", + "type": "Project" + }, + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--aeb392ebf54ffd820e45f27add22bae3a8c7da56", + "name": "Annie Bryan", + "email_address": "annie@honchodesign.com", + "personable_type": "User", + "title": "Central Markets Manager", + "bio": "To open a store is easy, to keep it open is an art", + "location": null, + "created_at": "2022-11-22T08:23:21.911Z", + "updated_at": "2022-11-22T08:23:21.911Z", + "admin": false, + "owner": false, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMtkkT4=--avatar", + "can_manage_projects": true, + "can_manage_people": true + } } ] diff --git a/spec/fixtures/todolist_groups/get.json b/spec/fixtures/todolist_groups/get.json index 1536bea1e..44d8909af 100644 --- a/spec/fixtures/todolist_groups/get.json +++ b/spec/fixtures/todolist_groups/get.json @@ -11,6 +11,7 @@ "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479600", "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NjAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0.json", "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479600/subscription.json", + "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479600/bubble_up.json", "comments_count": 0, "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479600/comments.json", "position": 1, diff --git a/spec/fixtures/todolist_groups/list.json b/spec/fixtures/todolist_groups/list.json index b608a0430..6efde70bc 100644 --- a/spec/fixtures/todolist_groups/list.json +++ b/spec/fixtures/todolist_groups/list.json @@ -12,6 +12,7 @@ "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479600", "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NjAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0.json", "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479600/subscription.json", + "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479600/bubble_up.json", "comments_count": 0, "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479600/comments.json", "position": 1, @@ -70,6 +71,7 @@ "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479601", "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NjAxP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0.json", "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479601/subscription.json", + "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479601/bubble_up.json", "comments_count": 0, "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479601/comments.json", "position": 2, diff --git a/spec/fixtures/todolists/get.json b/spec/fixtures/todolists/get.json index eee40ebf8..e38bd4831 100644 --- a/spec/fixtures/todolists/get.json +++ b/spec/fixtures/todolists/get.json @@ -11,6 +11,7 @@ "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479519", "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NTE5P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--ebab26d1c66e726e17dd785d0e46bfc4e5cba3f5.json", "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/subscription.json", + "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/bubble_up.json", "comments_count": 0, "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/comments.json", "position": 1, diff --git a/spec/fixtures/todolists/list.json b/spec/fixtures/todolists/list.json index fd33019db..b2cd2c95b 100644 --- a/spec/fixtures/todolists/list.json +++ b/spec/fixtures/todolists/list.json @@ -12,6 +12,7 @@ "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479519", "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NTE5P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--ebab26d1c66e726e17dd785d0e46bfc4e5cba3f5.json", "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/subscription.json", + "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/bubble_up.json", "comments_count": 0, "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/comments.json", "position": 1, @@ -73,6 +74,7 @@ "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479522", "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NTIyP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--c4e2c9d5b4c3a2b1e0f9d8c7b6a5948372615141.json", "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479522/subscription.json", + "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479522/bubble_up.json", "comments_count": 0, "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479522/comments.json", "position": 2, diff --git a/spec/overlays/examples.smithy b/spec/overlays/examples.smithy index 9e62f1f5f..400ce6ef6 100644 --- a/spec/overlays/examples.smithy +++ b/spec/overlays/examples.smithy @@ -15,6 +15,7 @@ apply GetTodolistOrGroup @examples([ title: "Launch Tasks", inherits_status: true, type: "Todolist", description_attachments: [], url: "https://3.basecampapi.com/999/buckets/12345678/todolists/987654.json", app_url: "https://3.basecamp.com/999/buckets/12345678/todolists/987654", + bubble_up_url: "https://3.basecampapi.com/999/buckets/12345678/recordings/987654/bubble_up.json", creator: { id: 1, name: "Someone", created_at: "2025-01-01T00:00:00Z", updated_at: "2025-01-01T00:00:00Z" }, bucket: { id: 12345678, name: "My Project", type: "Project" }, parent: { id: 99999, title: "To-dos", type: "Todoset", url: "https://3.basecampapi.com/999/buckets/12345678/todosets/99999.json", app_url: "https://3.basecamp.com/999/buckets/12345678/todosets/99999" } @@ -30,6 +31,7 @@ apply GetTodolistOrGroup @examples([ title: "Q1 Milestones", inherits_status: true, type: "TodolistGroup", url: "https://3.basecampapi.com/999/buckets/12345678/todolists/111222.json", app_url: "https://3.basecamp.com/999/buckets/12345678/todolists/111222", + bubble_up_url: "https://3.basecampapi.com/999/buckets/12345678/recordings/111222/bubble_up.json", creator: { id: 1, name: "Someone", created_at: "2025-01-01T00:00:00Z", updated_at: "2025-01-01T00:00:00Z" }, bucket: { id: 12345678, name: "My Project", type: "Project" }, parent: { id: 99999, title: "To-dos", type: "Todoset", url: "https://3.basecampapi.com/999/buckets/12345678/todosets/99999.json", app_url: "https://3.basecamp.com/999/buckets/12345678/todosets/99999" } diff --git a/swift/Sources/Basecamp/Generated/Models/Recording.swift b/swift/Sources/Basecamp/Generated/Models/Recording.swift index 9ae7b5a61..14df3ed13 100644 --- a/swift/Sources/Basecamp/Generated/Models/Recording.swift +++ b/swift/Sources/Basecamp/Generated/Models/Recording.swift @@ -17,6 +17,7 @@ public struct Recording: Codable, Sendable { public var bookmarkUrl: String? public var boostsCount: Int32? public var boostsUrl: String? + public var bubbleUpUrl: String? public var category: RecordingCategory? public var commentsCount: Int32? public var commentsUrl: String? @@ -50,6 +51,7 @@ public struct Recording: Codable, Sendable { bookmarkUrl: String? = nil, boostsCount: Int32? = nil, boostsUrl: String? = nil, + bubbleUpUrl: String? = nil, category: RecordingCategory? = nil, commentsCount: Int32? = nil, commentsUrl: String? = nil, @@ -82,6 +84,7 @@ public struct Recording: Codable, Sendable { self.bookmarkUrl = bookmarkUrl self.boostsCount = boostsCount self.boostsUrl = boostsUrl + self.bubbleUpUrl = bubbleUpUrl self.category = category self.commentsCount = commentsCount self.commentsUrl = commentsUrl diff --git a/swift/Sources/Basecamp/Generated/Models/SearchResult.swift b/swift/Sources/Basecamp/Generated/Models/SearchResult.swift index 00e7f155b..580cf7628 100644 --- a/swift/Sources/Basecamp/Generated/Models/SearchResult.swift +++ b/swift/Sources/Basecamp/Generated/Models/SearchResult.swift @@ -10,6 +10,7 @@ public struct SearchResult: Codable, Sendable { public let type: String public let url: String public var bookmarkUrl: String? + public var bubbleUpUrl: String? public var bucket: RecordingBucket? public var contentAttachments: [RichTextAttachment]? public var createdAt: String? @@ -33,6 +34,7 @@ public struct SearchResult: Codable, Sendable { type: String, url: String, bookmarkUrl: String? = nil, + bubbleUpUrl: String? = nil, bucket: RecordingBucket? = nil, contentAttachments: [RichTextAttachment]? = nil, createdAt: String? = nil, @@ -55,6 +57,7 @@ public struct SearchResult: Codable, Sendable { self.type = type self.url = url self.bookmarkUrl = bookmarkUrl + self.bubbleUpUrl = bubbleUpUrl self.bucket = bucket self.contentAttachments = contentAttachments self.createdAt = createdAt @@ -79,6 +82,7 @@ public struct SearchResult: Codable, Sendable { case type case url case bookmarkUrl + case bubbleUpUrl case bucket case contentAttachments case createdAt @@ -104,6 +108,7 @@ public struct SearchResult: Codable, Sendable { self.type = try container.decode(String.self, forKey: .type) self.url = try container.decode(String.self, forKey: .url) self.bookmarkUrl = try container.decodeIfPresent(String.self, forKey: .bookmarkUrl) + self.bubbleUpUrl = try container.decodeIfPresent(String.self, forKey: .bubbleUpUrl) self.bucket = try container.decodeIfPresent(RecordingBucket.self, forKey: .bucket) self.contentAttachments = try container.decodeIfPresent([RichTextAttachment].self, forKey: .contentAttachments) self.createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) @@ -129,6 +134,7 @@ public struct SearchResult: Codable, Sendable { try container.encode(self.type, forKey: .type) try container.encode(self.url, forKey: .url) try container.encodeIfPresent(self.bookmarkUrl, forKey: .bookmarkUrl) + try container.encodeIfPresent(self.bubbleUpUrl, forKey: .bubbleUpUrl) try container.encodeIfPresent(self.bucket, forKey: .bucket) try container.encodeIfPresent(self.contentAttachments, forKey: .contentAttachments) try container.encodeIfPresent(self.createdAt, forKey: .createdAt) diff --git a/swift/Sources/Basecamp/Generated/Models/Todolist.swift b/swift/Sources/Basecamp/Generated/Models/Todolist.swift index b597878e7..ef25c506e 100644 --- a/swift/Sources/Basecamp/Generated/Models/Todolist.swift +++ b/swift/Sources/Basecamp/Generated/Models/Todolist.swift @@ -3,6 +3,7 @@ import Foundation public struct Todolist: Codable, Sendable { public let appUrl: String + public let bubbleUpUrl: String public let bucket: TodoBucket public let createdAt: String public let creator: Person @@ -33,6 +34,7 @@ public struct Todolist: Codable, Sendable { public init( appUrl: String, + bubbleUpUrl: String, bucket: TodoBucket, createdAt: String, creator: Person, @@ -62,6 +64,7 @@ public struct Todolist: Codable, Sendable { todosUrl: String? = nil ) { self.appUrl = appUrl + self.bubbleUpUrl = bubbleUpUrl self.bucket = bucket self.createdAt = createdAt self.creator = creator diff --git a/swift/Sources/Basecamp/Generated/Models/TodolistGroup.swift b/swift/Sources/Basecamp/Generated/Models/TodolistGroup.swift index 6ce534571..dfc34c984 100644 --- a/swift/Sources/Basecamp/Generated/Models/TodolistGroup.swift +++ b/swift/Sources/Basecamp/Generated/Models/TodolistGroup.swift @@ -3,6 +3,7 @@ import Foundation public struct TodolistGroup: Codable, Sendable { public let appUrl: String + public let bubbleUpUrl: String public let bucket: TodoBucket public let createdAt: String public let creator: Person @@ -28,6 +29,7 @@ public struct TodolistGroup: Codable, Sendable { public init( appUrl: String, + bubbleUpUrl: String, bucket: TodoBucket, createdAt: String, creator: Person, @@ -52,6 +54,7 @@ public struct TodolistGroup: Codable, Sendable { todosUrl: String? = nil ) { self.appUrl = appUrl + self.bubbleUpUrl = bubbleUpUrl self.bucket = bucket self.createdAt = createdAt self.creator = creator diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index 73c364b33..682354f42 100644 --- a/typescript/src/generated/metadata.ts +++ b/typescript/src/generated/metadata.ts @@ -37,7 +37,7 @@ export interface MetadataOutput { const metadata: MetadataOutput = { "$schema": "https://basecamp.com/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-07-28T06:50:06.535Z", + "generated": "2026-07-28T07:10:08.052Z", "operations": { "GetAccount": { "retry": { diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index 616adad5f..13d9cf347 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -17278,6 +17278,7 @@ "description_attachments": [], "url": "https://3.basecampapi.com/999/buckets/12345678/todolists/987654.json", "app_url": "https://3.basecamp.com/999/buckets/12345678/todolists/987654", + "bubble_up_url": "https://3.basecampapi.com/999/buckets/12345678/recordings/987654/bubble_up.json", "creator": { "id": 1, "name": "Someone", @@ -17317,6 +17318,7 @@ "type": "TodolistGroup", "url": "https://3.basecampapi.com/999/buckets/12345678/todolists/111222.json", "app_url": "https://3.basecamp.com/999/buckets/12345678/todolists/111222", + "bubble_up_url": "https://3.basecampapi.com/999/buckets/12345678/recordings/111222/bubble_up.json", "creator": { "id": 1, "name": "Someone", @@ -26099,6 +26101,10 @@ "bookmark_url": { "type": "string" }, + "bubble_up_url": { + "type": "string", + "description": "URL of the Bubble Up record for this recording (BC5 addition). Optional\nhere because this is a polymorphic projection:\n`recordings/_recording.json.jbuilder` emits the key only when the caller\npasses `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the\nonly partial that does. So a Todolist-shaped instance carries it and the\nother recording types do not." + }, "content": { "type": "string" }, @@ -26802,6 +26808,10 @@ "bookmark_url": { "type": "string" }, + "bubble_up_url": { + "type": "string", + "description": "URL of the Bubble Up record for this recording (BC5 addition). Optional\nhere because this is a polymorphic projection:\n`recordings/_recording.json.jbuilder` emits the key only when the caller\npasses `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the\nonly partial that does. So a Todolist-shaped instance carries it and the\nother recording types do not." + }, "parent": { "$ref": "#/components/schemas/RecordingParent" }, @@ -27672,6 +27682,10 @@ "subscription_url": { "type": "string" }, + "bubble_up_url": { + "type": "string", + "description": "URL of the Bubble Up record for this recording (BC5 addition). Required:\n`todolists/_todolist.json.jbuilder` renders the shared recording partial\nwith `bubbleupable: true` unconditionally, and every list, show, and group\npath renders that partial — so the key is present on every projection of\nthis shape." + }, "comments_count": { "type": "integer", "format": "int32" @@ -27729,6 +27743,7 @@ }, "required": [ "app_url", + "bubble_up_url", "bucket", "created_at", "creator", @@ -27796,6 +27811,10 @@ "subscription_url": { "type": "string" }, + "bubble_up_url": { + "type": "string", + "description": "URL of the Bubble Up record for this recording (BC5 addition). Required:\n`todolists/_todolist.json.jbuilder` renders the shared recording partial\nwith `bubbleupable: true` unconditionally, and every list, show, and group\npath renders that partial — so the key is present on every projection of\nthis shape." + }, "comments_count": { "type": "integer", "format": "int32" @@ -27834,6 +27853,7 @@ }, "required": [ "app_url", + "bubble_up_url", "bucket", "created_at", "creator", diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index 8775fb936..731eca8fd 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -4494,6 +4494,15 @@ export interface components { url: string; app_url: string; bookmark_url?: string; + /** + * @description URL of the Bubble Up record for this recording (BC5 addition). Optional + * here because this is a polymorphic projection: + * `recordings/_recording.json.jbuilder` emits the key only when the caller + * passes `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the + * only partial that does. So a Todolist-shaped instance carries it and the + * other recording types do not. + */ + bubble_up_url?: string; content?: string; /** * @description Rich-text companion arrays carried through the generic recording @@ -4750,6 +4759,15 @@ export interface components { url: string; app_url: string; bookmark_url?: string; + /** + * @description URL of the Bubble Up record for this recording (BC5 addition). Optional + * here because this is a polymorphic projection: + * `recordings/_recording.json.jbuilder` emits the key only when the caller + * passes `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the + * only partial that does. So a Todolist-shaped instance carries it and the + * other recording types do not. + */ + bubble_up_url?: string; parent?: components["schemas"]["RecordingParent"]; bucket?: components["schemas"]["RecordingBucket"]; creator?: components["schemas"]["Person"]; @@ -5111,6 +5129,14 @@ export interface components { app_url: string; bookmark_url?: string; subscription_url?: string; + /** + * @description URL of the Bubble Up record for this recording (BC5 addition). Required: + * `todolists/_todolist.json.jbuilder` renders the shared recording partial + * with `bubbleupable: true` unconditionally, and every list, show, and group + * path renders that partial — so the key is present on every projection of + * this shape. + */ + bubble_up_url: string; /** Format: int32 */ comments_count?: number; comments_url?: string; @@ -5145,6 +5171,14 @@ export interface components { app_url: string; bookmark_url?: string; subscription_url?: string; + /** + * @description URL of the Bubble Up record for this recording (BC5 addition). Required: + * `todolists/_todolist.json.jbuilder` renders the shared recording partial + * with `bubbleupable: true` unconditionally, and every list, show, and group + * path renders that partial — so the key is present on every projection of + * this shape. + */ + bubble_up_url: string; /** Format: int32 */ comments_count?: number; comments_url?: string; diff --git a/typescript/tests/services/todolists.test.ts b/typescript/tests/services/todolists.test.ts index bb268096a..cd363eb09 100644 --- a/typescript/tests/services/todolists.test.ts +++ b/typescript/tests/services/todolists.test.ts @@ -41,6 +41,11 @@ describe("TodolistsService", () => { const todolist = await client.todolists.get(id); expect(todolist.id).toBe(id); expect(todolist.name).toBe(todolistFixture.name); + // bubble_up_url is @required on Todolist: todolists/_todolist.json.jbuilder + // renders the shared recording partial with bubbleupable: true + // unconditionally, so every projection of this shape carries it. The stub + // is the canonical fixture, so this also proves the fixture carries it. + expect(todolist.bubble_up_url).toBeDefined(); }); it("should throw not_found for missing todolist", async () => {