Skip to content
Open
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
26 changes: 26 additions & 0 deletions conformance/runner/typescript/live-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,32 @@ export const LIVE_OPERATIONS: Record<string, DispatchSpec> = {
},
},

ListRecordings: {
fixtures: [],
call: async (ctx) => {
// Backs the type=Door external-links canary; validates the door shape
// (external url + service + description) against the Recording schema.
const result = await ctx.client.recordings.list("Door");
return { resolvedIds: {}, result };
},
},

GetProgressReport: {
fixtures: [],
call: async (ctx) => {
const result = await ctx.client.reports.progress();
return { resolvedIds: {}, result };
},
},

GetBubbleUps: {
fixtures: [],
call: async (ctx) => {
const result = await ctx.client.myNotifications.bubbleUps();
return { resolvedIds: {}, result };
},
},

GetMyProfile: {
fixtures: [],
call: async (ctx) => {
Expand Down
14 changes: 14 additions & 0 deletions conformance/tests/live-my-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -182,5 +182,19 @@
{ "type": "liveCallSucceeds" },
{ "type": "liveSchemaValidate" }
]
},
{
"mode": "live",
"name": "ListRecordings type=Door decodes the external-link (door) shape",
"description": "DECODING coverage for the absorbed external-links/doors list surface (spec/api-gaps/external-links-doors.md, bc3 #12375). Drives the type=Door recordings query and validates each recording against the Recording schema, including the door-specific url/service/description/position fields. Live-dormant: validates statically until credentials are provisioned. Create/image/composite remain residual gaps (see the entry).",
"operation": "ListRecordings",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Register the Door call in the live dispatcher

When the live canary runs with BASECAMP_LIVE=1, live-runner.test.ts passes every fixture operation to assertDispatchCoverage, but LIVE_OPERATIONS has no ListRecordings entry. The runner therefore aborts in beforeAll with “missing dispatch cases for: ListRecordings” before issuing any request, so this new Door field coverage can never execute; add a dispatch that calls ctx.client.recordings.list("Door", ...).

AGENTS.md reference: AGENTS.md:L310-L313

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: This new live fixture adds operation: "ListRecordings", but the live dispatcher also needs a matching ListRecordings dispatch case; otherwise dispatch-coverage checks fail in beforeAll and the Door canary never executes when BASECAMP_LIVE=1. Adding the corresponding live dispatch entry keeps this fixture runnable in live mode.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At conformance/tests/live-my-surface.json, line 190:

<comment>This new live fixture adds `operation: "ListRecordings"`, but the live dispatcher also needs a matching `ListRecordings` dispatch case; otherwise dispatch-coverage checks fail in `beforeAll` and the Door canary never executes when `BASECAMP_LIVE=1`. Adding the corresponding live dispatch entry keeps this fixture runnable in live mode.</comment>

<file context>
@@ -182,5 +182,19 @@
+    "mode": "live",
+    "name": "ListRecordings type=Door decodes the external-link (door) shape",
+    "description": "DECODING coverage for the absorbed external-links/doors list surface (spec/api-gaps/external-links-doors.md, bc3 #12375). Drives the type=Door recordings query and validates each recording against the Recording schema, including the door-specific url/service/description/position fields. Live-dormant: validates statically until credentials are provisioned. Create/image/composite remain residual gaps (see the entry).",
+    "operation": "ListRecordings",
+    "method": "GET",
+    "path": "/projects/recordings.json",
</file context>

"method": "GET",
"path": "/projects/recordings.json",
"queryParams": { "type": "Door" },
"tags": ["live", "read-only", "bc5-additive"],
"liveAssertions": [
{ "type": "liveCallSucceeds" },
{ "type": "liveSchemaValidate" }
]
}
]
40 changes: 37 additions & 3 deletions go/pkg/basecamp/recordings.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type RecordingType string
const (
RecordingTypeComment RecordingType = "Comment"
RecordingTypeDocument RecordingType = "Document"
RecordingTypeDoor RecordingType = "Door"
RecordingTypeKanbanCard RecordingType = "Kanban::Card"
RecordingTypeKanbanStep RecordingType = "Kanban::Step"
RecordingTypeMessage RecordingType = "Message"
Expand Down Expand Up @@ -61,9 +62,28 @@ type Recording struct {
CommentsCount int `json:"comments_count,omitempty"`
CommentsURL string `json:"comments_url,omitempty"`
SubscriptionURL string `json:"subscription_url,omitempty"`
Parent *Parent `json:"parent,omitempty"`
Bucket *Bucket `json:"bucket,omitempty"`
Creator *Person `json:"creator,omitempty"`
// Position, Description, and Service are door-specific (external-link)
// fields, populated only on Door recordings returned by the type=Door
// recordings query (the only endpoint that returns the full door shape).
// See spec/api-gaps/external-links-doors.md.
Position int32 `json:"position,omitempty"`
Description string `json:"description,omitempty"`
Service *DoorService `json:"service,omitempty"`
Parent *Parent `json:"parent,omitempty"`
Bucket *Bucket `json:"bucket,omitempty"`
Creator *Person `json:"creator,omitempty"`
}

// DoorService describes the recognized external service backing an external
// link (Door recording): its display name, a canonical example URL, a short
// code (or "other" for a generic link), the URL patterns Basecamp recognizes,
// and human supporting text.
type DoorService struct {
Name string `json:"name,omitempty"`
ExampleURL string `json:"example_url,omitempty"`
Code string `json:"code,omitempty"`
ValidPatterns []string `json:"valid_patterns,omitempty"`
SupportingText string `json:"supporting_text,omitempty"`
}

// DefaultRecordingLimit is the default number of recordings to return when no limit is specified.
Expand Down Expand Up @@ -425,5 +445,19 @@ func recordingFromGenerated(gr generated.Recording) Recording {
r.ContentAttachments = richTextAttachmentsPtrFromGenerated(gr.ContentAttachments)
r.DescriptionAttachments = richTextAttachmentsPtrFromGenerated(gr.DescriptionAttachments)

// Door-specific fields (populated only for type=Door recordings).
r.Position = gr.Position
r.Description = gr.Description
if gr.Service.Name != "" || gr.Service.Code != "" || gr.Service.ExampleUrl != "" ||
gr.Service.SupportingText != "" || len(gr.Service.ValidPatterns) > 0 {
r.Service = &DoorService{
Name: gr.Service.Name,
ExampleURL: gr.Service.ExampleUrl,
Code: gr.Service.Code,
ValidPatterns: append([]string(nil), gr.Service.ValidPatterns...),
SupportingText: gr.Service.SupportingText,
}
}

return r
}
81 changes: 81 additions & 0 deletions go/pkg/basecamp/recordings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ func TestRecordingType_Constants(t *testing.T) {
}{
{RecordingTypeComment, "Comment"},
{RecordingTypeDocument, "Document"},
{RecordingTypeDoor, "Door"},
{RecordingTypeKanbanCard, "Kanban::Card"},
{RecordingTypeKanbanStep, "Kanban::Step"},
{RecordingTypeMessage, "Message"},
Expand All @@ -286,6 +287,86 @@ func TestRecordingType_Constants(t *testing.T) {
}
}

// TestRecording_UnmarshalDoor verifies that a type=Door recording (external
// link) decodes with the full door shape — the outside url, the service struct,
// the description, and the position — through the shared Recording projection.
func TestRecording_UnmarshalDoor(t *testing.T) {
data := `[
{
"id": 1069480290,
"status": "active",
"visible_to_clients": false,
"created_at": "2026-07-22T15:51:54.872Z",
"updated_at": "2026-07-22T15:51:54.886Z",
"title": "Design system",
"inherits_status": true,
"type": "Door",
"url": "https://www.figma.com/file/abc123/Design-system",
"app_url": "https://3.basecampapi.com/195539477/buckets/2085958504/dock/doors/1069480290",
"position": 8,
"bucket": {"id": 2085958504, "name": "The Leto Laptop", "type": "Project"},
"creator": {"id": 1049715913, "name": "Victor Cooper"},
"service": {
"name": "Figma",
"example_url": "https://www.figma.com/file/aGVsbG8gZmlnbWEgZmlsZQ",
"code": "figma",
"valid_patterns": ["(.*?\\.)?figma\\.com(\\/.*)?"],
"supporting_text": "a file or project on Figma"
},
"description": "<div>Shared Figma workspace</div>"
}
]`

var recordings []Recording
if err := json.Unmarshal([]byte(data), &recordings); err != nil {
t.Fatalf("failed to unmarshal door recording: %v", err)
}
if len(recordings) != 1 {
t.Fatalf("expected 1 recording, got %d", len(recordings))
}
d := recordings[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: TestRecording_UnmarshalDoor includes "example_url" in the service fixture but never asserts on d.Service.ExampleURL. The test comment says it verifies "the full door shape" and other service fields (Name, Code, ValidPatterns, SupportingText) are checked, so omitting ExampleURL means a regression in its decoding wouldn't be caught.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At go/pkg/basecamp/recordings_test.go, line 327:

<comment>TestRecording_UnmarshalDoor includes "example_url" in the service fixture but never asserts on d.Service.ExampleURL. The test comment says it verifies "the full door shape" and other service fields (Name, Code, ValidPatterns, SupportingText) are checked, so omitting ExampleURL means a regression in its decoding wouldn't be caught.</comment>

<file context>
@@ -286,6 +287,86 @@ func TestRecordingType_Constants(t *testing.T) {
+	if len(recordings) != 1 {
+		t.Fatalf("expected 1 recording, got %d", len(recordings))
+	}
+	d := recordings[0]
+	if d.Type != "Door" {
+		t.Errorf("expected type Door, got %q", d.Type)
</file context>

if d.Type != "Door" {
t.Errorf("expected type Door, got %q", d.Type)
}
if d.URL != "https://www.figma.com/file/abc123/Design-system" {
t.Errorf("expected external url, got %q", d.URL)
}
if d.Position != 8 {
t.Errorf("expected position 8, got %d", d.Position)
}
if d.Description != "<div>Shared Figma workspace</div>" {
t.Errorf("unexpected description: %q", d.Description)
}
if d.Service == nil {
t.Fatal("expected service struct to be non-nil for a Door")
}
if d.Service.Name != "Figma" || d.Service.Code != "figma" {
t.Errorf("unexpected service name/code: %q/%q", d.Service.Name, d.Service.Code)
}
if len(d.Service.ValidPatterns) != 1 || d.Service.ValidPatterns[0] == "" {
t.Errorf("expected 1 valid_pattern, got %v", d.Service.ValidPatterns)
}
if d.Service.SupportingText != "a file or project on Figma" {
t.Errorf("unexpected supporting_text: %q", d.Service.SupportingText)
}
}

// TestRecording_UnmarshalNonDoorOmitsDoorFields verifies a non-door recording
// leaves the door-specific fields empty (they are optional).
func TestRecording_UnmarshalNonDoorOmitsDoorFields(t *testing.T) {
data := `{"id": 1, "type": "Message", "title": "Hi", "url": "https://x/1.json"}`
var r Recording
if err := json.Unmarshal([]byte(data), &r); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if r.Service != nil {
t.Errorf("expected nil service for non-door, got %+v", r.Service)
}
if r.Description != "" || r.Position != 0 {
t.Errorf("expected empty door fields, got description=%q position=%d", r.Description, r.Position)
}
}

func TestRecordingsListOptions_BuildsQueryParams(t *testing.T) {
// This is a structural test to ensure the options fields exist
opts := RecordingsListOptions{
Expand Down
50 changes: 42 additions & 8 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
@@ -0,0 +1,20 @@
package com.basecamp.sdk.generated.models

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject

/**
* DoorService entity from the Basecamp API.
*
* @generated from OpenAPI spec — do not edit directly
*/
@Serializable
data class DoorService(
val name: String? = null,
@SerialName("example_url") val exampleUrl: String? = null,
val code: String? = null,
@SerialName("valid_patterns") val validPatterns: List<String> = emptyList(),
@SerialName("supporting_text") val supportingText: String? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,8 @@ data class Recording(
@SerialName("description_attachments") val descriptionAttachments: List<RichTextAttachment>? = null,
@SerialName("comments_count") val commentsCount: Int = 0,
@SerialName("comments_url") val commentsUrl: String? = null,
@SerialName("subscription_url") val subscriptionUrl: String? = null
@SerialName("subscription_url") val subscriptionUrl: String? = null,
val position: Int = 0,
val description: String? = null,
val service: DoorService? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class RecordingsService(client: AccountClient) : BaseService(client) {

/**
* List recordings of a given type across projects
* @param type Comment|Document|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault
* @param type Comment|Document|Door|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault
* @param options Optional query parameters and pagination control
*/
suspend fun list(type: String, options: ListRecordingsOptions? = null): ListResult<Recording> {
Expand Down
43 changes: 40 additions & 3 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -10253,10 +10253,10 @@
{
"name": "type",
"in": "query",
"description": "Comment|Document|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault",
"description": "Comment|Document|Door|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault",
"schema": {
"type": "string",
"description": "Comment|Document|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault"
"description": "Comment|Document|Door|Kanban::Card|Kanban::Step|Message|Question::Answer|Schedule::Entry|Todo|Todolist|Upload|Vault"
},
"required": true,
"examples": {
Expand Down Expand Up @@ -23844,6 +23844,30 @@
"visible_to_clients"
]
},
"DoorService": {
"type": "object",
"description": "Metadata describing the recognized external service backing an external link\n(`Door` recording): its display name, a canonical example URL, a short code,\nthe URL patterns Basecamp recognizes for it, and human supporting text. `code`\nis `other` for a generic link.",
"properties": {
"name": {
"type": "string"
},
"example_url": {
"type": "string"
},
"code": {
"type": "string"
},
"valid_patterns": {
"type": "array",
"items": {
"type": "string"
}
},
"supporting_text": {
"type": "string"
}
}
},
"EnableCardColumnOnHoldResponseContent": {
"$ref": "#/components/schemas/CardColumn"
},
Expand Down Expand Up @@ -26591,7 +26615,8 @@
"type": "string"
},
"url": {
"type": "string"
"type": "string",
"description": "API URL of the recording. Exception: in the `type=Door` (external-link)\nprojection, `url` is the door's **external destination address** (e.g. the\nFigma/Dropbox URL) and `app_url` is the Basecamp redirector — see the\ndoor-specific `service`/`description` fields below."
},
"app_url": {
"type": "string"
Expand Down Expand Up @@ -26626,6 +26651,18 @@
"subscription_url": {
"type": "string"
},
"position": {
"type": "integer",
"description": "Ordinal position within the project's External links section. Present on\n`Door` (external-link) recordings.",
"format": "int32"
},
"description": {
"type": "string",
"description": "Rich-text (HTML) description shown beneath an external link. Present only\non `Door` recordings returned by the `type=Door` recordings query (the only\nendpoint that returns the full door shape). The external destination\naddress is `url` (not this field); `app_url` is the Basecamp redirector.\nSee `spec/api-gaps/external-links-doors.md`."
},
"service": {
"$ref": "#/components/schemas/DoorService"
},
"parent": {
"$ref": "#/components/schemas/RecordingParent"
},
Expand Down
Loading
Loading