From 2536724f1f60840186c53da61fcbc77e0203ea53 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Fri, 30 Oct 2026 13:42:48 -0400 Subject: [PATCH 1/3] Bump basecamp-sdk to v0.34.0, for posting hill chart progress updates v0.34.0 adds HillChartsService.CreateVersion (basecamp-sdk#1068), which posts a progress update on a to-do set's hill chart. Nothing else in the CLI breaks. Syncs the vendored MCP model to go/v0.34.0, which serves it as create_hill_chart_version in basecamp mcp, and refreshes the Nix vendorHash. --- go.mod | 2 +- go.sum | 4 +- internal/mcpserver/catalog_test.go | 2 +- internal/mcpserver/model/PROVENANCE.json | 4 +- internal/mcpserver/model/behavior-model.json | 11 ++ internal/mcpserver/model/openapi.json | 168 +++++++++++++++++- .../mcpserver/testdata/catalog_snapshot.txt | 1 + internal/version/sdk-provenance.json | 6 +- nix/package.nix | 2 +- 9 files changed, 189 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index aaa63928d..a38193a45 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.33.0 + github.com/basecamp/basecamp-sdk/go v0.34.0 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 1ffd70edb..fb9688bfd 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.33.0 h1:ZyDMeLiHy3oParlc7Z6CRp0V/yj3G8VYFk3NGOLr9uU= -github.com/basecamp/basecamp-sdk/go v0.33.0/go.mod h1:mUm/MQGMyg1E1cddyxdG7tQHiq0NsYee2zUeAUT5VjU= +github.com/basecamp/basecamp-sdk/go v0.34.0 h1:Gp0Z2kJApHdzTBykXlJfy1B1edySD42SuP4NUBdcWXk= +github.com/basecamp/basecamp-sdk/go v0.34.0/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/mcpserver/catalog_test.go b/internal/mcpserver/catalog_test.go index 5efe20ac3..b6ea7f0e3 100644 --- a/internal/mcpserver/catalog_test.go +++ b/internal/mcpserver/catalog_test.go @@ -78,7 +78,7 @@ func TestCatalogExcludesUnservedOperations(t *testing.T) { assert.False(t, excluded[op.ID], "operation %q should be excluded from the vendored model", op.ID) } } - assert.Equal(t, 286, model, "served model operation count") + assert.Equal(t, 287, model, "served model operation count") assert.Equal(t, 3, composite, "served composite operation count") } diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 26b87c757..512e102b5 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/basecamp-sdk", - "commit": "57e8a94c1f77efb4ab06bae9829acbae8b648941", - "ref": "go/v0.33.0", + "commit": "f890273667777361c5c08812d9b7defe2380e6bd", + "ref": "go/v0.34.0", "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/behavior-model.json b/internal/mcpserver/model/behavior-model.json index 5e417eb10..346098cb9 100644 --- a/internal/mcpserver/model/behavior-model.json +++ b/internal/mcpserver/model/behavior-model.json @@ -240,6 +240,17 @@ ] } }, + "CreateHillChartVersion": { + "retry": { + "max": 2, + "base_delay_ms": 1000, + "backoff": "exponential", + "retry_on": [ + 429, + 503 + ] + } + }, "CreateLineupMarker": { "retry": { "max": 2, diff --git a/internal/mcpserver/model/openapi.json b/internal/mcpserver/model/openapi.json index 1fd89e35b..b5f645109 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Basecamp", - "version": "2026-10-27", + "version": "2026-10-30", "description": "Basecamp API", "contact": { "name": "Basecamp", @@ -28970,6 +28970,125 @@ 503 ] } + }, + "post": { + "description": "Post a progress update on a todoset's hill chart.\n\nMoves the todolists named in `dots` and saves a progress update, as\nupdating the hill chart in Basecamp does. Todolists left out of `dots` keep\ntheir current position, so one dot can move without sending the others.\nEvery field is optional: with none, the update records the chart as it\nstands. To track or untrack a todolist, use `UpdateHillChartSettings`.\n\nAnswers 201 with the new progress update, in the shape\n`GetHillChartVersion` returns, including where every tracked todolist now\nsits. Answers 422, with an `error` message and nothing saved, when `dots`\nisn't a list of objects, a todolist appears in it more than once or isn't\ntracked on the chart, a `position` isn't a whole number from 0 to 100, or\nthe chart tracks no todolists.", + "operationId": "CreateHillChartVersion", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateHillChartVersionRequestContent" + } + } + } + }, + "parameters": [ + { + "name": "accountId", + "in": "path", + "description": "Basecamp account ID (numeric string)", + "schema": { + "type": "string", + "pattern": "^[0-9]+$", + "description": "Basecamp account ID (numeric string)" + }, + "required": true + }, + { + "name": "todosetId", + "in": "path", + "schema": { + "type": "integer", + "format": "int64" + }, + "required": true + } + ], + "responses": { + "201": { + "description": "CreateHillChartVersion 201 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateHillChartVersionResponseContent" + } + } + } + }, + "401": { + "description": "UnauthorizedError 401 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponseContent" + } + } + } + }, + "403": { + "description": "ForbiddenError 403 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenErrorResponseContent" + } + } + } + }, + "404": { + "description": "NotFoundError 404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponseContent" + } + } + } + }, + "422": { + "description": "ValidationError 422 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponseContent" + } + } + } + }, + "429": { + "description": "RateLimitError 429 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitErrorResponseContent" + } + } + } + }, + "500": { + "description": "InternalServerError 500 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerErrorResponseContent" + } + } + } + } + }, + "tags": [ + "Todos" + ], + "x-basecamp-retry": { + "maxAttempts": 2, + "baseDelayMs": 1000, + "backoff": "exponential", + "retryOn": [ + 429, + 503 + ] + } } }, "/{accountId}/todosets/{todosetId}/hills/versions/{versionId}": { @@ -32943,6 +33062,33 @@ "CreateGoogleDocumentResponseContent": { "$ref": "#/components/schemas/GoogleDocument" }, + "CreateHillChartVersionRequestContent": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A note about the update, as rich text (HTML)." + }, + "dots": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HillChartDotPosition" + }, + "description": "Todolists to move, each with its ID and new position. Todolists left\nout keep their current position." + }, + "subscriptions": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + }, + "description": "People to notify of the update and subscribe to it. If absent, nobody\nelse is notified." + } + } + }, + "CreateHillChartVersionResponseContent": { + "$ref": "#/components/schemas/HillChartVersion" + }, "CreateLineupMarkerRequestContent": { "type": "object", "properties": { @@ -35746,6 +35892,26 @@ "position" ] }, + "HillChartDotPosition": { + "type": "object", + "description": "Where to move one tracked todolist in a progress update.", + "properties": { + "id": { + "type": "integer", + "description": "The todolist's ID. It must be tracked on the hill chart.", + "format": "int64" + }, + "position": { + "type": "integer", + "description": "A whole number from 0 to 100, on the same scale as the hill chart's dots.", + "format": "int32" + } + }, + "required": [ + "id", + "position" + ] + }, "HillChartVersion": { "type": "object", "description": "A progress update on a hill chart: the note someone wrote when they\nupdated the chart, and where each tracked todolist sat at that moment.", diff --git a/internal/mcpserver/testdata/catalog_snapshot.txt b/internal/mcpserver/testdata/catalog_snapshot.txt index 63581f10f..de5e7c5e1 100644 --- a/internal/mcpserver/testdata/catalog_snapshot.txt +++ b/internal/mcpserver/testdata/catalog_snapshot.txt @@ -26,6 +26,7 @@ Call {"action": "describe", "params": {"action": "NAME"}} for an action's full p ACTIONS (RO = read-only): - complete_subtask: Mark a subtask as completed - complete_todo: Mark a todo as complete +- create_hill_chart_version: Post a progress update on a todoset's hill chart - create_subtask: Create a subtask under a to-do or a card - create_todo: Create a new todo in a todolist - create_todolist: Create a new todolist in a todoset diff --git a/internal/version/sdk-provenance.json b/internal/version/sdk-provenance.json index b777d3f65..f85c16f12 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.33.0", - "revision": "57e8a94c1f77", - "updated_at": "2026-10-29T18:23:12Z" + "version": "v0.34.0", + "revision": "f89027366777", + "updated_at": "2026-10-30T17:10:13Z" }, "api": { "repo": "basecamp/bc3", diff --git a/nix/package.nix b/nix/package.nix index 0f29e2a58..64d0fa559 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-rlu9ysWg5Ff3v1SaAvGutmfeViNKUPZlbuPTL5TRIvw="; + vendorHash = "sha256-ImYhmwudmDdOrpVBPIoAeHhhhreguyuH2D3zom3umR0="; subPackages = [ "cmd/basecamp" ]; From 3ec106b5a0a4ce14d25381d9b26b79ee0499305c Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 29 Oct 2026 17:20:20 -0400 Subject: [PATCH 2/3] Move hill chart dots and post a progress update hillcharts update --dot =<0-100> moves tracked todolists, as updating the hill in Basecamp does; lists left out keep their position. --note is Markdown converted to rich text (- reads stdin), and --notify names who is told, since the API notifies nobody otherwise. --- .surface | 28 +++ API-COVERAGE.md | 12 +- e2e/hillcharts.bats | 36 ++++ e2e/smoke/smoke_hillcharts.bats | 13 ++ internal/commands/commands.go | 2 +- internal/commands/hillcharts.go | 4 +- internal/commands/hillcharts_update.go | 201 ++++++++++++++++++ internal/commands/hillcharts_update_test.go | 216 ++++++++++++++++++++ skills/basecamp/SKILL.md | 1 + 9 files changed, 507 insertions(+), 6 deletions(-) create mode 100644 internal/commands/hillcharts_update.go create mode 100644 internal/commands/hillcharts_update_test.go diff --git a/.surface b/.surface index 96d0826d2..46d0bd250 100644 --- a/.surface +++ b/.surface @@ -992,6 +992,7 @@ CMD basecamp hillcharts CMD basecamp hillcharts show CMD basecamp hillcharts track CMD basecamp hillcharts untrack +CMD basecamp hillcharts update CMD basecamp hillcharts updates CMD basecamp hillcharts updates list CMD basecamp hillcharts updates show @@ -11616,6 +11617,32 @@ FLAG basecamp hillcharts untrack --styled type=bool FLAG basecamp hillcharts untrack --todolist type=string FLAG basecamp hillcharts untrack --todoset type=string FLAG basecamp hillcharts untrack --verbose type=count +FLAG basecamp hillcharts update --account type=string +FLAG basecamp hillcharts update --agent type=bool +FLAG basecamp hillcharts update --cache-dir type=string +FLAG basecamp hillcharts update --count type=bool +FLAG basecamp hillcharts update --dot type=stringArray +FLAG basecamp hillcharts update --format type=string +FLAG basecamp hillcharts update --help type=bool +FLAG basecamp hillcharts update --hints type=bool +FLAG basecamp hillcharts update --ids-only type=bool +FLAG basecamp hillcharts update --in type=string +FLAG basecamp hillcharts update --jq type=string +FLAG basecamp hillcharts update --json type=bool +FLAG basecamp hillcharts update --markdown type=bool +FLAG basecamp hillcharts update --md type=bool +FLAG basecamp hillcharts update --no-hints type=bool +FLAG basecamp hillcharts update --no-stats type=bool +FLAG basecamp hillcharts update --note type=string +FLAG basecamp hillcharts update --notify type=string +FLAG basecamp hillcharts update --profile type=string +FLAG basecamp hillcharts update --project type=string +FLAG basecamp hillcharts update --quiet type=bool +FLAG basecamp hillcharts update --stats type=bool +FLAG basecamp hillcharts update --styled type=bool +FLAG basecamp hillcharts update --todolist type=string +FLAG basecamp hillcharts update --todoset type=string +FLAG basecamp hillcharts update --verbose type=count FLAG basecamp hillcharts updates --account type=string FLAG basecamp hillcharts updates --agent type=bool FLAG basecamp hillcharts updates --all type=bool @@ -21264,6 +21291,7 @@ SUB basecamp hillcharts SUB basecamp hillcharts show SUB basecamp hillcharts track SUB basecamp hillcharts untrack +SUB basecamp hillcharts update SUB basecamp hillcharts updates SUB basecamp hillcharts updates list SUB basecamp hillcharts updates show diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 4b96f2b89..0be3bbb41 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -6,12 +6,16 @@ Coverage of Basecamp 3 API endpoints. Source: [bc3-api/sections](https://github. | Status | Sections | Endpoints | |--------|----------|-----------| -| ✅ Implemented | 58 | 220 | +| ✅ Implemented | 58 | 221 | | ⚠️ Blocked | 0 | 0 | | ⏭️ Out of scope | 4 | 12 | -| **Total tracked** | **62** | **232** | +| **Total tracked** | **62** | **233** | -**220 of 220 tracked in-scope endpoints.** Reading a hill chart's progress +**221 of 221 tracked in-scope endpoints.** Posting a hill chart progress update +(`POST /todosets/:id/hills/versions.json`, added by +[bc3#14214](https://github.com/basecamp/bc3/pull/14214) and modelled by +[basecamp/basecamp-sdk#1068](https://github.com/basecamp/basecamp-sdk/pull/1068), not yet +released; the SDK is pinned to that branch) lands as `hillcharts update`. Reading a hill chart's progress updates (`GET /todosets/:id/hills/versions.json` and `GET /todosets/:id/hills/versions/:id.json`, modelled by [basecamp/basecamp-sdk#1061](https://github.com/basecamp/basecamp-sdk/pull/1061) and @@ -231,7 +235,7 @@ cannot faithfully cover at least one endpoint for a reason outside the CLI. A | dock_tools | 7 | `tools` | ✅ | BC4 | - | Dock tool management: show, update, trash, enable, disable, reposition (also `tools move --position`; `tools move --to-project` moves a tool to another project through recordings (moves, copies)). `create` is BC5-only (create-by-type: `POST /buckets/{id}/dock/tools.json`), replacing the removed clone call; create-time `visible_to_clients` behind `tools create --visible-to-clients` (chat/kanban only) | | **Hill Charts** | | hill_charts | 2 | `hillcharts` | ✅ | BC4 | - | show, track/untrack todolists | -| hill_charts (progress updates) | 2 | `hillcharts updates` | ✅ | BC5 | - | list (`GET /todosets/:id/hills/versions.json`, paginated, newest first; bare `hillcharts updates` or `updates list`), show (`GET /todosets/:id/hills/versions/:id.json`; an id with `--in`/`--todoset`, or the update's web or API URL, which names its todoset). Each update carries its note (`description`, rich text) and every tracked todolist's position (`dots`; `id` is null for a todolist since deleted or moved). Todoset resolved as `hillcharts show` does | +| hill_charts (progress updates) | 3 | `hillcharts updates`, `hillcharts update` | ✅ | BC5 | - | post (`POST /todosets/:id/hills/versions.json`; `hillcharts update --dot =<0-100>`, repeatable, lists left out keep their position; `--note` Markdown converted to rich text, `-` reads stdin; `--notify` names who is told, nobody otherwise), list (`GET /todosets/:id/hills/versions.json`, paginated, newest first; bare `hillcharts updates` or `updates list`), show (`GET /todosets/:id/hills/versions/:id.json`; an id with `--in`/`--todoset`, or the update's web or API URL, which names its todoset). Each update carries its note (`description`, rich text) and every tracked todolist's position (`dots`; `id` is null for a todolist since deleted or moved). Todoset resolved as `hillcharts show` does | | gauges | 7 | `gauges` | ✅ | BC4 | - | list, needles, needle, create, update, delete, enable/disable | | **Communication** | | messages | 10 | `messages`, `message` | ✅ | BC4 | - | list, show, create, update, publish, pin, unpin. Create supports `--subscribe`/`--no-subscribe` and `--draft`. Publish promotes drafts to active. `move`/`copy` file a message onto a message board in another project (see recordings (moves, copies)) | diff --git a/e2e/hillcharts.bats b/e2e/hillcharts.bats index acfd589c2..0b7ebac6f 100644 --- a/e2e/hillcharts.bats +++ b/e2e/hillcharts.bats @@ -136,6 +136,42 @@ load test_helper } +@test "hillcharts update with nothing to post shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp hillcharts update --todoset 12345 --json + assert_failure + assert_output_contains "No update fields specified" +} + +@test "hillcharts update rejects a --dot without a position" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp hillcharts update --todoset 12345 --dot Design + assert_failure + assert_output_contains "needs a position" +} + +@test "hillcharts update rejects a position outside 0 to 100" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp hillcharts update --todoset 12345 --dot "Design=101" + assert_failure + assert_output_contains "whole number from 0 to 100" +} + +@test "hillcharts update --note - with nothing piped shows error" { + create_credentials + create_global_config '{"account_id": 99999}' + + run basecamp hillcharts update --todoset 12345 --note - < /dev/null + assert_failure +} + + # Help @test "hillcharts --help shows help" { diff --git a/e2e/smoke/smoke_hillcharts.bats b/e2e/smoke/smoke_hillcharts.bats index ce02b9f4d..b87dd21cc 100644 --- a/e2e/smoke/smoke_hillcharts.bats +++ b/e2e/smoke/smoke_hillcharts.bats @@ -44,3 +44,16 @@ setup_file() { assert_success assert_json_value '.data.id' "$id" } + +@test "hillcharts update posts a progress update" { + run_smoke basecamp hillcharts show -p "$QA_PROJECT" --json + [[ "$status" -eq 0 ]] || mark_unverifiable "Hill chart not enabled on project" + local id position + id=$(echo "$output" | jq -r '.data.dots[0].id // empty') + position=$(echo "$output" | jq -r '.data.dots[0].position // empty') + [[ -n "$id" && -n "$position" ]] || mark_unverifiable "No tracked todolists on the project's hill chart" + # Re-post the dot where it sits, so the chart is left as it was. + run_smoke basecamp hillcharts update --dot "$id=$position" --note "Smoke test" -p "$QA_PROJECT" --json + assert_success + assert_json_value '.ok' 'true' +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 634be00bf..80922adba 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -39,7 +39,7 @@ func CommandCategories() []CommandCategory { {Name: "subtasks", Category: "core", Description: "Manage subtasks on to-dos and cards", Actions: []string{"list", "show", "create", "update", "complete", "uncomplete", "move", "delete"}}, {Name: "todolists", Category: "core", Description: "Manage to-do lists", Actions: []string{"list", "show", "create", "update", "position", "copy", "trash", "archive", "restore"}}, {Name: "todosets", Category: "core", Description: "Manage to-do set containers", Actions: []string{"list", "show"}}, - {Name: "hillcharts", Category: "core", Description: "Manage hill charts", Actions: []string{"show", "track", "untrack", "updates"}}, + {Name: "hillcharts", Category: "core", Description: "Manage hill charts", Actions: []string{"show", "track", "untrack", "updates", "update"}}, {Name: "gauges", Category: "core", Description: "Manage gauges", Actions: []string{"list", "needles", "needle", "create", "update", "delete", "enable", "disable"}}, {Name: "todolistgroups", Category: "core", Description: "Manage to-do list groups", Actions: []string{"list", "show", "create", "update", "position", "copy"}}, {Name: "messages", Category: "core", Description: "Manage messages", Actions: []string{"list", "show", "create", "update", "publish", "pin", "unpin", "move", "copy", "trash", "archive", "restore"}}, diff --git a/internal/commands/hillcharts.go b/internal/commands/hillcharts.go index 8d94ca6e4..2f5dd3e7f 100644 --- a/internal/commands/hillcharts.go +++ b/internal/commands/hillcharts.go @@ -26,7 +26,8 @@ A hill chart shows the progress of todolists in a project's todoset, with each tracked todolist represented as a dot on the hill.`, Annotations: map[string]string{ "agent_notes": "Hill charts track todolist progress. Requires a todoset (auto-detected from project). Use track/untrack with todolist IDs.\n" + - "updates lists past progress updates (newest first); updates show reads one, with its note and each todolist's position.", + "updates lists past progress updates (newest first); updates show reads one, with its note and each todolist's position.\n" + + "update --dot =<0-100> (repeatable) moves tracked todolists and posts a progress update; --note (Markdown, - for stdin) adds a note; --notify names who is told (nobody otherwise). Lists left out keep their position.", }, } @@ -38,6 +39,7 @@ with each tracked todolist represented as a dot on the hill.`, newHillchartsTrackCmd(&project), newHillchartsUntrackCmd(&project), newHillchartsUpdatesCmd(&project), + newHillchartsUpdateCmd(&project), ) return cmd diff --git a/internal/commands/hillcharts_update.go b/internal/commands/hillcharts_update.go new file mode 100644 index 000000000..22383db35 --- /dev/null +++ b/internal/commands/hillcharts_update.go @@ -0,0 +1,201 @@ +package commands + +import ( + "fmt" + "strconv" + "strings" + + "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/output" +) + +// newHillchartsUpdateCmd creates `hillcharts update`: move dots on a hill +// chart and post a progress update, as updating the hill in Basecamp does. +// Todolists not named keep their position, so one dot can move alone. +func newHillchartsUpdateCmd(project *string) *cobra.Command { + var todosetID string + var dots []string + var note string + var notify string + + cmd := &cobra.Command{ + Use: "update", + Short: "Move dots on the hill chart and post a progress update", + Long: `Move tracked todolists on a project's hill chart and post a progress update. + +Each --dot moves one tracked todolist, named by ID or name, to a position from +0 (the bottom left, still figuring it out) to 100 (the bottom right, done); +50 is the top of the hill. Todolists you leave out keep their position. + +--note is a note about the update, as Markdown (converted to rich text; pass +--format html to send HTML as written). Use - to read it from stdin. + +Nobody else is notified unless named with --notify (names, emails, IDs or +"me", comma-separated). + + basecamp hillcharts update --dot "Design=60" --in MyProject + basecamp hillcharts update --dot 111=40 --dot 222=100 --note "Design is done" --in MyProject + echo "Building starts Monday" | basecamp hillcharts update --dot Research=55 --note - --notify "Jason,Ann" --in MyProject`, + Example: `basecamp hillcharts update --dot "Design=60" --note "Over the hill" --in MyProject`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runHillchartsUpdate(cmd, *project, todosetID, dots, note, notify) + }, + } + + cmd.Flags().StringVarP(&todosetID, "todoset", "t", "", "Todoset ID (auto-detected from project)") + cmd.Flags().StringArrayVar(&dots, "dot", nil, "Move a tracked todolist: =<0-100> (repeatable)") + cmd.Flags().StringVar(¬e, "note", "", "Note about the update (Markdown); use - to read from stdin") + cmd.Flags().StringVar(¬ify, "notify", "", "People to notify (comma-separated names, emails, IDs, or \"me\")") + addRichTextFormatFlag(cmd) + allowDash(cmd, "flag:note") + + return cmd +} + +// hillDotArg is one parsed --dot: the todolist as typed, and its position. +type hillDotArg struct { + todolist string + position int +} + +// parseHillDotArg reads "=". It splits at the last "=", +// so a todolist name may itself contain one. +func parseHillDotArg(arg string) (hillDotArg, error) { + i := strings.LastIndex(arg, "=") + if i < 0 { + return hillDotArg{}, output.ErrUsageHint( + fmt.Sprintf("--dot %q needs a position", arg), + `Use --dot =<0-100>, e.g. --dot "Design=60"`) + } + todolist := strings.TrimSpace(arg[:i]) + posStr := strings.TrimSpace(arg[i+1:]) + if todolist == "" { + return hillDotArg{}, output.ErrUsageHint( + fmt.Sprintf("--dot %q names no todolist", arg), + `Use --dot =<0-100>, e.g. --dot "Design=60"`) + } + pos, err := strconv.Atoi(posStr) + if err != nil || pos < 0 || pos > 100 { + return hillDotArg{}, output.ErrUsage(fmt.Sprintf( + "--dot %q: the position must be a whole number from 0 to 100", arg)) + } + return hillDotArg{todolist: todolist, position: pos}, nil +} + +func runHillchartsUpdate(cmd *cobra.Command, project, todosetID string, dotArgs []string, note, notify string) error { + // Flag checks first: they need neither the account nor the network. + parsed := make([]hillDotArg, 0, len(dotArgs)) + for _, arg := range dotArgs { + d, err := parseHillDotArg(arg) + if err != nil { + return err + } + parsed = append(parsed, d) + } + if cmd.Flags().Changed("notify") && strings.TrimSpace(strings.ReplaceAll(notify, ",", "")) == "" { + return output.ErrUsage("--notify needs at least one person") + } + if len(parsed) == 0 && !cmd.Flags().Changed("note") { + return noChanges(cmd) + } + + // Then stdin, then the account: a bad pipe gets the stdin error rather + // than "--account is required". + note, err := resolveContentValue(cmd, note, -1, "--note") + if err != nil { + return err + } + if cmd.Flags().Changed("note") && strings.TrimSpace(note) == "" && len(parsed) == 0 { + return output.ErrUsage("--note is empty, and no --dot was given: there is nothing to post") + } + + app := appctx.FromContext(cmd.Context()) + followUp := newFollowUpScope(app) + + if err := ensureAccount(cmd, app); err != nil { + return err + } + + resolvedProjectID, resolvedTodosetID, tsID, err := resolveHillchartTodoset(cmd, app, project, todosetID) + if err != nil { + return err + } + + req := &basecamp.CreateHillChartVersionRequest{} + + seen := make(map[int64]string, len(parsed)) + for _, d := range parsed { + resolved, err := resolveTodolistInTodoset(cmd, app, d.todolist, resolvedProjectID, resolvedTodosetID) + if err != nil { + return err + } + id, err := strconv.ParseInt(resolved, 10, 64) + if err != nil { + return output.ErrUsage(fmt.Sprintf("Invalid todolist ID: %s", d.todolist)) + } + if first, dup := seen[id]; dup { + return output.ErrUsage(fmt.Sprintf( + "--dot %q and --dot %q move the same todolist (%d)", first, d.todolist, id)) + } + seen[id] = d.todolist + req.Dots = append(req.Dots, basecamp.HillChartDotPosition{ID: id, Position: d.position}) + } + + var mentionNotice string + if strings.TrimSpace(note) != "" { + html := richTextToHTML(cmd, note) + mentions, err := resolveMentions(cmd.Context(), app.Names, mentionScope(app, resolvedProjectID), html) + if err != nil { + return err + } + req.Description = mentions.HTML + mentionNotice = unresolvedMentionWarning(mentions.Unresolved) + } + + if notify != "" { + ids, err := resolvePersonIDs(cmd.Context(), app.Names, notify) + if err != nil { + return err + } + req.Subscriptions = &ids + } + + version, err := app.Account().HillCharts().CreateVersion(cmd.Context(), tsID, req) + if err != nil { + return convertSDKError(err) + } + + accountArgs := followUp.args(app.Config.AccountID) + scope := hillchartScope(resolvedProjectID, todosetID) + accountArgs + + respOpts := []output.ResponseOption{ + output.WithSummary(fmt.Sprintf("Posted progress update #%d: %d dot(s) on the hill", version.ID, len(version.Dots))), + output.WithEntity("hill_chart_version"), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "hill", + Cmd: fmt.Sprintf("basecamp hillcharts show %s", scope), + Description: "View the hill chart", + }, + output.Breadcrumb{ + Action: "updates", + Cmd: fmt.Sprintf("basecamp hillcharts updates %s", scope), + Description: "List progress updates", + }, + output.Breadcrumb{ + Action: "comments", + Cmd: fmt.Sprintf("basecamp comments list %d%s", version.ID, accountArgs), + Description: "Read the discussion on this update", + }, + ), + } + if mentionNotice != "" { + respOpts = append(respOpts, output.WithDiagnostic(mentionNotice)) + } + + return app.OK(version, respOpts...) +} diff --git a/internal/commands/hillcharts_update_test.go b/internal/commands/hillcharts_update_test.go new file mode 100644 index 000000000..9aaf4bb4a --- /dev/null +++ b/internal/commands/hillcharts_update_test.go @@ -0,0 +1,216 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/appctx" + "github.com/basecamp/basecamp-cli/internal/output" +) + +// hillCreateVersionRoute answers the progress-update POST with the fixture +// bc3's 201 resembles. +func hillCreateVersionRoute() stubRoute { + return stubRoute{method: http.MethodPost, path: hillVersionsPath, status: http.StatusCreated, body: hillVersionFixture} +} + +// hillTodolistsRoute serves the todoset's todolists, which name resolution +// reads to turn a --dot name into an ID. +func hillTodolistsRoute() stubRoute { + return stubRoute{method: http.MethodGet, path: "/99999/todosets/12345/todolists.json", status: http.StatusOK, + body: `[{"id":111,"name":"Background and research"},{"id":222,"name":"Design = layout"}]`} +} + +func hillPeopleRoute() stubRoute { + return stubRoute{method: http.MethodGet, path: "/99999/people.json", status: http.StatusOK, + body: `[{"id":7,"name":"Victor Cooper","email_address":"victor@example.com"},` + + `{"id":8,"name":"Annie Bryan","email_address":"annie@example.com"}]`} +} + +// postedHillBody returns the JSON body of the progress-update POST, failing +// the test when none was sent. +func postedHillBody(t *testing.T, transport *recordingTransport) map[string]any { + t.Helper() + for _, call := range transport.recorded() { + if call.Method == http.MethodPost && call.Path == hillVersionsPath { + var body map[string]any + require.NoError(t, json.Unmarshal([]byte(call.Body), &body), call.Body) + return body + } + } + t.Fatalf("no POST to %s; requests: %+v", hillVersionsPath, transport.recorded()) + return nil +} + +// hillErrText is an error's message and, for a usage error, its hint. +func hillErrText(err error) string { + var e *output.Error + if errors.As(err, &e) { + return e.Message + "\n" + e.Hint + } + return err.Error() +} + +func requireNoHillPost(t *testing.T, transport *recordingTransport) { + t.Helper() + for _, call := range transport.recorded() { + assert.False(t, call.Method == http.MethodPost, "unexpected POST %s", call.Path) + } +} + +func TestHillchartsUpdateMovesDotsByIDAndName(t *testing.T) { + app, transport, out := setupHillUpdatesApp(t, hillTodolistsRoute(), hillCreateVersionRoute()) + + require.NoError(t, executeHillchartsCommand(NewHillchartsCmd(), app, "update", + "--todoset", "12345", "--dot", "222=100", "--dot", "Background and research=40")) + + body := postedHillBody(t, transport) + assert.Equal(t, []any{ + map[string]any{"id": float64(222), "position": float64(100)}, + map[string]any{"id": float64(111), "position": float64(40)}, + }, body["dots"]) + assert.NotContains(t, body, "description") + assert.NotContains(t, body, "subscriptions", "nobody is notified unless named") + + data, summary := hillEnvelope(t, out) + assert.Equal(t, "Posted progress update #501: 2 dot(s) on the hill", summary) + assert.Contains(t, string(data), `"id": 501`) +} + +// A todolist name can hold "=": the position is after the last one. +func TestHillchartsUpdateSplitsDotAtTheLastEquals(t *testing.T) { + app, transport, _ := setupHillUpdatesApp(t, hillTodolistsRoute(), hillCreateVersionRoute()) + + require.NoError(t, executeHillchartsCommand(NewHillchartsCmd(), app, "update", + "--todoset", "12345", "--dot", "Design = layout=55")) + + assert.Equal(t, []any{map[string]any{"id": float64(222), "position": float64(55)}}, + postedHillBody(t, transport)["dots"]) +} + +func TestHillchartsUpdateConvertsTheNoteFromMarkdown(t *testing.T) { + app, transport, _ := setupHillUpdatesApp(t, hillCreateVersionRoute()) + + require.NoError(t, executeHillchartsCommand(NewHillchartsCmd(), app, "update", + "--todoset", "12345", "--note", "Research is **done**")) + + body := postedHillBody(t, transport) + desc, _ := body["description"].(string) + assert.Contains(t, desc, "done") + assert.NotContains(t, body, "dots", "lists left out keep their position, so none are sent") +} + +func TestHillchartsUpdateSendsHTMLAsWrittenWithFormatHTML(t *testing.T) { + app, transport, _ := setupHillUpdatesApp(t, hillCreateVersionRoute()) + + require.NoError(t, executeHillchartsCommand(NewHillchartsCmd(), app, "update", + "--todoset", "12345", "--note", "
**as is**
", "--format", "html")) + + assert.Equal(t, "
**as is**
", postedHillBody(t, transport)["description"]) +} + +func TestHillchartsUpdateReadsTheNoteFromStdin(t *testing.T) { + app, transport, _ := setupHillUpdatesApp(t, hillCreateVersionRoute()) + + cmd := NewHillchartsCmd() + cmd.SetArgs([]string{"update", "--todoset", "12345", "--note", "-"}) + cmd.SetContext(appctx.WithApp(context.Background(), app)) + cmd.SetIn(strings.NewReader("Building starts *Monday*\n")) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + require.NoError(t, cmd.Execute()) + + desc, _ := postedHillBody(t, transport)["description"].(string) + assert.Contains(t, desc, "Monday") +} + +func TestHillchartsUpdateNotifiesNamedPeople(t *testing.T) { + app, transport, _ := setupHillUpdatesApp(t, hillPeopleRoute(), hillCreateVersionRoute()) + + require.NoError(t, executeHillchartsCommand(NewHillchartsCmd(), app, "update", + "--todoset", "12345", "--dot", "111=60", "--notify", "Annie Bryan, 7")) + + assert.Equal(t, []any{float64(8), float64(7)}, postedHillBody(t, transport)["subscriptions"]) +} + +func TestHillchartsUpdateResolvesTheTodosetFromTheProject(t *testing.T) { + app, transport, out := setupHillUpdatesApp(t, projectsRoute(), hillProjectRoute(), hillCreateVersionRoute()) + app.Flags.Hints = true + + require.NoError(t, executeHillchartsCommand(NewHillchartsCmd(), app, "update", + "--in", "123", "--dot", "111=60")) + + postedHillBody(t, transport) + var envelope struct { + Breadcrumbs []struct { + Cmd string `json:"cmd"` + } `json:"breadcrumbs"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &envelope), out.String()) + cmds := make([]string, 0, len(envelope.Breadcrumbs)) + for _, b := range envelope.Breadcrumbs { + cmds = append(cmds, b.Cmd) + } + assert.Contains(t, cmds, "basecamp hillcharts show --in 123") + assert.Contains(t, cmds, "basecamp hillcharts updates --in 123") + assert.Contains(t, cmds, "basecamp comments list 501") +} + +// Every mistake the flags alone reveal is refused before any request: no +// account lookup, no name resolution, and above all no POST. +func TestHillchartsUpdateRefusesBadFlagsBeforeAnyRequest(t *testing.T) { + cases := map[string]struct { + args []string + want string + }{ + "nothing to post": {[]string{"update", "--todoset", "12345"}, "No update fields specified"}, + "no position": {[]string{"update", "--todoset", "12345", "--dot", "Design"}, "needs a position"}, + "no todolist": {[]string{"update", "--todoset", "12345", "--dot", "=50"}, "names no todolist"}, + "position too high": {[]string{"update", "--todoset", "12345", "--dot", "111=101"}, "0 to 100"}, + "position negative": {[]string{"update", "--todoset", "12345", "--dot", "111=-1"}, "0 to 100"}, + "position not whole": {[]string{"update", "--todoset", "12345", "--dot", "111=50.5"}, "0 to 100"}, + "position empty": {[]string{"update", "--todoset", "12345", "--dot", "111="}, "0 to 100"}, + "empty notify": {[]string{"update", "--todoset", "12345", "--dot", "111=5", "--notify", " , "}, "at least one person"}, + "empty note alone": {[]string{"update", "--todoset", "12345", "--note", " "}, "nothing to post"}, + "positional arg": {[]string{"update", "--todoset", "12345", "111=5"}, "unknown command"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + app, transport, _ := setupHillUpdatesApp(t) + app.Flags.JSON = true // with a terminal, "nothing to post" shows help instead + err := executeHillchartsCommand(NewHillchartsCmd(), app, tc.args...) + require.Error(t, err) + assert.Contains(t, hillErrText(err), tc.want) + assert.Empty(t, transport.recorded()) + }) + } +} + +func TestHillchartsUpdateRefusesTwoDotsForOneTodolist(t *testing.T) { + app, transport, _ := setupHillUpdatesApp(t, hillTodolistsRoute()) + + err := executeHillchartsCommand(NewHillchartsCmd(), app, "update", + "--todoset", "12345", "--dot", "111=10", "--dot", "Background and research=20") + requireUsageError(t, err, "move the same todolist (111)") + requireNoHillPost(t, transport) +} + +// A refusal from bc3 (an untracked todolist, say) reaches the caller with the +// server's message, and is not reported as success. +func TestHillchartsUpdateSurfacesTheServersRefusal(t *testing.T) { + app, _, out := setupHillUpdatesApp(t, stubRoute{method: http.MethodPost, path: hillVersionsPath, + status: http.StatusUnprocessableEntity, body: `{"error":"To-do list 333 isn't tracked on this hill chart"}`}) + + err := executeHillchartsCommand(NewHillchartsCmd(), app, "update", "--todoset", "12345", "--dot", "333=10") + require.Error(t, err) + assert.Contains(t, err.Error(), "isn't tracked") + assert.Empty(t, out.String()) +} diff --git a/skills/basecamp/SKILL.md b/skills/basecamp/SKILL.md index bce68e4c0..0fa87ef9c 100644 --- a/skills/basecamp/SKILL.md +++ b/skills/basecamp/SKILL.md @@ -37,6 +37,7 @@ triggers: - basecamp gauge - basecamp hill chart - hill chart progress + - update the hill chart - basecamp assignment - basecamp notification - basecamp account From 945ef9e331d1c8f78746d4e6935679fb6d0af303 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 29 Oct 2026 17:23:46 -0400 Subject: [PATCH 3/3] Let --notify alone post a progress update, and refuse a bad --todoset before reading stdin The API takes subscriptions on their own: the chart is recorded as it stands and the people named are told. --- internal/commands/hillcharts_update.go | 17 ++++++++++++----- internal/commands/hillcharts_update_test.go | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/internal/commands/hillcharts_update.go b/internal/commands/hillcharts_update.go index 22383db35..fdc8dc2c4 100644 --- a/internal/commands/hillcharts_update.go +++ b/internal/commands/hillcharts_update.go @@ -34,7 +34,8 @@ Each --dot moves one tracked todolist, named by ID or name, to a position from --format html to send HTML as written). Use - to read it from stdin. Nobody else is notified unless named with --notify (names, emails, IDs or -"me", comma-separated). +"me", comma-separated). --notify alone posts the chart as it stands and tells +the people named. basecamp hillcharts update --dot "Design=60" --in MyProject basecamp hillcharts update --dot 111=40 --dot 222=100 --note "Design is done" --in MyProject @@ -96,10 +97,16 @@ func runHillchartsUpdate(cmd *cobra.Command, project, todosetID string, dotArgs } parsed = append(parsed, d) } - if cmd.Flags().Changed("notify") && strings.TrimSpace(strings.ReplaceAll(notify, ",", "")) == "" { + if todosetID != "" && !isAllDigits(todosetID) { + return output.ErrUsage("Invalid todoset ID: " + todosetID) + } + notifying := cmd.Flags().Changed("notify") + if notifying && strings.TrimSpace(strings.ReplaceAll(notify, ",", "")) == "" { return output.ErrUsage("--notify needs at least one person") } - if len(parsed) == 0 && !cmd.Flags().Changed("note") { + // --notify alone is a post: it records the chart as it stands and tells + // the people named, which the API allows. + if len(parsed) == 0 && !cmd.Flags().Changed("note") && !notifying { return noChanges(cmd) } @@ -109,8 +116,8 @@ func runHillchartsUpdate(cmd *cobra.Command, project, todosetID string, dotArgs if err != nil { return err } - if cmd.Flags().Changed("note") && strings.TrimSpace(note) == "" && len(parsed) == 0 { - return output.ErrUsage("--note is empty, and no --dot was given: there is nothing to post") + if cmd.Flags().Changed("note") && strings.TrimSpace(note) == "" && len(parsed) == 0 && !notifying { + return output.ErrUsage("--note is empty, and no --dot or --notify was given: there is nothing to post") } app := appctx.FromContext(cmd.Context()) diff --git a/internal/commands/hillcharts_update_test.go b/internal/commands/hillcharts_update_test.go index 9aaf4bb4a..ab04ba9b6 100644 --- a/internal/commands/hillcharts_update_test.go +++ b/internal/commands/hillcharts_update_test.go @@ -181,6 +181,7 @@ func TestHillchartsUpdateRefusesBadFlagsBeforeAnyRequest(t *testing.T) { "empty notify": {[]string{"update", "--todoset", "12345", "--dot", "111=5", "--notify", " , "}, "at least one person"}, "empty note alone": {[]string{"update", "--todoset", "12345", "--note", " "}, "nothing to post"}, "positional arg": {[]string{"update", "--todoset", "12345", "111=5"}, "unknown command"}, + "todoset not an id": {[]string{"update", "--todoset", "bogus", "--note", "-"}, "Invalid todoset ID: bogus"}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { @@ -214,3 +215,16 @@ func TestHillchartsUpdateSurfacesTheServersRefusal(t *testing.T) { assert.Contains(t, err.Error(), "isn't tracked") assert.Empty(t, out.String()) } + +// --notify alone records the chart as it stands and tells the people named. +func TestHillchartsUpdateNotifyAlonePosts(t *testing.T) { + app, transport, _ := setupHillUpdatesApp(t, hillPeopleRoute(), hillCreateVersionRoute()) + + require.NoError(t, executeHillchartsCommand(NewHillchartsCmd(), app, "update", + "--todoset", "12345", "--notify", "7")) + + body := postedHillBody(t, transport) + assert.Equal(t, []any{float64(7)}, body["subscriptions"]) + assert.NotContains(t, body, "dots") + assert.NotContains(t, body, "description") +}