BC5 readiness: Go wrapper forward-compat audit + field-level drift check#309
BC5 readiness: Go wrapper forward-compat audit + field-level drift check#309jeremy wants to merge 8 commits into
Conversation
Every *FromGenerated function that built a nested *Person value (Creator, Completer, Approver, Booster, Participants, top-level Person) previously inlined a 6-field shallow copy that silently dropped Bio, Location, Title, PersonableType, AttachableSGID, Client, Employee, TimeZone, CanPing, CanAccessHillCharts, CanAccessTimesheet, CanManageProjects, CanManagePeople, Company, CreatedAt, and UpdatedAt on every nested context. Replace each shallow copy with a personFromGenerated() call. Pure refactor; zero field additions on the wrapper struct side. The change is forward-compat additive — fields that were dropped before are now populated when present in the generated payload; no removals. This makes every subsequent additive Person field (e.g. Tagline) flow through every nested context for free, instead of requiring per-context re-adds.
Add the pervasive Category B fields (BookmarkURL, BoostsCount, BoostsURL,
CommentsCount, CommentsURL, SubscriptionURL, InheritsStatus, VisibleToClients,
Title) — and Content where applicable — to the wrappers that already share
the Recording shape but were missing one or more.
Affected wrappers:
- Recording: + Content, CommentsCount, CommentsURL, SubscriptionURL
- Comment: + VisibleToClients, Title, InheritsStatus, BookmarkURL,
BoostsCount, BoostsURL
- Message: + VisibleToClients, Title, InheritsStatus, BookmarkURL,
BoostsURL, CommentsCount, CommentsURL, SubscriptionURL
- MessageBoard: + VisibleToClients, InheritsStatus, BookmarkURL
- Forward: + VisibleToClients, Title, InheritsStatus, BookmarkURL,
SubscriptionURL
- ForwardReply: + VisibleToClients, Title, InheritsStatus, BookmarkURL,
BoostsCount, BoostsURL
- Inbox: + VisibleToClients, InheritsStatus, BookmarkURL
- CampfireLine: + BookmarkURL, BoostsURL
- ScheduleEntry: + BoostsCount, BoostsURL
- QuestionAnswer: + BoostsCount, BoostsURL
- Todolist: + BoostsCount, BoostsURL
- TimesheetEntry: + Status, VisibleToClients, Title, InheritsStatus, Type,
URL, AppURL, BookmarkURL
Forward-compat additive — fields default to zero/empty when absent. Wrapper
JSON serialization uses omitempty on newly-added fields to match the
upstream serialization behavior.
Surface the BC5 forward-compat fields filed in go/BRIEF-bc5-forward-compat-wrappers.md through the hand-written wrapper layer. PR 1 (#293) added these on the generated client; this commit closes the wrapper gap so basecamp-cli Phase 1 can read them. - Todo: + Steps []CardStep (BC5 jbuilder reuses the steps/step partial, matching the existing CardStep shape; reuse rather than introduce a new type) - Todoset: + TodosCount, CompletedLooseTodosCount, TodosURL, AppTodosURL - Person: + Tagline (alias of Bio; surfaced as a separate wrapper field per the spec note so consumers can distinguish presence) - Notification: + BubbleUpURL, BubbleUpAt (time.Time, following the existing ReadAt/UnreadAt zero-value pattern) - NotificationsResult: + BubbleUps, ScheduledBubbleUps These wrappers all decode through the existing FromGenerated paths (Todo, Todoset, Person) or direct JSON unmarshal (Notification, NotificationsResult) with struct tags; no new conversion pattern is needed. Refs: go/BRIEF-bc5-forward-compat-wrappers.md
Close the remaining Category B one-off structural gaps identified in the
field-level wrapper audit (predates BC5, surfaced during the BC5 readiness
sweep).
- Campfire: + Topic, Position, BookmarkURL, SubscriptionURL
- MessageBoard: + AppMessagesURL, Position
- Template: + URL, AppURL, Dock []DockItem (reuses the existing DockItem
type from projects.go; conversion shared via dockItemFromGenerated helper)
- Person: + CanAccessHillCharts, CanAccessTimesheet; also wire CanPing into
personFromGenerated (struct field existed; propagation did not)
- Notification: + Creator, Participants, PreviewableAttachments (with new
companion PreviewableAttachment type mirroring generated.PreviewableAttachment)
- Gauge: + Creator, Bucket
- GaugeNeedle: + Creator, Bucket, Parent
- Inbox: + Position, ForwardsCount, ForwardsURL
- Forward: + RepliesCount, RepliesURL
The Notification sentinel-creator test fixture was updated to include
`personable_type: "LocalPerson"` (the actual BC3 wire shape for system
actors) so normalizeJSON correctly coerces the string "basecamp" id to 0
and preserves it as system_label; the test now asserts the wrapper exposes
the normalized creator end-to-end rather than silently dropping it.
The field-level audit (formalized as the new check-wrapper-drift script
in the next commit) surfaced these additional pre-existing structural
gaps that the explicit lists in the prior commits did not cover.
- Card: + BoostsURL (sibling of existing BoostsCount)
- CardColumn: + CommentsCount (alongside the existing oddly-named
CommentCount whose json tag was `comment_count`; the canonical
`comments_count` tag now also flows through)
- CardStep: + CompletionURL
- Document: + BoostsURL
- Event: + BoostsCount, BoostsURL
- Todo: + VisibleToClients, SubscriptionURL, BoostsURL, CommentsCount,
CommentsURL, CompletionURL, CompletionSubscribers []Person
- Upload: + BoostsURL
All additions follow the same forward-compat additive pattern: zero/empty
when absent on the wire, populated through the existing *FromGenerated
conversion paths.
Adds a Go AST-based field-level drift check, sibling of the existing
operation-level scripts/check-service-drift.sh. The existing check confirms
that every generated operation has a hand-written service wrapper; this new
check confirms that every generated *struct field* is propagated through the
hand-written wrapper layer (or explicitly marked as intentionally-omitted).
Discovery: walks (wrapper, generated) pairs in two ways —
1. signature reading of every `xFromGenerated(g generated.Y) X` function
declaration in go/pkg/basecamp/*.go (non-test).
2. an explicit `directDecodePairs` map covering wrappers that decode
straight from JSON bytes (Notification, NotificationsResult,
MyAssignment, Gauge, GaugeNeedle) without a *FromGenerated function.
Matching is keyed on JSON tag, not Go field name, so shape-equivalent
collisions (`URL string \`json:"url"\`` vs `Url string \`json:"url"\``) are
handled correctly. Opt-out marker format
// intentionally-omitted: <tag> - <reason>
(ASCII hyphen + space + reason, validated by regex) can appear anywhere
inside the wrapper struct body.
Wired into:
- new `make go-check-wrapper-drift` target
- existing `make check` aggregate (next to `go-check-drift`)
- go.work `use` block, so `go run ./scripts/check-wrapper-drift/`
works without extra setup.
Self-tests in main_test.go exercise the parser/extractor helpers, the
marker regex (reason-required), the excluded-FromGenerated exception
(webhookPersonFromGenerated → WebhookEventPerson), and a happy/drift
end-to-end pair.
Result on the fully-fixed tree: 51 (wrapper, generated) pairs walked,
842 generated fields verified, zero drift, zero intentionally-omitted
markers required.
Adds wrapper_propagation_test.go with three test patterns:
1. Test<Type>FromGenerated_PropagatesNewFields — one per wrapper file
that gained fields. Asserts only the new fields against a
fully-populated generated.X fixture; the existing
*FromGenerated_FullPopulated tests still cover the full surface.
2. TestPerson_NewFields_NestedPropagation — end-to-end check that the
personFromGenerated standardization causes a fully-populated nested
generated.Person (Bio, Tagline, Location, Title, AttachableSGID,
PersonableType, Client, Employee, TimeZone, CanPing,
CanAccessHillCharts, CanAccessTimesheet, CanManageProjects,
CanManagePeople, Company, CreatedAt, UpdatedAt) to flow through
every nested context (Recording, Comment, Message, Todo,
ScheduleEntry.Creator + Participants, Card.Creator + Completer +
Assignees). Pins commit 1's standardization end-to-end.
3. Test<Type>_DirectDecode_PropagatesNewFields — JSON-fixture tests for
the §C-2 direct-decode wrappers (Notification, NotificationsResult,
MyAssignment, Gauge, GaugeNeedle) that decode via json.Unmarshal on
the (sometimes pre-normalized) raw body.
Also commits the original brief at go/BRIEF-bc5-forward-compat-wrappers.md
that triggered this PR — filed by the basecamp-cli dev agent against
SDK commit 7e9c434. Preserving it in-tree keeps the audit trail of why
Category A field additions look the way they do, alongside the wider
audit (Categories B + C) that the brief flagged would be welcome.
21 new tests in total; all pass on the current tree.
`gofmt -s -w` rewrites the column-aligned tag layouts in Notification, NotificationsResult, Person, Todo, and Todoset where the newly-added fields pushed the existing alignment past gofmt's preferred width. No behavior change; closes `make fmt-check` (and thus `make check`).
There was a problem hiding this comment.
Pull request overview
This PR updates the Go SDK’s hand-written wrapper layer (go/pkg/basecamp/) to surface fields that already exist on the regenerated generated client types (go/pkg/generated/), and adds an automated field-level drift check to prevent wrapper-vs-generated mismatches from recurring (BC5 readiness / forward-compat).
Changes:
- Propagate missing generated fields into Go wrapper structs and their
*FromGeneratedconversions (including standardizing nestedPersonmapping viapersonFromGenerated). - Add
scripts/check-wrapper-drift(with unit tests) and wire it intomake checkviago-check-wrapper-drift. - Add wrapper propagation tests to pin the newly surfaced fields (including coverage for “direct-decode” wrappers that unmarshal JSON bytes).
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 36 out of 36 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/check-wrapper-drift/main.go | Implements field-level drift detection between generated structs and Go wrappers (by JSON tag). |
| scripts/check-wrapper-drift/main_test.go | Self-tests for tag parsing, marker parsing, exclusions, and end-to-end drift detection. |
| scripts/check-wrapper-drift/go.mod | Declares a small Go module for the drift checker so it can be run/tested cleanly. |
| Makefile | Adds go-check-wrapper-drift and runs it as part of make check. |
| go.work | Adds the drift-checker module to the workspace. |
| go/pkg/basecamp/wrapper_propagation_test.go | New tests asserting the newly surfaced fields actually propagate through wrappers (incl. direct-decode structs). |
| go/pkg/basecamp/people.go | Expands personFromGenerated to include newly surfaced Person fields (e.g., Tagline, access flags). |
| go/pkg/basecamp/todos.go | Adds BC5 and structural fields to Todo + extends todoFromGenerated (steps, URLs, counts, subscribers) and expands Person wrapper. |
| go/pkg/basecamp/todosets.go | Adds BC5 todoset count/URL fields and propagates them from generated. |
| go/pkg/basecamp/my_notifications.go | Adds BC5 notification bubble-up fields plus creator/participants/attachments on direct-decode wrapper structs. |
| go/pkg/basecamp/my_notifications_test.go | Updates sentinel-creator test now that Notification.Creator is exposed by the wrapper. |
| go/pkg/basecamp/recordings.go | Surfaces recording-shaped fields (content/comments/subscription) in wrapper + conversion. |
| go/pkg/basecamp/comments.go | Surfaces recording-shaped fields in Comment wrapper + conversion. |
| go/pkg/basecamp/messages.go | Surfaces recording-shaped fields in Message wrapper + conversion. |
| go/pkg/basecamp/message_boards.go | Adds structural fields (bookmark/app messages URL/position/visibility flags) to wrapper + conversion. |
| go/pkg/basecamp/campfires.go | Adds missing campfire + line fields (topic/position/bookmark/subscription/boosts URL) and standardizes nested Person. |
| go/pkg/basecamp/schedules.go | Adds boosts fields and standardizes nested Person + participants mapping. |
| go/pkg/basecamp/checkins.go | Adds boosts fields and standardizes nested Person mappings in questionnaire/question/answer wrappers. |
| go/pkg/basecamp/todolists.go | Adds boosts fields and standardizes nested Person in todolist wrapper. |
| go/pkg/basecamp/todolist_groups.go | Standardizes nested Person in todolist group wrapper. |
| go/pkg/basecamp/timesheet.go | Expands TimesheetEntry wrapper to recording-shaped fields and standardizes nested Person mappings. |
| go/pkg/basecamp/forwards.go | Expands Inbox/Forward/ForwardReply wrappers with recording-shaped fields + forwards/replies counters and standardizes nested Person. |
| go/pkg/basecamp/events.go | Adds boosts fields and standardizes nested Person in event wrapper. |
| go/pkg/basecamp/vaults.go | Adds boosts URL fields and standardizes nested Person for vault/document/upload. |
| go/pkg/basecamp/templates.go | Adds URL/AppURL/Dock to template wrapper and propagates Dock items. |
| go/pkg/basecamp/projects.go | Refactors DockItem conversion into shared dockItemFromGenerated. |
| go/pkg/basecamp/cards.go | Adds boosts URL + card-step completion URL + column comments_count, and standardizes nested Person mappings in card types. |
| go/pkg/basecamp/gauges.go | Surfaces creator/bucket/parent on direct-decode gauge wrappers. |
| go/pkg/basecamp/search.go | Standardizes nested Person in search result wrapper. |
| go/pkg/basecamp/reports.go | Standardizes nested Person in reports wrapper. |
| go/pkg/basecamp/timeline.go | Standardizes nested Person in timeline wrapper. |
| go/pkg/basecamp/client_approvals.go | Standardizes nested Person mapping for creator/approver/response creator. |
| go/pkg/basecamp/client_correspondences.go | Standardizes nested Person mapping for creator. |
| go/pkg/basecamp/client_replies.go | Standardizes nested Person mapping for creator. |
| go/pkg/basecamp/boosts.go | Standardizes nested Person mapping for booster. |
| go/BRIEF-bc5-forward-compat-wrappers.md | Adds/updates the brief documenting the wrapper-forward-compat motivation and acceptance criteria. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for k, v := range collectFromGeneratedPairs(f) { | ||
| if !excludedFromGenerated[k+"FromGenerated"] && !excludedFromGenerated[lowercaseFirst(k)+"FromGenerated"] { | ||
| fromGenPairs[k] = v | ||
| } |
| // ----------------------------------------------------------------------------- | ||
| // types helper (silences unused-import lint when go test rebuilds) | ||
| // ----------------------------------------------------------------------------- | ||
|
|
||
| var _ = types.Date{} |
There was a problem hiding this comment.
9 issues found across 36 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="Makefile">
<violation number="1" location="Makefile:226">
P3: Quote or remove the `cd $(CURDIR)` step in the wrapper drift target; unquoted `$(CURDIR)` can fail in paths with spaces.</violation>
</file>
<file name="go/pkg/basecamp/my_notifications.go">
<violation number="1" location="go/pkg/basecamp/my_notifications.go:35">
P2: `omitempty` does not suppress zero `time.Time` values here, so an unset `bubble_up_at` will marshal as `0001-01-01T00:00:00Z` instead of disappearing.</violation>
</file>
<file name="go/pkg/basecamp/timesheet.go">
<violation number="1" location="go/pkg/basecamp/timesheet.go:16">
P2: Remove `omitempty` from the new bool flags. A legitimate `false` value currently disappears when `TimesheetEntry` is marshaled, so callers won't see these BC5 fields unless they happen to be `true`.</violation>
</file>
<file name="scripts/check-wrapper-drift/main_test.go">
<violation number="1" location="scripts/check-wrapper-drift/main_test.go:182">
P2: This "end-to-end" test reimplements only part of the drift check, so regressions in `run()`'s direct-decode pair handling or omitted-marker validation can slip through unnoticed.</violation>
</file>
<file name="scripts/check-wrapper-drift/main.go">
<violation number="1" location="scripts/check-wrapper-drift/main.go:174">
P3: This exclusion check is dead code: `k` is the wrapper struct name (e.g. `WebhookEventPerson`), so `k+"FromGenerated"` produces `WebhookEventPersonFromGenerated` — which never matches the actual exclusion key `webhookPersonFromGenerated`. The `lowercaseFirst` variant yields `webhookEventPersonFromGenerated`, also no match. The exclusion already works correctly inside `collectFromGeneratedPairs` (which checks `fd.Name.Name`), so this outer guard is misleading. Remove it to avoid implying there are two exclusion paths when only the inner one is effective.</violation>
<violation number="2" location="scripts/check-wrapper-drift/main.go:219">
P1: This audit only checks wrapper struct tags, not whether `*FromGenerated` actually populates those fields, so future field additions can still silently stay zero-valued while the drift check passes.</violation>
</file>
<file name="go/pkg/basecamp/wrapper_propagation_test.go">
<violation number="1" location="go/pkg/basecamp/wrapper_propagation_test.go:204">
P2: Compare nested `CreatedAt`/`UpdatedAt` against the expected formatted timestamps instead of only checking for non-empty strings.</violation>
<violation number="2" location="go/pkg/basecamp/wrapper_propagation_test.go:373">
P2: Use exact URL comparisons here; a wrong field mapping still passes as long as each wrapper field is non-empty.</violation>
</file>
<file name="go/pkg/basecamp/forwards.go">
<violation number="1" location="go/pkg/basecamp/forwards.go:62">
P2: `omitempty` on the new forward/inbox response fields will hide false/zero values when callers marshal these wrappers, so the added BC5 fields still drift from the API shape.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Re-trigger cubic
| sort.Strings(tags) | ||
|
|
||
| var missing []string | ||
| for _, tag := range tags { |
There was a problem hiding this comment.
P1: This audit only checks wrapper struct tags, not whether *FromGenerated actually populates those fields, so future field additions can still silently stay zero-valued while the drift check passes.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/check-wrapper-drift/main.go, line 219:
<comment>This audit only checks wrapper struct tags, not whether `*FromGenerated` actually populates those fields, so future field additions can still silently stay zero-valued while the drift check passes.</comment>
<file context>
@@ -0,0 +1,446 @@
+ sort.Strings(tags)
+
+ var missing []string
+ for _, tag := range tags {
+ totalFieldsChecked++
+ if wrap.tags[tag] {
</file context>
| BubbleUpURL string `json:"bubble_up_url,omitempty"` | ||
| // BubbleUpAt is the BC5-added scheduled resurfacing time when this item is | ||
| // queued as a scheduled Bubble Up. Zero when there is no scheduled time. | ||
| BubbleUpAt time.Time `json:"bubble_up_at,omitempty"` |
There was a problem hiding this comment.
P2: omitempty does not suppress zero time.Time values here, so an unset bubble_up_at will marshal as 0001-01-01T00:00:00Z instead of disappearing.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At go/pkg/basecamp/my_notifications.go, line 35:
<comment>`omitempty` does not suppress zero `time.Time` values here, so an unset `bubble_up_at` will marshal as `0001-01-01T00:00:00Z` instead of disappearing.</comment>
<file context>
@@ -11,34 +11,64 @@ import (
+ BubbleUpURL string `json:"bubble_up_url,omitempty"`
+ // BubbleUpAt is the BC5-added scheduled resurfacing time when this item is
+ // queued as a scheduled Bubble Up. Zero when there is no scheduled time.
+ BubbleUpAt time.Time `json:"bubble_up_at,omitempty"`
+ UnreadURL string `json:"unread_url,omitempty"`
+ SubscriptionURL string `json:"subscription_url,omitempty"`
</file context>
| VisibleToClients bool `json:"visible_to_clients,omitempty"` | ||
| Title string `json:"title,omitempty"` | ||
| InheritsStatus bool `json:"inherits_status,omitempty"` |
There was a problem hiding this comment.
P2: Remove omitempty from the new bool flags. A legitimate false value currently disappears when TimesheetEntry is marshaled, so callers won't see these BC5 fields unless they happen to be true.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At go/pkg/basecamp/timesheet.go, line 16:
<comment>Remove `omitempty` from the new bool flags. A legitimate `false` value currently disappears when `TimesheetEntry` is marshaled, so callers won't see these BC5 fields unless they happen to be `true`.</comment>
<file context>
@@ -11,17 +11,25 @@ import (
- UpdatedAt time.Time `json:"updated_at"`
+ ID int64 `json:"id"`
+ Status string `json:"status,omitempty"`
+ VisibleToClients bool `json:"visible_to_clients,omitempty"`
+ Title string `json:"title,omitempty"`
+ InheritsStatus bool `json:"inherits_status,omitempty"`
</file context>
| VisibleToClients bool `json:"visible_to_clients,omitempty"` | |
| Title string `json:"title,omitempty"` | |
| InheritsStatus bool `json:"inherits_status,omitempty"` | |
| VisibleToClients bool `json:"visible_to_clients"` | |
| Title string `json:"title,omitempty"` | |
| InheritsStatus bool `json:"inherits_status"` |
| } | ||
| genStructs := collectStructsAndMarkers(fset, genFile) | ||
| wrapStructs := collectStructsAndMarkers(fset, wrapFile) | ||
| pairs := collectFromGeneratedPairs(wrapFile) |
There was a problem hiding this comment.
P2: This "end-to-end" test reimplements only part of the drift check, so regressions in run()'s direct-decode pair handling or omitted-marker validation can slip through unnoticed.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/check-wrapper-drift/main_test.go, line 182:
<comment>This "end-to-end" test reimplements only part of the drift check, so regressions in `run()`'s direct-decode pair handling or omitted-marker validation can slip through unnoticed.</comment>
<file context>
@@ -0,0 +1,245 @@
+ }
+ genStructs := collectStructsAndMarkers(fset, genFile)
+ wrapStructs := collectStructsAndMarkers(fset, wrapFile)
+ pairs := collectFromGeneratedPairs(wrapFile)
+
+ // Foo: in sync (Hidden marked as intentionally-omitted).
</file context>
| if !w.VisibleToClients || w.Title != "M" || !w.InheritsStatus { | ||
| t.Errorf("base recording-shaped fields not propagated: %+v", w) | ||
| } | ||
| if w.BookmarkURL == "" || w.BoostsURL == "" || w.CommentsURL == "" || w.SubscriptionURL == "" { |
There was a problem hiding this comment.
P2: Use exact URL comparisons here; a wrong field mapping still passes as long as each wrapper field is non-empty.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At go/pkg/basecamp/wrapper_propagation_test.go, line 373:
<comment>Use exact URL comparisons here; a wrong field mapping still passes as long as each wrapper field is non-empty.</comment>
<file context>
@@ -0,0 +1,774 @@
+ if !w.VisibleToClients || w.Title != "M" || !w.InheritsStatus {
+ t.Errorf("base recording-shaped fields not propagated: %+v", w)
+ }
+ if w.BookmarkURL == "" || w.BoostsURL == "" || w.CommentsURL == "" || w.SubscriptionURL == "" {
+ t.Errorf("URL fields not propagated: %+v", w)
+ }
</file context>
| if p.CreatedAt == "" { | ||
| t.Error("CreatedAt: expected non-empty string from non-zero time") | ||
| } | ||
| if p.UpdatedAt == "" { | ||
| t.Error("UpdatedAt: expected non-empty string from non-zero time") | ||
| } |
There was a problem hiding this comment.
P2: Compare nested CreatedAt/UpdatedAt against the expected formatted timestamps instead of only checking for non-empty strings.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At go/pkg/basecamp/wrapper_propagation_test.go, line 204:
<comment>Compare nested `CreatedAt`/`UpdatedAt` against the expected formatted timestamps instead of only checking for non-empty strings.</comment>
<file context>
@@ -0,0 +1,774 @@
+ t.Errorf("Company.Name: got %q, want %q", p.Company.Name, gp.Company.Name)
+ }
+ }
+ if p.CreatedAt == "" {
+ t.Error("CreatedAt: expected non-empty string from non-zero time")
+ }
</file context>
| if p.CreatedAt == "" { | |
| t.Error("CreatedAt: expected non-empty string from non-zero time") | |
| } | |
| if p.UpdatedAt == "" { | |
| t.Error("UpdatedAt: expected non-empty string from non-zero time") | |
| } | |
| if p.CreatedAt != gp.CreatedAt.Format("2006-01-02T15:04:05Z07:00") { | |
| t.Errorf("CreatedAt: got %q, want %q", p.CreatedAt, gp.CreatedAt.Format("2006-01-02T15:04:05Z07:00")) | |
| } | |
| if p.UpdatedAt != gp.UpdatedAt.Format("2006-01-02T15:04:05Z07:00") { | |
| t.Errorf("UpdatedAt: got %q, want %q", p.UpdatedAt, gp.UpdatedAt.Format("2006-01-02T15:04:05Z07:00")) | |
| } |
| Creator *Person `json:"creator,omitempty"` | ||
| ID int64 `json:"id"` | ||
| Status string `json:"status"` | ||
| VisibleToClients bool `json:"visible_to_clients,omitempty"` |
There was a problem hiding this comment.
P2: omitempty on the new forward/inbox response fields will hide false/zero values when callers marshal these wrappers, so the added BC5 fields still drift from the API shape.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At go/pkg/basecamp/forwards.go, line 62:
<comment>`omitempty` on the new forward/inbox response fields will hide false/zero values when callers marshal these wrappers, so the added BC5 fields still drift from the API shape.</comment>
<file context>
@@ -57,48 +57,67 @@ type ForwardReplyListResult struct {
- Creator *Person `json:"creator,omitempty"`
+ ID int64 `json:"id"`
+ Status string `json:"status"`
+ VisibleToClients bool `json:"visible_to_clients,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
</file context>
| # operation-level, this one is field-level. | ||
| go-check-wrapper-drift: | ||
| @echo "==> Checking wrapper field-level drift..." | ||
| @cd $(CURDIR) && go run ./scripts/check-wrapper-drift/ |
There was a problem hiding this comment.
P3: Quote or remove the cd $(CURDIR) step in the wrapper drift target; unquoted $(CURDIR) can fail in paths with spaces.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Makefile, line 226:
<comment>Quote or remove the `cd $(CURDIR)` step in the wrapper drift target; unquoted `$(CURDIR)` can fail in paths with spaces.</comment>
<file context>
@@ -218,6 +218,13 @@ go-check-drift:
+# operation-level, this one is field-level.
+go-check-wrapper-drift:
+ @echo "==> Checking wrapper field-level drift..."
+ @cd $(CURDIR) && go run ./scripts/check-wrapper-drift/
+
.PHONY: auth-routable-check
</file context>
| @cd $(CURDIR) && go run ./scripts/check-wrapper-drift/ | |
| @go run ./scripts/check-wrapper-drift/ |
| wrapperStructs[k] = v | ||
| } | ||
| for k, v := range collectFromGeneratedPairs(f) { | ||
| if !excludedFromGenerated[k+"FromGenerated"] && !excludedFromGenerated[lowercaseFirst(k)+"FromGenerated"] { |
There was a problem hiding this comment.
P3: This exclusion check is dead code: k is the wrapper struct name (e.g. WebhookEventPerson), so k+"FromGenerated" produces WebhookEventPersonFromGenerated — which never matches the actual exclusion key webhookPersonFromGenerated. The lowercaseFirst variant yields webhookEventPersonFromGenerated, also no match. The exclusion already works correctly inside collectFromGeneratedPairs (which checks fd.Name.Name), so this outer guard is misleading. Remove it to avoid implying there are two exclusion paths when only the inner one is effective.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/check-wrapper-drift/main.go, line 174:
<comment>This exclusion check is dead code: `k` is the wrapper struct name (e.g. `WebhookEventPerson`), so `k+"FromGenerated"` produces `WebhookEventPersonFromGenerated` — which never matches the actual exclusion key `webhookPersonFromGenerated`. The `lowercaseFirst` variant yields `webhookEventPersonFromGenerated`, also no match. The exclusion already works correctly inside `collectFromGeneratedPairs` (which checks `fd.Name.Name`), so this outer guard is misleading. Remove it to avoid implying there are two exclusion paths when only the inner one is effective.</comment>
<file context>
@@ -0,0 +1,446 @@
+ wrapperStructs[k] = v
+ }
+ for k, v := range collectFromGeneratedPairs(f) {
+ if !excludedFromGenerated[k+"FromGenerated"] && !excludedFromGenerated[lowercaseFirst(k)+"FromGenerated"] {
+ fromGenPairs[k] = v
+ }
</file context>
Summary
The Go SDK is unique among 37signals' SDKs in having hand-written wrapper structs in
go/pkg/basecamp/that re-shape the generated types fromgo/pkg/generated/. PR 1 (#293) regenerated the generated Go client with BC5 forward-compat fields, but the wrappers were not updated — so external consumers (basecamp-cli Phase 1) couldn't see the new fields. A field-level audit (filed atgo/BRIEF-bc5-forward-compat-wrappers.mdby basecamp-cli's dev agent) revealed the gap predates BC5: ~65 fields across 22 wrapper structs were missing.This PR closes the gap on three scope categories and installs a field-level drift check to prevent recurrence.
Stacked on: PR #293 (
bc5-readiness-spec-foundations) because the generated fields in commit7e9c4345are inputs the wrapper propagation depends on. Will retarget tomainafter #293 merges.Downstream impact: unblocks basecamp-cli Phase 1, which is blocked on this PR.
Scope
Category A — BC5 forward-compat (9 fields, original brief scope)
Todo.Steps []CardStepTodoset.{TodosCount, CompletedLooseTodosCount, TodosURL, AppTodosURL}Person.Tagline(alongside existingBio— surfaced as a separate wrapper field per the spec note)Notification.{BubbleUpURL, BubbleUpAt}(time.Timematching the existingReadAt/UnreadAtpattern)NotificationsResult.{BubbleUps, ScheduledBubbleUps}Category B — Cross-cutting structural (~50 fields, pre-existing gaps)
Pervasive Recording-shaped fields (
BookmarkURL,BoostsCount,BoostsURL,CommentsCount,CommentsURL,SubscriptionURL,InheritsStatus,VisibleToClients,Title) added toRecording,Comment,Message,MessageBoard,Forward,ForwardReply,CampfireLine,ScheduleEntry,QuestionAnswer,Todolist,TimesheetEntry,Inbox. One-offs:Campfire.{Topic, Position, BookmarkURL, SubscriptionURL},MessageBoard.{AppMessagesURL, Position},Template.{URL, AppURL, Dock []DockItem}(reusing existingDockItem),Person.{CanPing, CanAccessHillCharts, CanAccessTimesheet}(CanPing field existed; propagation didn't),Notification.{Creator, Participants, PreviewableAttachments}(newPreviewableAttachmentcompanion type),Gauge.{Creator, Bucket},GaugeNeedle.{Creator, Bucket, Parent},Inbox.{Position, ForwardsCount, ForwardsURL},Forward.{RepliesCount, RepliesURL}, plus Card-table fields surfaced by the drift audit:Card.BoostsURL,CardColumn.CommentsCount,CardStep.CompletionURL,Document.BoostsURL,Event.{BoostsCount, BoostsURL},Todo.{VisibleToClients, SubscriptionURL, BoostsURL, CommentsCount, CommentsURL, CompletionURL, CompletionSubscribers},Upload.BoostsURL.Category C — Structural pattern fixes
personFromGenerated— every*FromGeneratedthat built a nested*Personpreviously inlined a 6-field shallow copy that silently droppedBio,Tagline,Location,Title,PersonableType,AttachableSGID,Client,Employee,TimeZone,CanPing,CanAccessHillCharts,CanAccessTimesheet,CanManageProjects,CanManagePeople,Company,CreatedAt,UpdatedAt. Replaced withpersonFromGeneratedcalls. Pure refactor; makes commit 3'sPerson.Taglineflow through every nested context for free.Notification,NotificationsResult,MyAssignment,Gauge,GaugeNeedledecode viajson.Unmarshalon raw bytes. Added the missing fields with the appropriate JSON tags; decode pattern unchanged.Cadence (8 commits)
Each commit individually builds and passes existing tests. Ordering chosen so the drift check (commit 5) goes green at introduction.
wrappers: standardize nested Person on personFromGeneratedwrappers: cross-cutting Recording-shaped fieldswrappers: BC5 forward-compat fieldswrappers: one-off structural fieldswrappers: close remaining structural gaps surfaced by drift auditscripts: field-level wrapper drift checkwrappers: tests for new field propagationwrappers: gofmt long-tag struct alignmentDrift check
scripts/check-wrapper-drift/(new), wired intomake go-check-wrapper-driftandmake check. Discovery is signature-based on*FromGenerateddeclarations plus an explicit map for the 5 direct-decode wrappers. Matching is keyed on JSON tag (not Go field name), so shape-equivalent collisions (URLvsUrl, both tagged"url") are handled correctly. Opt-out marker format:// intentionally-omitted: <tag> - <reason>(ASCII hyphen + reason required, validated by regex). On the fully-fixed tree:intentionally-omittedmarkers requiredSelf-tests in
scripts/check-wrapper-drift/main_test.goexercise the parser, marker regex, exclusion logic, and an end-to-end happy/drift case.Verification
cd go && go build ./...cd go && go test ./pkg/basecamp/...make go-check-drift(operation-level)make go-check-wrapper-drift(NEW field-level)make conformance-gomake checkmake conformance-go-replayis not applicable on this branch (the replay target is part of PR 3, on a separate branch).Risks + open questions
Personvalues gain ~17 fields. Any user assertingJSONEqagainst the previously-truncated wrapper output sees additions. Mitigation: this is forward-compat additive (no removals or renames); basecamp-cli's bump-PR is well-understood.Person.{CreatedAt, UpdatedAt}in nested contexts.personFromGeneratedformats these as RFC3339 strings; nested contexts now also populate them when present (previously dropped). Forward-compat additive.scripts/check-wrapper-drift/main.goheader.MyAssignmentDate semantics.StartsOn/DueOnalready usedstring(matchingTodo.DueOn); no change needed.Coordination
basecamp-cli Phase 1 is blocked on this PR. Please notify the basecamp-cli
fivebranch on merge so they canmake bump-sdkand resume.Summary by cubic
Propagates BC5 fields through the Go wrapper layer and closes wrapper-vs-generated field drift. Unblocks basecamp-cli Phase 1 by making the new fields available to consumers.
New Features
Todo.Steps,Todoset.{TodosCount, CompletedLooseTodosCount, TodosURL, AppTodosURL},Person.Tagline,Notification.{BubbleUpURL, BubbleUpAt},NotificationsResult.{BubbleUps, ScheduledBubbleUps}.go/pkg/basecamp/: add pervasive fields likeBookmarkURL,BoostsCount,BoostsURL,CommentsCount,CommentsURL,SubscriptionURL,InheritsStatus,VisibleToClients,Title, plus one-offs (e.g.,Template.Dock,MessageBoard.Position,Inbox.{ForwardsCount, ForwardsURL},Forward.{RepliesCount, RepliesURL},Event.{BoostsCount, BoostsURL}).scripts/check-wrapper-driftwithmake go-check-wrapper-driftandmake checkto prevent regressions; includes self-tests and coverage for direct-decode wrappers.Refactors
Personmappings onpersonFromGeneratedso full person fields flow in every context.Notification,NotificationsResult,MyAssignment,Gauge,GaugeNeedle) decoding pattern; extend structs with JSON tags to expose new fields.Written for commit 9a6af51. Summary will update on new commits. Review in cubic