Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <todolist-id\|name>=<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)) |
Expand Down
36 changes: 36 additions & 0 deletions e2e/hillcharts.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
13 changes: 13 additions & 0 deletions e2e/smoke/smoke_hillcharts.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
Expand Down
4 changes: 3 additions & 1 deletion internal/commands/hillcharts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id|url> reads one, with its note and each todolist's position.",
"updates lists past progress updates (newest first); updates show <id|url> reads one, with its note and each todolist's position.\n" +
"update --dot <todolist-id|name>=<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.",
},
}

Expand All @@ -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
Expand Down
208 changes: 208 additions & 0 deletions internal/commands/hillcharts_update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
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). --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
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: <todolist-id|name>=<0-100> (repeatable)")
cmd.Flags().StringVar(&note, "note", "", "Note about the update (Markdown); use - to read from stdin")
cmd.Flags().StringVar(&notify, "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 "<todolist>=<position>". 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 <todolist-id|name>=<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 <todolist-id|name>=<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 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")
}
// --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)
}

// 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 && !notifying {
return output.ErrUsage("--note is empty, and no --dot or --notify 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...)
}
Loading
Loading