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
22 changes: 11 additions & 11 deletions behavior-model.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,6 @@
]
}
},
"CloneTool": {
"retry": {
"max": 2,
"base_delay_ms": 1000,
"backoff": "exponential",
"retry_on": [
429,
503
]
}
},
"CompleteTodo": {
"idempotent": true,
"retry": {
Expand Down Expand Up @@ -324,6 +313,17 @@
]
}
},
"CreateTool": {
"retry": {
"max": 2,
"base_delay_ms": 1000,
"backoff": "exponential",
"retry_on": [
429,
503
]
}
},
"CreateUpload": {
"retry": {
"max": 2,
Expand Down
11 changes: 6 additions & 5 deletions conformance/runner/go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -493,14 +493,15 @@ func executeOperation(ctx context.Context, account *basecamp.AccountClient, tc T
_, err := account.Tools().Get(ctx, toolID)
return operationResult{err: err}

case "CloneTool":
sourceRecordingID := getInt64Param(tc.RequestBody, "source_recording_id")
case "CreateTool":
bucketID := getInt64Param(tc.PathParams, "bucketId")
toolType := getStringParam(tc.RequestBody, "tool_type")
title := getStringParam(tc.RequestBody, "title")
var opts *basecamp.CloneToolOptions
var opts *basecamp.CreateToolOptions
if title != "" {
opts = &basecamp.CloneToolOptions{Title: title}
opts = &basecamp.CreateToolOptions{Title: title}
}
_, err := account.Tools().Create(ctx, sourceRecordingID, opts)
_, err := account.Tools().Create(ctx, bucketID, toolType, opts)
return operationResult{err: err}

case "EnableTool":
Expand Down
8 changes: 6 additions & 2 deletions conformance/runner/python/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,12 @@ def __call__(self, operation: str, *, path_params: dict, query_params: dict, bod
return self._account.reports.person_progress(person_id=path_params["personId"])
case "GetTool":
return self._account.tools.get(tool_id=path_params["toolId"])
case "CloneTool":
return self._account.tools.clone(source_recording_id=body["source_recording_id"], title=body["title"])
case "CreateTool":
return self._account.tools.create(
bucket_id=path_params["bucketId"],
tool_type=body["tool_type"],
title=body.get("title"),
)
case "EnableTool":
return self._account.tools.enable(tool_id=path_params["toolId"])
case "UploadsDownload":
Expand Down
7 changes: 4 additions & 3 deletions conformance/runner/ruby/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,10 @@ def call(operation, path_params: {}, query_params: {}, body: nil, path: "")
@account.tools.get(
tool_id: path_params["toolId"]
)
when "CloneTool"
@account.tools.clone(
source_recording_id: body["source_recording_id"],
when "CreateTool"
@account.tools.create(
bucket_id: path_params["bucketId"],
tool_type: body["tool_type"],
title: body["title"]
)
when "EnableTool"
Expand Down
8 changes: 4 additions & 4 deletions conformance/runner/typescript/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,10 @@ async function executeOperation(
await client.tools.get(Number(params.toolId));
break;

case "CloneTool":
await client.tools.clone({
sourceRecordingId: Number(body.source_recording_id),
title: String(body.title || "Conformance Test"),
case "CreateTool":
await client.tools.create(Number(params.bucketId), {
toolType: String(body.tool_type),
title: body.title === undefined ? undefined : String(body.title),
});
break;

Expand Down
32 changes: 25 additions & 7 deletions conformance/tests/paths.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,17 +145,35 @@
"tags": ["path", "tools"]
},
{
"name": "CloneTool uses flat /dock/tools.json path (no bucketId)",
"description": "Verifies that CloneTool constructs the flat URL path /dock/tools.json without bucket scoping",
"operation": "CloneTool",
"name": "CreateTool uses bucket-scoped /buckets/{bucketId}/dock/tools.json path",
"description": "Verifies that CreateTool constructs the bucket-scoped URL path /buckets/{bucketId}/dock/tools.json",
"operation": "CreateTool",
"method": "POST",
"path": "/dock/tools.json",
"requestBody": {"source_recording_id": 789, "title": "To-dos (Copy)"},
"path": "/buckets/{bucketId}/dock/tools.json",
"pathParams": {"bucketId": 456},
"requestBody": {"tool_type": "Message::Board", "title": "Message Board (Copy)"},
"mockResponses": [
{"status": 201, "body": {"id": 800, "name": "message_board", "title": "Message Board (Copy)", "status": "active", "enabled": true, "position": 5, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", "url": "https://3.basecampapi.com/999/buckets/456/message_boards/800.json", "app_url": "https://3.basecamp.com/999/buckets/456/message_boards/800", "bucket": {"id": 456, "name": "Test", "type": "Project"}}}
],
"assertions": [
{"type": "requestPath", "expected": "/999/buckets/456/dock/tools.json"},
{"type": "noError"}
],
"tags": ["path", "tools"]
},
{
"name": "CreateTool accepts omitted title on bucket-scoped path",
"description": "Verifies that CreateTool can omit the optional title while using /buckets/{bucketId}/dock/tools.json",
"operation": "CreateTool",
"method": "POST",
"path": "/buckets/{bucketId}/dock/tools.json",
"pathParams": {"bucketId": 456},
"requestBody": {"tool_type": "Message::Board"},
"mockResponses": [
{"status": 201, "body": {"id": 800, "name": "todoset", "title": "To-dos (Copy)", "status": "active", "enabled": true, "position": 5, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", "url": "https://3.basecampapi.com/999/buckets/456/todosets/800.json", "app_url": "https://3.basecamp.com/999/buckets/456/todosets/800", "bucket": {"id": 456, "name": "Test", "type": "Project"}}}
{"status": 201, "body": {"id": 801, "name": "message_board", "title": "Message Board", "status": "active", "enabled": true, "position": 6, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", "url": "https://3.basecampapi.com/999/buckets/456/message_boards/801.json", "app_url": "https://3.basecamp.com/999/buckets/456/message_boards/801", "bucket": {"id": 456, "name": "Test", "type": "Project"}}}
],
"assertions": [
{"type": "requestPath", "expected": "/999/dock/tools.json"},
{"type": "requestPath", "expected": "/999/buckets/456/dock/tools.json"},
{"type": "noError"}
],
"tags": ["path", "tools"]
Expand Down
4 changes: 2 additions & 2 deletions go/pkg/basecamp/api-provenance.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"bc3": {
"revision": "056a356ee0d3b018362adbc8b44703df0567adbf",
"date": "2026-03-23"
"revision": "ae96a694deda4a0a5791be0a3b92acbc3509b61b",
"date": "2026-06-01"
}
}
20 changes: 10 additions & 10 deletions go/pkg/basecamp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ type Tool struct {
Bucket *Bucket `json:"bucket,omitempty"`
}

// CloneToolOptions specifies optional parameters for cloning a tool.
type CloneToolOptions struct {
// Title for the cloned tool. If empty, the source tool's title is used.
// CreateToolOptions specifies optional parameters for creating a tool.
type CreateToolOptions struct {
// Title for the new tool. If empty, Basecamp assigns the next available default title for the tool type.
Title string
}

Expand Down Expand Up @@ -77,14 +77,14 @@ func (s *ToolsService) Get(ctx context.Context, toolID int64) (result *Tool, err
return &tool, nil
}

// Create clones an existing tool to create a new one.
// An optional title can be provided; if empty, the source tool's title is used.
// Create adds a tool to the destination bucket.
// An optional title can be provided; if empty, Basecamp assigns the next available default title for the tool type.
// Returns the newly created tool.
func (s *ToolsService) Create(ctx context.Context, sourceToolID int64, opts *CloneToolOptions) (result *Tool, err error) {
func (s *ToolsService) Create(ctx context.Context, bucketID int64, toolType string, opts *CreateToolOptions) (result *Tool, err error) {
op := OperationInfo{
Service: "Tools", Operation: "Create",
ResourceType: "tool", IsMutation: true,
ResourceID: sourceToolID,
ResourceID: bucketID,
}
if gater, ok := s.client.parent.hooks.(GatingHooks); ok {
if ctx, err = gater.OnOperationGate(ctx, op); err != nil {
Expand All @@ -95,14 +95,14 @@ func (s *ToolsService) Create(ctx context.Context, sourceToolID int64, opts *Clo
ctx = s.client.parent.hooks.OnOperationStart(ctx, op)
defer func() { s.client.parent.hooks.OnOperationEnd(ctx, op, err, time.Since(start)) }()

body := generated.CloneToolJSONRequestBody{
SourceRecordingId: sourceToolID,
body := generated.CreateToolJSONRequestBody{
ToolType: toolType,
}
if opts != nil && opts.Title != "" {
body.Title = opts.Title
}

resp, err := s.client.parent.gen.CloneToolWithResponse(ctx, s.client.accountID, body)
resp, err := s.client.parent.gen.CreateToolWithResponse(ctx, s.client.accountID, bucketID, body)
if err != nil {
return nil, err
}
Expand Down
61 changes: 61 additions & 0 deletions go/pkg/basecamp/tools_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package basecamp

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -77,6 +81,63 @@ func TestTool_UnmarshalGet(t *testing.T) {
}
}

func TestToolsServiceCreatePostsToBucketDock(t *testing.T) {
const (
accountID = "5245563"
bucketID = int64(33861629)
toolType = "Message::Board"
title = "Intervention Log / Journal"
)

expectedPath := fmt.Sprintf("/%s/buckets/%d/dock/tools.json", accountID, bucketID)

var capturedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
if r.Method != http.MethodPost || r.URL.Path != expectedPath {
http.NotFound(w, r)
return
}

body := decodeRequestBody(t, r)
if got := body["tool_type"]; got != toolType {
t.Fatalf("tool_type = %v, want %q", got, toolType)
}
if got := body["title"]; got != title {
t.Fatalf("title = %v, want %q", got, title)
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(loadToolsFixture(t, "create.json"))
}))
defer server.Close()

cfg := DefaultConfig()
cfg.BaseURL = server.URL
var capturedOp OperationInfo
hooks := &testHooks{
onOperationStart: func(ctx context.Context, op OperationInfo) context.Context {
capturedOp = op
return ctx
},
}
client := NewClient(cfg, &StaticTokenProvider{Token: "test-token"}, WithHooks(hooks))

_, err := client.ForAccount(accountID).Tools().Create(
context.Background(),
bucketID,
toolType,
&CreateToolOptions{Title: title},
)
if err != nil {
t.Fatalf("Create() error = %v; request path = %s; want bucket %d dock tools endpoint", err, capturedPath, bucketID)
}
if capturedOp.ResourceID != bucketID {
t.Fatalf("Create() operation ResourceID = %d, want destination bucket %d", capturedOp.ResourceID, bucketID)
}
}

func TestTool_UnmarshalCreate(t *testing.T) {
data := loadToolsFixture(t, "create.json")

Expand Down
30 changes: 17 additions & 13 deletions go/pkg/basecamp/url-routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,23 @@
}
}
},
{
"pattern": "/{accountId}/buckets/{bucketId}/dock/tools",
"resource": "Automation",
"operations": {
"POST": "CreateTool"
},
"params": {
"accountId": {
"role": "account",
"type": "string"
},
"bucketId": {
"role": "parent",
"type": "int64"
}
}
},
{
"pattern": "/{accountId}/buckets/{bucketId}/webhooks",
"resource": "Automation",
Expand Down Expand Up @@ -635,19 +652,6 @@
}
}
},
{
"pattern": "/{accountId}/dock/tools",
"resource": "Automation",
"operations": {
"POST": "CloneTool"
},
"params": {
"accountId": {
"role": "account",
"type": "string"
}
}
},
{
"pattern": "/{accountId}/dock/tools/{toolId}",
"resource": "Automation",
Expand Down
18 changes: 18 additions & 0 deletions go/pkg/basecamp/url_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,24 @@ func TestMatchAPI(t *testing.T) {
t.Errorf("Operation = %q, want ListRecordingBoosts", m.Operation)
}

// CreateTool uses the bucket-scoped API route.
m = r.MatchAPI("https://3.basecampapi.com/123/buckets/456/dock/tools.json")
if m == nil {
t.Fatal("MatchAPI should match bucket-scoped CreateTool route")
}
if m.Operation != "CreateTool" {
t.Errorf("Operation = %q, want CreateTool", m.Operation)
}
if m.Params["bucketId"] != "456" {
t.Errorf("Params[bucketId] = %q, want 456", m.Params["bucketId"])
}

// The flat CreateTool route is not an API route.
m = r.MatchAPI("https://3.basecampapi.com/123/dock/tools.json")
if m != nil {
t.Errorf("MatchAPI should return nil for flat CreateTool route, got %+v", m)
}

// Web-only URL should NOT match
m = r.MatchAPI("https://3.basecamp.com/123/buckets/456/sometype")
if m != nil {
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.7.3"

// APIVersion is the Basecamp API version this SDK targets.
const APIVersion = "2026-03-23"
const APIVersion = "2026-06-01"
Loading
Loading