Skip to content

Latest commit

 

History

History
127 lines (106 loc) · 33.6 KB

File metadata and controls

127 lines (106 loc) · 33.6 KB

pfix is an unofficial, public, open-source command-line client for the Planfix REST API, written in Go. It ships as a single self-contained binary. It is an independent project — not affiliated with, endorsed, sponsored, or funded by Planfix.

Status

Milestones 1–22 are implemented and merged to main:

  • M1: the config/profile layer, the Planfix transport client, auth (login/status/logout — login resolves its target profile before prompting and confirms before overwriting an existing one, with --force to skip the prompt; --profile <name> saves a second account as a distinct profile), and the raw api passthrough.

  • M2: the typed task command group (list, view, create, update, comment list, comment add) and the internal/output rendering layer (table/detail/raw-JSON) that makes --json/--fields/--quiet meaningful.

  • M3: the typed project command group (list, view, create, update — projects have no comments), plus extraction of the shared command helpers into cmdutil (FieldsCSV/ValidateID/DecodeJSON/ClientFunc) and output (ColumnsFor) so every resource reuses them.

  • M4: the typed contact command group (list, view, create, update) for people and companies. contact create requires --template (Planfix rejects a templateless contact).

  • M5: the typed user command group (list, view — read-only; the API disables user create and update is sensitive). Resolves the owner/assignee user:N references on tasks/projects.

  • M6: the typed report command group (list, view — read-only). view decodes the single-report response defensively: Planfix returns it under the misspelled key repost, with a report fallback.

  • M7: the config command group (list, use, show) for managing profiles locally (no API), plus cmdutil.MaskToken shared with auth status.

  • M8: the typed datatag command group (list, view — read-only). Envelope keys are camelCase (dataTags/dataTag).

  • M9: input-validation hardening — cmdutil.ValidateID now rejects non-positive ids across every resource.

  • M10: the typed template command (list <type> — read-only). A new GET-based shape: GET /<type>/templates with an object-type path segment and no pagination; adds shared cmdutil.ValidateObjectType.

  • M11: the typed customfield command (list <type> — read-only). GET /customfield/<type> (fixed prefix + type segment), envelope customfields; columns ID/NAME/TYPE.

  • M12: the typed object command group (list, view — read-only). POST-list + GET-view with pagination; envelopes objects/object; object status is fat so STATUS uses status.name.

  • M13: a --filter <json> pass-through on the 7 POST-list commands (task/project/contact/user/report/datatag/object) via cmdutil.ApplyFilter — forwards a raw Planfix filters array in the request body. GET-based template/customfield are excluded.

  • M14: typed field flags on task create/task update--template (create-only), --project, --parent, --status (now also on create), --priority (client-validated: the API silently resets invalid values to NotUrgent), --counterparty (contact id or contact:N), --assignees/--auditors/--participants (comma-separated user:N/contact:N/group:N; update replaces the list), --start-date/--end-date (ISO input → Planfix dd-MM-yyyy/HH:mm, interpreted in the account timezone). Shared parsers cmdutil.ParsePeople/cmdutil.ParseTimePoint; task-local taskFields registers/applies the flag set for both commands.

  • M15: the ping command (GET /ping) — a connectivity + token-validity check that prints OK (--json passes the raw {"result":"success"} through; -q prints nothing and just sets the exit code). auth status now validates the token via GET /ping instead of POST /task/list — lighter and scope-independent (a task-list probe would misreport a valid token scoped only to, say, contacts). Adds shared cmdutil.DescribeAPIError, which maps the Planfix auth app-codes to actionable hints (code 1 unknown token → pfix auth login; code 5 scope denied → the token lacks the scope), used by both ping and auth status.

  • M16: saved task filters — the read-only task filters command (POST /task/filters; envelope filters; columns ID/NAME/OWNER via owner.name) and a --saved-filter <id> flag on task list that forwards a filterId in the request body. The id is an opaque string (system tokens :all/:in/:out/:audit or a numeric id) forwarded verbatim; an unknown id surfaces the API's code 41 error. This is distinct from the declined typed filter flags — it applies an existing named filter rather than building a filters array. When both are supplied, --saved-filter and --filter combine as a logical AND — the raw filter further narrows the saved view (both constraints apply; verified live against the API).

  • M17: the global --jq <expr> flag — filters JSON output through an embedded jq engine (github.com/itchyny/gojq, no external jq binary needed). Setting --jq implies --json, and the expression is compiled and validated up front (GlobalOpts.PreRun), so an invalid expression fails before any API call. Applied at the single JSON choke point, output.EmitJSON, which every command now calls instead of output.JSON directly — so it works wherever JSON is emitted: all typed commands, ping, and api.

  • M18: task status discovery — task statuses [task-id | --process id] (resolves a task's processId via GET /task/{id}?fields=processId, then GET /process/task/{id}/statuses; envelope statuses; columns ID/NAME/ACTIVE; a note on stderr when the set is empty, e.g. an unknown process). Adds task processes and contact processes (GET /process/{type}; envelope processes; columns ID/NAME) sharing a new internal/cmd/processes package. Also allows --status 0 — the valid Draft status — on task create/update, which the shared positive-id check previously rejected. Notes from live recon: GET /process/task lists custom processes only (the built-in process is hidden, but reachable via a task's processId); the object status endpoint (GET /object/{id}/statuses) is unused (dead on process-based accounts); a task's process is fixed at creation (not writable); Draft is create-only (the API rejects moving an existing task to Draft).

  • M19: task custom-field values — a repeatable --cf <id>=<value> on task create/task update and rendered custom-field rows on task view. Values are typed from the field definition: pfix does one GET /customfield/task?fields=id,name,type,enumValues to map each id to its type code (and, for a list, its option labels), then formats short/multiline text as a string, number as a JSON number, and list (type 8) as the bare option label string (unsupported type codes error, pointing to pfix api). A list has no option ids over REST — the definition exposes options only as enumValues strings — and the API validates nothing, storing any unrecognized value verbatim as a phantom, so pfix rejects a label outside the set before the write (the error lists the valid options, each quoted, since a label may contain spaces or commas). Labels match exactly: no trimming, no case folding. Unknown ids and type mismatches also fail before the write; the field must be bound to the task's template/process or the API drops the value silently (documented, not detected). task view renders customFieldData as field.name = stringValue when the numeric ids are requested via --fields, dropping the numeric-id columns from the table. Shared cmdutil.ParseCustomFieldSpecs/BuildCustomFieldData; output.Detail gained trailing extra ...KV rows.

  • M20: lookup endpoints — user groups, user positions, contact groups (shared internal/cmd/groups package, GET /<type>/groups; envelope groups), customfield types (GET /customfield/type; envelope customFieldTypes), and a TYPE-name decode in customfield list via a new optional output.Column.Format hook + an embedded typeNames catalog. All read-only, GET-based; only user/groups resolves the assignee group:N refs (recon: contact/groups are the segments a contact base is divided into — the vendor's English docs call these "contact groups", so pfix does too, rather than "categories" from the localized UI — and project/groups/directory/groups come back empty).

  • M21: --fields discovery in help — every read command (list/view for task/project/contact/user/report/datatag/object; extended to the rest in M23) now lists its default and selectable fields in --help. Sourced once from the API's OpenAPI description (swagger.json, not committed) and baked into co-located xxxAvailableFields constants; task help also notes numeric custom-field ids are selectable. Shared cmdutil.FieldsHelp formats the block into each command's Cobra Long. Help text only — --fields behavior is unchanged, and the API silently ignores unknown fields, so the list is advisory. Note from recon: the API accepts unknown field names without error (result: success, field dropped), so list field sets that swagger leaves un-enumerated (project/user/report/datatag list) reuse the sibling view vocabulary, and object uses the ObjectResponse schema.

  • M22: the file command group (view, download — read-only) and a shared files list subcommand on task/contact/project (GET /<type>/{id}/files; envelope files; columns ID/NAME/SIZE). --source attached (default) mirrors the endpoint; --source inline scrapes uniqueid=(\d+) file ids out of description/comment HTML for editor-uploaded images, which the attachment API never returns under any flag, then resolves each id with GET /file/{id} and composes a files[] response (no source key — same shape as the endpoint, so --json looks identical either way). --description-only (task/contact only; passes the endpoint's own onlyFromDescription) and --limit/--offset (project only; the endpoint's own paging) are each valid only with --source attached — combining either with --source inline errors. file view <id> renders ID/NAME/SIZE, plus LINK when the file carries one. file download <id> streams bytes byte-exact via a new planfix.Client.Stream; -o <path>/-o <dir>//-o - resolve the destination (auto-naming via a metadata lookup when -o is omitted or names a directory), refuses to overwrite an existing file without --force, and rejects --json/--jq (it writes raw bytes, not JSON). Streaming removes the whole-request timeout that bounded every prior command, so main.go now wires a signal.NotifyContextCtrl-C cancels a stalled download instead of hanging forever. Also fixes pfix api, which previously appended a spurious newline to any non-JSON (i.e. binary) response body; it now branches on the response Content-Type, writing anything other than application/json verbatim, and names the Content-Type in the error when --jq is pointed at a non-JSON response instead of claiming it isn't valid JSON. Recon: the vendor-documented size field is ceil(bytes / 1024), not bytes — never use it for an exact count, only Content-Length; an object's files field (task view --fields files, etc.) is description-scoped, matching onlyFromDescription=true, not every file attached to the object; inline editor uploads appear in no files[] array anywhere, regardless of that flag; and description is HTML on task and project but plaintext on contact, so a contact's inline scrape reads its comment feed instead of its description field — reading the field directly would silently find nothing.

  • M23: the comment command group (view, edit, delete) plus the flags that finish the task-scoped pair. A Planfix comment is one global entity with an optional parent link (task or contact) — CommentCreateRequest/CommentUpdateRequest/CommentResponse are shared by both scopes and ids come from one sequence — so only list/add are genuinely parent-scoped and stay under task, while view/edit/delete take a bare comment id and work on task- and contact-owned comments alike. comment view renders ID/CREATED/AUTHOR/TASK-or-CONTACT/PINNED/HIDDEN/TEXT, dropping whichever parent row the comment lacks. comment edit sends only the flags the user set (the API applies partial updates), and resolves the comment's real parent via GET /comment/{id}?fields=task,contact before posting: the update endpoint's own parent segment is ignored by the API (verified — a task comment edits fine through contact/3/... and through a task id that does not exist), and pfix must not depend on that. The parent-scoped endpoint is also the only write path — /comment/{id} answers 405 Method Not Allowed to both POST and PUT, advertising Allow: DELETE,GET,OPTIONS (an HTML error page, no JSON envelope, so it parses as code 0 with an empty message) — so the resolving GET is not avoidable by posting to the entity endpoint instead. comment delete requires --force, deletes softly (the comment then 400s on view/edit but still lists under task comment list --include-deleted, with no way back), and maps the API's opaque code 0 to the one known cause: a task's first comment holds its description and cannot be deleted. Also --include-deleted on task comment list (typeList: "Deleted", which returns deleted comments, live comments, and system/audit entries — e.g. the record written on a task rename — that the default listing hides; type does not separate them: a rename audit record and a task's description comment both report type: "None"), --pinned/--hidden on task comment add, and --silent on add/edit. Recon: editing a task's first comment does not change task.description — the description seeds comment #1 at creation and the two diverge afterwards; typeList All behaves like Comments (deleted stay hidden); the standalone POST /comment/list is cross-entity but requires a filters array, failing with code 0 without one. M23 also completed M21's --fields help sweep: task comment list and comment view share one exported comment.AvailableFields (both read the same object), and the block now also covers customfield list/types, user groups/positions, contact groups, task/contact processes, task statuses, and the three files commands. Two carry caveats: the files block lists columns, not requestable fields (the endpoints have no server-side selection), and template list gets a Default-fields line but no block — a template's vocabulary is the listed object type's own, so its help points at that type's view --help instead. groups.Long(short) exists because user/contact override the shared command's Short; rebuilding Long through it keeps the block that a raw string assignment would have silently dropped.

  • M24: the task checklist group (list, view, add, update) over the four checklist endpoints. Checklists hang off tasks only, so the group is task-scoped like task comment: POST /task/{id}/checklist/list (envelope items; offset/pageSize ≤100/fields), GET /task/{id}/checklist/{itemId} (envelope item), POST /task/{id}/checklist (create; name required, answers {result, id}), POST /task/{id}/checklist/{itemId} (update). There is no delete — verified, not assumed: DELETE answers 405 on both the item and the collection route, their OPTIONS/WADL advertise only GET/POST, /checklist/{id}/delete is a 404, an isDeleted key is refused (code 30, Cannot deserialize), and DELETE /task/{itemId} on the underlying record is 405 too — so the group's help says so rather than leaving a silent hole. The item vocabulary is enumerated by the API (id,name,isDone,parent,dateTime,assignees), so unusually the --fields block is exact rather than advisory; the server default is id alone, so pfix ships its own (id,name,isDone; view adds dateTime,assignees). Items nest: --parent <item-id> on add nests and on update moves (at least three levels deep), the listing is flat but depth-first with each child following its parent, and the API validates the reference — a parent from another task is code 6, an unknown one code 601. update sends only the flags the user set (the comment edit contract) and --done=false unticks; with no flags it refuses instead of posting an empty body. The update endpoint's documented 200/202 response carries a failures array beside result: "success", so pfix decodes it and turns a non-empty one into an error — otherwise a rejected write could print as a success (under --json the response still passes through, then the error sets the exit code); no live probe has produced a non-empty one, so this is a contract honored rather than an observed behavior. view/update take both ids, and the two sides treat the pair differently: a read ignores the task segment (an item resolves through a task id that does not exist — the M23 comment edit trap), while a write validates it (Checklist item does not belong to task by id - N, Task not found by id - N). Recon notes: a checklist item is a Checkmark task record sharing the task id sequence, but GET /task/{itemId} refuses it, so the checklist routes are the only way in; unknown keys in a write body are rejected outright (code 30) rather than ignored the way unknown fields names are; and an assignee reference the API cannot resolve is dropped silently — since the list is replaced wholesale, --assignees user:999999 clears the item's people and still answers success, which the flag help warns about because the response cannot betray it. All four commands, both read and write, are verified live against a disposable account.

All tested. Keep this file in sync as code lands — completed work is logged above and in Build order; planned, postponed, and declined work lives in Roadmap below.

Roadmap

Kept separate from the milestone log above so landing a new milestone only appends to Status and Build order — this section changes on its own schedule.

  • Next (as API access allows): a typed directory resource.
  • Postponed — not exposed via REST: process mutation and running processes / workflow actions. (Process listing/status endpoints already back task status discovery; see M18 and M20.)
  • Declined by the user: deletes (except comment delete, shipped in M23 at the user's request), user update, typed filter flags, and color.

Project rules

  • Public and vendor-neutral. Describe pfix's behavior on its own terms. Committed artifacts (code, comments, docstrings, identifiers, fixtures, docs, README, commit messages) must not name, reference, or compare against other products or tools, and must not include copied or cited third-party material. (Sole exception: a committed CLAUDE.md whose entire content is the import line @AGENTS.md, so agent tooling that looks for that filename loads this vendor-neutral file instead. AGENTS.md is the canonical, tool-agnostic source; CLAUDE.md is only a pointer to it.)
  • Unofficial. pfix is an independent project with no affiliation to Planfix. Keep the disclaimers in README.md and this file accurate.
  • Public dependencies only. Every dependency must be installable from public sources. No private package indexes or internal libraries.
  • License: Apache-2.0.

Stack

  • Go (latest stable) with github.com/spf13/cobra for the command tree.
  • Standard-library net/http for the API client; gopkg.in/yaml.v3 for config; golang.org/x/time/rate for request throttling; golang.org/x/term for hidden token entry; github.com/itchyny/gojq — embedded jq engine for --jq output filtering.
  • Module path: github.com/a68366/pfix-cli. Binary: pfix.
  • Deliberately lean: no config framework (no Viper), no color libraries; gojq is the one dependency added for a specific feature (--jq), kept direct and minimal (its timefmt-go transitive stays // indirect).

Layout

Implemented:

  • main.go — entry point. Wires signal.NotifyContext(os.Interrupt) and calls cmd.Execute(ctx), so Ctrl-C cancels a long-running command (e.g. a stalled file download) instead of hanging until the process is killed.
  • internal/cmd/ — Cobra commands: root (its --version flag aliases version), version, ping (connectivity + token check), auth/ (login/status/logout), api/, task/ (list, view, create, update, statuses, processes, files, and the comment and checklist sub-groups — checklist is list/view/add/update, split across checklist.go (reads) and checklist_write.go (writes) — create/update take a repeatable --cf <id>=<value> to set typed custom-field values, and view renders them as field.name = stringValue rows when the numeric ids are requested via --fields; comment list takes --include-deleted, and comment add takes --pinned/--hidden/--silent), project/ (list, view, create, update, files), contact/ (list, view, create, update, processes, groups, files), user/ (list, view, groups, positions — read-only), report/ (list, view — read-only), datatag/ (list, view — read-only), template/ (list <type> — read-only, GET-based), customfield/ (list <type>, types — read-only, GET-based), object/ (list, view — read-only), file/ (view, download — read-only), comment/ (view, edit, delete — a global comment id, not scoped to a parent), config/ (list, use, show — local profile management). The data package internal/config is imported as pfconfig inside internal/cmd/config to avoid the package-name collision.
  • internal/cmd/processes/ — the shared processes list command backing both task processes and contact processes (GET /process/<type>; envelope processes; columns ID/NAME).
  • internal/cmd/groups/ — the shared groups list command backing both user groups and contact groups (GET /<type>/groups; envelope groups; columns ID/NAME).
  • internal/cmd/files/ — the shared files list command backing task files, contact files, and project files (files.NewCmd(g, files.Options{Type, Paging, DescriptionOnly}) lets each parent register the subset of flags its endpoint supports; GET /<type>/{id}/files; envelope files; columns ID/NAME/SIZE). --source inline scrapes description/comment HTML instead of calling the endpoint, using a per-resource strategy since task/contact keep their HTML in comments while project keeps it on the object itself.
  • internal/cmd/comment/ — the top-level comment group (view, edit, delete) operating on a global comment id. edit resolves the comment's parent (task/{id} or contact/contact:{id}) before posting, since the update endpoint ignores the parent segment it requires.
  • internal/cmdutil/GlobalOpts (persistent flags), the Client()/ClientFunc() helpers that build a configured client from the active profile, and the resource-agnostic command helpers shared by every typed command (FieldsCSV, ValidateID, DecodeJSON, ApplyFilter, ParsePeople, ParseTimePoint, DescribeAPIError, ParseCustomFieldSpecs, BuildCustomFieldData, FieldsHelp, ScanFileIDs, SafeFileName).
  • internal/planfix/ — Planfix REST client. A low-level Client.Do(ctx, method, path, body, headers) carries auth, throttling, and retries; Client.JSON(ctx, method, path, body) is the typed-command convenience over it (marshals the body, returns raw response bytes, maps status ≥300 to *APIError). Client.Stream(ctx, path) is a GET sibling for file download: it shares Do's throttle/retry loop but returns the response with Body unread and no whole-request timeout (only a response-header timeout), so a large download's body read is never cut off mid-stream. errors.go holds APIError (incl. the Planfix app Code)/ParseError.
  • internal/output/ — renders decoded JSON: Table/Detail via text/tabwriter, a dot-path Flatten (e.g. status.name; an object with no name falls back to its id), ColumnsFor (default vs --fields-derived columns), an optional per-Column Format render hook (used by customfield list to decode the TYPE code to its name; honored by Table only, not Detail), rune-safe Truncate, JSON (pretty-print/raw passthrough — shared with api), and jq.go (CompileJQ/EmitJSON). EmitJSON is the flag-aware JSON entry point every command calls now — it runs the compiled --jq query over the decoded response when one is set, and otherwise falls back to JSON unchanged.
  • internal/config/ — profile load/save (atomic, mode 0600) and value precedence (Resolve, ResolveProfileName).
  • internal/buildinfo/ — version/commit/date injected at build time.

Build order

  1. Done (M1): auth + generic api — credentials/profiles plus the raw passthrough make every endpoint reachable immediately.
  2. Done (M2): task — list, view, create, update, and comments + the internal/output rendering layer.
  3. Done (M3): project — list, view, create, update + shared command-helper extraction.
  4. Done (M4): contact — list, view, create, update (people + companies).
  5. Done (M5): user — list, view (read-only).
  6. Done (M6): report — list, view (read-only).
  7. Done (M7): config — list, use, show (local profile management).
  8. Done (M8): datatag — list, view (read-only).
  9. Done (M9): input-validation hardening (ValidateID rejects non-positive ids).
  10. Done (M10): template — list per object type (read-only, GET-based).
  11. Done (M11): customfield — list per object type (read-only, GET-based).
  12. Done (M12): object — list, view (read-only).
  13. Done (M13): --filter JSON pass-through on the POST-list commands.
  14. Done (M14): typed field flags on task create/task update.
  15. Done (M15): ping command + auth status token check via GET /ping (shared cmdutil.DescribeAPIError auth-error hints).
  16. Done (M16): saved task filters — task filters (read-only) + task list --saved-filter (filterId pass-through).
  17. Done (M17): global --jq <expr> output filter (embedded jq engine, implies --json, validated up front).
  18. Done (M18): task status discovery (task statuses, task processes, contact processes) + --status 0 (Draft) fix.
  19. Done (M19): task custom-field values (--cf on create/update; rendered on view; type-aware via GET /customfield/task).
  20. Done (M20): lookup endpoints (user groups, user positions, contact groups, customfield types) + a TYPE-name decode in customfield list via the new output.Column.Format hook.
  21. Done (M21): --fields discovery in --help (shared cmdutil.FieldsHelp; co-located xxxAvailableFields constants on the 14 read commands).
  22. Done (M22): files — list/view/download + inline scrape + binary-output fix.
  23. Done (M23): comments — comment view|edit|delete + --include-deleted, --pinned, --hidden, --silent.
  24. Done (M24): task checklists — task checklist list|view|add|update (the API has no delete).

Conventions

  • Auth: Bearer token + account domain; base URL https://<domain>/rest/....
  • Config file: ~/.config/pfix/config.yml (mode 0600) with multiple named profiles.
  • Precedence: command-line flags > environment (PFIX_DOMAIN, PFIX_TOKEN, PFIX_PROFILE, PFIX_CONFIG) > config file. Profile name resolves through config.ResolveProfileName (flag > PFIX_PROFILE > current_profile > "default") — use it everywhere a command needs the active profile, so the commands stay consistent.
  • Output: typed task commands default to a human-readable table (list) or key/value detail (single object), rendered by internal/output (stdlib text/tabwriter, no color). --json emits the API response unmodified (pretty-printed); --fields overrides the requested fields and table columns; -q/--quiet drops the header row (lists) or prints only the affected id (create/update/comment add/comment edit/comment delete). --jq <expr> filters the JSON output through a jq expression (implies --json). api always emits raw JSON. Errors go to stderr with a non-zero exit code. The Planfix layer stays thin — commands render generically from decoded map[string]any via dot-paths rather than typed structs, so unconfirmed nested shapes need no model.
  • Transport: Client.Do returns the HTTP response for any status (callers inspect StatusCode and use planfix.ParseError for detail). It retries connection errors + 5xx, never 4xx. Every request carries a User-Agent of pfix/<version> (from buildinfo.Version, set on the Client.UserAgent field in New); a caller-supplied User-Agent header — e.g. api -H "User-Agent: ..." — overrides it.
  • Proxy: the client follows the standard Go proxy environment variables (HTTP(S)_PROXY/NO_PROXY) for every request. Both HTTP paths resolve the proxy through the Client.Proxy field (default http.ProxyFromEnvironment, set in New): New clones the standard transport for the shared Do/JSON client, and Stream (file download) builds its own timeout-free transport — both point Proxy at c.Proxy, resolved per request, so overriding the field (or nil-ing it to force a direct connection) governs all requests. A bare http.Transport would instead disable proxying outright, which is why Stream must not use one. ALL_PROXY is not honored (the Go stdlib httpproxy package does not read it).
  • API specifics: list endpoints are POST with pageSize/offset/fields/filters; fields must be requested explicitly — ship sensible per-resource defaults, overridable with --fields.
  • Files: task files/contact files/project files --source inline composes its JSON output ({"result":"success","files":[...]} built from resolved ids) rather than echoing an API response — there is no single endpoint for inline files, so --json/--jq there reflect pfix's aggregation, not one call's response body.
  • Files: --fields on the files commands (task files, contact files, project files) selects table columns only. No file-listing endpoint supports server-side field selection — /file/{id}'s own fields parameter is accepted and silently ignored — so unlike other typed commands, --fields there never changes what the API is asked for.
  • Comments: a task's description is its first comment — it seeds comment #1 at task creation and shows up as such in task comment list/view. The two values diverge from then on: editing that first comment via pfix comment edit changes only the comment, never the task's description field, and there is no re-sync in either direction.
  • Checklists: task-only, and the item route's two methods disagree about the task segment — the GET ignores it (an item resolves through any task id, including one that does not exist — the comment edit trap) while the update POST validates it and answers 400. task checklist view/update therefore take both ids and forward what the user gave. The update response also carries a failures array while still answering 200 success, so every checklist write decodes it and errors on a non-empty list. Items nest through parent, which the API validates; an unresolvable assignees reference, by contrast, is dropped silently and clears the list.
  • Destructive commands (e.g. comment delete): refuse to run without --force and never prompt for confirmation, even interactively. This is deliberate — behavior stays identical whether the command runs from a terminal or a script, and there is no TTY-detection branch to keep in sync. Follow this rule for any future destructive command.

Toolchain

  • Use the project's Go toolchain (latest stable). The go directive is pinned in go.mod; keep the module graph tidy (go mod tidy) — every imported dependency must be in the direct require block, not // indirect.
  • Format: gofmt -l . (output must be empty).
  • Vet: go vet ./....
  • Test: go test ./.... Table-driven tests; stand up a fake API with net/http/httptest; mock only at the HTTP boundary, never the code under test.
  • Lint (required — CI gates on it): golangci-lint run ./... must report 0 issues before a milestone is considered done or a release is tagged. gofmt/go vet are not a substitute (errcheck flags unchecked error returns, including in tests). Config in .golangci.yml.
  • Build: go build -o pfix .; release builds embed metadata via -ldflags "-X github.com/a68366/pfix-cli/internal/buildinfo.Version=..." (and Commit/Date).
  • CI: GitHub Actions — .github/workflows/ci.yml runs gofmt/vet/build/go test -race/tidy-check plus golangci-lint (config in .golangci.yml) on pushes to main and PRs; .github/workflows/release.yml + .goreleaser.yml publish multi-platform binaries to GitHub Releases on v* tags via GoReleaser.
  • Releasing: on a green main, create and push an annotated tag — git tag -a vX.Y.Z -m "pfix vX.Y.Z" && git push origin vX.Y.Z. The tag triggers the release workflow: GoReleaser builds linux/darwin/windows × amd64/arm64 archives (version/commit/date embedded via ldflags), writes checksums.txt, assembles the changelog from Conventional Commit subjects (docs/test/chore/ci types are excluded — pick commit types with the changelog in mind), and publishes the GitHub Release immediately (draft: false in .goreleaser.yml). Verify the Actions run and the Releases page; go install github.com/a68366/pfix-cli@latest resolves the new tag once the Go module proxy refreshes.

Testing rules

  • All behaviour changes must be covered by tests — if it isn't tested, it isn't done.
  • Test decisions and branches (error mapping, config precedence, retry behavior, request building), not glue code.