From 6ba61381144aa927357944c1ce988bb093a9796a Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 23 Oct 2026 13:29:11 -0400 Subject: [PATCH 1/7] Show that the activity feed can't reach past midnight bc3's progress.json serves one day per request, today unless `date` names another. The TUI's activity feed asks once, undated, so after midnight it is empty and its Yesterday and Older groups never fill. `basecamp timeline` has no way to ask for another day at all. The feed tests serve progress.json one day at a time, as bc3 does, and expect the feed to step back a day at a time until it is full or a week has passed, keeping an event it sees twice once. The command tests expect `timeline --date` to send the day, offer the day before as the next step, and refuse a date that isn't one, a future date, and a date for the project or person timelines, which bc3 doesn't filter by day. --- internal/commands/timeline_test.go | 72 +++++++++++++ .../tui/workspace/data/timeline_feed_test.go | 102 ++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 internal/commands/timeline_test.go create mode 100644 internal/tui/workspace/data/timeline_feed_test.go diff --git a/internal/commands/timeline_test.go b/internal/commands/timeline_test.go new file mode 100644 index 000000000..6b4c54a7f --- /dev/null +++ b/internal/commands/timeline_test.go @@ -0,0 +1,72 @@ +package commands + +import ( + "errors" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/output" +) + +// progressTransport records the query of each progress.json request. +type progressTransport struct { + queries []url.Values +} + +func (p *progressTransport) RoundTrip(req *http.Request) (*http.Response, error) { + header := http.Header{"Content-Type": []string{"application/json"}} + if req.URL.Path != "/99999/reports/progress.json" { + return &http.Response{StatusCode: http.StatusNotFound, Header: header, Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil + } + p.queries = append(p.queries, req.URL.Query()) + return &http.Response{StatusCode: http.StatusOK, Header: header, Body: io.NopCloser(strings.NewReader(`[]`)), Request: req}, nil +} + +func TestTimelineDateReadsThatDay(t *testing.T) { + transport := &progressTransport{} + app, buf := newTestAppWithTransport(t, transport) + app.Flags.Hints = true + + require.NoError(t, executeCommand(NewTimelineCmd(), app, "--date", "2026-08-30")) + + require.Len(t, transport.queries, 1) + assert.Equal(t, "2026-08-30", transport.queries[0].Get("date")) + assert.Contains(t, buf.String(), "basecamp timeline --date 2026-08-29") +} + +func TestTimelineWithoutDateReadsToday(t *testing.T) { + transport := &progressTransport{} + app, _ := newTestAppWithTransport(t, transport) + + require.NoError(t, executeCommand(NewTimelineCmd(), app)) + + require.Len(t, transport.queries, 1) + assert.False(t, transport.queries[0].Has("date")) +} + +func TestTimelineDateRefusals(t *testing.T) { + for name, args := range map[string][]string{ + "not a date": {"--date", "someday"}, + "in future": {"--date", "tomorrow"}, + "for project": {"--date", "2026-08-30", "--in", "123"}, + "for person": {"--date", "2026-08-30", "me"}, + } { + t.Run(name, func(t *testing.T) { + transport := &progressTransport{} + app, _ := newTestAppWithTransport(t, transport) + + err := executeCommand(NewTimelineCmd(), app, args...) + + var e *output.Error + require.True(t, errors.As(err, &e), "expected *output.Error, got %T: %v", err, err) + assert.Equal(t, output.CodeUsage, e.Code) + assert.Empty(t, transport.queries) + }) + } +} diff --git a/internal/tui/workspace/data/timeline_feed_test.go b/internal/tui/workspace/data/timeline_feed_test.go new file mode 100644 index 000000000..7dd089b2e --- /dev/null +++ b/internal/tui/workspace/data/timeline_feed_test.go @@ -0,0 +1,102 @@ +package data + +import ( + "context" + "io" + "net/http" + "strconv" + "strings" + "testing" + "time" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// activityDaysTransport serves progress.json the way bc3 does: one day per +// request, today unless `date` names another day. +type activityDaysTransport struct { + today string + days map[string][]int64 + asked []string +} + +func (m *activityDaysTransport) RoundTrip(req *http.Request) (*http.Response, error) { + header := make(http.Header) + header.Set("Content-Type", "application/json") + if req.URL.Path != "/99999/reports/progress.json" { + return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(`{}`)), Header: header, Request: req}, nil + } + date := req.URL.Query().Get("date") + m.asked = append(m.asked, date) + if date == "" { + date = m.today + } + events := make([]string, 0, len(m.days[date])) + for _, id := range m.days[date] { + events = append(events, `{"id":`+strconv.FormatInt(id, 10)+`,"created_at":"`+date+`T12:00:00Z","kind":"todo_created","action":"created","target":"Todo","title":"x"}`) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("[" + strings.Join(events, ",") + "]")), Header: header, Request: req}, nil +} + +func fetchActivityDays(t *testing.T, transport *activityDaysTransport) []int64 { + t.Helper() + originalNow := hubNow + hubNow = func() time.Time { return time.Date(2026, 10, 23, 0, 30, 0, 0, time.Local) } + t.Cleanup(func() { hubNow = originalNow }) + + sdk := basecamp.NewClient(&basecamp.Config{BaseURL: "https://3.basecampapi.com"}, hubCheckinsTestTokenProvider{}, + basecamp.WithTransport(transport), + basecamp.WithMaxRetries(1), + ) + events, err := fetchTimelineEvents(context.Background(), sdk.ForAccount("99999"), AccountInfo{ID: "99999", Name: "Acme"}) + require.NoError(t, err) + ids := make([]int64, 0, len(events)) + for _, e := range events { + ids = append(ids, e.ID) + } + return ids +} + +func TestFetchTimelineEventsReachesPastMidnight(t *testing.T) { + transport := &activityDaysTransport{today: "2026-10-23", days: map[string][]int64{ + "2026-10-23": {1}, + "2026-10-22": {2, 3}, + "2026-10-20": {4}, + }} + + assert.Equal(t, []int64{1, 2, 3, 4}, fetchActivityDays(t, transport)) +} + +func TestFetchTimelineEventsStopsOnceTheFeedIsFull(t *testing.T) { + today := make([]int64, timelineFeedSize) + for i := range today { + today[i] = int64(i + 1) + } + transport := &activityDaysTransport{today: "2026-10-23", days: map[string][]int64{ + "2026-10-23": today, + "2026-10-22": {1000}, + }} + + assert.Len(t, fetchActivityDays(t, transport), timelineFeedSize) + assert.Equal(t, []string{""}, transport.asked) +} + +func TestFetchTimelineEventsStopsAtTheLookback(t *testing.T) { + transport := &activityDaysTransport{today: "2026-10-23", days: map[string][]int64{ + "2026-10-16": {1}, + }} + + assert.Empty(t, fetchActivityDays(t, transport)) + assert.Equal(t, []string{"", "2026-10-22", "2026-10-21", "2026-10-20", "2026-10-19", "2026-10-18", "2026-10-17"}, transport.asked) +} + +func TestFetchTimelineEventsKeepsAnEventSeenTwiceOnce(t *testing.T) { + transport := &activityDaysTransport{today: "2026-10-22", days: map[string][]int64{ + "2026-10-22": {1, 2}, + "2026-10-21": {3}, + }} + + assert.Equal(t, []int64{1, 2, 3}, fetchActivityDays(t, transport)) +} From 94167e5eba9135acacaf857fe8b3f3dbdb52287d Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 23 Oct 2026 13:38:11 -0400 Subject: [PATCH 2/7] Step the activity feed back a day at a time, and add timeline --date Bumps the SDK to basecamp-sdk#1038's head (732fb9cd2bbd), which teaches Progress the `date`, `project_ids[]` and `people_ids[]` parameters through a new ProgressOptions. The TUI's activity feed asks for today, undated so the day is the one in the person's Basecamp time zone, then steps back from yesterday on this machine one day per request until it has 80 events or has read a week. If the two time zones disagree on the date a day can come back twice, so an event already seen is skipped. A busy account still makes one request. `basecamp timeline --date` reads another day of the account-wide feed. It accepts YYYY-MM-DD, today and yesterday, refuses anything else (bc3 reads an unparseable date as today) and a future day (dateparse reads a weekday name as the next one), and refuses --date with a project or person timeline, which bc3 doesn't filter by day. Its breadcrumbs offer the day before. --- .surface | 1 + go.mod | 2 +- go.sum | 4 +- internal/commands/timeline.go | 53 ++++++++++++++++++++--- internal/mcpserver/model/PROVENANCE.json | 4 +- internal/mcpserver/model/openapi.json | 39 +++++++++++++++++ internal/tui/workspace/data/hub_global.go | 41 +++++++++++++++--- internal/version/sdk-provenance.json | 6 +-- 8 files changed, 129 insertions(+), 21 deletions(-) diff --git a/.surface b/.surface index 41bf791e4..819d8a5c3 100644 --- a/.surface +++ b/.surface @@ -14775,6 +14775,7 @@ FLAG basecamp timeline --agent type=bool FLAG basecamp timeline --all type=bool FLAG basecamp timeline --cache-dir type=string FLAG basecamp timeline --count type=bool +FLAG basecamp timeline --date type=string FLAG basecamp timeline --help type=bool FLAG basecamp timeline --hints type=bool FLAG basecamp timeline --ids-only type=bool diff --git a/go.mod b/go.mod index 6ad7e9f21..1d4e2031e 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261022230746-63115e7978c9 + github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261023173703-732fb9cd2bbd github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9 github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d github.com/basecamp/surfguard/go v0.1.0 diff --git a/go.sum b/go.sum index bf81fac46..741a9b83d 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg= github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261022230746-63115e7978c9 h1:ZcW7c/cnQTgFkNHMcnkxPqHIko2d0v2MrOgyykBrOj0= -github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261022230746-63115e7978c9/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU= +github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261023173703-732fb9cd2bbd h1:HufSyAOcNXVi3deliDfXqo6FF0hmnU78IqmVVAlVhjw= +github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261023173703-732fb9cd2bbd/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU= github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9 h1:3NfGs+zx23Tp6cf+97+UewrJHlzZMLzmvEXOmOJyQAk= github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9/go.mod h1:iTBTaWvsPEFIcZfkxQHEfISyJ6sZ7036K6bNx0RY3EE= github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4= diff --git a/internal/commands/timeline.go b/internal/commands/timeline.go index 302a0cd87..863914a03 100644 --- a/internal/commands/timeline.go +++ b/internal/commands/timeline.go @@ -3,11 +3,13 @@ package commands import ( "fmt" "strconv" + "time" "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" "github.com/spf13/cobra" "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/dateparse" "github.com/basecamp/basecamp-cli/internal/output" ) @@ -18,20 +20,22 @@ func NewTimelineCmd() *cobra.Command { var limit int var page int var all bool + var date string cmd := &cobra.Command{ Use: "timeline [me]", Short: "View activity timeline", Long: `View activity timelines for the account, a project, or a person. -By default, shows the account-wide activity feed (recent activity across all projects). +By default, shows today's account-wide activity feed (activity across all projects). +Basecamp serves that feed one day at a time: use --date to read another day. Use --in to view a specific project's timeline. Use "me" or --person to view a person's activity timeline.`, - Annotations: map[string]string{"agent_notes": "Timeline shows activity feed — account-wide by default, or scoped with --in or --person "}, + Annotations: map[string]string{"agent_notes": "Timeline shows activity feed — account-wide by default, one day per call (--date YYYY-MM-DD for an earlier day), or scoped with --in or --person "}, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runTimeline(cmd, args, project, person, limit, page, all) + return runTimeline(cmd, args, project, person, date, limit, page, all) }, } @@ -41,6 +45,7 @@ Use "me" or --person to view a person's activity timeline.`, cmd.Flags().IntVarP(&limit, "limit", "n", 0, "Maximum number of events to fetch (0 = default 100)") cmd.Flags().BoolVar(&all, "all", false, "Fetch all events (no limit)") cmd.Flags().IntVar(&page, "page", 0, "Fetch a single page (use --all for everything)") + cmd.Flags().StringVar(&date, "date", "", "Day of the account-wide feed to read (YYYY-MM-DD, today, yesterday)") return cmd } @@ -71,10 +76,34 @@ func timelineListOpts(limit, page int, all bool) *basecamp.TimelineListOptions { return opts } -func runTimeline(cmd *cobra.Command, args []string, project, person string, limit, page int, all bool) error { +// timelineDay parses --date. Basecamp reads a day it can't parse as today, +// so anything that isn't a date is refused here, as is a future day, which +// has no activity; dateparse reads a weekday name as the next one. +func timelineDay(date string, now time.Time) (basecamp.Date, error) { + if date == "" { + return basecamp.Date{}, nil + } + day, err := basecamp.ParseDate(dateparse.ParseFrom(date, now)) + if err != nil { + return basecamp.Date{}, output.ErrUsageHint(fmt.Sprintf("invalid --date %q", date), "Use YYYY-MM-DD, today or yesterday.") + } + if day.In(time.Local).After(now) { + return basecamp.Date{}, output.ErrUsageHint(fmt.Sprintf("--date %s is in the future", day), "The activity feed has nothing after today.") + } + return day, nil +} + +func runTimeline(cmd *cobra.Command, args []string, project, person, date string, limit, page int, all bool) error { if err := validateTimelinePagination(limit, page, all); err != nil { return err } + if date != "" && (project != "" || person != "" || len(args) > 0) { + return output.ErrUsage("--date reads the account-wide feed; Basecamp doesn't filter project or person timelines by day") + } + day, err := timelineDay(date, time.Now()) + if err != nil { + return err + } app := appctx.FromContext(cmd.Context()) @@ -116,14 +145,26 @@ func runTimeline(cmd *cobra.Command, args []string, project, person string, limi } // Default: account-wide activity feed - result, err := app.Account().Timeline().Progress(cmd.Context(), opts) + result, err := app.Account().Timeline().Progress(cmd.Context(), &basecamp.ProgressOptions{Limit: opts.Limit, Page: opts.Page, Date: day}) if err != nil { return convertSDKError(err) } + summary := fmt.Sprintf("%d events today", len(result.Events)) + previous := basecamp.DateOf(time.Now().AddDate(0, 0, -1)) + if !day.IsZero() { + summary = fmt.Sprintf("%d events on %s", len(result.Events), day) + previous = basecamp.DateOf(day.In(time.Local).AddDate(0, 0, -1)) + } + respOpts := []output.ResponseOption{ - output.WithSummary(fmt.Sprintf("%d recent events", len(result.Events))), + output.WithSummary(summary), output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "previous", + Cmd: fmt.Sprintf("basecamp timeline --date %s", previous), + Description: "View the day before", + }, output.Breadcrumb{ Action: "project", Cmd: "basecamp timeline --in ", diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 42ac0b999..db81395bd 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "63115e7978c989a271220cee8b025135315db882", - "ref": "v0.27.1-3-g63115e79", + "commit": "732fb9cd2bbdbcd04164f4c0a61e00e17218a927", + "ref": "v0.27.1-5-g732fb9cd", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "binary-upload operations dropped because the toolkit refuses their non-JSON bodies (EXCLUDED_OPERATIONS) and the stream-ticket mint dropped by policy (POLICY_EXCLUDED_OPERATIONS); no tag patches applied (PATCHED_TAGS is empty — the export tags every operation) — see the sync script" diff --git a/internal/mcpserver/model/openapi.json b/internal/mcpserver/model/openapi.json index d95723c84..2dd36b948 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -21515,6 +21515,45 @@ }, "required": true }, + { + "name": "date", + "in": "query", + "description": "The day to read (YYYY-MM-DD). The feed serves one day per request and defaults to today, so step this back a day at a time to read history; `page` pages within the day. The server reads an unparseable date as today.", + "schema": { + "type": "string", + "description": "The day to read (YYYY-MM-DD). The feed serves one day per request and defaults to today, so step this back a day at a time to read history; `page` pages within the day. The server reads an unparseable date as today." + } + }, + { + "name": "project_ids[]", + "in": "query", + "description": "Only events in these projects. When omitted, the server applies the project filter the caller last saved in the web's activity view, not \"all projects\". If none of the IDs is a project the caller can access, the response is empty and the server clears that saved filter, so send only IDs the API just returned.", + "style": "form", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "description": "Only events in these projects. When omitted, the server applies the project filter the caller last saved in the web's activity view, not \"all projects\". If none of the IDs is a project the caller can access, the response is empty and the server clears that saved filter, so send only IDs the API just returned." + }, + "explode": true + }, + { + "name": "people_ids[]", + "in": "query", + "description": "Only events created by these people. When omitted, the server applies the people filter the caller last saved in the web's activity view, not \"everyone\". If none of the IDs is a person the caller can see, the response is empty and the server clears that saved filter, so send only IDs the API just returned.", + "style": "form", + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "description": "Only events created by these people. When omitted, the server applies the people filter the caller last saved in the web's activity view, not \"everyone\". If none of the IDs is a person the caller can see, the response is empty and the server clears that saved filter, so send only IDs the API just returned." + }, + "explode": true + }, { "name": "page", "in": "query", diff --git a/internal/tui/workspace/data/hub_global.go b/internal/tui/workspace/data/hub_global.go index a37970e02..20b875e69 100644 --- a/internal/tui/workspace/data/hub_global.go +++ b/internal/tui/workspace/data/hub_global.go @@ -592,8 +592,8 @@ func (h *Hub) Timeline() *Pool[[]TimelineEventInfo] { sort.Slice(all, func(i, j int) bool { return all[i].CreatedAtTS > all[j].CreatedAtTS }) - if len(all) > 80 { - all = all[:80] + if len(all) > timelineFeedSize { + all = all[:timelineFeedSize] } return all, nil }) @@ -605,14 +605,41 @@ func (h *Hub) Timeline() *Pool[[]TimelineEventInfo] { // -- Shared fetch helpers +// timelineFeedSize is how many events the activity feed shows. +const timelineFeedSize = 80 + +// timelineLookbackDays is how many days a quiet account's feed reads back. +const timelineLookbackDays = 7 + // fetchTimelineEvents fetches timeline events from a single account. +// Basecamp serves the activity feed one day per request, so this asks for +// today (undated, so in the person's Basecamp time zone) and then steps back +// a day at a time from yesterday on this machine until the feed is full. +// When the two time zones disagree on the date a day can come back twice, +// so an event already seen is skipped. func fetchTimelineEvents(ctx context.Context, client *basecamp.AccountClient, acct AccountInfo) ([]TimelineEventInfo, error) { - result, err := client.Timeline().Progress(ctx, nil) - if err != nil { - return nil, err + var events []basecamp.TimelineEvent + seen := make(map[int64]bool) + today := hubNow() + for back := 0; back < timelineLookbackDays && len(events) < timelineFeedSize; back++ { + opts := &basecamp.ProgressOptions{Limit: timelineFeedSize - len(events)} + if back > 0 { + opts.Date = basecamp.DateOf(today.AddDate(0, 0, -back)) + } + result, err := client.Timeline().Progress(ctx, opts) + if err != nil { + return nil, err + } + for _, e := range result.Events { + if !seen[e.ID] { + seen[e.ID] = true + events = append(events, e) + } + } } - infos := make([]TimelineEventInfo, 0, len(result.Events)) - for _, e := range result.Events { + + infos := make([]TimelineEventInfo, 0, len(events)) + for _, e := range events { project := "" var projectID int64 if e.Bucket != nil { diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index f0239d2a0..919c42920 100644 --- a/internal/version/sdk-provenance.json +++ b/internal/version/sdk-provenance.json @@ -1,9 +1,9 @@ { "sdk": { "module": "github.com/basecamp/basecamp-sdk/go", - "version": "v0.27.1-0.20261022230746-63115e7978c9", - "revision": "63115e7978c9", - "updated_at": "2026-10-22T23:07:46Z" + "version": "v0.27.1-0.20261023173703-732fb9cd2bbd", + "revision": "732fb9cd2bbd", + "updated_at": "2026-10-23T17:37:03Z" }, "api": { "repo": "basecamp/bc3", From a4c40c0c554bdd9350de287381d20be9f8e59b16 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 23 Oct 2026 14:11:27 -0400 Subject: [PATCH 3/7] Update the Nix vendorHash for the SDK bump --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index 650dca96b..eb942f356 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -8,7 +8,7 @@ buildGoModule.override { go = go_1_26; } (finalAttrs: { src = lib.cleanSource ./..; # To update: set to lib.fakeHash, run `nix build`, use the hash from the error. - vendorHash = "sha256-TqjNLGbA3/mruCH8TA4HaGHYLnsXLK66fUuXp4ye9eQ="; + vendorHash = "sha256-UBUTO79eBEkdKupOitpRgoGQkTrgCaN2ZANnr+Yurwo="; subPackages = [ "cmd/basecamp" ]; From ad9242de52669b0ae73c963594ec75e8687c9c67 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 23 Oct 2026 14:19:39 -0400 Subject: [PATCH 4/7] Skip no day when Basecamp's date is ahead of this machine's The feed's undated request reads today in the person's Basecamp time zone, and the dated walk began at yesterday on this machine. When Basecamp's date was a day ahead, the machine's today was never read. The walk now begins at the machine's today; when the dates agree that day comes back twice, which the seen-set already absorbs, at one extra request for a quiet account. `timeline` without --date had the same gap in its breadcrumb, which named the machine's yesterday. Undated, the CLI can't know Basecamp's today, so the breadcrumb now offers `--date `; with --date it still names the exact day before. --- internal/commands/timeline.go | 16 ++++++++------ internal/commands/timeline_test.go | 22 +++++++++++++++++-- internal/tui/workspace/data/hub_global.go | 16 ++++++++------ .../tui/workspace/data/timeline_feed_test.go | 12 +++++++++- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/internal/commands/timeline.go b/internal/commands/timeline.go index 863914a03..43ae6ebd8 100644 --- a/internal/commands/timeline.go +++ b/internal/commands/timeline.go @@ -150,21 +150,23 @@ func runTimeline(cmd *cobra.Command, args []string, project, person, date string return convertSDKError(err) } + // Undated, the day is today in the person's Basecamp time zone, which + // this machine's clock can't name, so neither can the day before it. summary := fmt.Sprintf("%d events today", len(result.Events)) - previous := basecamp.DateOf(time.Now().AddDate(0, 0, -1)) + earlier := output.Breadcrumb{Action: "earlier", Cmd: "basecamp timeline --date ", Description: "View an earlier day"} if !day.IsZero() { summary = fmt.Sprintf("%d events on %s", len(result.Events), day) - previous = basecamp.DateOf(day.In(time.Local).AddDate(0, 0, -1)) + earlier = output.Breadcrumb{ + Action: "previous", + Cmd: fmt.Sprintf("basecamp timeline --date %s", basecamp.DateOf(day.In(time.Local).AddDate(0, 0, -1))), + Description: "View the day before", + } } respOpts := []output.ResponseOption{ output.WithSummary(summary), output.WithBreadcrumbs( - output.Breadcrumb{ - Action: "previous", - Cmd: fmt.Sprintf("basecamp timeline --date %s", previous), - Description: "View the day before", - }, + earlier, output.Breadcrumb{ Action: "project", Cmd: "basecamp timeline --in ", diff --git a/internal/commands/timeline_test.go b/internal/commands/timeline_test.go index 6b4c54a7f..4d9e5a8ce 100644 --- a/internal/commands/timeline_test.go +++ b/internal/commands/timeline_test.go @@ -1,6 +1,8 @@ package commands import ( + "bytes" + "encoding/json" "errors" "io" "net/http" @@ -28,6 +30,18 @@ func (p *progressTransport) RoundTrip(req *http.Request) (*http.Response, error) return &http.Response{StatusCode: http.StatusOK, Header: header, Body: io.NopCloser(strings.NewReader(`[]`)), Request: req}, nil } +func firstBreadcrumb(t *testing.T, buf *bytes.Buffer) string { + t.Helper() + var envelope struct { + Breadcrumbs []struct { + Cmd string `json:"cmd"` + } `json:"breadcrumbs"` + } + require.NoError(t, json.Unmarshal(buf.Bytes(), &envelope)) + require.NotEmpty(t, envelope.Breadcrumbs) + return envelope.Breadcrumbs[0].Cmd +} + func TestTimelineDateReadsThatDay(t *testing.T) { transport := &progressTransport{} app, buf := newTestAppWithTransport(t, transport) @@ -37,17 +51,21 @@ func TestTimelineDateReadsThatDay(t *testing.T) { require.Len(t, transport.queries, 1) assert.Equal(t, "2026-08-30", transport.queries[0].Get("date")) - assert.Contains(t, buf.String(), "basecamp timeline --date 2026-08-29") + assert.Equal(t, "basecamp timeline --date 2026-08-29", firstBreadcrumb(t, buf)) } func TestTimelineWithoutDateReadsToday(t *testing.T) { transport := &progressTransport{} - app, _ := newTestAppWithTransport(t, transport) + app, buf := newTestAppWithTransport(t, transport) + app.Flags.Hints = true require.NoError(t, executeCommand(NewTimelineCmd(), app)) require.Len(t, transport.queries, 1) assert.False(t, transport.queries[0].Has("date")) + // Undated, the day is Basecamp's today, which this machine's clock can't + // name, so the breadcrumb can't name the day before it either. + assert.Equal(t, "basecamp timeline --date ", firstBreadcrumb(t, buf)) } func TestTimelineDateRefusals(t *testing.T) { diff --git a/internal/tui/workspace/data/hub_global.go b/internal/tui/workspace/data/hub_global.go index 20b875e69..ce6dd761b 100644 --- a/internal/tui/workspace/data/hub_global.go +++ b/internal/tui/workspace/data/hub_global.go @@ -608,22 +608,24 @@ func (h *Hub) Timeline() *Pool[[]TimelineEventInfo] { // timelineFeedSize is how many events the activity feed shows. const timelineFeedSize = 80 -// timelineLookbackDays is how many days a quiet account's feed reads back. +// timelineLookbackDays is how many days, today included, a quiet account's +// feed reads. const timelineLookbackDays = 7 // fetchTimelineEvents fetches timeline events from a single account. // Basecamp serves the activity feed one day per request, so this asks for -// today (undated, so in the person's Basecamp time zone) and then steps back -// a day at a time from yesterday on this machine until the feed is full. -// When the two time zones disagree on the date a day can come back twice, -// so an event already seen is skipped. +// today undated, which is today in the person's Basecamp time zone, then +// steps back a day at a time from today on this machine until the feed is +// full. Starting the dated walk at this machine's today, not yesterday, keeps +// a day from being skipped when Basecamp's date is ahead; when the dates +// agree that day comes back twice, so an event already seen is skipped. func fetchTimelineEvents(ctx context.Context, client *basecamp.AccountClient, acct AccountInfo) ([]TimelineEventInfo, error) { var events []basecamp.TimelineEvent seen := make(map[int64]bool) today := hubNow() - for back := 0; back < timelineLookbackDays && len(events) < timelineFeedSize; back++ { + for back := -1; back < timelineLookbackDays && len(events) < timelineFeedSize; back++ { opts := &basecamp.ProgressOptions{Limit: timelineFeedSize - len(events)} - if back > 0 { + if back >= 0 { opts.Date = basecamp.DateOf(today.AddDate(0, 0, -back)) } result, err := client.Timeline().Progress(ctx, opts) diff --git a/internal/tui/workspace/data/timeline_feed_test.go b/internal/tui/workspace/data/timeline_feed_test.go index 7dd089b2e..7814a47d9 100644 --- a/internal/tui/workspace/data/timeline_feed_test.go +++ b/internal/tui/workspace/data/timeline_feed_test.go @@ -89,7 +89,7 @@ func TestFetchTimelineEventsStopsAtTheLookback(t *testing.T) { }} assert.Empty(t, fetchActivityDays(t, transport)) - assert.Equal(t, []string{"", "2026-10-22", "2026-10-21", "2026-10-20", "2026-10-19", "2026-10-18", "2026-10-17"}, transport.asked) + assert.Equal(t, []string{"", "2026-10-23", "2026-10-22", "2026-10-21", "2026-10-20", "2026-10-19", "2026-10-18", "2026-10-17"}, transport.asked) } func TestFetchTimelineEventsKeepsAnEventSeenTwiceOnce(t *testing.T) { @@ -100,3 +100,13 @@ func TestFetchTimelineEventsKeepsAnEventSeenTwiceOnce(t *testing.T) { assert.Equal(t, []int64{1, 2, 3}, fetchActivityDays(t, transport)) } + +func TestFetchTimelineEventsMissesNoDayWhenBasecampIsAhead(t *testing.T) { + transport := &activityDaysTransport{today: "2026-10-24", days: map[string][]int64{ + "2026-10-24": {1}, + "2026-10-23": {2}, + "2026-10-22": {3}, + }} + + assert.Equal(t, []int64{1, 2, 3}, fetchActivityDays(t, transport)) +} From 7b9aee1131039e41cff4e2181ef577d9087da365 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 23 Oct 2026 15:16:18 -0400 Subject: [PATCH 5/7] Step back by calendar day, and refuse a blank --date Stepping a local timestamp back a day skips a date where a clock change removes midnight: in America/Santiago, 2026-09-07 00:30 minus a day is 2026-09-05 23:30. The TUI walk and the --date breadcrumb now subtract from a calendar date (Date.AddDays), and the future check compares dates. `--date ' '` trimmed to nothing, parsed as the zero date and read today's feed; a date that parses to nothing is now refused like any other. --- internal/commands/timeline.go | 6 ++--- internal/commands/timeline_test.go | 20 +++++++++++++++++ internal/tui/workspace/data/hub_global.go | 4 ++-- .../tui/workspace/data/timeline_feed_test.go | 22 ++++++++++++++++++- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/internal/commands/timeline.go b/internal/commands/timeline.go index 43ae6ebd8..a97d1fd52 100644 --- a/internal/commands/timeline.go +++ b/internal/commands/timeline.go @@ -84,10 +84,10 @@ func timelineDay(date string, now time.Time) (basecamp.Date, error) { return basecamp.Date{}, nil } day, err := basecamp.ParseDate(dateparse.ParseFrom(date, now)) - if err != nil { + if err != nil || day.IsZero() { return basecamp.Date{}, output.ErrUsageHint(fmt.Sprintf("invalid --date %q", date), "Use YYYY-MM-DD, today or yesterday.") } - if day.In(time.Local).After(now) { + if day.After(basecamp.DateOf(now)) { return basecamp.Date{}, output.ErrUsageHint(fmt.Sprintf("--date %s is in the future", day), "The activity feed has nothing after today.") } return day, nil @@ -158,7 +158,7 @@ func runTimeline(cmd *cobra.Command, args []string, project, person, date string summary = fmt.Sprintf("%d events on %s", len(result.Events), day) earlier = output.Breadcrumb{ Action: "previous", - Cmd: fmt.Sprintf("basecamp timeline --date %s", basecamp.DateOf(day.In(time.Local).AddDate(0, 0, -1))), + Cmd: fmt.Sprintf("basecamp timeline --date %s", day.AddDays(-1)), Description: "View the day before", } } diff --git a/internal/commands/timeline_test.go b/internal/commands/timeline_test.go index 4d9e5a8ce..6558e974b 100644 --- a/internal/commands/timeline_test.go +++ b/internal/commands/timeline_test.go @@ -9,6 +9,7 @@ import ( "net/url" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -71,6 +72,7 @@ func TestTimelineWithoutDateReadsToday(t *testing.T) { func TestTimelineDateRefusals(t *testing.T) { for name, args := range map[string][]string{ "not a date": {"--date", "someday"}, + "blank": {"--date", " "}, "in future": {"--date", "tomorrow"}, "for project": {"--date", "2026-08-30", "--in", "123"}, "for person": {"--date", "2026-08-30", "me"}, @@ -88,3 +90,21 @@ func TestTimelineDateRefusals(t *testing.T) { }) } } + +// Chile starts daylight saving at midnight on 2026-09-06, so that day has no +// midnight and local-midnight arithmetic lands a day early. +func TestTimelineDateBreadcrumbAcrossAMidnightClockChange(t *testing.T) { + santiago, err := time.LoadLocation("America/Santiago") + require.NoError(t, err) + originalLocal := time.Local + time.Local = santiago + t.Cleanup(func() { time.Local = originalLocal }) + + transport := &progressTransport{} + app, buf := newTestAppWithTransport(t, transport) + app.Flags.Hints = true + + require.NoError(t, executeCommand(NewTimelineCmd(), app, "--date", "2026-09-06")) + + assert.Equal(t, "basecamp timeline --date 2026-09-05", firstBreadcrumb(t, buf)) +} diff --git a/internal/tui/workspace/data/hub_global.go b/internal/tui/workspace/data/hub_global.go index ce6dd761b..859ac0cb7 100644 --- a/internal/tui/workspace/data/hub_global.go +++ b/internal/tui/workspace/data/hub_global.go @@ -622,11 +622,11 @@ const timelineLookbackDays = 7 func fetchTimelineEvents(ctx context.Context, client *basecamp.AccountClient, acct AccountInfo) ([]TimelineEventInfo, error) { var events []basecamp.TimelineEvent seen := make(map[int64]bool) - today := hubNow() + today := basecamp.DateOf(hubNow()) for back := -1; back < timelineLookbackDays && len(events) < timelineFeedSize; back++ { opts := &basecamp.ProgressOptions{Limit: timelineFeedSize - len(events)} if back >= 0 { - opts.Date = basecamp.DateOf(today.AddDate(0, 0, -back)) + opts.Date = today.AddDays(-back) } result, err := client.Timeline().Progress(ctx, opts) if err != nil { diff --git a/internal/tui/workspace/data/timeline_feed_test.go b/internal/tui/workspace/data/timeline_feed_test.go index 7814a47d9..3a896b03b 100644 --- a/internal/tui/workspace/data/timeline_feed_test.go +++ b/internal/tui/workspace/data/timeline_feed_test.go @@ -41,9 +41,14 @@ func (m *activityDaysTransport) RoundTrip(req *http.Request) (*http.Response, er } func fetchActivityDays(t *testing.T, transport *activityDaysTransport) []int64 { + t.Helper() + return fetchActivityDaysAt(t, transport, time.Date(2026, 10, 23, 0, 30, 0, 0, time.Local)) +} + +func fetchActivityDaysAt(t *testing.T, transport *activityDaysTransport, now time.Time) []int64 { t.Helper() originalNow := hubNow - hubNow = func() time.Time { return time.Date(2026, 10, 23, 0, 30, 0, 0, time.Local) } + hubNow = func() time.Time { return now } t.Cleanup(func() { hubNow = originalNow }) sdk := basecamp.NewClient(&basecamp.Config{BaseURL: "https://3.basecampapi.com"}, hubCheckinsTestTokenProvider{}, @@ -110,3 +115,18 @@ func TestFetchTimelineEventsMissesNoDayWhenBasecampIsAhead(t *testing.T) { assert.Equal(t, []int64{1, 2, 3}, fetchActivityDays(t, transport)) } + +// Chile starts daylight saving at midnight on 2026-09-06, so that day has no +// 00:30 and stepping a timestamp back a day from 2026-09-07 00:30 lands on +// 2026-09-05. +func TestFetchTimelineEventsMissesNoDayAcrossAMidnightClockChange(t *testing.T) { + santiago, err := time.LoadLocation("America/Santiago") + require.NoError(t, err) + transport := &activityDaysTransport{today: "2026-09-07", days: map[string][]int64{ + "2026-09-07": {1}, + "2026-09-06": {2}, + "2026-09-05": {3}, + }} + + assert.Equal(t, []int64{1, 2, 3}, fetchActivityDaysAt(t, transport, time.Date(2026, 9, 7, 0, 30, 0, 0, santiago))) +} From a058c749ed5c7df15bf34532d67879317404937e Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 23 Oct 2026 15:43:36 -0400 Subject: [PATCH 6/7] Pin basecamp-sdk#1038's current head (ab6fefd0) Its only change since 732fb9cd is a MIGRATING.md note, but the vendored MCP model has to be synced from the commit go.mod pins. --- go.mod | 2 +- go.sum | 4 ++-- internal/version/sdk-provenance.json | 6 +++--- nix/package.nix | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 1d4e2031e..dacd4ec26 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261023173703-732fb9cd2bbd + github.com/basecamp/basecamp-sdk/go v0.27.2-0.20261023175748-ab6fefd08980 github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9 github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d github.com/basecamp/surfguard/go v0.1.0 diff --git a/go.sum b/go.sum index 741a9b83d..941f8a261 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v1.2.0 h1:t0MxlfMTcmaGXZ5ztgKRs7ZiY1MwXtBRwSSSAh1ofVg= github.com/basecamp/actioncable-go v1.2.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261023173703-732fb9cd2bbd h1:HufSyAOcNXVi3deliDfXqo6FF0hmnU78IqmVVAlVhjw= -github.com/basecamp/basecamp-sdk/go v0.27.1-0.20261023173703-732fb9cd2bbd/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU= +github.com/basecamp/basecamp-sdk/go v0.27.2-0.20261023175748-ab6fefd08980 h1:UWxSoySz1Ojy4LTjiYmZl1J1zvmDu/pZOQ+7hHpTq6Q= +github.com/basecamp/basecamp-sdk/go v0.27.2-0.20261023175748-ab6fefd08980/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU= github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9 h1:3NfGs+zx23Tp6cf+97+UewrJHlzZMLzmvEXOmOJyQAk= github.com/basecamp/cli v0.2.2-0.20261023171439-c9bce8206df9/go.mod h1:iTBTaWvsPEFIcZfkxQHEfISyJ6sZ7036K6bNx0RY3EE= github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4= diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index 919c42920..1a78fd0a5 100644 --- a/internal/version/sdk-provenance.json +++ b/internal/version/sdk-provenance.json @@ -1,9 +1,9 @@ { "sdk": { "module": "github.com/basecamp/basecamp-sdk/go", - "version": "v0.27.1-0.20261023173703-732fb9cd2bbd", - "revision": "732fb9cd2bbd", - "updated_at": "2026-10-23T17:37:03Z" + "version": "v0.27.2-0.20261023175748-ab6fefd08980", + "revision": "ab6fefd08980", + "updated_at": "2026-10-23T17:57:48Z" }, "api": { "repo": "basecamp/bc3", diff --git a/nix/package.nix b/nix/package.nix index eb942f356..4a11dd314 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -8,7 +8,7 @@ buildGoModule.override { go = go_1_26; } (finalAttrs: { src = lib.cleanSource ./..; # To update: set to lib.fakeHash, run `nix build`, use the hash from the error. - vendorHash = "sha256-UBUTO79eBEkdKupOitpRgoGQkTrgCaN2ZANnr+Yurwo="; + vendorHash = "sha256-d3efrpWjPqgsnwLyx9eNcp9DiHugVPg3fU2CAuaO1HA="; subPackages = [ "cmd/basecamp" ]; From 0426ca02da756bc8661d162ef5d0531f907ad54a Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 23 Oct 2026 15:43:36 -0400 Subject: [PATCH 7/7] Keep the activity filters off the MCP surface Syncing the MCP model to basecamp-sdk#1038 served GetProgressReport's new project_ids[] and people_ids[] to MCP clients, read-only mode included. bc3 clears the caller's saved web activity filter when such a list names no ID the caller can see, so that read-only GET could change a user setting. The model sync now drops parameters by policy, refusing to run if one it names is gone, and drops those two; `date` stays. The CLI exposes neither list anywhere yet, so this keeps MCP in step with it. TestCatalogKeepsTheActivityFilterOffTheProgressReport failed before the sync (extra project_ids[] and people_ids[]) and passes after. --- internal/mcpserver/catalog_test.go | 23 ++++++++++++++++++ internal/mcpserver/model/PROVENANCE.json | 4 ++-- internal/mcpserver/model/openapi.json | 30 ------------------------ scripts/sync-mcp-model.sh | 20 ++++++++++++++++ 4 files changed, 45 insertions(+), 32 deletions(-) diff --git a/internal/mcpserver/catalog_test.go b/internal/mcpserver/catalog_test.go index e92884a0e..408e416e6 100644 --- a/internal/mcpserver/catalog_test.go +++ b/internal/mcpserver/catalog_test.go @@ -337,3 +337,26 @@ func TestCatalogRefusesAnUndecidedPaginationStyle(t *testing.T) { assert.Contains(t, err.Error(), `"offset"`) assert.Empty(t, cat.Domains[0].Operations[0].Params, "nothing may be synthesized for an undecided style") } + +// TestCatalogKeepsTheActivityFilterOffTheProgressReport pins a policy the +// model sync applies. bc3 reads project_ids[] and people_ids[] on the +// progress report, but a list naming no ID the caller can see clears the +// caller's saved web activity filter. That makes a GET change state, which a +// read-only server must not do, so neither list is served; the day is. +func TestCatalogKeepsTheActivityFilterOffTheProgressReport(t *testing.T) { + cat := loadForTest(t) + var query []string + for _, d := range cat.Domains { + for _, op := range d.Operations { + if op.ID != "GetProgressReport" { + continue + } + for _, p := range op.Params { + if p.In == "query" { + query = append(query, p.Name) + } + } + } + } + assert.ElementsMatch(t, []string{"date", "page"}, query) +} diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index db81395bd..1fe492426 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "732fb9cd2bbdbcd04164f4c0a61e00e17218a927", - "ref": "v0.27.1-5-g732fb9cd", + "commit": "ab6fefd0898055b78f397f269e53e378bc6ffa6f", + "ref": "v0.27.1-6-gab6fefd0", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh", "patches": "binary-upload operations dropped because the toolkit refuses their non-JSON bodies (EXCLUDED_OPERATIONS) and the stream-ticket mint dropped by policy (POLICY_EXCLUDED_OPERATIONS); no tag patches applied (PATCHED_TAGS is empty — the export tags every operation) — see the sync script" diff --git a/internal/mcpserver/model/openapi.json b/internal/mcpserver/model/openapi.json index 2dd36b948..3feac669f 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -21524,36 +21524,6 @@ "description": "The day to read (YYYY-MM-DD). The feed serves one day per request and defaults to today, so step this back a day at a time to read history; `page` pages within the day. The server reads an unparseable date as today." } }, - { - "name": "project_ids[]", - "in": "query", - "description": "Only events in these projects. When omitted, the server applies the project filter the caller last saved in the web's activity view, not \"all projects\". If none of the IDs is a project the caller can access, the response is empty and the server clears that saved filter, so send only IDs the API just returned.", - "style": "form", - "schema": { - "type": "array", - "items": { - "type": "integer", - "format": "int64" - }, - "description": "Only events in these projects. When omitted, the server applies the project filter the caller last saved in the web's activity view, not \"all projects\". If none of the IDs is a project the caller can access, the response is empty and the server clears that saved filter, so send only IDs the API just returned." - }, - "explode": true - }, - { - "name": "people_ids[]", - "in": "query", - "description": "Only events created by these people. When omitted, the server applies the people filter the caller last saved in the web's activity view, not \"everyone\". If none of the IDs is a person the caller can see, the response is empty and the server clears that saved filter, so send only IDs the API just returned.", - "style": "form", - "schema": { - "type": "array", - "items": { - "type": "integer", - "format": "int64" - }, - "description": "Only events created by these people. When omitted, the server applies the people filter the caller last saved in the web's activity view, not \"everyone\". If none of the IDs is a person the caller can see, the response is empty and the server clears that saved filter, so send only IDs the API just returned." - }, - "explode": true - }, { "name": "page", "in": "query", diff --git a/scripts/sync-mcp-model.sh b/scripts/sync-mcp-model.sh index 5912137bc..5922c385d 100755 --- a/scripts/sync-mcp-model.sh +++ b/scripts/sync-mcp-model.sh @@ -70,6 +70,15 @@ POLICY_EXCLUDED_OPERATIONS = { "CreateStreamTicket", } +# Query parameters dropped by policy. bc3's progress report reads +# project_ids[] and people_ids[], but a list naming no ID the caller can see +# clears the caller's saved web activity filter, so a GET that carries one can +# change state. The catalog marks the operation read-only, so neither list is +# served; `date` and `page` are. +POLICY_EXCLUDED_PARAMETERS: dict[str, set[str]] = { + "GetProgressReport": {"project_ids[]", "people_ids[]"}, +} + PATCHED_TAGS: dict[str, str] = { # Empty: basecamp-sdk now tags every exported operation itself # (basecamp-sdk#878). Kept as a seam, because the export has left @@ -82,6 +91,7 @@ with open(f"{sdk}/openapi.json") as f: patched = [] excluded = [] +dropped = [] for path in list(doc["paths"]): methods = doc["paths"][path] for method in list(methods): @@ -100,6 +110,13 @@ for path in list(doc["paths"]): del methods[method] excluded.append(oid) continue + if oid in POLICY_EXCLUDED_PARAMETERS: + drop = POLICY_EXCLUDED_PARAMETERS[oid] + names = {p.get("name") for p in op.get("parameters", [])} + if not drop <= names: + sys.exit(f"{oid} no longer takes {sorted(drop - names)} — drop them from POLICY_EXCLUDED_PARAMETERS") + op["parameters"] = [p for p in op["parameters"] if p.get("name") not in drop] + dropped.append(oid) tags = op.get("tags") or [] if oid in PATCHED_TAGS: if tags: @@ -114,6 +131,9 @@ for path in list(doc["paths"]): missing = sorted(set(PATCHED_TAGS) - set(patched)) if missing: sys.exit(f"PATCHED_TAGS entries not found in the export: {missing}") +missing = sorted(set(POLICY_EXCLUDED_PARAMETERS) - set(dropped)) +if missing: + sys.exit(f"POLICY_EXCLUDED_PARAMETERS operations not found in the export: {missing}") missing = sorted((EXCLUDED_OPERATIONS | POLICY_EXCLUDED_OPERATIONS) - set(excluded)) if missing: sys.exit(f"excluded operations not found in the export: {missing}")