From afebd93cb2eeae446901bcbdf365e8b6910d817e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 24 Jul 2026 22:11:15 -0700 Subject: [PATCH 1/5] Absorb the Bubble Ups successor surface (bc3 #11628) doc/api/sections/my_notifications.md (codified by bc3 #11628) documents the BC5 bubble-up surface that memories-emptied-regression.md explicitly defers to a separate additive PR. Absorb it: - GetMyNotificationsOutput: add required bubble_ups_count and scheduled_bubble_ups_count (top-level UI counts, present independent of the limit_bubble_ups cap and even when the scheduled_bubble_ups array is omitted). - GetMyNotificationsInput: add optional limit_bubble_ups boolean @httpQuery (true caps bubble_ups at 2 current items and omits scheduled_bubble_ups). - New GetBubbleUps operation (GET /my/readings/bubble_ups.json): a paginated bare array (50/page, Link-header pagination, page query param) of current bubble-ups first (most recently bubbled up) then scheduled bubble-ups (scheduled time). Same Notification item shape as GetMyNotifications. Added to the service operations list and tagged MyNotifications. Go wrapper: MyNotificationsService.BubbleUps follows the Link header across all pages by default; a positive page returns only that page. Get gained a non-breaking WithLimitBubbleUps() functional option. Tests: a multi-page Link-following pagination test (with X-Total-Count metadata and current-then-scheduled ordering), a single-page test, and a test proving limit_bubble_ups=true sends the param and decodes a response omitting scheduled_bubble_ups while keeping the counts. The grouped bubble_ups/scheduled_bubble_ups arrays and their item fields were already modeled and are not re-added. memories-emptied-regression.md is left intact; a new bubble-ups-surface.md entry records this absorption. Updated the GetMyNotifications conformance validator fixtures for the now-required counts and added a GetBubbleUps canary entry (validates statically; live dormant). --- behavior-model.json | 16 ++ .../typescript/schema-validator.test.ts | 11 +- conformance/tests/live-my-surface.json | 13 ++ go/pkg/basecamp/my_notifications.go | 133 ++++++++++- go/pkg/basecamp/my_notifications_test.go | 125 +++++++++++ go/pkg/basecamp/url-routes.json | 13 ++ go/pkg/generated/client.gen.go | 212 +++++++++++++++++- .../com/basecamp/sdk/generator/Config.kt | 1 + .../com/basecamp/sdk/generated/Metadata.kt | 1 + .../sdk/generated/models/Notification.kt | 41 ++++ .../generated/models/PreviewableAttachment.kt | 23 ++ .../basecamp/sdk/generated/services/Types.kt | 11 +- .../generated/services/my-notifications.kt | 24 ++ .../com/basecamp/sdk/BubbleUpsDecodeTest.kt | 54 +++++ openapi.json | 129 ++++++++++- python/src/basecamp/generated/metadata.json | 11 + .../generated/services/my_notifications.py | 20 +- python/src/basecamp/generated/types.py | 2 + .../tests/services/test_my_notifications.py | 54 +++++ ruby/lib/basecamp/generated/metadata.json | 18 +- .../services/my_notifications_service.rb | 18 +- ruby/lib/basecamp/generated/types.rb | 2 +- .../services/my_notifications_service_test.rb | 50 +++++ spec/api-gaps/README.md | 1 + spec/api-gaps/bubble-ups-surface.md | 102 +++++++++ spec/basecamp.smithy | 48 ++++ spec/overlays/tags.smithy | 1 + .../Sources/Basecamp/Generated/Metadata.swift | 1 + .../GetMyNotificationsResponseContent.swift | 20 ++ .../Services/MyNotificationsService.swift | 31 ++- .../BasecampTests/GeneratedServiceTests.swift | 46 ++++ typescript/scripts/generate-services.ts | 1 + typescript/src/generated/metadata.ts | 18 +- .../src/generated/openapi-stripped.json | 118 +++++++++- typescript/src/generated/path-mapping.ts | 1 + typescript/src/generated/schema.d.ts | 103 +++++++++ .../generated/services/my-notifications.ts | 47 +++- .../tests/services/my-notifications.test.ts | 40 ++++ 38 files changed, 1538 insertions(+), 22 deletions(-) create mode 100644 kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Notification.kt create mode 100644 kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/PreviewableAttachment.kt create mode 100644 kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/BubbleUpsDecodeTest.kt create mode 100644 python/tests/services/test_my_notifications.py create mode 100644 ruby/test/basecamp/services/my_notifications_service_test.rb create mode 100644 spec/api-gaps/bubble-ups-surface.md diff --git a/behavior-model.json b/behavior-model.json index 58f0acc09..5fe249fe0 100644 --- a/behavior-model.json +++ b/behavior-model.json @@ -621,6 +621,22 @@ ] } }, + "GetBubbleUps": { + "readonly": true, + "pagination": { + "style": "link", + "maxPageSize": 50 + }, + "retry": { + "max": 3, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "GetCampfire": { "readonly": true, "retry": { diff --git a/conformance/runner/typescript/schema-validator.test.ts b/conformance/runner/typescript/schema-validator.test.ts index f2c768c55..75cb06475 100644 --- a/conformance/runner/typescript/schema-validator.test.ts +++ b/conformance/runner/typescript/schema-validator.test.ts @@ -69,13 +69,18 @@ describe("validateResponse — ListProjects (array root)", () => { // ============================================================================= describe("validateResponse — GetMyNotifications (object root, array fields)", () => { - it("validates an empty payload (all arrays absent)", () => { - const result = validateResponse("GetMyNotifications", {}); + // bubble_ups_count / scheduled_bubble_ups_count are @required on the output, + // so every valid payload carries them (the arrays remain optional). + const counts = { bubble_ups_count: 0, scheduled_bubble_ups_count: 0 }; + + it("validates a minimal payload (arrays absent, required counts present)", () => { + const result = validateResponse("GetMyNotifications", { ...counts }); expect(result.ok).toBe(true); }); it("validates with empty arrays", () => { const result = validateResponse("GetMyNotifications", { + ...counts, unreads: [], reads: [], memories: [], @@ -87,6 +92,7 @@ describe("validateResponse — GetMyNotifications (object root, array fields)", it("collects extras at the root", () => { const result = validateResponse("GetMyNotifications", { + ...counts, unreads: [], hypothetical_new_top_level: 42, }); @@ -101,6 +107,7 @@ describe("validateResponse — GetMyNotifications (object root, array fields)", updated_at: "2026-01-02T00:00:00Z", }; const result = validateResponse("GetMyNotifications", { + ...counts, unreads: [{ ...minimalNotification, future_envelope_field: "BC5 addition" }], }); expect(result.ok).toBe(true); diff --git a/conformance/tests/live-my-surface.json b/conformance/tests/live-my-surface.json index 1523adc07..88696f0ab 100644 --- a/conformance/tests/live-my-surface.json +++ b/conformance/tests/live-my-surface.json @@ -12,6 +12,19 @@ { "type": "liveSchemaValidate" } ] }, + { + "mode": "live", + "name": "GetBubbleUps returns a paginated bubble-ups list and validates against schema", + "description": "Exercises the dedicated bubble-ups endpoint (current + scheduled, 50/page) and validates each notification item against the schema. Live-dormant: validates statically until credentials are provisioned.", + "operation": "GetBubbleUps", + "method": "GET", + "path": "/my/readings/bubble_ups.json", + "tags": ["live", "read-only"], + "liveAssertions": [ + { "type": "liveCallSucceeds" }, + { "type": "liveSchemaValidate" } + ] + }, { "mode": "live", "name": "ListProjects returns a list and validates against schema", diff --git a/go/pkg/basecamp/my_notifications.go b/go/pkg/basecamp/my_notifications.go index f960a8308..d443784ec 100644 --- a/go/pkg/basecamp/my_notifications.go +++ b/go/pkg/basecamp/my_notifications.go @@ -70,8 +70,26 @@ type NotificationsResult struct { // integrations should prefer BubbleUps. BubbleUps []Notification `json:"bubble_ups,omitempty"` // ScheduledBubbleUps is the BC5-added list of scheduled Bubble Up - // notifications (resurface time in the future). + // notifications (resurface time in the future). Omitted from the response + // entirely when the request sets limit_bubble_ups=true. ScheduledBubbleUps []Notification `json:"scheduled_bubble_ups,omitempty"` + // BubbleUpsCount is the total number of current bubble-ups, independent of + // the limit_bubble_ups cap on the BubbleUps array. + BubbleUpsCount int32 `json:"bubble_ups_count"` + // ScheduledBubbleUpsCount is the total number of scheduled bubble-ups, + // present even when limit_bubble_ups omits the ScheduledBubbleUps array. + ScheduledBubbleUpsCount int32 `json:"scheduled_bubble_ups_count"` +} + +// BubbleUpsResult contains a page-followed list of bubble-up notifications +// with pagination metadata. +type BubbleUpsResult struct { + // BubbleUps is the combined list of current bubble-ups (first, ordered by + // most recently bubbled up) followed by scheduled bubble-ups (ordered by + // scheduled time). + BubbleUps []Notification + // Meta contains pagination metadata (total count, truncation). + Meta ListMeta } // MyNotificationsService handles notification operations for the current user. @@ -84,9 +102,20 @@ func NewMyNotificationsService(client *AccountClient) *MyNotificationsService { return &MyNotificationsService{client: client} } +// MyNotificationsGetOption customizes a MyNotifications Get request. +type MyNotificationsGetOption func(*generated.GetMyNotificationsParams) + +// WithLimitBubbleUps caps the bubble_ups array at 2 current bubble-ups and +// omits the scheduled_bubble_ups array from the response (the counts are still +// returned). Use the BubbleUps method to page through all bubble-ups. +func WithLimitBubbleUps() MyNotificationsGetOption { + return func(p *generated.GetMyNotificationsParams) { p.LimitBubbleUps = true } +} + // Get returns notifications for the current user. -// page is optional; pass 0 to use the default (page 1). -func (s *MyNotificationsService) Get(ctx context.Context, page int32) (result *NotificationsResult, err error) { +// page is optional; pass 0 to use the default (page 1). Optional functional +// options (e.g. WithLimitBubbleUps) tune the request. +func (s *MyNotificationsService) Get(ctx context.Context, page int32, opts ...MyNotificationsGetOption) (result *NotificationsResult, err error) { op := OperationInfo{ Service: "MyNotifications", Operation: "Get", ResourceType: "notification", IsMutation: false, @@ -100,11 +129,12 @@ func (s *MyNotificationsService) Get(ctx context.Context, page int32) (result *N ctx = s.client.parent.hooks.OnOperationStart(ctx, op) defer func() { s.client.parent.hooks.OnOperationEnd(ctx, op, err, time.Since(start)) }() - var params *generated.GetMyNotificationsParams + params := &generated.GetMyNotificationsParams{} if page > 0 { - params = &generated.GetMyNotificationsParams{ - Page: page, - } + params.Page = page + } + for _, opt := range opts { + opt(params) } resp, err := s.client.parent.gen.GetMyNotificationsWithResponse(ctx, s.client.accountID, params) @@ -158,3 +188,92 @@ func (s *MyNotificationsService) MarkAsRead(ctx context.Context, readables []str } return checkResponse(resp.HTTPResponse, resp.Body) } + +// BubbleUps returns the current user's current and scheduled bubble-ups. +// Current bubble-ups come first (ordered by most recently bubbled up), then +// scheduled bubble-ups (ordered by scheduled time). The list is paginated at +// 50 per page; by default this follows the Link header across all pages. +// Pass a positive page to disable auto-pagination and return only that page. +func (s *MyNotificationsService) BubbleUps(ctx context.Context, page int32) (result *BubbleUpsResult, err error) { + op := OperationInfo{ + Service: "MyNotifications", Operation: "BubbleUps", + ResourceType: "notification", IsMutation: false, + } + if gater, ok := s.client.parent.hooks.(GatingHooks); ok { + if ctx, err = gater.OnOperationGate(ctx, op); err != nil { + return + } + } + start := time.Now() + ctx = s.client.parent.hooks.OnOperationStart(ctx, op) + defer func() { s.client.parent.hooks.OnOperationEnd(ctx, op, err, time.Since(start)) }() + + var params *generated.GetBubbleUpsParams + if page > 0 { + params = &generated.GetBubbleUpsParams{Page: page} + } + + resp, err := s.client.parent.gen.GetBubbleUpsWithResponse(ctx, s.client.accountID, params) + if err != nil { + return nil, err + } + if err = checkResponse(resp.HTTPResponse, resp.Body); err != nil { + return nil, err + } + + items, err := decodeBubbleUpPage(resp.Body) + if err != nil { + return nil, err + } + + totalCount := parseTotalCount(resp.HTTPResponse) + + // A positive page disables auto-pagination (single page only). + if page > 0 { + return &BubbleUpsResult{BubbleUps: items, Meta: ListMeta{TotalCount: totalCount}}, nil + } + + rawMore, truncated, err := s.client.parent.followPagination(ctx, resp.HTTPResponse, len(items), 0) + if err != nil { + return nil, err + } + for _, raw := range rawMore { + n, decErr := decodeNotificationItem(raw) + if decErr != nil { + return nil, decErr + } + items = append(items, n) + } + + return &BubbleUpsResult{BubbleUps: items, Meta: ListMeta{TotalCount: totalCount, Truncated: truncated}}, nil +} + +// decodeBubbleUpPage decodes a bubble-ups page (a bare JSON array of +// notifications) into clean Notification values, applying the embedded-people +// id normalization the Notification shape relies on. +func decodeBubbleUpPage(body []byte) ([]Notification, error) { + normalized, normErr := normalizeEmbeddedPeopleJSON(body) + if normErr != nil { + normalized = body // fallback to raw + } + var items []Notification + if err := json.Unmarshal(normalized, &items); err != nil { + return nil, fmt.Errorf("failed to parse bubble-ups: %w", err) + } + return items, nil +} + +// decodeNotificationItem decodes a single notification item (a raw JSON object +// from a followed pagination page) into a clean Notification, applying the same +// embedded-people id normalization as the first page. +func decodeNotificationItem(raw json.RawMessage) (Notification, error) { + normalized, normErr := normalizeEmbeddedPeopleJSON(raw) + if normErr != nil { + normalized = raw // fallback to raw + } + var n Notification + if err := json.Unmarshal(normalized, &n); err != nil { + return Notification{}, fmt.Errorf("failed to parse bubble-up notification: %w", err) + } + return n, nil +} diff --git a/go/pkg/basecamp/my_notifications_test.go b/go/pkg/basecamp/my_notifications_test.go index 54944790d..ef724c318 100644 --- a/go/pkg/basecamp/my_notifications_test.go +++ b/go/pkg/basecamp/my_notifications_test.go @@ -62,6 +62,131 @@ func TestMyNotificationsService_Get_WithPage(t *testing.T) { } } +// TestMyNotificationsService_Get_LimitBubbleUps verifies that +// WithLimitBubbleUps sends limit_bubble_ups=true and that a response which omits +// the scheduled_bubble_ups key (per the documented cap) still decodes, with the +// counts preserved. +func TestMyNotificationsService_Get_LimitBubbleUps(t *testing.T) { + var capturedQuery string + svc := testMyNotificationsServer(t, func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + // scheduled_bubble_ups key intentionally omitted; counts still present. + w.Write([]byte(`{ + "unreads": [], "reads": [], "memories": [], + "bubble_ups": [{"id": 10, "title": "Bubbled"}, {"id": 11, "title": "Bubbled 2"}], + "bubble_ups_count": 5, + "scheduled_bubble_ups_count": 3 + }`)) + }) + + result, err := svc.Get(context.Background(), 0, WithLimitBubbleUps()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if capturedQuery != "limit_bubble_ups=true" { + t.Errorf("expected query limit_bubble_ups=true, got %q", capturedQuery) + } + if len(result.BubbleUps) != 2 { + t.Errorf("expected 2 bubble_ups, got %d", len(result.BubbleUps)) + } + if result.ScheduledBubbleUps != nil { + t.Errorf("expected scheduled_bubble_ups omitted (nil), got %v", result.ScheduledBubbleUps) + } + if result.BubbleUpsCount != 5 { + t.Errorf("expected bubble_ups_count 5, got %d", result.BubbleUpsCount) + } + if result.ScheduledBubbleUpsCount != 3 { + t.Errorf("expected scheduled_bubble_ups_count 3, got %d", result.ScheduledBubbleUpsCount) + } +} + +// TestMyNotificationsService_BubbleUps_MultiPage exercises the dedicated +// bubble-ups endpoint across multiple pages, verifying Link-header following and +// generated pagination metadata (X-Total-Count), not just a single-page decode. +// Current bubble-ups appear first, scheduled bubble-ups follow. +func TestMyNotificationsService_BubbleUps_MultiPage(t *testing.T) { + var serverURL string + var page1Hits, page2Hits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/99999/my/readings/bubble_ups.json" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Total-Count", "3") + if r.URL.Query().Get("page") == "2" { + page2Hits++ + w.WriteHeader(200) + // scheduled bubble-up, ordered after the current ones + w.Write([]byte(`[{"id":30,"title":"Scheduled","bubble_up_at":"2026-08-01T00:00:00Z"}]`)) + return + } + page1Hits++ + // page 1: two current bubble-ups, with a Link header to page 2 + w.Header().Set("Link", fmt.Sprintf(`<%s/99999/my/readings/bubble_ups.json?page=2>; rel="next"`, serverURL)) + w.WriteHeader(200) + w.Write([]byte(`[{"id":10,"title":"Current A"},{"id":11,"title":"Current B"}]`)) + })) + t.Cleanup(server.Close) + serverURL = server.URL + + cfg := DefaultConfig() + cfg.BaseURL = server.URL + client := NewClient(cfg, &StaticTokenProvider{Token: "test-token"}) + svc := client.ForAccount("99999").MyNotifications() + + result, err := svc.BubbleUps(context.Background(), 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if page1Hits != 1 || page2Hits != 1 { + t.Fatalf("expected one hit per page, got page1=%d page2=%d", page1Hits, page2Hits) + } + if len(result.BubbleUps) != 3 { + t.Fatalf("expected 3 bubble_ups across pages, got %d", len(result.BubbleUps)) + } + // Ordering: current bubble-ups first, then scheduled. + if result.BubbleUps[0].ID != 10 || result.BubbleUps[1].ID != 11 || result.BubbleUps[2].ID != 30 { + t.Errorf("unexpected ordering: %d, %d, %d", result.BubbleUps[0].ID, result.BubbleUps[1].ID, result.BubbleUps[2].ID) + } + if result.BubbleUps[2].BubbleUpAt == nil { + t.Errorf("expected scheduled item to carry bubble_up_at") + } + if result.Meta.TotalCount != 3 { + t.Errorf("expected TotalCount 3, got %d", result.Meta.TotalCount) + } +} + +// TestMyNotificationsService_BubbleUps_SinglePage verifies that a positive page +// disables auto-pagination and returns only that page (no Link-following). +func TestMyNotificationsService_BubbleUps_SinglePage(t *testing.T) { + var hits int + svc := testMyNotificationsServer(t, func(w http.ResponseWriter, r *http.Request) { + hits++ + if r.URL.Query().Get("page") != "2" { + t.Errorf("expected page=2, got %q", r.URL.Query().Get("page")) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Total-Count", "3") + // A Link header is present but must NOT be followed for an explicit page. + w.Header().Set("Link", `; rel="next"`) + w.WriteHeader(200) + w.Write([]byte(`[{"id":30,"title":"Scheduled"}]`)) + }) + + result, err := svc.BubbleUps(context.Background(), 2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hits != 1 { + t.Errorf("expected exactly 1 request for explicit page, got %d", hits) + } + if len(result.BubbleUps) != 1 { + t.Errorf("expected 1 bubble_up for single page, got %d", len(result.BubbleUps)) + } +} + func TestMyNotificationsService_Get_SentinelCreatorID(t *testing.T) { // The BC3 API returns system-generated notifications with creator.id: "basecamp" // and personable_type: "LocalPerson". The normalize pass walks Person-shaped objects diff --git a/go/pkg/basecamp/url-routes.json b/go/pkg/basecamp/url-routes.json index 5a7a19f0a..bcf7db246 100644 --- a/go/pkg/basecamp/url-routes.json +++ b/go/pkg/basecamp/url-routes.json @@ -1020,6 +1020,19 @@ } } }, + { + "pattern": "/{accountId}/my/readings/bubble_ups", + "resource": "MyNotifications", + "operations": { + "GET": "GetBubbleUps" + }, + "params": { + "accountId": { + "role": "account", + "type": "string" + } + } + }, { "pattern": "/{accountId}/my/unreads", "resource": "MyNotifications", diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go index 8ccc25a43..8a901f112 100644 --- a/go/pkg/generated/client.gen.go +++ b/go/pkg/generated/client.gen.go @@ -990,6 +990,9 @@ type GetAssignedTodosResponseContent struct { // GetBoostResponseContent defines model for GetBoostResponseContent. type GetBoostResponseContent = Boost +// GetBubbleUpsResponseContent defines model for GetBubbleUpsResponseContent. +type GetBubbleUpsResponseContent = []Notification + // GetCampfireLineResponseContent defines model for GetCampfireLineResponseContent. type GetCampfireLineResponseContent = CampfireLine @@ -1069,6 +1072,10 @@ type GetMyNotificationsResponseContent struct { // `scheduled_bubble_ups` for the time-deferred subset. BubbleUps []Notification `json:"bubble_ups,omitempty"` + // BubbleUpsCount Total number of current bubble-ups, for notification UI counts + // (independent of the `limit_bubble_ups` cap on the `bubble_ups` array). + BubbleUpsCount int32 `json:"bubble_ups_count"` + // Memories Legacy "save forever" collection. Permanently `[]` on BC5 by documented // contract (`doc/api/sections/my_notifications.md`, codified by BC3 #11628): // an always-empty placeholder superseded by `bubble_ups`. BC4 (the `four` @@ -1081,7 +1088,12 @@ type GetMyNotificationsResponseContent struct { // ScheduledBubbleUps Bubble Ups scheduled to resurface in the future (BC5 addition). ScheduledBubbleUps []Notification `json:"scheduled_bubble_ups,omitempty"` - Unreads []Notification `json:"unreads,omitempty"` + + // ScheduledBubbleUpsCount Total number of scheduled bubble-ups, for notification UI counts + // (present even when `limit_bubble_ups` omits the `scheduled_bubble_ups` + // array). + ScheduledBubbleUpsCount int32 `json:"scheduled_bubble_ups_count"` + Unreads []Notification `json:"unreads,omitempty"` } // GetMyPreferencesResponseContent defines model for GetMyPreferencesResponseContent. @@ -3019,6 +3031,18 @@ type GetMyDueAssignmentsParams struct { type GetMyNotificationsParams struct { // Page Page number for paginating through read items. Defaults to 1. Page int32 `form:"page,omitempty" json:"page,omitempty"` + + // LimitBubbleUps Set to true to cap `bubble_ups` at 2 current bubble-ups and omit the + // `scheduled_bubble_ups` key entirely. Defaults to false. Use the dedicated + // bubble-ups endpoint (GetBubbleUps) to page through all current and + // scheduled bubble-ups. + LimitBubbleUps bool `form:"limit_bubble_ups,omitempty" json:"limit_bubble_ups,omitempty"` +} + +// GetBubbleUpsParams defines parameters for GetBubbleUps. +type GetBubbleUpsParams struct { + // Page Page number. Defaults to 1. + Page int32 `form:"page,omitempty" json:"page,omitempty"` } // ListProjectsParams defines parameters for ListProjects. @@ -4011,6 +4035,9 @@ type ClientInterface interface { // GetMyNotifications request GetMyNotifications(ctx context.Context, accountId string, params *GetMyNotificationsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetBubbleUps request + GetBubbleUps(ctx context.Context, accountId string, params *GetBubbleUpsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // MarkAsReadWithBody request with any body MarkAsReadWithBody(ctx context.Context, accountId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -5831,6 +5858,16 @@ func (c *Client) GetMyNotifications(ctx context.Context, accountId string, param } +// GetBubbleUps is marked as idempotent and will be retried on transient failures. + +func (c *Client) GetBubbleUps(ctx context.Context, accountId string, params *GetBubbleUpsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + + return c.doWithRetry(ctx, func() (*http.Request, error) { + return NewGetBubbleUpsRequest(c.Server, accountId, params) + }, true, "GetBubbleUps", reqEditors...) + +} + // MarkAsReadWithBody is marked as idempotent and will be retried on transient failures. func (c *Client) MarkAsReadWithBody(ctx context.Context, accountId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { @@ -12013,6 +12050,78 @@ func NewGetMyNotificationsRequest(server string, accountId string, params *GetMy return nil, err } + if params != nil { + queryValues := queryURL.Query() + + if params.Page != 0 { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "page", runtime.ParamLocationQuery, params.Page); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.LimitBubbleUps { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "limit_bubble_ups", runtime.ParamLocationQuery, params.LimitBubbleUps); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetBubbleUpsRequest generates requests for GetBubbleUps +func NewGetBubbleUpsRequest(server string, accountId string, params *GetBubbleUpsParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "accountId", runtime.ParamLocationPath, accountId) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/%s/my/readings/bubble_ups.json", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + if params != nil { queryValues := queryURL.Query() @@ -17970,6 +18079,7 @@ var operationMetadata = map[string]OperationMetadata{ "UpdateMyProfile": {Idempotent: true, HasSensitiveParams: false}, "GetQuestionReminders": {Idempotent: true, HasSensitiveParams: false}, "GetMyNotifications": {Idempotent: true, HasSensitiveParams: false}, + "GetBubbleUps": {Idempotent: true, HasSensitiveParams: false}, "MarkAsRead": {Idempotent: true, HasSensitiveParams: false}, "ListPeople": {Idempotent: true, HasSensitiveParams: false}, "GetPerson": {Idempotent: true, HasSensitiveParams: false}, @@ -19365,6 +19475,9 @@ type ClientWithResponsesInterface interface { // GetMyNotificationsWithResponse request GetMyNotificationsWithResponse(ctx context.Context, accountId string, params *GetMyNotificationsParams, reqEditors ...RequestEditorFn) (*GetMyNotificationsResponse, error) + // GetBubbleUpsWithResponse request + GetBubbleUpsWithResponse(ctx context.Context, accountId string, params *GetBubbleUpsParams, reqEditors ...RequestEditorFn) (*GetBubbleUpsResponse, error) + // MarkAsReadWithBodyWithResponse request with any body MarkAsReadWithBodyWithResponse(ctx context.Context, accountId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*MarkAsReadResponse, error) @@ -22975,6 +23088,40 @@ func (r GetMyNotificationsResponse) ContentType() string { return "" } +type GetBubbleUpsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *GetBubbleUpsResponseContent + JSON401 *UnauthorizedErrorResponseContent + JSON403 *ForbiddenErrorResponseContent + JSON429 *RateLimitErrorResponseContent + JSON500 *InternalServerErrorResponseContent +} + +// Status returns HTTPResponse.Status +func (r GetBubbleUpsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetBubbleUpsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetBubbleUpsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type MarkAsReadResponse struct { Body []byte HTTPResponse *http.Response @@ -28019,6 +28166,15 @@ func (c *ClientWithResponses) GetMyNotificationsWithResponse(ctx context.Context return ParseGetMyNotificationsResponse(rsp) } +// GetBubbleUpsWithResponse request returning *GetBubbleUpsResponse +func (c *ClientWithResponses) GetBubbleUpsWithResponse(ctx context.Context, accountId string, params *GetBubbleUpsParams, reqEditors ...RequestEditorFn) (*GetBubbleUpsResponse, error) { + rsp, err := c.GetBubbleUps(ctx, accountId, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetBubbleUpsResponse(rsp) +} + // MarkAsReadWithBodyWithResponse request with arbitrary body returning *MarkAsReadResponse func (c *ClientWithResponses) MarkAsReadWithBodyWithResponse(ctx context.Context, accountId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*MarkAsReadResponse, error) { rsp, err := c.MarkAsReadWithBody(ctx, accountId, contentType, body, reqEditors...) @@ -34601,6 +34757,60 @@ func ParseGetMyNotificationsResponse(rsp *http.Response) (*GetMyNotificationsRes return response, nil } +// ParseGetBubbleUpsResponse parses an HTTP response from a GetBubbleUpsWithResponse call +func ParseGetBubbleUpsResponse(rsp *http.Response) (*GetBubbleUpsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetBubbleUpsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest GetBubbleUpsResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest UnauthorizedErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ForbiddenErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest RateLimitErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalServerErrorResponseContent + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseMarkAsReadResponse parses an HTTP response from a MarkAsReadWithResponse call func ParseMarkAsReadResponse(rsp *http.Response) (*MarkAsReadResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt b/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt index f5009ff11..8892a0b42 100644 --- a/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt +++ b/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt @@ -313,6 +313,7 @@ val TYPE_ALIASES = mapOf( "ClientCorrespondence" to "ClientCorrespondence", "ClientReply" to "ClientReply", "Boost" to "Boost", + "Notification" to "Notification", "TimelineEvent" to "TimelineEvent", "TimesheetEntry" to "TimesheetEntry", "HillChart" to "HillChart", diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt index 977b71d07..896be78a0 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt @@ -74,6 +74,7 @@ object Metadata { "GetAnswersByPerson" to OperationConfig(false, RetryConfig(3, 1000L, "exponential", setOf(429, 503))), "GetAssignedTodos" to OperationConfig(false, RetryConfig(3, 1000L, "exponential", setOf(429, 503))), "GetBoost" to OperationConfig(false, RetryConfig(3, 1000L, "exponential", setOf(429, 503))), + "GetBubbleUps" to OperationConfig(false, RetryConfig(3, 1000L, "exponential", setOf(429, 503))), "GetCampfire" to OperationConfig(false, RetryConfig(3, 1000L, "exponential", setOf(429, 503))), "GetCampfireLine" to OperationConfig(false, RetryConfig(3, 1000L, "exponential", setOf(429, 503))), "GetCard" to OperationConfig(false, RetryConfig(3, 1000L, "exponential", setOf(429, 503))), diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Notification.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Notification.kt new file mode 100644 index 000000000..783bdb227 --- /dev/null +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Notification.kt @@ -0,0 +1,41 @@ +package com.basecamp.sdk.generated.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * Notification entity from the Basecamp API. + * + * @generated from OpenAPI spec — do not edit directly + */ +@Serializable +data class Notification( + val id: Long, + @SerialName("created_at") val createdAt: String, + @SerialName("updated_at") val updatedAt: String, + val section: String? = null, + @SerialName("unread_count") val unreadCount: Int = 0, + @SerialName("unread_at") val unreadAt: String? = null, + @SerialName("read_at") val readAt: String? = null, + @SerialName("readable_sgid") val readableSgid: String? = null, + @SerialName("readable_identifier") val readableIdentifier: String? = null, + val title: String? = null, + val type: String? = null, + @SerialName("bucket_name") val bucketName: String? = null, + val creator: Person? = null, + @SerialName("content_excerpt") val contentExcerpt: String? = null, + @SerialName("app_url") val appUrl: String? = null, + @SerialName("unread_url") val unreadUrl: String? = null, + @SerialName("bookmark_url") val bookmarkUrl: String? = null, + @SerialName("memory_url") val memoryUrl: String? = null, + @SerialName("bubble_up_url") val bubbleUpUrl: String? = null, + @SerialName("bubble_up_at") val bubbleUpAt: String? = null, + @SerialName("subscription_url") val subscriptionUrl: String? = null, + val subscribed: Boolean = false, + @SerialName("previewable_attachments") val previewableAttachments: List = emptyList(), + val participants: List = emptyList(), + val named: Boolean = false, + @SerialName("image_url") val imageUrl: String? = null +) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/PreviewableAttachment.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/PreviewableAttachment.kt new file mode 100644 index 000000000..d1ce9a793 --- /dev/null +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/PreviewableAttachment.kt @@ -0,0 +1,23 @@ +package com.basecamp.sdk.generated.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * PreviewableAttachment entity from the Basecamp API. + * + * @generated from OpenAPI spec — do not edit directly + */ +@Serializable +data class PreviewableAttachment( + val id: Long = 0L, + val url: String? = null, + @SerialName("app_url") val appUrl: String? = null, + @SerialName("content_type") val contentType: String? = null, + val filename: String? = null, + val filesize: Long = 0L, + val width: Int = 0, + val height: Int = 0 +) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt index 50595515f..afab0fe30 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt @@ -321,10 +321,19 @@ data class GetMyDueAssignmentsOptions( /** Options for GetMyNotifications. */ data class GetMyNotificationsOptions( - val page: Long? = null + val page: Long? = null, + val limitBubbleUps: Boolean? = null ) { } +/** Options for GetBubbleUps. */ +data class GetBubbleUpsOptions( + val page: Long? = null, + val maxItems: Int? = null +) { + fun toPaginationOptions(): PaginationOptions = PaginationOptions(maxItems = maxItems) +} + /** Request body for MarkAsRead. */ data class MarkAsReadBody( val readables: List diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/my-notifications.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/my-notifications.kt index 8e223f268..2a9a340fc 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/my-notifications.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/my-notifications.kt @@ -27,6 +27,7 @@ class MyNotificationsService(client: AccountClient) : BaseService(client) { ) val qs = buildQueryString( "page" to options?.page, + "limit_bubble_ups" to options?.limitBubbleUps, ) return request(info, { httpGet("/my/readings.json" + qs, operationName = info.operation) @@ -35,6 +36,29 @@ class MyNotificationsService(client: AccountClient) : BaseService(client) { } } + /** + * Get the current user's current and scheduled bubble-ups as a paginated + * @param options Optional query parameters and pagination control + */ + suspend fun bubbleUps(options: GetBubbleUpsOptions? = null): ListResult { + val info = OperationInfo( + service = "MyNotifications", + operation = "GetBubbleUps", + resourceType = "bubble_up", + isMutation = false, + projectId = null, + resourceId = null, + ) + val qs = buildQueryString( + "page" to options?.page, + ) + return requestPaginated(info, options?.toPaginationOptions(), { + httpGet("/my/readings/bubble_ups.json" + qs, operationName = info.operation) + }) { body -> + json.decodeFromString>(body) + } + } + /** * Mark specified items as read * @param body Request body diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/BubbleUpsDecodeTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/BubbleUpsDecodeTest.kt new file mode 100644 index 000000000..f0adbe163 --- /dev/null +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/BubbleUpsDecodeTest.kt @@ -0,0 +1,54 @@ +package com.basecamp.sdk + +import com.basecamp.sdk.generated.models.Notification +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class BubbleUpsDecodeTest { + + private val json = Json { ignoreUnknownKeys = true } + + private val fixtureJson = """ + [ + { + "id": 2, + "created_at": "2026-07-21T00:01:43.009Z", + "updated_at": "2026-07-21T00:01:43.031Z", + "section": "bubbles", + "unread_count": 0, + "read_at": "2026-07-21T00:01:43.031Z", + "title": "We won Leto!", + "type": "Message", + "bucket_name": "The Leto Laptop" + }, + { + "id": 3, + "created_at": "2026-07-21T00:02:00.000Z", + "updated_at": "2026-07-21T00:02:00.000Z", + "section": "bubbles", + "unread_count": 1, + "title": "Scheduled follow-up", + "type": "Todo", + "bubble_up_at": "2026-08-01T00:00:00Z" + } + ] + """.trimIndent() + + @Test + fun decodesBubbleUpsNotifications() { + val notifications = json.decodeFromString>(fixtureJson) + + assertEquals(2, notifications.size) + + // Notification 0: current bubble-up (no scheduled bubble_up_at). + assertEquals(2L, notifications[0].id) + assertEquals("We won Leto!", notifications[0].title) + assertEquals("Message", notifications[0].type) + assertNull(notifications[0].bubbleUpAt) + + // Notification 1: scheduled bubble-up carries bubble_up_at. + assertEquals("2026-08-01T00:00:00Z", notifications[1].bubbleUpAt) + } +} diff --git a/openapi.json b/openapi.json index 5e4e71847..23e43ad57 100644 --- a/openapi.json +++ b/openapi.json @@ -9297,6 +9297,15 @@ "description": "Page number for paginating through read items. Defaults to 1.", "format": "int32" } + }, + { + "name": "limit_bubble_ups", + "in": "query", + "description": "Set to true to cap `bubble_ups` at 2 current bubble-ups and omit the\n`scheduled_bubble_ups` key entirely. Defaults to false. Use the dedicated\nbubble-ups endpoint (GetBubbleUps) to page through all current and\nscheduled bubble-ups.", + "schema": { + "type": "boolean", + "description": "Set to true to cap `bubble_ups` at 2 current bubble-ups and omit the\n`scheduled_bubble_ups` key entirely. Defaults to false. Use the dedicated\nbubble-ups endpoint (GetBubbleUps) to page through all current and\nscheduled bubble-ups." + } } ], "responses": { @@ -9355,6 +9364,104 @@ } } }, + "/{accountId}/my/readings/bubble_ups.json": { + "get": { + "description": "Get the current user's current and scheduled bubble-ups as a paginated\nlist (50 per page). Current bubble-ups are returned first, ordered by most\nrecently bubbled up; scheduled bubble-ups follow, ordered by scheduled\nbubble-up time. Each item uses the same notification object shape as\nGetMyNotifications.", + "operationId": "GetBubbleUps", + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "page", + "in": "query", + "description": "Page number. Defaults to 1.", + "schema": { + "type": "integer", + "description": "Page number. Defaults to 1.", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "GetBubbleUps 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBubbleUpsResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "MyNotifications" + ], + "x-basecamp-pagination": { + "style": "link", + "totalCountHeader": "X-Total-Count", + "maxPageSize": 50 + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, "/{accountId}/my/unreads.json": { "put": { "description": "Mark specified items as read", @@ -24301,6 +24408,12 @@ "GetBoostResponseContent": { "$ref": "#/components/schemas/Boost" }, + "GetBubbleUpsResponseContent": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Notification" + } + }, "GetCampfireLineResponseContent": { "$ref": "#/components/schemas/CampfireLine" }, @@ -24405,6 +24518,16 @@ "$ref": "#/components/schemas/Notification" } }, + "bubble_ups_count": { + "type": "integer", + "description": "Total number of current bubble-ups, for notification UI counts\n(independent of the `limit_bubble_ups` cap on the `bubble_ups` array).", + "format": "int32" + }, + "scheduled_bubble_ups_count": { + "type": "integer", + "description": "Total number of scheduled bubble-ups, for notification UI counts\n(present even when `limit_bubble_ups` omits the `scheduled_bubble_ups`\narray).", + "format": "int32" + }, "memories": { "type": "array", "items": { @@ -24426,7 +24549,11 @@ }, "description": "Bubble Ups scheduled to resurface in the future (BC5 addition)." } - } + }, + "required": [ + "bubble_ups_count", + "scheduled_bubble_ups_count" + ] }, "GetMyPreferencesResponseContent": { "$ref": "#/components/schemas/Preferences" diff --git a/python/src/basecamp/generated/metadata.json b/python/src/basecamp/generated/metadata.json index acbfe7925..c6cf346a4 100644 --- a/python/src/basecamp/generated/metadata.json +++ b/python/src/basecamp/generated/metadata.json @@ -608,6 +608,17 @@ ] } }, + "GetBubbleUps": { + "retry": { + "backoff": "exponential", + "base_delay_ms": 1000, + "max": 3, + "retry_on": [ + 429, + 503 + ] + } + }, "GetCampfire": { "retry": { "backoff": "exponential", diff --git a/python/src/basecamp/generated/services/my_notifications.py b/python/src/basecamp/generated/services/my_notifications.py index de954467a..2ba8a1f55 100644 --- a/python/src/basecamp/generated/services/my_notifications.py +++ b/python/src/basecamp/generated/services/my_notifications.py @@ -11,11 +11,18 @@ class MyNotificationsService(BaseService): - def get_my_notifications(self, *, page: int | None = None) -> dict[str, Any]: + def get_my_notifications(self, *, page: int | None = None, limit_bubble_ups: bool | None = None) -> dict[str, Any]: return self._request( OperationInfo(service="mynotifications", operation="get_my_notifications", is_mutation=False), "GET", "/my/readings.json", + params=self._compact(page=page, limit_bubble_ups=limit_bubble_ups), + ) + + def get_bubble_ups(self, *, page: int | None = None) -> ListResult: + return self._request_paginated( + OperationInfo(service="mynotifications", operation="get_bubble_ups", is_mutation=False), + "/my/readings/bubble_ups.json", params=self._compact(page=page), ) @@ -30,11 +37,20 @@ def mark_as_read(self, *, readables: list[str]) -> None: class AsyncMyNotificationsService(AsyncBaseService): - async def get_my_notifications(self, *, page: int | None = None) -> dict[str, Any]: + async def get_my_notifications( + self, *, page: int | None = None, limit_bubble_ups: bool | None = None + ) -> dict[str, Any]: return await self._request( OperationInfo(service="mynotifications", operation="get_my_notifications", is_mutation=False), "GET", "/my/readings.json", + params=self._compact(page=page, limit_bubble_ups=limit_bubble_ups), + ) + + async def get_bubble_ups(self, *, page: int | None = None) -> ListResult: + return await self._request_paginated( + OperationInfo(service="mynotifications", operation="get_bubble_ups", is_mutation=False), + "/my/readings/bubble_ups.json", params=self._compact(page=page), ) diff --git a/python/src/basecamp/generated/types.py b/python/src/basecamp/generated/types.py index adbb4bdad..83f3aecff 100644 --- a/python/src/basecamp/generated/types.py +++ b/python/src/basecamp/generated/types.py @@ -730,9 +730,11 @@ class GetMyAssignmentsResponseContent(TypedDict): class GetMyNotificationsResponseContent(TypedDict): bubble_ups: NotRequired[list[Notification]] + bubble_ups_count: int memories: NotRequired[list[Notification]] reads: NotRequired[list[Notification]] scheduled_bubble_ups: NotRequired[list[Notification]] + scheduled_bubble_ups_count: int unreads: NotRequired[list[Notification]] diff --git a/python/tests/services/test_my_notifications.py b/python/tests/services/test_my_notifications.py new file mode 100644 index 000000000..89d65d9e7 --- /dev/null +++ b/python/tests/services/test_my_notifications.py @@ -0,0 +1,54 @@ +"""Tests for generated my_notifications service routes.""" + +from __future__ import annotations + +import httpx +import respx + +from basecamp import Client + + +def _bubble_ups() -> list[dict]: + return [ + { + "id": 2, + "created_at": "2026-07-21T00:01:43.009Z", + "updated_at": "2026-07-21T00:01:43.031Z", + "section": "bubbles", + "unread_count": 0, + "read_at": "2026-07-21T00:01:43.031Z", + "title": "We won Leto!", + "type": "Message", + "bucket_name": "The Leto Laptop", + }, + { + "id": 3, + "created_at": "2026-07-21T00:02:00.000Z", + "updated_at": "2026-07-21T00:02:00.000Z", + "section": "bubbles", + "unread_count": 1, + "title": "Scheduled follow-up", + "type": "Todo", + "bubble_up_at": "2026-08-01T00:00:00Z", + }, + ] + + +class TestSyncMyNotifications: + @respx.mock + def test_get_bubble_ups_returns_parsed_readings(self): + route = respx.get("https://3.basecampapi.com/12345/my/readings/bubble_ups.json").mock( + return_value=httpx.Response(200, json=_bubble_ups()) + ) + + from basecamp.generated.services.my_notifications import MyNotificationsService + + account = Client(access_token="test-token").for_account("12345") + result = MyNotificationsService(account).get_bubble_ups() + + assert route.called + assert len(result) == 2 + assert result[0]["id"] == 2 + assert result[0]["title"] == "We won Leto!" + assert result[0]["type"] == "Message" + assert result[1]["bubble_up_at"] == "2026-08-01T00:00:00Z" diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index 66dc4e7f8..99069d09e 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-25T07:00:12Z", + "generated": "2026-07-25T07:14:10Z", "operations": { "GetAccount": { "retry": { @@ -1193,6 +1193,22 @@ ] } }, + "GetBubbleUps": { + "retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + }, + "pagination": { + "style": "link", + "totalCountHeader": "X-Total-Count", + "maxPageSize": 50 + } + }, "MarkAsRead": { "retry": { "maxAttempts": 2, diff --git a/ruby/lib/basecamp/generated/services/my_notifications_service.rb b/ruby/lib/basecamp/generated/services/my_notifications_service.rb index 7f43ca14c..e6e70da44 100644 --- a/ruby/lib/basecamp/generated/services/my_notifications_service.rb +++ b/ruby/lib/basecamp/generated/services/my_notifications_service.rb @@ -9,10 +9,24 @@ class MyNotificationsService < BaseService # Get the current user's notification inbox (the "Hey!" menu). # @param page [Integer, nil] Page number for paginating through read items. Defaults to 1. + # @param limit_bubble_ups [Boolean, nil] Set to true to cap `bubble_ups` at 2 current bubble-ups and omit the + # `scheduled_bubble_ups` key entirely. Defaults to false. Use the dedicated + # bubble-ups endpoint (GetBubbleUps) to page through all current and + # scheduled bubble-ups. # @return [Hash] response data - def get_my_notifications(page: nil) + def get_my_notifications(page: nil, limit_bubble_ups: nil) with_operation(service: "mynotifications", operation: "get_my_notifications", is_mutation: false) do - http_get("/my/readings.json", params: compact_query_params(page: page)).json + http_get("/my/readings.json", params: compact_query_params(page: page, limit_bubble_ups: limit_bubble_ups)).json + end + end + + # Get the current user's current and scheduled bubble-ups as a paginated + # @param page [Integer, nil] Page number. Defaults to 1. + # @return [Enumerator] paginated results + def get_bubble_ups(page: nil) + wrap_paginated(service: "mynotifications", operation: "get_bubble_ups", is_mutation: false) do + params = compact_query_params(page: page) + paginate("/my/readings/bubble_ups.json", params: params) end end diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index 882fde9e9..56c26cbec 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-25T07:00:13Z +# Generated: 2026-07-25T07:14:10Z require "json" require "time" diff --git a/ruby/test/basecamp/services/my_notifications_service_test.rb b/ruby/test/basecamp/services/my_notifications_service_test.rb new file mode 100644 index 000000000..0a879e553 --- /dev/null +++ b/ruby/test/basecamp/services/my_notifications_service_test.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# Tests for the MyNotificationsService (generated from OpenAPI spec) + +require "test_helper" + +class MyNotificationsServiceTest < Minitest::Test + include TestHelper + + def setup + @account = create_account_client(account_id: "12345") + end + + def test_get_bubble_ups + bubble_ups = [ + { + "id" => 2, + "created_at" => "2026-07-21T00:01:43.009Z", + "updated_at" => "2026-07-21T00:01:43.031Z", + "section" => "bubbles", + "unread_count" => 0, + "read_at" => "2026-07-21T00:01:43.031Z", + "title" => "We won Leto!", + "type" => "Message", + "bucket_name" => "The Leto Laptop" + }, + { + "id" => 3, + "created_at" => "2026-07-21T00:02:00.000Z", + "updated_at" => "2026-07-21T00:02:00.000Z", + "section" => "bubbles", + "unread_count" => 1, + "title" => "Scheduled follow-up", + "type" => "Todo", + "bubble_up_at" => "2026-08-01T00:00:00Z" + } + ] + + stub_get("/12345/my/readings/bubble_ups.json", response_body: bubble_ups) + + result = @account.my_notifications.get_bubble_ups.to_a + + assert_kind_of Array, result + assert_equal 2, result.length + assert_equal 2, result[0]["id"] + assert_equal "We won Leto!", result[0]["title"] + assert_equal "Message", result[0]["type"] + assert_equal "2026-08-01T00:00:00Z", result[1]["bubble_up_at"] + end +end diff --git a/spec/api-gaps/README.md b/spec/api-gaps/README.md index 7e242f506..2f04d986b 100644 --- a/spec/api-gaps/README.md +++ b/spec/api-gaps/README.md @@ -59,6 +59,7 @@ making the absorption journey publicly auditable. | [external-links-doors](external-links-doors.md) | addressed-in-bc3-pr-12375 | post-train | low | | [dock-tool-visible-to-clients](dock-tool-visible-to-clients.md) | absorbed-in-sdk | post-train | low | | [card-table-wormholes](card-table-wormholes.md) | absorbed-in-sdk | post-train | medium | +| [bubble-ups-surface](bubble-ups-surface.md) | absorbed-in-sdk | launch | high | > Statuses reflect how BC3's **BC5 API train** actually shipped (8 PRs merged > to `master`, 2026-07-18..21); BC3 #10947 closed unmerged, superseded by the diff --git a/spec/api-gaps/bubble-ups-surface.md b/spec/api-gaps/bubble-ups-surface.md new file mode 100644 index 000000000..a176f4a60 --- /dev/null +++ b/spec/api-gaps/bubble-ups-surface.md @@ -0,0 +1,102 @@ +--- +gap: bubble-ups-surface +status: absorbed-in-sdk +detected: 2026-07-24 +sdk_demand: high +bc3_pr: 11628 +smithy_refs: + - "GetMyNotificationsOutput.bubble_ups_count member (spec/basecamp.smithy:8745)" + - "GetMyNotificationsOutput.scheduled_bubble_ups_count member (spec/basecamp.smithy:8751)" + - "GetMyNotificationsInput.limit_bubble_ups member (spec/basecamp.smithy:8735)" + - "GetBubbleUps operation (spec/basecamp.smithy:8803)" +bc3_refs: + introduced_in: master + routes: + - "GET /:account_id/my/readings.json (existing GetMyNotifications — bubble_ups_count/scheduled_bubble_ups_count/limit_bubble_ups additive)" + - "GET /:account_id/my/readings/bubble_ups.json (new — paginated bubble-ups list)" + controllers: + - app/controllers/my/readings_controller.rb + - app/controllers/my/readings/bubble_ups_controller.rb + related_existing_api: + - GetMyNotifications +--- + +# Bubble Ups successor surface (counts, limit param, dedicated list) + +## What's missing + +The bubble-up successor surface on `doc/api/sections/my_notifications.md` +(codified by BC3 **#11628**) that +[[memories-emptied-regression]] explicitly **defers to a separate additive +PR**. That entry settles the subtractive `memories: []` delta and — by its own +scope statement — leaves the adjacent bubble-up additions to this entry: + +- **`bubble_ups_count` / `scheduled_bubble_ups_count`** — top-level integer + counts on `GET /my/readings.json`, for notification-UI badges. Present + independent of the `limit_bubble_ups` cap (and `scheduled_bubble_ups_count` + is returned even when the `scheduled_bubble_ups` array is omitted). +- **`limit_bubble_ups`** — optional boolean query param on `GET + /my/readings.json`: set `true` to cap `bubble_ups` at 2 current items and + **omit the `scheduled_bubble_ups` key entirely**. Defaults to `false`. +- **`GET /my/readings/bubble_ups.json`** — a **new** dedicated operation + returning the current user's current and scheduled bubble-ups as a + **paginated bare array** (50 per page, Link-header pagination). Current + bubble-ups are returned first (ordered by most recently bubbled up), then + scheduled bubble-ups (ordered by scheduled bubble-up time). Each item uses + the same notification object shape as `GET /my/readings.json`. This is **not** + "the ≤50 most-recently-read `bubble_ups` field" — it is the full, + page-through-able current+scheduled list. + +The grouped `bubble_ups` / `scheduled_bubble_ups` arrays and their notification +item fields (`bubble_up_url`, `bubble_up_at`, …) were **already modeled** on +`GetMyNotificationsOutput` / `Notification`; this entry does not re-add them. + +## Why it matters + +Bubble Up is the BC5 successor to the BC4 "save forever" `memories` collection. +Without the counts, integrations can't render notification badges without +over-fetching; without the dedicated endpoint, they can only see the capped +`bubble_ups` field on the readings payload and cannot page through the full +current+scheduled set; without `limit_bubble_ups`, a lightweight badge fetch +still pays for the full scheduled list. + +## Suggested API shape + +- Additive required `bubble_ups_count` / `scheduled_bubble_ups_count` integers + on the existing `GetMyNotificationsOutput`. +- Additive optional `limit_bubble_ups: Boolean` `@httpQuery` on + `GetMyNotificationsInput`. +- New `GetBubbleUps` operation (`GET /my/readings/bubble_ups.json`) with an + optional `page` query param and `@basecampPagination(style: "link", …)`, + returning a bare `NotificationList`. + +## Implementation notes for BC3 + +Shipped — nothing pending. `my/readings_controller.rb` renders the counts and +honors `limit_bubble_ups`; `my/readings/bubble_ups_controller.rb` serves the +paginated dedicated list. `doc/api/sections/my_notifications.md` on `master` +is the contract of record (codified by BC3 #11628). + +## SDK absorption plan when this lands + +Absorbed (basecamp-sdk PR-3 of the post-#401 follow-up program). + +- Added required `bubble_ups_count` / `scheduled_bubble_ups_count` to + `GetMyNotificationsOutput` and optional `limit_bubble_ups` to + `GetMyNotificationsInput`. +- Added the new `GetBubbleUps` operation (bare-array, Link-paginated, `page` + query param) with tag/service mapping and the Go wrapper + (`MyNotificationsService.BubbleUps`, following the Link header across all + pages by default; a positive page returns only that page). +- The Go `Get` wrapper gained a non-breaking `WithLimitBubbleUps()` option. +- Tests: a Go **multi-page** pagination test for `BubbleUps` (Link-following + + `X-Total-Count` metadata, current-then-scheduled ordering), a single-page + test, and a test proving `limit_bubble_ups=true` sends the param and decodes + a response that **omits `scheduled_bubble_ups`** while keeping the counts. +- Registry: this is a **new** entry — [[memories-emptied-regression]] keeps its + status and `pairwise`-retirement explanation intact; its scope was the + subtractive empty-`memories` delta, which explicitly deferred these + successors here. +- Canary: a `GetBubbleUps` entry validates statically in + `live-my-surface.json` (the live canary is dormant pending a safe token + mechanism). diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy index fa007fe5b..b7b74e1c8 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -291,6 +291,7 @@ service Basecamp { // Batch 16 - My Notifications GetMyNotifications, + GetBubbleUps, MarkAsRead, // Batch 17 - Out of Office @@ -8725,12 +8726,30 @@ structure GetMyNotificationsInput { /// Page number for paginating through read items. Defaults to 1. @httpQuery("page") page: Integer + + /// Set to true to cap `bubble_ups` at 2 current bubble-ups and omit the + /// `scheduled_bubble_ups` key entirely. Defaults to false. Use the dedicated + /// bubble-ups endpoint (GetBubbleUps) to page through all current and + /// scheduled bubble-ups. + @httpQuery("limit_bubble_ups") + limit_bubble_ups: Boolean } structure GetMyNotificationsOutput { unreads: NotificationList reads: NotificationList + /// Total number of current bubble-ups, for notification UI counts + /// (independent of the `limit_bubble_ups` cap on the `bubble_ups` array). + @required + bubble_ups_count: Integer + + /// Total number of scheduled bubble-ups, for notification UI counts + /// (present even when `limit_bubble_ups` omits the `scheduled_bubble_ups` + /// array). + @required + scheduled_bubble_ups_count: Integer + /// Legacy "save forever" collection. Permanently `[]` on BC5 by documented /// contract (`doc/api/sections/my_notifications.md`, codified by BC3 #11628): /// an always-empty placeholder superseded by `bubble_ups`. BC4 (the `four` @@ -8772,6 +8791,35 @@ structure MarkAsReadInput { structure MarkAsReadOutput {} +/// Get the current user's current and scheduled bubble-ups as a paginated +/// list (50 per page). Current bubble-ups are returned first, ordered by most +/// recently bubbled up; scheduled bubble-ups follow, ordered by scheduled +/// bubble-up time. Each item uses the same notification object shape as +/// GetMyNotifications. +@readonly +@basecampRetry(maxAttempts: 3, baseDelayMs: 1000, backoff: "exponential", retryOn: [429, 503]) +@basecampPagination(style: "link", totalCountHeader: "X-Total-Count", maxPageSize: 50) +@http(method: "GET", uri: "/{accountId}/my/readings/bubble_ups.json") +operation GetBubbleUps { + input: GetBubbleUpsInput + output: GetBubbleUpsOutput + errors: [UnauthorizedError, ForbiddenError, RateLimitError, InternalServerError] +} + +structure GetBubbleUpsInput { + @required + @httpLabel + accountId: AccountId + + /// Page number. Defaults to 1. + @httpQuery("page") + page: Integer +} + +structure GetBubbleUpsOutput { + bubble_ups: NotificationList +} + // ===== Notification Shapes ===== list NotificationList { diff --git a/spec/overlays/tags.smithy b/spec/overlays/tags.smithy index 95b01a9f7..958079c46 100644 --- a/spec/overlays/tags.smithy +++ b/spec/overlays/tags.smithy @@ -221,6 +221,7 @@ apply GetMyDueAssignments @tags(["MyAssignments"]) // My Notifications apply GetMyNotifications @tags(["MyNotifications"]) +apply GetBubbleUps @tags(["MyNotifications"]) apply MarkAsRead @tags(["MyNotifications"]) // Out of Office diff --git a/swift/Sources/Basecamp/Generated/Metadata.swift b/swift/Sources/Basecamp/Generated/Metadata.swift index 1eacefbfb..82a49ffa5 100644 --- a/swift/Sources/Basecamp/Generated/Metadata.swift +++ b/swift/Sources/Basecamp/Generated/Metadata.swift @@ -57,6 +57,7 @@ enum Metadata { "GetAnswersByPerson": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "GetAssignedTodos": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "GetBoost": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), + "GetBubbleUps": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "GetCampfire": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "GetCampfireLine": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), "GetCard": RetryConfig(maxAttempts: 3, baseDelayMs: 1000, backoff: .exponential, retryOn: [429, 503]), diff --git a/swift/Sources/Basecamp/Generated/Models/GetMyNotificationsResponseContent.swift b/swift/Sources/Basecamp/Generated/Models/GetMyNotificationsResponseContent.swift index 882493d5b..0ed5fc7b9 100644 --- a/swift/Sources/Basecamp/Generated/Models/GetMyNotificationsResponseContent.swift +++ b/swift/Sources/Basecamp/Generated/Models/GetMyNotificationsResponseContent.swift @@ -2,9 +2,29 @@ import Foundation public struct GetMyNotificationsResponseContent: Codable, Sendable { + public let bubbleUpsCount: Int32 + public let scheduledBubbleUpsCount: Int32 public var bubbleUps: [Notification]? public var memories: [Notification]? public var reads: [Notification]? public var scheduledBubbleUps: [Notification]? public var unreads: [Notification]? + + public init( + bubbleUpsCount: Int32, + scheduledBubbleUpsCount: Int32, + bubbleUps: [Notification]? = nil, + memories: [Notification]? = nil, + reads: [Notification]? = nil, + scheduledBubbleUps: [Notification]? = nil, + unreads: [Notification]? = nil + ) { + self.bubbleUpsCount = bubbleUpsCount + self.scheduledBubbleUpsCount = scheduledBubbleUpsCount + self.bubbleUps = bubbleUps + self.memories = memories + self.reads = reads + self.scheduledBubbleUps = scheduledBubbleUps + self.unreads = unreads + } } diff --git a/swift/Sources/Basecamp/Generated/Services/MyNotificationsService.swift b/swift/Sources/Basecamp/Generated/Services/MyNotificationsService.swift index 144d1fcee..15a452a07 100644 --- a/swift/Sources/Basecamp/Generated/Services/MyNotificationsService.swift +++ b/swift/Sources/Basecamp/Generated/Services/MyNotificationsService.swift @@ -1,21 +1,50 @@ // @generated from OpenAPI spec — do not edit directly import Foundation +public struct BubbleUpsMyNotificationOptions: Sendable { + public var page: Int? + public var maxItems: Int? + + public init(page: Int? = nil, maxItems: Int? = nil) { + self.page = page + self.maxItems = maxItems + } +} + public struct MyNotificationsMyNotificationOptions: Sendable { public var page: Int? + public var limitBubbleUps: Bool? - public init(page: Int? = nil) { + public init(page: Int? = nil, limitBubbleUps: Bool? = nil) { self.page = page + self.limitBubbleUps = limitBubbleUps } } public final class MyNotificationsService: BaseService, @unchecked Sendable { + public func bubbleUps(options: BubbleUpsMyNotificationOptions? = nil) async throws -> ListResult { + var queryItems: [URLQueryItem] = [] + if let page = options?.page { + queryItems.append(URLQueryItem(name: "page", value: String(page))) + } + return try await requestPaginated( + OperationInfo(service: "MyNotifications", operation: "GetBubbleUps", resourceType: "bubble_up", isMutation: false), + path: "/my/readings/bubble_ups.json", + queryItems: queryItems.isEmpty ? nil : queryItems, + paginationOpts: options.flatMap { PaginationOptions(maxItems: $0.maxItems) }, + retryConfig: Metadata.retryConfig(for: "GetBubbleUps") + ) + } + public func myNotifications(options: MyNotificationsMyNotificationOptions? = nil) async throws -> GetMyNotificationsResponseContent { var queryItems: [URLQueryItem] = [] if let page = options?.page { queryItems.append(URLQueryItem(name: "page", value: String(page))) } + if let limitBubbleUps = options?.limitBubbleUps { + queryItems.append(URLQueryItem(name: "limit_bubble_ups", value: String(limitBubbleUps))) + } return try await request( OperationInfo(service: "MyNotifications", operation: "GetMyNotifications", resourceType: "my_notification", isMutation: false), method: "GET", diff --git a/swift/Tests/BasecampTests/GeneratedServiceTests.swift b/swift/Tests/BasecampTests/GeneratedServiceTests.swift index fc8c33dd2..205fb94d5 100644 --- a/swift/Tests/BasecampTests/GeneratedServiceTests.swift +++ b/swift/Tests/BasecampTests/GeneratedServiceTests.swift @@ -889,4 +889,50 @@ final class GeneratedServiceTests: XCTestCase { XCTAssertEqual(blob.previewable, true) XCTAssertNil(blob.width) } + + // MARK: - GetBubbleUps (paginated bare-array decode) + + // Bubble-ups return a bare array of Notification. Proves the full decode path + // (.convertFromSnakeCase) maps bubble_up_at → bubbleUpAt and carries type/title. + func testBubbleUpsDecodesNotifications() async throws { + let fixture = """ + [ + { + "id": 2, + "created_at": "2026-07-21T00:01:43.009Z", + "updated_at": "2026-07-21T00:01:43.031Z", + "section": "bubbles", + "unread_count": 0, + "read_at": "2026-07-21T00:01:43.031Z", + "title": "We won Leto!", + "type": "Message", + "bucket_name": "The Leto Laptop" + }, + { + "id": 3, + "created_at": "2026-07-21T00:02:00.000Z", + "updated_at": "2026-07-21T00:02:00.000Z", + "section": "bubbles", + "unread_count": 1, + "title": "Scheduled follow-up", + "type": "Todo", + "bubble_up_at": "2026-08-01T00:00:00Z" + } + ] + """ + let transport = MockTransport(statusCode: 200, data: Data(fixture.utf8), + headers: ["X-Total-Count": "2"]) + let account = makeTestAccountClient(transport: transport) + + let bubbleUps = try await account.myNotifications.bubbleUps() + + XCTAssertEqual(bubbleUps.count, 2) + XCTAssertEqual(bubbleUps[0].id, 2) + XCTAssertEqual(bubbleUps[0].title, "We won Leto!") + XCTAssertEqual(bubbleUps[0].type, "Message") + XCTAssertEqual(bubbleUps[1].bubbleUpAt, "2026-08-01T00:00:00Z") + + let sentURL = transport.lastRequest!.request.url!.absoluteString + XCTAssertTrue(sentURL.hasSuffix("/my/readings/bubble_ups.json"), "Got \(sentURL)") + } } diff --git a/typescript/scripts/generate-services.ts b/typescript/scripts/generate-services.ts index 76ce5a8e0..559eb266d 100644 --- a/typescript/scripts/generate-services.ts +++ b/typescript/scripts/generate-services.ts @@ -458,6 +458,7 @@ const TYPE_ALIASES: Record ClientCorrespondence: ["ClientCorrespondence", "entity"], ClientReply: ["ClientReply", "entity"], TimelineEvent: ["TimelineEvent", "entity"], + Notification: ["Notification", "entity"], TimesheetEntry: ["TimesheetEntry", "entity"], }; diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index 76c12dfbb..3f0637e55 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-25T07:00:12.083Z", + "generated": "2026-07-25T07:14:09.617Z", "operations": { "GetAccount": { "retry": { @@ -1229,6 +1229,22 @@ const metadata: MetadataOutput = { ] } }, + "GetBubbleUps": { + "retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + }, + "pagination": { + "style": "link", + "totalCountHeader": "X-Total-Count", + "maxPageSize": 50 + } + }, "MarkAsRead": { "retry": { "maxAttempts": 2, diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index d22828dbc..16459ccb7 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -8257,6 +8257,15 @@ "description": "Page number for paginating through read items. Defaults to 1.", "format": "int32" } + }, + { + "name": "limit_bubble_ups", + "in": "query", + "description": "Set to true to cap `bubble_ups` at 2 current bubble-ups and omit the\n`scheduled_bubble_ups` key entirely. Defaults to false. Use the dedicated\nbubble-ups endpoint (GetBubbleUps) to page through all current and\nscheduled bubble-ups.", + "schema": { + "type": "boolean", + "description": "Set to true to cap `bubble_ups` at 2 current bubble-ups and omit the\n`scheduled_bubble_ups` key entirely. Defaults to false. Use the dedicated\nbubble-ups endpoint (GetBubbleUps) to page through all current and\nscheduled bubble-ups." + } } ], "responses": { @@ -8315,6 +8324,93 @@ } } }, + "/my/readings/bubble_ups.json": { + "get": { + "description": "Get the current user's current and scheduled bubble-ups as a paginated\nlist (50 per page). Current bubble-ups are returned first, ordered by most\nrecently bubbled up; scheduled bubble-ups follow, ordered by scheduled\nbubble-up time. Each item uses the same notification object shape as\nGetMyNotifications.", + "operationId": "GetBubbleUps", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "Page number. Defaults to 1.", + "schema": { + "type": "integer", + "description": "Page number. Defaults to 1.", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "GetBubbleUps 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBubbleUpsResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "MyNotifications" + ], + "x-basecamp-pagination": { + "style": "link", + "totalCountHeader": "X-Total-Count", + "maxPageSize": 50 + }, + "x-basecamp-retry": { + "maxAttempts": 3, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } + } + }, "/my/unreads.json": { "put": { "description": "Mark specified items as read", @@ -21923,6 +22019,12 @@ "GetBoostResponseContent": { "$ref": "#/components/schemas/Boost" }, + "GetBubbleUpsResponseContent": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Notification" + } + }, "GetCampfireLineResponseContent": { "$ref": "#/components/schemas/CampfireLine" }, @@ -22027,6 +22129,16 @@ "$ref": "#/components/schemas/Notification" } }, + "bubble_ups_count": { + "type": "integer", + "description": "Total number of current bubble-ups, for notification UI counts\n(independent of the `limit_bubble_ups` cap on the `bubble_ups` array).", + "format": "int32" + }, + "scheduled_bubble_ups_count": { + "type": "integer", + "description": "Total number of scheduled bubble-ups, for notification UI counts\n(present even when `limit_bubble_ups` omits the `scheduled_bubble_ups`\narray).", + "format": "int32" + }, "memories": { "type": "array", "items": { @@ -22048,7 +22160,11 @@ }, "description": "Bubble Ups scheduled to resurface in the future (BC5 addition)." } - } + }, + "required": [ + "bubble_ups_count", + "scheduled_bubble_ups_count" + ] }, "GetMyPreferencesResponseContent": { "$ref": "#/components/schemas/Preferences" diff --git a/typescript/src/generated/path-mapping.ts b/typescript/src/generated/path-mapping.ts index 1876140da..723b74331 100644 --- a/typescript/src/generated/path-mapping.ts +++ b/typescript/src/generated/path-mapping.ts @@ -195,6 +195,7 @@ export const PATH_TO_OPERATION: Record = { "PUT:/{accountId}/my/profile.json": "UpdateMyProfile", "GET:/{accountId}/my/question_reminders.json": "GetQuestionReminders", "GET:/{accountId}/my/readings.json": "GetMyNotifications", + "GET:/{accountId}/my/readings/bubble_ups.json": "GetBubbleUps", "PUT:/{accountId}/my/unreads.json": "MarkAsRead", // Projects diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index 82d84e400..f85c30618 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -1168,6 +1168,29 @@ export interface paths { patch?: never; trace?: never; }; + "/my/readings/bubble_ups.json": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * @description Get the current user's current and scheduled bubble-ups as a paginated + * list (50 per page). Current bubble-ups are returned first, ordered by most + * recently bubbled up; scheduled bubble-ups follow, ordered by scheduled + * bubble-up time. Each item uses the same notification object shape as + * GetMyNotifications. + */ + get: operations["GetBubbleUps"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/my/unreads.json": { parameters: { query?: never; @@ -3380,6 +3403,7 @@ export interface components { todos?: components["schemas"]["Todo"][]; }; GetBoostResponseContent: components["schemas"]["Boost"]; + GetBubbleUpsResponseContent: components["schemas"]["Notification"][]; GetCampfireLineResponseContent: components["schemas"]["CampfireLine"]; GetCampfireResponseContent: components["schemas"]["Campfire"]; GetCardColumnResponseContent: components["schemas"]["CardColumn"]; @@ -3409,6 +3433,19 @@ export interface components { GetMyNotificationsResponseContent: { unreads?: components["schemas"]["Notification"][]; reads?: components["schemas"]["Notification"][]; + /** + * Format: int32 + * @description Total number of current bubble-ups, for notification UI counts + * (independent of the `limit_bubble_ups` cap on the `bubble_ups` array). + */ + bubble_ups_count: number; + /** + * Format: int32 + * @description Total number of scheduled bubble-ups, for notification UI counts + * (present even when `limit_bubble_ups` omits the `scheduled_bubble_ups` + * array). + */ + scheduled_bubble_ups_count: number; /** * @description Legacy "save forever" collection. Permanently `[]` on BC5 by documented * contract (`doc/api/sections/my_notifications.md`, codified by BC3 #11628): @@ -10792,6 +10829,13 @@ export interface operations { query?: { /** @description Page number for paginating through read items. Defaults to 1. */ page?: number; + /** + * @description Set to true to cap `bubble_ups` at 2 current bubble-ups and omit the + * `scheduled_bubble_ups` key entirely. Defaults to false. Use the dedicated + * bubble-ups endpoint (GetBubbleUps) to page through all current and + * scheduled bubble-ups. + */ + limit_bubble_ups?: boolean; }; header?: never; path?: never; @@ -10837,6 +10881,65 @@ export interface operations { }; }; }; + GetBubbleUps: { + parameters: { + query?: { + /** @description Page number. Defaults to 1. */ + page?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description GetBubbleUps 200 response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetBubbleUpsResponseContent"]; + }; + }; + /** @description UnauthorizedError 401 response */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnauthorizedErrorResponseContent"]; + }; + }; + /** @description ForbiddenError 403 response */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ForbiddenErrorResponseContent"]; + }; + }; + /** @description RateLimitError 429 response */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RateLimitErrorResponseContent"]; + }; + }; + /** @description InternalServerError 500 response */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InternalServerErrorResponseContent"]; + }; + }; + }; + }; MarkAsRead: { parameters: { query?: never; diff --git a/typescript/src/generated/services/my-notifications.ts b/typescript/src/generated/services/my-notifications.ts index d823fb825..1041fdae7 100644 --- a/typescript/src/generated/services/my-notifications.ts +++ b/typescript/src/generated/services/my-notifications.ts @@ -6,12 +6,16 @@ import { BaseService } from "../../services/base.js"; import type { components } from "../schema.js"; +import { ListResult } from "../../pagination.js"; +import type { PaginationOptions } from "../../pagination.js"; import { Errors } from "../../errors.js"; // ============================================================================= // Types // ============================================================================= +/** Notification entity from the Basecamp API. */ +export type Notification = components["schemas"]["Notification"]; /** * Options for myNotifications. @@ -19,6 +23,19 @@ import { Errors } from "../../errors.js"; export interface MyNotificationsMyNotificationOptions { /** Page number for paginating through read items. Defaults to 1. */ page?: number; + /** Set to true to cap `bubble_ups` at 2 current bubble-ups and omit the +`scheduled_bubble_ups` key entirely. Defaults to false. Use the dedicated +bubble-ups endpoint (GetBubbleUps) to page through all current and +scheduled bubble-ups. */ + limitBubbleUps?: boolean; +} + +/** + * Options for bubbleUps. + */ +export interface BubbleUpsMyNotificationOptions extends PaginationOptions { + /** Page number. Defaults to 1. */ + page?: number; } /** @@ -60,13 +77,41 @@ export class MyNotificationsService extends BaseService { () => this.client.GET("/my/readings.json", { params: { - query: { page: options?.page }, + query: { page: options?.page, "limit_bubble_ups": options?.limitBubbleUps }, }, }) ); return response; } + /** + * Get the current user's current and scheduled bubble-ups as a paginated + * @param options - Optional query parameters + * @returns All Notification across all pages, with .meta.totalCount + * + * @example + * ```ts + * const result = await client.myNotifications.bubbleUps(); + * ``` + */ + async bubbleUps(options?: BubbleUpsMyNotificationOptions): Promise> { + return this.requestPaginated( + { + service: "MyNotifications", + operation: "GetBubbleUps", + resourceType: "bubble_up", + isMutation: false, + }, + () => + this.client.GET("/my/readings/bubble_ups.json", { + params: { + query: { page: options?.page }, + }, + }) + , options + ); + } + /** * Mark specified items as read * @param req - Resource request parameters diff --git a/typescript/tests/services/my-notifications.test.ts b/typescript/tests/services/my-notifications.test.ts index 54603fce1..8fc3e3f9d 100644 --- a/typescript/tests/services/my-notifications.test.ts +++ b/typescript/tests/services/my-notifications.test.ts @@ -150,4 +150,44 @@ describe("MyNotificationsService", () => { expect(creatorObj.system_label).toBe("9223372036854775808"); }); }); + + describe("bubbleUps", () => { + it("should return current and scheduled bubble-ups", async () => { + server.use( + http.get(`${BASE_URL}/my/readings/bubble_ups.json`, () => { + return HttpResponse.json([ + { + id: 2, + created_at: "2026-07-21T00:01:43.009Z", + updated_at: "2026-07-21T00:01:43.031Z", + section: "bubbles", + unread_count: 0, + read_at: "2026-07-21T00:01:43.031Z", + title: "We won Leto!", + type: "Message", + bucket_name: "The Leto Laptop", + }, + { + id: 3, + created_at: "2026-07-21T00:02:00.000Z", + updated_at: "2026-07-21T00:02:00.000Z", + section: "bubbles", + unread_count: 1, + title: "Scheduled follow-up", + type: "Todo", + bubble_up_at: "2026-08-01T00:00:00Z", + }, + ]); + }) + ); + + const result = await client.myNotifications.bubbleUps(); + + expect(result).toHaveLength(2); + expect(result[0]!.id).toBe(2); + expect(result[0]!.title).toBe("We won Leto!"); + expect((result[0] as Record).type).toBe("Message"); + expect((result[1] as Record).bubble_up_at).toBe("2026-08-01T00:00:00Z"); + }); + }); }); From d98de9c2cf3f7b77410b79a95061879a2255db9c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 25 Jul 2026 13:40:00 -0700 Subject: [PATCH 2/5] Add Bubble Ups error-path tests across TS/Ruby/Python Per the SDK Change Completeness Bar (AGENTS.md), every new operation needs a happy-path AND an error case in each validating SDK. GetBubbleUps had success tests only; add canonical 4xx propagation (404 -> BasecampError/NotFoundError) in TypeScript, Ruby, and Python. --- python/tests/services/test_my_notifications.py | 14 ++++++++++++++ .../services/my_notifications_service_test.rb | 8 ++++++++ typescript/tests/services/my-notifications.test.ts | 11 +++++++++++ 3 files changed, 33 insertions(+) diff --git a/python/tests/services/test_my_notifications.py b/python/tests/services/test_my_notifications.py index 89d65d9e7..95dd24a09 100644 --- a/python/tests/services/test_my_notifications.py +++ b/python/tests/services/test_my_notifications.py @@ -3,6 +3,7 @@ from __future__ import annotations import httpx +import pytest import respx from basecamp import Client @@ -52,3 +53,16 @@ def test_get_bubble_ups_returns_parsed_readings(self): assert result[0]["title"] == "We won Leto!" assert result[0]["type"] == "Message" assert result[1]["bubble_up_at"] == "2026-08-01T00:00:00Z" + + @respx.mock + def test_get_bubble_ups_propagates_not_found(self): + respx.get("https://3.basecampapi.com/12345/my/readings/bubble_ups.json").mock( + return_value=httpx.Response(404, json={"error": "Not found"}) + ) + + from basecamp.errors import NotFoundError + from basecamp.generated.services.my_notifications import MyNotificationsService + + account = Client(access_token="test-token").for_account("12345") + with pytest.raises(NotFoundError): + MyNotificationsService(account).get_bubble_ups() diff --git a/ruby/test/basecamp/services/my_notifications_service_test.rb b/ruby/test/basecamp/services/my_notifications_service_test.rb index 0a879e553..ec4210707 100644 --- a/ruby/test/basecamp/services/my_notifications_service_test.rb +++ b/ruby/test/basecamp/services/my_notifications_service_test.rb @@ -47,4 +47,12 @@ def test_get_bubble_ups assert_equal "Message", result[0]["type"] assert_equal "2026-08-01T00:00:00Z", result[1]["bubble_up_at"] end + + def test_get_bubble_ups_propagates_not_found + stub_get("/12345/my/readings/bubble_ups.json", response_body: "", status: 404) + + assert_raises(Basecamp::NotFoundError) do + @account.my_notifications.get_bubble_ups.to_a + end + end end diff --git a/typescript/tests/services/my-notifications.test.ts b/typescript/tests/services/my-notifications.test.ts index 8fc3e3f9d..1eae59e27 100644 --- a/typescript/tests/services/my-notifications.test.ts +++ b/typescript/tests/services/my-notifications.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { http, HttpResponse } from "msw"; import { server } from "../setup.js"; import { createBasecampClient } from "../../src/client.js"; +import { BasecampError } from "../../src/errors.js"; import type { BasecampClient } from "../../src/client.js"; const BASE_URL = "https://3.basecampapi.com/12345"; @@ -189,5 +190,15 @@ describe("MyNotificationsService", () => { expect((result[0] as Record).type).toBe("Message"); expect((result[1] as Record).bubble_up_at).toBe("2026-08-01T00:00:00Z"); }); + + it("should propagate a 4xx as a BasecampError", async () => { + server.use( + http.get(`${BASE_URL}/my/readings/bubble_ups.json`, () => { + return HttpResponse.json({ error: "Not found" }, { status: 404 }); + }) + ); + + await expect(client.myNotifications.bubbleUps()).rejects.toThrow(BasecampError); + }); }); }); From be7e3082428b53b4da6ccdc959f2b383868fc556 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 25 Jul 2026 14:26:44 -0700 Subject: [PATCH 3/5] Bump documented op count to 209 and tag BubbleUps as bubble_up surface GetBubbleUps raises the generated surface from 208 to 209 operations; update the count in AGENTS.md and SPEC.md so the docs match behavior-model.json. Also tag the Go BubbleUps wrapper with ResourceType "bubble_up" to match the generated SDKs' observability identity (was the generic "notification"). --- AGENTS.md | 4 ++-- SPEC.md | 4 ++-- go/pkg/basecamp/my_notifications.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a23b4cf81..986ce4840 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ | Component | Status | Details | |-----------|--------|---------| -| **Smithy Spec** | 208 operations | Single source of truth for all APIs | +| **Smithy Spec** | 209 operations | Single source of truth for all APIs | | **Go SDK** | Production-ready | Full generated client + service wrappers | | **TypeScript SDK** | Production-ready | 45 generated services, openapi-fetch based | | **Ruby SDK** | Production-ready | 45 generated services | @@ -31,7 +31,7 @@ Smithy Spec → OpenAPI → Generated Client → Service Layer → User | **Kotlin** | Ktor via `BaseService` | `sdk/src/commonMain/kotlin/.../generated/services/*.kt` | | **Python** | httpx via `HttpClient` | `src/basecamp/generated/services/*.py` | -All 208 operations across the ~45-service per-SDK layer are generated. Hand-written code is limited to infrastructure: +All 209 operations across the ~45-service per-SDK layer are generated. Hand-written code is limited to infrastructure: | Purpose | TypeScript | Ruby | Swift | Kotlin | Python | |---------|-----------|------|-------|--------|--------| diff --git a/SPEC.md b/SPEC.md index eb3983992..bee20d06e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -496,7 +496,7 @@ END ### behavior-model.json Retry Patterns -All 208 operations in `behavior-model.json` use `retry_on: [429, 503]`. Three `(max, base_delay_ms)` patterns exist: +All 209 operations in `behavior-model.json` use `retry_on: [429, 503]`. Three `(max, base_delay_ms)` patterns exist: - `(2, 1000)` — most create operations - `(3, 1000)` — most read/update/delete operations - `(3, 2000)` — `CreateAttachment`, `CreateCampfireUpload` (file uploads) @@ -1548,7 +1548,7 @@ Every operation has a `retry` block, including non-idempotent POSTs. For non-ide ### Operation Counts -- Total operations: 208 +- Total operations: 209 - Idempotent: 69 (flagged with `idempotent: true`) - Non-idempotent: 139 (no `idempotent` field, or not present) - All operations use `retry_on: [429, 503]` diff --git a/go/pkg/basecamp/my_notifications.go b/go/pkg/basecamp/my_notifications.go index d443784ec..3f38db4b0 100644 --- a/go/pkg/basecamp/my_notifications.go +++ b/go/pkg/basecamp/my_notifications.go @@ -197,7 +197,7 @@ func (s *MyNotificationsService) MarkAsRead(ctx context.Context, readables []str func (s *MyNotificationsService) BubbleUps(ctx context.Context, page int32) (result *BubbleUpsResult, err error) { op := OperationInfo{ Service: "MyNotifications", Operation: "BubbleUps", - ResourceType: "notification", IsMutation: false, + ResourceType: "bubble_up", IsMutation: false, } if gater, ok := s.client.parent.hooks.(GatingHooks); ok { if ctx, err = gater.OnOperationGate(ctx, op); err != nil { From 09834067ccd53b58a48c407e5b58ed733fd30acc Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 25 Jul 2026 15:12:57 -0700 Subject: [PATCH 4/5] Fix non-idempotent operation count to 140 in SPEC.md GetBubbleUps is non-idempotent, so the behavior-model breakdown is now 69 idempotent + 140 non-idempotent = 209; the summary still said 139. --- SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index bee20d06e..6fa96123d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1550,7 +1550,7 @@ Every operation has a `retry` block, including non-idempotent POSTs. For non-ide - Total operations: 209 - Idempotent: 69 (flagged with `idempotent: true`) -- Non-idempotent: 139 (no `idempotent` field, or not present) +- Non-idempotent: 140 (no `idempotent` field, or not present) - All operations use `retry_on: [429, 503]` --- From c9ad2f52ee3f08fb1c03089b1664f8c14e6a3c8e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 25 Jul 2026 15:45:17 -0700 Subject: [PATCH 5/5] Complete the GetBubbleUps doc first line The first /// line ended mid-sentence ("...as a paginated"); the TS generator emits only line 1 into method JSDoc, so it truncated. Make line 1 a complete sentence. --- .../basecamp/sdk/generated/services/my-notifications.kt | 2 +- openapi.json | 2 +- .../generated/services/my_notifications_service.rb | 2 +- spec/basecamp.smithy | 9 ++++----- typescript/src/generated/openapi-stripped.json | 2 +- typescript/src/generated/schema.d.ts | 9 ++++----- typescript/src/generated/services/my-notifications.ts | 2 +- 7 files changed, 13 insertions(+), 15 deletions(-) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/my-notifications.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/my-notifications.kt index 2a9a340fc..ac1544b00 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/my-notifications.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/my-notifications.kt @@ -37,7 +37,7 @@ class MyNotificationsService(client: AccountClient) : BaseService(client) { } /** - * Get the current user's current and scheduled bubble-ups as a paginated + * Get the current user's current and scheduled bubble-ups (paginated, 50 per page). * @param options Optional query parameters and pagination control */ suspend fun bubbleUps(options: GetBubbleUpsOptions? = null): ListResult { diff --git a/openapi.json b/openapi.json index 23e43ad57..58b440cd8 100644 --- a/openapi.json +++ b/openapi.json @@ -9366,7 +9366,7 @@ }, "/{accountId}/my/readings/bubble_ups.json": { "get": { - "description": "Get the current user's current and scheduled bubble-ups as a paginated\nlist (50 per page). Current bubble-ups are returned first, ordered by most\nrecently bubbled up; scheduled bubble-ups follow, ordered by scheduled\nbubble-up time. Each item uses the same notification object shape as\nGetMyNotifications.", + "description": "Get the current user's current and scheduled bubble-ups (paginated, 50 per page).\nCurrent bubble-ups are returned first, ordered by most recently bubbled up;\nscheduled bubble-ups follow, ordered by scheduled bubble-up time. Each item\nuses the same notification object shape as GetMyNotifications.", "operationId": "GetBubbleUps", "parameters": [ { diff --git a/ruby/lib/basecamp/generated/services/my_notifications_service.rb b/ruby/lib/basecamp/generated/services/my_notifications_service.rb index e6e70da44..0d8b9068d 100644 --- a/ruby/lib/basecamp/generated/services/my_notifications_service.rb +++ b/ruby/lib/basecamp/generated/services/my_notifications_service.rb @@ -20,7 +20,7 @@ def get_my_notifications(page: nil, limit_bubble_ups: nil) end end - # Get the current user's current and scheduled bubble-ups as a paginated + # Get the current user's current and scheduled bubble-ups (paginated, 50 per page). # @param page [Integer, nil] Page number. Defaults to 1. # @return [Enumerator] paginated results def get_bubble_ups(page: nil) diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy index b7b74e1c8..26ef87bf9 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -8791,11 +8791,10 @@ structure MarkAsReadInput { structure MarkAsReadOutput {} -/// Get the current user's current and scheduled bubble-ups as a paginated -/// list (50 per page). Current bubble-ups are returned first, ordered by most -/// recently bubbled up; scheduled bubble-ups follow, ordered by scheduled -/// bubble-up time. Each item uses the same notification object shape as -/// GetMyNotifications. +/// Get the current user's current and scheduled bubble-ups (paginated, 50 per page). +/// Current bubble-ups are returned first, ordered by most recently bubbled up; +/// scheduled bubble-ups follow, ordered by scheduled bubble-up time. Each item +/// uses the same notification object shape as GetMyNotifications. @readonly @basecampRetry(maxAttempts: 3, baseDelayMs: 1000, backoff: "exponential", retryOn: [429, 503]) @basecampPagination(style: "link", totalCountHeader: "X-Total-Count", maxPageSize: 50) diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index 16459ccb7..488d51b5c 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -8326,7 +8326,7 @@ }, "/my/readings/bubble_ups.json": { "get": { - "description": "Get the current user's current and scheduled bubble-ups as a paginated\nlist (50 per page). Current bubble-ups are returned first, ordered by most\nrecently bubbled up; scheduled bubble-ups follow, ordered by scheduled\nbubble-up time. Each item uses the same notification object shape as\nGetMyNotifications.", + "description": "Get the current user's current and scheduled bubble-ups (paginated, 50 per page).\nCurrent bubble-ups are returned first, ordered by most recently bubbled up;\nscheduled bubble-ups follow, ordered by scheduled bubble-up time. Each item\nuses the same notification object shape as GetMyNotifications.", "operationId": "GetBubbleUps", "parameters": [ { diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index f85c30618..984158920 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -1176,11 +1176,10 @@ export interface paths { cookie?: never; }; /** - * @description Get the current user's current and scheduled bubble-ups as a paginated - * list (50 per page). Current bubble-ups are returned first, ordered by most - * recently bubbled up; scheduled bubble-ups follow, ordered by scheduled - * bubble-up time. Each item uses the same notification object shape as - * GetMyNotifications. + * @description Get the current user's current and scheduled bubble-ups (paginated, 50 per page). + * Current bubble-ups are returned first, ordered by most recently bubbled up; + * scheduled bubble-ups follow, ordered by scheduled bubble-up time. Each item + * uses the same notification object shape as GetMyNotifications. */ get: operations["GetBubbleUps"]; put?: never; diff --git a/typescript/src/generated/services/my-notifications.ts b/typescript/src/generated/services/my-notifications.ts index 1041fdae7..adb94174d 100644 --- a/typescript/src/generated/services/my-notifications.ts +++ b/typescript/src/generated/services/my-notifications.ts @@ -85,7 +85,7 @@ export class MyNotificationsService extends BaseService { } /** - * Get the current user's current and scheduled bubble-ups as a paginated + * Get the current user's current and scheduled bubble-ups (paginated, 50 per page). * @param options - Optional query parameters * @returns All Notification across all pages, with .meta.totalCount *