fix(cmdutils): write a JSON error object when --output=json
Description
Opened as a Draft on purpose: !2801 (closed) was moved back to draft pending consensus in #8138, and that consensus has not been recorded yet. This MR implements @timofurrer's proposal from #8138 (comment 3054017698) so there is something concrete to react to, and it flags the one question that is still open below.
The bug
--output=json (-F json) only affects the success path. When a command fails, stdout gets nothing at all and the failure is reported solely through Fang's styled error box on stderr — in both TTY and non-TTY runs. A caller that pipes glab into a JSON parser sees an empty stream.
$ glab variable get DUMMY -F json # before
STDOUT: (empty)
STDERR:
ERROR
404 Not Found.What this changes
Following the proposal's first iteration:
- The human-readable error still goes to
stderrin every output mode. Unchanged — this is whatclig.devasks for and whatkubectl/gh/docker/awsall do. - A machine-readable object now goes to
stdoutwhen JSON was requested.
$ glab variable get DUMMY -F json # after
STDOUT:
{"error":{"message":"404 Not Found"}}
STDERR: (the same error box as before)
$ echo $?
1How it works
Output format is per-command state (each of the 55 cmdutils.EnableJSONOutput call sites binds its own *string), and Fang hands the global error handler only the error — never the *cobra.Command — so the handler had no way to know what the user asked for.
IOStreamsgains an unexportedoutputFormatplusSetOutputFormat/IsJSONOutput, mirroring how the equally cross-cutting--jqfilter already lives onIOStreams.cmdutils.EnableJSONOutputwraps the enum flag value so cobra records the format onIOStreamswhile parsing flags. That is deliberately earlier than aPreRunEhook: it means the format is known even when the command fails during argument validation, and it cannot be clobbered by a command that sets its ownPreRunElater. The wrapper still satisfiescmdutils.AllowedValuer, so the MCP tool schema keeps publishing the enum (covered by a test).cmdutils.GitLabErrorHandlerbecomescmdutils.NewGitLabErrorHandler(streams), closing over the sharedIOStreamsfromcmd/glab/main.go.- The object itself is written by a new
IOStreams.PrintJSONErrorhelper (issue proposal item 3, "maybe with a helper wrapped iniostreams"), so every command that usesEnableJSONOutputgets the behaviour without a per-command change.
SilentError, the interrupted-command message, exit codes, and all text-mode output are unchanged.
Known scope boundary: SilentError still yields an empty stdout under -F json — the same symptom this MR otherwise closes. I checked its one use alongside EnableJSONOutput, internal/commands/ci/status/status.go:294 (returned from inside the --wait/--live polling loop when the pipeline ends up failed) — that command's own --output flag description already says "JSON output is not compatible with --live, --wait, or --compact flags," so this isn't a reachable combination today. I didn't special-case SilentError in the handler itself; flagging this in case there's another caller I didn't find, or if that's judged worth handling generically anyway.
The open question, and why I picked this branch
Item 2 of the proposal offered two options, and #8138 never settled on one:
- (a) a well-defined machine-readable error object on
stdout— what this MR implements. - (b) a zero-value object matching the success shape (
[],null,{}) — @phikai's preference, "with the benefit to not break downstream parsing".
I implemented (a) because (b) cannot be centralised. The zero value depends on each command's success shape — [] for securefile list, an object for variable get — which is exactly the knowledge the global error handler does not have. Doing (b) would mean touching all 55 commands to declare a zero value, and would still leave glab variable get MISSING -F json printing {}, which a caller cannot distinguish from a real empty result. Happy to switch if you'd rather have (b) — it is a bigger, more mechanical change and I'd want the decision recorded on the issue first.
On the shape: the message is nested under error rather than flat so that (i) it cannot be mistaken for the success payload written to the same stream, and (ii) a status / machine-readable reason can be added later without changing the type of an existing field. I did not attempt an RFC 9457 mapping — type/instance have no honest CLI meaning, and a partial mapping seemed worse than a small explicit shape. Deferring to you on this.
--jq is deliberately not applied to the error object: the expression targets the command's success output, so running it here would replace the failure the user needs to see with a filter error.
Related Issues
Relates to #8138
I used Relates to rather than Resolves on purpose — this is the first iteration of a multi-part proposal, so I don't think it should auto-close the issue. Also related: !2801 (closed) (the earlier attempt, closed).
How has this been tested?
Automated — all new, no existing tests changed or removed:
internal/cmdutils/output_format_test.go(new, 8 tests) — the format is recorded for--output json/-F text, is not recorded when the flag is absent or invalid, is recorded even when argument validation fails, and the flag still implementsAllowedValuerfor the MCP schema.internal/cmdutils/errors_test.go(new, 8 tests) — text mode leavesstdoutempty; JSON mode writes the object tostdoutand the human-readable error tostderr;SilentErrorstays silent in both modes;context.Canceledmaps to the interrupted message in both modes;--jqis ignored for errors. Plus a table-driven end-to-end test that drives a realcobra.Commandwired throughEnableJSONOutputand feeds the resulting error to the handler the way Fang does.internal/iostreams/print_json_test.go(3 added) —PrintJSONErrorenvelope shape,--jqbypass, and the nil-JQpath.
Manual, with the real binary against a local HTTP server returning 404, run both inside a pty and piped:
| invocation | stdout | stderr | exit |
|---|---|---|---|
variable get DUMMY -F json |
{"error":{"message":"404 Not Found"}} |
error box | 1 |
variable get DUMMY |
(empty) | error box | 1 |
variable get DUMMY -F text |
(empty) | error box | 1 |
variable get -F json (missing arg) |
{"error":{"message":"accepts between 1 and 1 arg(s), received 0"}} |
error box | 1 |
variable get OKVAR -F json (success) |
the variable object, as before | (empty) | 0 |
variable get OKVAR -F json --jq .value |
hello, as before |
(empty) | 0 |
variable get DUMMY -F json --jq .value |
{"error":{"message":"404 Not Found"}} |
error box | 1 |
variable get DUMMY -F bogus |
(empty) | must be one of [text json] |
1 |
Repository-wide checks, since EnableJSONOutput has 55 call sites:
go build ./...— clean.go vet ./...— clean.gofmt -l .— empty.golangci-lint run ./...(v2.12.2, per.tool-versions) —0 issues.go test ./...— all 285 packages pass, no pre-existing failures. Run withmake test's environment (VISUAL/EDITOR/PAGER/GITLAB_TOKENcleared,CI_PROJECT_PATHset).go run ./cmd/gen-docs/docs.go— no diff; this MR changes no command help text.
Not verified locally: lefthook is not installed here, so the full pre-push chain (markdownlint, vale, lychee) did not run — no Markdown or docs files are touched by this MR, so I expect those to be no-ops. The commit-msg hook was validated by running npx commitlint --config .commitlintrc.js against the commit message directly.
Screenshots (if appropriate):
n/a — the before/after is the table above.