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
24 changes: 24 additions & 0 deletions behavior-model.json
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,18 @@
]
}
},
"GetHillChart": {
"readonly": true,
"retry": {
"max": 3,
"base_delay_ms": 1000,
"backoff": "exponential",
"retry_on": [
429,
503
]
}
},
"GetInbox": {
"readonly": true,
"retry": {
Expand Down Expand Up @@ -2067,6 +2079,18 @@
]
}
},
"UpdateHillChartSettings": {
"idempotent": true,
"retry": {
"max": 3,
"base_delay_ms": 1000,
"backoff": "exponential",
"retry_on": [
429,
503
]
}
},
"UpdateLineupMarker": {
"idempotent": true,
"retry": {
Expand Down
8 changes: 4 additions & 4 deletions go/pkg/basecamp/api-provenance.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"bc3_api": {
"revision": "d223eb505fb99bbd8c5c7a15a378ddc10bab8dfd",
"date": "2026-03-11"
"revision": "421996190124387dc65db51ce2eb32a82086562e",
"date": "2026-03-17"
},
"bc3": {
"revision": "30169b756c949e67770df53fd18563e78dfa29bb",
"date": "2026-03-11"
"revision": "d57b6994a74f123aaf1fd685f993befb28a012fd",
"date": "2026-03-17"
}
}
11 changes: 11 additions & 0 deletions go/pkg/basecamp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ type AccountClient struct {
projects *ProjectsService
todos *TodosService
todosets *TodosetsService
hillCharts *HillChartsService
todolists *TodolistsService
todolistGroups *TodolistGroupsService
people *PeopleService
Expand Down Expand Up @@ -928,6 +929,16 @@ func (ac *AccountClient) Todosets() *TodosetsService {
return ac.todosets
}

// HillCharts returns the HillChartsService for hill chart operations.
func (ac *AccountClient) HillCharts() *HillChartsService {
ac.mu.Lock()
defer ac.mu.Unlock()
if ac.hillCharts == nil {
ac.hillCharts = NewHillChartsService(ac)
}
return ac.hillCharts
}

// Todolists returns the TodolistsService for todolist operations.
func (ac *AccountClient) Todolists() *TodolistsService {
ac.mu.Lock()
Expand Down
136 changes: 136 additions & 0 deletions go/pkg/basecamp/hill_charts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package basecamp

import (
"context"
"fmt"
"time"

"github.com/basecamp/basecamp-sdk/go/pkg/generated"
)

// HillChart represents a hill chart for a todoset.
type HillChart struct {
Enabled bool `json:"enabled"`
Stale bool `json:"stale"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
AppUpdateURL string `json:"app_update_url,omitempty"`
AppVersionsURL string `json:"app_versions_url,omitempty"`
Dots []HillChartDot `json:"dots,omitempty"`
}

// HillChartDot represents a single dot on a hill chart, corresponding to a tracked todolist.
type HillChartDot struct {
ID int64 `json:"id"`
Label string `json:"label"`
Color string `json:"color"`
Position int `json:"position"`
URL string `json:"url,omitempty"`
AppURL string `json:"app_url,omitempty"`
}

// HillChartsService handles hill chart operations.
type HillChartsService struct {
client *AccountClient
}

// NewHillChartsService creates a new HillChartsService.
func NewHillChartsService(client *AccountClient) *HillChartsService {
return &HillChartsService{client: client}
}

// Get returns the hill chart for a todoset.
func (s *HillChartsService) Get(ctx context.Context, todosetID int64) (result *HillChart, err error) {
op := OperationInfo{
Service: "HillCharts", Operation: "Get",
ResourceType: "hill_chart", IsMutation: false,
ResourceID: todosetID,
}
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)) }()

resp, err := s.client.parent.gen.GetHillChartWithResponse(ctx, s.client.accountID, todosetID)
if err != nil {
return nil, err
}
if err = checkResponse(resp.HTTPResponse); err != nil {
return nil, err
}
if resp.JSON200 == nil {
err = fmt.Errorf("unexpected empty response")
return nil, err
}

hillChart := hillChartFromGenerated(*resp.JSON200)
return &hillChart, nil
}

// UpdateSettings tracks or untracks todolists on a hill chart.
// Pass todolist IDs to tracked and/or untracked. Both are optional.
func (s *HillChartsService) UpdateSettings(ctx context.Context, todosetID int64, tracked, untracked []int64) (result *HillChart, err error) {
op := OperationInfo{
Service: "HillCharts", Operation: "UpdateSettings",
ResourceType: "hill_chart", IsMutation: true,
ResourceID: todosetID,
}
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)) }()

body := generated.UpdateHillChartSettingsJSONRequestBody{
Tracked: tracked,
Untracked: untracked,
}

resp, err := s.client.parent.gen.UpdateHillChartSettingsWithResponse(ctx, s.client.accountID, todosetID, body)
if err != nil {
return nil, err
}
if err = checkResponse(resp.HTTPResponse); err != nil {
return nil, err
}
if resp.JSON200 == nil {
err = fmt.Errorf("unexpected empty response")
return nil, err
}

hillChart := hillChartFromGenerated(*resp.JSON200)
return &hillChart, nil
}

// hillChartFromGenerated converts a generated HillChart to our clean HillChart type.
func hillChartFromGenerated(ghc generated.HillChart) HillChart {
hc := HillChart{
Enabled: ghc.Enabled,
Stale: ghc.Stale,
UpdatedAt: ghc.UpdatedAt,
AppUpdateURL: ghc.AppUpdateUrl,
AppVersionsURL: ghc.AppVersionsUrl,
}

if len(ghc.Dots) > 0 {
hc.Dots = make([]HillChartDot, 0, len(ghc.Dots))
for _, gd := range ghc.Dots {
hc.Dots = append(hc.Dots, HillChartDot{
ID: gd.Id,
Label: gd.Label,
Color: gd.Color,
Position: int(gd.Position),
URL: gd.Url,
AppURL: gd.AppUrl,
})
}
}

return hc
}
124 changes: 124 additions & 0 deletions go/pkg/basecamp/hill_charts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package basecamp

import (
"encoding/json"
"os"
"path/filepath"
"testing"
)

func hillChartsFixturesDir() string {
return filepath.Join("..", "..", "..", "spec", "fixtures", "hill_charts")
}

func loadHillChartsFixture(t *testing.T, name string) []byte {
t.Helper()
path := filepath.Join(hillChartsFixturesDir(), name)
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read fixture %s: %v", name, err)
}
return data
}

func TestHillChart_UnmarshalGet(t *testing.T) {
data := loadHillChartsFixture(t, "get.json")

var hc HillChart
if err := json.Unmarshal(data, &hc); err != nil {
t.Fatalf("failed to unmarshal get.json: %v", err)
}

if !hc.Enabled {
t.Error("expected Enabled to be true")
}
if hc.Stale {
t.Error("expected Stale to be false")
}
if hc.AppUpdateURL == "" {
t.Error("expected non-empty AppUpdateURL")
}
if hc.AppVersionsURL == "" {
t.Error("expected non-empty AppVersionsURL")
}
if len(hc.Dots) != 1 {
t.Fatalf("expected 1 dot, got %d", len(hc.Dots))
}

dot := hc.Dots[0]
if dot.ID != 1069479424 {
t.Errorf("expected dot ID 1069479424, got %d", dot.ID)
}
if dot.Label != "Background and research" {
t.Errorf("expected label 'Background and research', got %q", dot.Label)
}
if dot.Color != "blue" {
t.Errorf("expected color 'blue', got %q", dot.Color)
}
if dot.Position != 0 {
t.Errorf("expected position 0, got %d", dot.Position)
}
if dot.URL == "" {
t.Error("expected non-empty URL")
}
if dot.AppURL == "" {
t.Error("expected non-empty AppURL")
}
}

func TestHillChart_TimestampParsing(t *testing.T) {
data := loadHillChartsFixture(t, "get.json")

var hc HillChart
if err := json.Unmarshal(data, &hc); err != nil {
t.Fatalf("failed to unmarshal get.json: %v", err)
}

if hc.UpdatedAt.IsZero() {
t.Error("expected non-zero UpdatedAt")
}
if hc.UpdatedAt.Year() != 2026 {
t.Errorf("expected year 2026, got %d", hc.UpdatedAt.Year())
}
}

func TestHillChart_UnmarshalUpdateSettings(t *testing.T) {
data := loadHillChartsFixture(t, "update-settings.json")

var hc HillChart
if err := json.Unmarshal(data, &hc); err != nil {
t.Fatalf("failed to unmarshal update-settings.json: %v", err)
}

if !hc.Enabled {
t.Error("expected Enabled to be true")
}
if len(hc.Dots) != 2 {
t.Fatalf("expected 2 dots, got %d", len(hc.Dots))
}
if hc.Dots[1].Label != "Design mockups" {
t.Errorf("expected second dot label 'Design mockups', got %q", hc.Dots[1].Label)
}
if hc.Dots[1].Position != 42 {
t.Errorf("expected second dot position 42, got %d", hc.Dots[1].Position)
}
}

func TestHillChart_UnmarshalUpdateSettingsRequest(t *testing.T) {
data := loadHillChartsFixture(t, "update-settings-request.json")

var req struct {
Tracked []int64 `json:"tracked"`
Untracked []int64 `json:"untracked"`
}
if err := json.Unmarshal(data, &req); err != nil {
t.Fatalf("failed to unmarshal update-settings-request.json: %v", err)
}

if len(req.Tracked) != 1 || req.Tracked[0] != 1069479573 {
t.Errorf("expected tracked [1069479573], got %v", req.Tracked)
}
if len(req.Untracked) != 1 || req.Untracked[0] != 1069479511 {
t.Errorf("expected untracked [1069479511], got %v", req.Untracked)
}
}
Loading
Loading