fix(time): decode a millisecond field whichever type ClickUp sends - #28
Conversation
ClickUp reports time_spent as the number 0 while nothing is tracked and as the string "2040000" once something is — in the same response. Every millisecond field was typed as a Go scalar, so a single tracked subtask aborted the decode of its whole parent: `clickup task view <parent>` failed outright, with no name, no status and no siblings, only a json.Unmarshal error (#27). clickup.Millis accepts either form and marshals back as a number, so the --json contract is unchanged and jq filters over the output keep working. It now backs Task.time_spent/time_estimate — which covers subtasks, since they are Tasks too — and, via an x-go-type in the spec patch, every millisecond field in a generated response type: the timer stop and running endpoints returned duration as an int and had the same latent break. Request bodies deliberately keep their plain integer type. The CLI is the side sending them, so it always sends a number, and a plain scalar is what the generated flag sets need to bind --duration. The time-entry listing takes the same treatment through the existing flexibleInt, which preserves duration as a string in --json output. Only the real API produces the mixed types, so smoke.sh now logs time on a subtask and reads the parent back — the exact shape that used to fail. Fixes #27
Review of the previous commit found the same defect one field over. A time entry's start, end and at swing between string and number exactly as its duration does — the spec's own stop-timer example shows a string start beside a numeric end, while the running-timer example makes all three strings. The generated code inherited the contradiction: Start string, End int, At int on stop; Start string, At string on current. Stop is the worst place to break. The timer is already stopped server-side by the time the CLI decodes the response, so a decode error leaves the user with a failed command and, on retry, no timer running. The timestamp fix is scoped to time-entry paths — `start` and `end` mean other things elsewhere in the spec — and components is now narrowed to schemas and responses so request bodies keep the plain scalars their flag sets need, as the comment above it already claimed. Also: a timer stopped inside a minute reported "Timer stopped — logged", because formatMillisDuration renders anything non-positive as empty. Back to formatDuration, which says 0m. smoke.sh now runs the timer round trip — start, running, stop — since only the real API produces the mixed types.
|
Review pass on this branch turned up four things; all four are fixed in ed67612, each with a test that was red first. 1. The same defect one field over (medium). A time entry's Stop is the worst place for this: the timer is already stopped server-side by the time the CLI decodes the response, so the user gets an error and then, on retry, "no timer is running." Both forms now decode. The timestamp fix is scoped to time-entry paths by name — 2. 3. 4. smoke.sh assertions couldn't fire (low). Under smoke.sh also gained the timer round trip (start → running → stop), since only the real API produces the mixed types. Full run green against the workspace. |
Ten review passes over the previous two commits. Seven findings held up; each is fixed here with a test that was red first. The output regression that mattered: `clickup view tasks --json` had begun dropping time_spent and time_estimate entirely for any untracked task. The generated fields carry `omitempty`, and encoding/json decides that from the reflect kind before MarshalJSON is ever consulted — so a Millis of 0 vanished where the Nullable[int] it replaced emitted 0. The spec patch now sets x-omitempty:false, which the codegen honours. Millis accepted values int64 cannot hold. ParseInt's range error fell through to ParseFloat, which takes Inf, NaN and any magnitude, and converting one of those is implementation-defined in Go: 1e30 decoded to MaxInt64 on arm64 and MinInt64 on amd64 from identical bytes. Out of range is now an error, boundary included — MaxInt64 is not representable as a float64. The time-entry listing decoded through flexibleInt, which keeps the raw token. A float duration — the form Millis grew a branch for — printed as "1800000.0" in the duration column and was silently dropped from the total: two entries of 1h and 30m reported "Total: 1h across 2 entries", a wrong billing number presented as authoritative. It uses clickup.Millis now, like the generated models for the sibling endpoints, so one jq filter works across the whole command group. That makes duration, start and end JSON numbers in `task time list --json` where they were quoted strings; the timer commands moved the same way in ed67612. Both shapes are now pinned by tests rather than left to drift. Also: duration is scoped to the time-entry paths, as start/end/at already were — a `duration` elsewhere meaning seconds would decode clean and be wrong by a thousand. The real spec is asserted against, not just synthetic fixtures, so a path rename fails here instead of in a user's timer. `--time-estimate` is in task edit's skip list now that the narrowed patch generates it as a scalar flag; only registration order stood between that and a pflag panic at startup. The spec target depends on the patch file, so editing the jq re-derives the spec instead of leaving every existing checkout generating from stale rules. smoke.sh no longer displaces a real timer: it skips the round trip when the account already has one running, registers the timer and the logged entry with cleanup(), keeps stderr in its failure messages, and asserts the subtask count rather than printing whatever it found.
|
Ten review passes over the branch. Seven findings held up and are fixed in 0cc0499, each with a test that failed first. The rest are recorded below with why they were not acted on. Fixed1. 2. 3. The timesheet total was silently short. 4. Two normalisations in one command group. Consequence of (3): 5. 6. Nothing asserted the patch fires on the real spec. Every millis test invented its own path, so a rename upstream would leave the suite green and the fix gone. Two tests now run jq against the pinned spec: every millisecond field on the timer and task endpoints must carry the flexible type, and no request body may. 7. smoke.sh no longer displaces a real timer: it skips the round trip when the account already has one running (none of Reported, not acted on
|
Two codegen gaps the review surfaced, neither of them live yet. cmd/gen-api resolves the $ref chains oapi-codegen cannot, and its type mapping read only `type` — so it ignored the x-go-type the spec patch uses to pin every millisecond field, and a union type fell through to `any`. No millis field reaches a struct through a $ref today, so fixes.gen.go is byte-identical. But the DELETE time-entry response already $refs the stop response, and if a re-pin ever makes that chain resolvable this generator would emit `Duration int` beside the patched `clickup.Millis` and reopen issue #27 on the endpoint the patch had just fixed. It now honours x-go-type and the x-go-type-import beside it, emitting an import block when one is needed. The other: CI installed oapi-codegen-exp at @latest. `make check` regenerates from source and compares, which proves nothing if the tool doing the generating floats — the specs are pinned by checksum for exactly this reason and the generator was not. Pinned to the version the current output was produced with, installed by `make tools`, which CI now calls and api-gen points at when the binary is missing.
Two unchecked tmp.Close() calls on the failure branches of the index write, from bd748fb. They have been failing golangci-lint on main since, so no branch cut from it can be green — including this one. The returned error is the one worth reporting on those paths, so the Close errors are explicitly discarded rather than wrapped.
Fixes #27.
What was wrong
ClickUp is inconsistent about millisecond fields:
time_spentcomes back as the number0while nothing is tracked and as the string"2040000"once something is — both in the same response. Every millisecond field in the CLI was a Go scalar, so one tracked subtask aborted the decode of its whole parent:No partial output, no fallback — the task's name, status and every other subtask went with it.
The report noted the parent's own
time_spentseemed to be handled already. It wasn't:Subtasksis[]Task, so parent and child share the field. Viewing the tracked subtask directly only worked because the API sent that same value as a number when the task was the top-level object.The fix
clickup.Millisdecodes from a number or a string (and fromnull, an empty string, or an in-range float) and marshals back as a number. It now backs:Task.time_spent/Task.time_estimate— which covers subtasks, since they areTasks too. This is the reported bug.x-go-typeinpatch-v2-spec.jq, scoped to the time-entry paths. This caught two latent breaks of the same class:task time stop/task time runningdecodeddurationasintandstartasstring, and stop is the worst place to fail — the timer is already stopped server-side, so the user gets an error and then "no timer is running" on retry.task time listentry struct, so the timesheet total is arithmetic on a real integer rather than a re-parsed string.Request bodies deliberately keep plain scalars: the CLI is the side sending them, and widening them silently drops the
--durationflag the generated flag sets bind.Output contract
Two deliberate changes, both pinned by tests:
task time list --jsonduration,start,endtask time stop/start/running --jsonstart,atThe old shapes were internally inconsistent — a quoted
startbeside a numericendin the same object — and could not decode at all when ClickUp swapped them. One numeric shape across the command group means a singlejqfilter works on all of it. The documented.duration | tonumberrecipe still works.view tasks --jsonkeeps emittingtime_spent/time_estimatefor untracked tasks: the patch setsx-omitempty: false, becauseomitemptyon an integer kind drops a zero beforeMarshalJSONis ever consulted.Review
Twelve review passes over three rounds. Every finding that held up is fixed here, each with a test that was red first — the full accounting is in the comments below. The ones worth knowing about:
view tasks --jsonhad begun dropping zero-valued time fields (omitempty+ int64 kind).MillisacceptedInf/NaN/overflow through its float fallback, and the int64 conversion is implementation-defined —1e30decoded to MaxInt64 on arm64 and MinInt64 on amd64.1800000.0and was dropped from the sum, so 1h + 30m reportedTotal: 1h across 2 entries.durationwas patched spec-wide while the timestamps were path-scoped — backwards, sincedurationis the more generic word.--time-estimatecollided withtask edit's hand-written flag, safe only by registration order; pflag panics on a redefinition at startup.api/specs/clickup-v2.jsonhad no prerequisite on the jq patch, so editing it left existing checkouts generating from stale rules withmake api-genreporting nothing to do.cmd/gen-apiignoredx-go-type, so a$ref-resolved millis field would have been emitted as a rigid scalar — reopeningclickup task view --jsonfails to decode any task whose subtask has tracked time #27 on the endpoint the patch had just fixed.oapi-codegen-exp@latest;make checkregenerates and compares, which proves nothing if the generator floats. Pinned viamake tools.Tests
Written first, red before each fix — the type (number, string, null, empty, negative, float, garbage, out-of-range, the 2^63 boundary),
clickup.Taskdecode, the issue's exact response, the list endpoint,task viewand its--jsonshape,view tasks --json,task time listincluding the timesheet total, all three timer commands, the spec patch against both synthetic fixtures and the real pinned spec, and thegen-apitype mapping.Only the real API produces the mixed types, so
smoke.shgained the time-tracking path: log time on a subtask, list it back, read the parent, then the timer round trip. It skips the timer steps when the account already has one running — none ofstart/running/stoptake an id, so they would otherwise displace a developer's real tracking — and registers both the timer and the logged entry withcleanup().go build,go test ./...,go vet,gofmtand the docs check are clean after a fullmake api-clean api-gen, and smoke passes against the workspace.golangci-lintreports two pre-existingerrcheckfindings ininternal/taskindexfrom bd748fb, unrelated to this branch.🤖 Generated with Claude Code
https://claude.ai/code/session_018A2UDG7xce7xjTJU3HPLy3