Skip to content

feat(controls): add 'kosli update control' command#988

Open
pbeckham wants to merge 1 commit into
mainfrom
6096-add-cli-command-for-control-update
Open

feat(controls): add 'kosli update control' command#988
pbeckham wants to merge 1 commit into
mainfrom
6096-add-cli-command-for-control-update

Conversation

@pbeckham

@pbeckham pbeckham commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

What

Adds kosli update control CONTROL-IDENTIFIER — the missing update of CRUD for controls, wrapping the released PUT /api/v2/controls/{org}/{identifier} endpoint (body ControlPutInput). Follows on from the controls CLI in #986.

Flags:

  • --name / -n
  • --description / -d
  • --link (repeatable, name=url)

How it matches the API semantics

ControlPutInput has all-optional fields and the server merges based on model_fields_set — only keys present in the JSON body are touched. So:

  • The payload is built as an untyped map[string]interface{} and a key is added only when the user set that flag (cmd.Flags().Changed(...)). Omitted flags leave that field unchanged; --link replaces the whole links map, matching the server.
  • No-flags case: RequireAtLeastOneOfFlags gives a clear client-side error before hitting the API.
  • Not-found / validation errors surface verbatim from the API, consistent with get/archive control.
  • Beta-gated (betaCLIAnnotation), consistent with the other control commands.
  • Untyped payload (like update service-account), so the OpenAPI contract drift test needs no new entry.

Testing

  • make test_integration_single TARGET=TestUpdateControlCommandTestSuite — green. Cases: arg validation, no-flags error, name/description/links/multi-field success, not-found error.
  • make lint — 0 issues; make vet — clean.
  • kosli update control --help renders the beta notice and flags correctly.

Closes kosli-dev/server#6096

🤖 Generated with Claude Code

Adds the missing "update" of CRUD for controls, wrapping the released
PUT /api/v2/controls/{org}/{identifier} endpoint (body ControlPutInput).

- --name/-n, --description/-d, and repeatable --link (name=url)
- only flags the user set are sent, so omitted fields are left untouched
  (matches the server's model_fields_set merge); --link replaces all links
- RequireAtLeastOneOfFlags gives an actionable error when no updatable
  flag is provided; not-found and validation errors surface verbatim
- beta-gated (betaCLIAnnotation), consistent with the other control commands

Closes #6096

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@claude

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Claude finished @pbeckham's task in 2m 26s —— View job


Review: feat(controls): add 'kosli update control' command

  • Gather context (related control commands, flag constants, helpers)
  • Static analysis (build, vet)
  • Review code quality, edge cases, bugs, tests
  • Post review

Static analysis: go build ./... ✅ · go vet ./cmd/kosli/ ✅ — both clean.


This is a clean, well-scoped PR that closely follows the existing controls commands (create/get/archive). No blocking issues. A few notes below.

👍 What's done well

  • Correct merge semantics. Building an untyped map[string]interface{} and adding a key only when cmd.Flags().Changed(...) is true is exactly right for the server's model_fields_set merge — omitted flags leave fields untouched. The inline comment explaining why is a nice touch.
  • Client-side guardrail. RequireAtLeastOneOfFlags gives a clear error before hitting the API for the no-flags case, with a matching test.
  • Consistency. Beta annotation, RequireGlobalFlags, URL construction, and the logger.Info success message all mirror the sibling control commands. Reusing the shared does not exist in org error assertion keeps the not-found test aligned.
  • PUT vs POST. Correctly not setting DisableConflictRetry here — PUT is idempotent, unlike create control's POST. Good that the distinction was considered.

🔎 Suggestions (non-blocking)

  1. Test the selective payload directly (inline comment on the test file). The "only send changed fields" behavior — the whole point of the command — isn't asserted; the tests only check the success string. A --dry-run case asserting the pretty-printed JSON body contains only the set keys would guard the most regression-prone part cheaply.
  2. No way to clear all links. The help says --link "Replaces all existing links", but since StringToStringVar requires name=url pairs there's no way to set links to empty. Probably fine/out of scope, but worth confirming that clearing links isn't a needed operation.
  3. Empty-string edge case. --name "" sends "name": "" (since the flag is Changed), relying on the server to reject it. That's acceptable and consistent with surfacing API errors verbatim — just flagging the behavior.

Minor nits

  • newUpdateControlCmd(out io.Writer) never uses out (output goes through logger) — but this matches newCreateControlCmd, so it's consistent with the repo pattern.
  • url, err := url.JoinPath(...) shadows the net/url import; harmless and identical to createControl.go.

Overall: solid, idiomatic, and ready to merge once you decide on the payload-assertion test.

Comment on lines +44 to +58
{
name: "updating a control's description works",
cmd: "update control update-me --description 'checks something new'" + suite.defaultKosliArguments,
golden: "control update-me was updated\n",
},
{
name: "updating a control's links works",
cmd: "update control update-me --link runbook=https://example.com/runbook" + suite.defaultKosliArguments,
golden: "control update-me was updated\n",
},
{
name: "updating multiple fields in one call works",
cmd: "update control update-me --name 'Another name' --description 'and a description'" + suite.defaultKosliArguments,
golden: "control update-me was updated\n",
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core value of this command — that only the changed flags land in the PUT body (model_fields_set merge semantics) — is never actually asserted here. These tests hit the real local server and only check the success message, so a regression where, say, an empty description gets sent on a name-only update would pass silently.

Since dry-run pretty-prints the outgoing JSON payload (see internal/requests/requests_test.go:583), you can pin the selective-payload behavior cheaply with a dry-run case, e.g.:

{
    name:   "dry-run sends only the fields that were set",
    cmd:    "update control update-me --name 'Only name' --dry-run" + suite.defaultKosliArguments,
    golden: "", // assert payload contains "name" but not "description"/"links"
},

(You may need goldenRegex/Contains rather than an exact golden depending on the dry-run wrapper output.) Not blocking, but it would guard the one behavior most likely to break.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant