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..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.20261022230746-63115e7978c9 + 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 bf81fac46..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.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.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/commands/timeline.go b/internal/commands/timeline.go index 302a0cd87..a97d1fd52 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 || day.IsZero() { + return basecamp.Date{}, output.ErrUsageHint(fmt.Sprintf("invalid --date %q", date), "Use YYYY-MM-DD, today or yesterday.") + } + 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 +} + +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,28 @@ 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) } + // 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)) + 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) + earlier = output.Breadcrumb{ + Action: "previous", + Cmd: fmt.Sprintf("basecamp timeline --date %s", day.AddDays(-1)), + Description: "View the day before", + } + } + respOpts := []output.ResponseOption{ - output.WithSummary(fmt.Sprintf("%d recent events", len(result.Events))), + output.WithSummary(summary), output.WithBreadcrumbs( + earlier, output.Breadcrumb{ Action: "project", Cmd: "basecamp timeline --in ", diff --git a/internal/commands/timeline_test.go b/internal/commands/timeline_test.go new file mode 100644 index 000000000..6558e974b --- /dev/null +++ b/internal/commands/timeline_test.go @@ -0,0 +1,110 @@ +package commands + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "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 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) + 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.Equal(t, "basecamp timeline --date 2026-08-29", firstBreadcrumb(t, buf)) +} + +func TestTimelineWithoutDateReadsToday(t *testing.T) { + transport := &progressTransport{} + 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) { + 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"}, + } { + 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) + }) + } +} + +// 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/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 42ac0b999..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": "63115e7978c989a271220cee8b025135315db882", - "ref": "v0.27.1-3-g63115e79", + "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 d95723c84..3feac669f 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -21515,6 +21515,15 @@ }, "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": "page", "in": "query", diff --git a/internal/tui/workspace/data/hub_global.go b/internal/tui/workspace/data/hub_global.go index a37970e02..859ac0cb7 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,43 @@ 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, 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, 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) { - result, err := client.Timeline().Progress(ctx, nil) - if err != nil { - return nil, err + var events []basecamp.TimelineEvent + seen := make(map[int64]bool) + 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 = today.AddDays(-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/tui/workspace/data/timeline_feed_test.go b/internal/tui/workspace/data/timeline_feed_test.go new file mode 100644 index 000000000..3a896b03b --- /dev/null +++ b/internal/tui/workspace/data/timeline_feed_test.go @@ -0,0 +1,132 @@ +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() + 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 now } + 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-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) { + 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)) +} + +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)) +} + +// 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))) +} diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index f0239d2a0..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.20261022230746-63115e7978c9", - "revision": "63115e7978c9", - "updated_at": "2026-10-22T23:07:46Z" + "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 650dca96b..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-TqjNLGbA3/mruCH8TA4HaGHYLnsXLK66fUuXp4ye9eQ="; + vendorHash = "sha256-d3efrpWjPqgsnwLyx9eNcp9DiHugVPg3fU2CAuaO1HA="; subPackages = [ "cmd/basecamp" ]; 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}")