Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions conformance/runner/go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,22 @@ func executeOperation(ctx context.Context, account *basecamp.AccountClient, tc T
_, err := account.Todos().Update(ctx, todoID, req)
return operationResult{err: err}

case "UpdateScheduleEntry":
// Participants are presence-bearing: absent means "leave them alone"
// (BC3 preserves only because the key is missing), an empty non-nil
// slice means "remove everyone".
entryID := getInt64Param(tc.PathParams, "entryId")
req := &basecamp.UpdateScheduleEntryRequest{
Summary: getStringParam(tc.RequestBody, "summary"),
StartsAt: getStringParam(tc.RequestBody, "starts_at"),
EndsAt: getStringParam(tc.RequestBody, "ends_at"),
}
Comment on lines +546 to +550

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Conformance cases supplying description, all_day, or notify will succeed against a request that silently drops those fixture fields, masking serialization regressions for this operation. Map the remaining UpdateScheduleEntryRequest fields, preserving all_day presence with a local bool pointer.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At conformance/runner/go/main.go, line 546:

<comment>Conformance cases supplying `description`, `all_day`, or `notify` will succeed against a request that silently drops those fixture fields, masking serialization regressions for this operation. Map the remaining `UpdateScheduleEntryRequest` fields, preserving `all_day` presence with a local bool pointer.</comment>

<file context>
@@ -538,6 +538,22 @@ func executeOperation(ctx context.Context, account *basecamp.AccountClient, tc T
+		// (BC3 preserves only because the key is missing), an empty non-nil
+		// slice means "remove everyone".
+		entryID := getInt64Param(tc.PathParams, "entryId")
+		req := &basecamp.UpdateScheduleEntryRequest{
+			Summary:  getStringParam(tc.RequestBody, "summary"),
+			StartsAt: getStringParam(tc.RequestBody, "starts_at"),
</file context>
Suggested change
req := &basecamp.UpdateScheduleEntryRequest{
Summary: getStringParam(tc.RequestBody, "summary"),
StartsAt: getStringParam(tc.RequestBody, "starts_at"),
EndsAt: getStringParam(tc.RequestBody, "ends_at"),
}
req := &basecamp.UpdateScheduleEntryRequest{
Summary: getStringParam(tc.RequestBody, "summary"),
StartsAt: getStringParam(tc.RequestBody, "starts_at"),
EndsAt: getStringParam(tc.RequestBody, "ends_at"),
Description: getStringParam(tc.RequestBody, "description"),
Notify: getBoolParam(tc.RequestBody, "notify"),
}
if _, ok := tc.RequestBody["all_day"]; ok {
allDay := getBoolParam(tc.RequestBody, "all_day")
req.AllDay = &allDay
}

if ids, ok := getInt64SliceParam(tc.RequestBody, "participant_ids"); ok {
req.ParticipantIDs = ids
}
_, err := account.Schedules().UpdateEntry(ctx, entryID, req)
return operationResult{err: err}

case "UpdateCard":
// Merge-safe composite: GET then PUT, resending the fetched due_on.
_, err := account.Cards().Update(ctx, getInt64Param(tc.PathParams, "cardId"), cardUpdateRequest(tc.RequestBody))
Expand Down
8 changes: 8 additions & 0 deletions conformance/runner/python/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
# Wire keys for todo write operations; identical to the Python kwarg /
# edit-attribute names, so fixtures map onto the SDK surface directly.
_TODO_WRITE_FIELDS = ("content", "description", "assignee_ids", "completion_subscriber_ids", "due_on", "starts_on", "notify")
_SCHEDULE_ENTRY_WRITE_FIELDS = ("summary", "starts_at", "ends_at", "description", "participant_ids", "all_day", "notify")
_CARD_WRITE_FIELDS = ("title", "content", "due_on", "assignee_ids")

# Sentinel distinguishing "key absent from the JSON body" from a present None.
Expand Down Expand Up @@ -102,6 +103,13 @@ def __call__(self, operation: str, *, path_params: dict, query_params: dict, bod
todo_id=path_params["todoId"],
**{k: body[k] for k in _TODO_WRITE_FIELDS if k in body},
)
case "UpdateScheduleEntry":
# Only pass keys the fixture carries: _compact strips None, so
# an absent participant_ids stays off the wire while [] survives.
return self._account.schedules.update_entry(
entry_id=path_params["entryId"],
**{k: body[k] for k in _SCHEDULE_ENTRY_WRITE_FIELDS if k in body},
)
case "UpdateCard":
# Merge-safe composite: GET then PUT, resending the fetched due_on.
return self._account.cards.update(
Expand Down
11 changes: 11 additions & 0 deletions conformance/runner/ruby/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ def call(operation, path_params: {}, query_params: {}, body: nil, path: "")
todo_id: path_params["todoId"],
**todo_write_kwargs(body)
)
when "UpdateScheduleEntry"
# Only pass keys the fixture carries: compact_params strips nil, so an
# absent participant_ids stays off the wire while [] survives.
@account.schedules.update_entry(entry_id: path_params["entryId"], **schedule_entry_write_kwargs(body))
when "UpdateCard"
# Merge-safe composite: GET then PUT, resending the fetched due_on.
@account.cards.update(card_id: path_params["cardId"], **card_write_kwargs(body))
Expand Down Expand Up @@ -217,6 +221,13 @@ def call(operation, path_params: {}, query_params: {}, body: nil, path: "")
content description assignee_ids completion_subscriber_ids due_on starts_on notify
].freeze

SCHEDULE_ENTRY_WRITE_KEYS = %w[summary starts_at ends_at description participant_ids all_day notify].freeze

def schedule_entry_write_kwargs(body)
SCHEDULE_ENTRY_WRITE_KEYS.select { |key| (body || {}).key?(key) } \
.to_h { |key| [key.to_sym, body[key]] }
end

CARD_WRITE_KEYS = %w[title content due_on assignee_ids].freeze

def card_write_kwargs(body)
Expand Down
11 changes: 11 additions & 0 deletions conformance/runner/typescript/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,17 @@ async function executeOperation(
await client.todos.update(Number(params.todoId), mapTodoWireFields(body));
break;

case "UpdateScheduleEntry":
// Only spread participantIds when the fixture carries the key: an
// absent key must not become [] or null on the wire.
await client.schedules.updateEntry(Number(params.entryId), {
...(body.summary !== undefined ? { summary: String(body.summary) } : {}),
...(body.participant_ids !== undefined
? { participantIds: body.participant_ids as number[] }
: {}),
});
break;

case "UpdateCard":
// Merge-safe composite: GET then PUT, resending the fetched due_on.
await client.cards.update(Number(params.cardId), {
Expand Down
192 changes: 192 additions & 0 deletions conformance/tests/schedule_entries_write.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
[
{
"name": "update-omits-participant-ids: an unaddressed participant list stays off the wire",
"description": "BC3 preserves the entry's participants only because the key is absent \u2014 until basecamp/bc3#12425 the controller called replace_participants unconditionally, so an omitted key wiped every participant and notified them. The SDK's job is narrow but load-bearing: when the caller says nothing about participants, participant_ids must not appear in the body at all. A compactor that emitted null, or a default that emitted [], would clear the list on the server.",
"operation": "UpdateScheduleEntry",
"method": "PUT",
"path": "/schedule_entries/{entryId}",
"pathParams": {
"entryId": 1069479523
},
"requestBody": {
"summary": "Team Meeting"
},
"mockResponses": [
{
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": {
"id": 1069479523,
"status": "active",
"visible_to_clients": false,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"title": "Team Meeting",
"inherits_status": true,
"type": "Schedule::Entry",
"url": "https://3.basecampapi.com/999/buckets/1/schedule_entries/1069479523.json",
"app_url": "https://3.basecamp.com/999/buckets/1/schedule_entries/1069479523",
"summary": "Team Meeting",
"all_day": false,
"starts_at": "2026-06-05T06:00:00Z",
"ends_at": "2026-06-05T08:30:00Z",
"participants": [
{
"id": 1049715914,
"name": "Victor Cooper"
},
{
"id": 1049715915,
"name": "Annie Bryan"
}
],
"description_attachments": [],
"parent": {
"id": 1069479521,
"title": "Schedule",
"type": "Schedule",
"url": "https://3.basecampapi.com/999/buckets/1/schedules/1069479521.json",
"app_url": "https://3.basecamp.com/999/buckets/1/schedules/1069479521"
},
"bucket": {
"id": 1,
"name": "The Leto Laptop",
"type": "Project"
},
"creator": {
"id": 1049715914,
"name": "Victor Cooper",
"email_address": "victor@honchodesign.com",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
}
}
],
"assertions": [
{
"type": "requestCount",
"expected": 1
},
{
"type": "requestMethod",
"expected": "PUT",
"index": 0
},
{
"type": "requestBody",
"path": "summary",
"expected": "Team Meeting",
"index": 0
},
{
"type": "requestBodyAbsent",
"path": "participant_ids",
"index": 0
},
{
"type": "noError"
}
],
"tags": [
"schedule-entries",
"participants",
"omission"
]
},
{
"name": "update-empty-participant-ids: an explicit empty list reaches the wire",
"description": "The counterpart to omission: passing an explicitly empty list means \"remove everyone\" and must be sent as participant_ids: []. Ruby's compact_params and Python's _compact strip nil/None but not empty collections, so this distinguishes \"not addressed\" from \"addressed and cleared\" \u2014 the whole point of the BC3 guard.",
"operation": "UpdateScheduleEntry",
"method": "PUT",
"path": "/schedule_entries/{entryId}",
"pathParams": {
"entryId": 1069479523
},
"requestBody": {
"summary": "Team Meeting",
"participant_ids": []
},
"mockResponses": [
{
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": {
"id": 1069479523,
"status": "active",
"visible_to_clients": false,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"title": "Team Meeting",
"inherits_status": true,
"type": "Schedule::Entry",
"url": "https://3.basecampapi.com/999/buckets/1/schedule_entries/1069479523.json",
"app_url": "https://3.basecamp.com/999/buckets/1/schedule_entries/1069479523",
"summary": "Team Meeting",
"all_day": false,
"starts_at": "2026-06-05T06:00:00Z",
"ends_at": "2026-06-05T08:30:00Z",
"participants": [
{
"id": 1049715914,
"name": "Victor Cooper"
},
{
"id": 1049715915,
"name": "Annie Bryan"
}
],
"description_attachments": [],
"parent": {
"id": 1069479521,
"title": "Schedule",
"type": "Schedule",
"url": "https://3.basecampapi.com/999/buckets/1/schedules/1069479521.json",
"app_url": "https://3.basecamp.com/999/buckets/1/schedules/1069479521"
},
"bucket": {
"id": 1,
"name": "The Leto Laptop",
"type": "Project"
},
"creator": {
"id": 1049715914,
"name": "Victor Cooper",
"email_address": "victor@honchodesign.com",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
}
}
],
"assertions": [
{
"type": "requestCount",
"expected": 1
},
{
"type": "requestMethod",
"expected": "PUT",
"index": 0
},
{
"type": "requestBody",
"path": "participant_ids",
"expected": [],
"index": 0
},
{
"type": "noError"
}
],
"tags": [
"schedule-entries",
"participants",
"clear"
]
}
]
4 changes: 2 additions & 2 deletions go/pkg/basecamp/api-provenance.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"bc3": {
"branch": "master",
"revision": "c308693171680e8ec9d3ae9e68181ef2ea80f077",
"date": "2026-07-26"
"revision": "dffa7e11b3337b17454d9a82301be3e94226a858",
"date": "2026-07-28"
},
"compatibility": {
"bc3-four": {
Expand Down
2 changes: 1 addition & 1 deletion go/pkg/basecamp/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ package basecamp
const Version = "0.9.0"

// APIVersion is the Basecamp API version this SDK targets.
const APIVersion = "2026-07-26"
const APIVersion = "2026-07-28"
18 changes: 14 additions & 4 deletions go/pkg/generated/client.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,20 @@ private suspend fun dispatchOperation(tc: TestCase, account: AccountClient): Dis
DispatchResult()
}

// Participants are presence-bearing: an absent key must not become an
// empty list on the wire, or BC3 clears the participants.
"UpdateScheduleEntry" -> {
val entryId = tc.pathParams.longParam("entryId")
val rb = tc.requestBody
account.schedules.updateEntry(entryId, UpdateScheduleEntryBody(
summary = rb?.get("summary")?.jsonPrimitive?.contentOrNull,
startsAt = rb?.get("starts_at")?.jsonPrimitive?.contentOrNull,
endsAt = rb?.get("ends_at")?.jsonPrimitive?.contentOrNull,
participantIds = rb?.get("participant_ids")?.jsonArray?.map { it.jsonPrimitive.long },
))
DispatchResult()
}

// Merge-safe composite: GET then PUT, resending the fetched due_on.
"UpdateCard" -> {
val cardId = tc.pathParams.longParam("cardId")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ data class BasecampConfig(
) {
companion object {
const val VERSION = "0.9.0"
const val API_VERSION = "2026-07-26"
const val API_VERSION = "2026-07-28"
const val DEFAULT_BASE_URL = "https://3.basecampapi.com"
const val DEFAULT_USER_AGENT = "basecamp-sdk-kotlin/$VERSION (api:$API_VERSION)"
const val DEFAULT_MAX_RETRIES = 3
Expand Down
5 changes: 3 additions & 2 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "Basecamp",
"version": "2026-07-26",
"version": "2026-07-28",
"description": "Basecamp API",
"contact": {
"name": "Basecamp",
Expand Down Expand Up @@ -31069,7 +31069,8 @@
"items": {
"type": "integer",
"format": "int64"
}
},
"description": "Replaces the entry's participants.\n\nOmitting this member preserves the current participants; sending an empty\narray clears them. That guarantee is BC3-side and recent: until\nbasecamp/bc3#12425, `Schedules::EntriesController#update` called\n`replace_participants` unconditionally, so any update omitting the key —\nincluding the shape in BC3's own \"Update a schedule entry\" doc example —\nsilently removed every participant and notified each one. The controller\nnow guards on the request actually addressing participants."
},
"all_day": {
"type": "boolean",
Expand Down
2 changes: 1 addition & 1 deletion python/src/basecamp/_version.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
VERSION = "0.9.0"
API_VERSION = "2026-07-26"
API_VERSION = "2026-07-28"
2 changes: 1 addition & 1 deletion ruby/lib/basecamp/generated/metadata.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://basecamp.com/schemas/sdk-metadata.json",
"version": "1.0.0",
"generated": "2026-07-28T07:10:09Z",
"generated": "2026-07-28T21:34:14Z",
"operations": {
"GetAccount": {
"retry": {
Expand Down
Loading
Loading