fix(cli): return non-zero exit code on command errors#31
Conversation
|
Hey, thanks for the PR, I will review this and give u some feedback. |
Finesssee
left a comment
There was a problem hiding this comment.
Requesting changes.
This is directionally the right behavioral fix, but it is not merge-ready yet.
-
cargo fmt --checkfails on this PR's changed files. That alone blocks merge for Rust code. The formatter wants changes insrc/commands/bulk.rs,src/commands/comments.rs,src/commands/issues.rs, andtests/cli_tests.rs. -
The exit-status policy is being implemented as scattered per-command machinery.
bulk_exit_status,comments_status,multi_get_status,wrap_resolve_error, andwrap_fetch_errorall encode variants of the same new contract: print successful per-item output, then return one aggregate error while preservingCliErrorkind/retry metadata. That pushes more local policy into already-large command files, especiallysrc/commands/issues.rs, which is already over 2k lines. This works, but it makes the surrounding code more spaghetti.
I think there is a code-judo move here: make the error boundary canonical instead of duplicating it in each command. At minimum, move the CliError wrapping/preservation logic into a shared helper, likely near error.rs, so callers can say "same kind/details/retry_after, new message" without repeating downcast_ref::<CliError>() and field-copying in command modules. Then keep each command's local logic to just: collect results, print results, return aggregate status. That would delete the duplicate wrapper concept and make the new exit-code contract easier to trust.
Validation I ran:
cargo test -q bulk_exit_statuspassedcargo test -q multi_get_statuspassedcargo test -q comments_statuspassedcargo test -q --test cli_tests test_failing_command_exits_nonzeropassedcargo fmt --checkfailed
Residual note: full clippy also reports pre-existing items_after_test_module failures in unrelated modules, so I am not treating the whole clippy result as PR-specific. The fmt failure is PR-specific.
|
Concrete changes needed before this can merge:
After that, rerun at least:
|
…th_message Address review feedback on Finesssee#31: - Move the duplicated CliError preservation logic (same kind/details/retry_after, new message) out of bulk.rs::wrap_resolve_error and issues.rs::wrap_fetch_error into one canonical `error::rewrap_with_message` helper at the error boundary. Command modules now just collect results, print them, and return the aggregate status; the helper keeps the exit code accurate (e.g. a 429 stays exit 4). - Run `cargo fmt` on the changed files. - Trim verbose comments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the review in
|
Some handlers printed an error then returned Ok(()), so the process exited 0
on failure -- unsafe for orchestrators trusting $?. The central plumbing in
main.rs already maps a handler Err to a non-zero code and emits the
{"error":true,...} JSON body on stderr; the fix is to stop swallowing:
- bulk.rs resolution failures (user/label) now return Err, preserving the
underlying CliError kind/retry hint.
- The four bulk handlers, the issues.rs multi-get, and the comments.rs
multi-issue listing return Err if any item failed (any-item-failure =>
non-zero), while still printing per-item results on stdout.
- Real fetch errors keep their ErrorKind: a rate-limited (429) failure stays
exit 4 instead of being collapsed to NotFound (exit 2), so retry logic works.
Adds offline exit-code regression tests (run_cli_isolated) and unit tests for
the pure aggregation helpers (bulk_exit_status / multi_get_status /
comments_status).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…th_message Address review feedback on Finesssee#31: - Move the duplicated CliError preservation logic (same kind/details/retry_after, new message) out of bulk.rs::wrap_resolve_error and issues.rs::wrap_fetch_error into one canonical `error::rewrap_with_message` helper at the error boundary. Command modules now just collect results, print them, and return the aggregate status; the helper keeps the exit code accurate (e.g. a 429 stays exit 4). - Run `cargo fmt` on the changed files. - Trim verbose comments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…th_message Address review feedback on Finesssee#31: - Move the duplicated CliError preservation logic (same kind/details/retry_after, new message) out of bulk.rs::wrap_resolve_error and issues.rs::wrap_fetch_error into one canonical `error::rewrap_with_message` helper at the error boundary. Command modules now just collect results, print them, and return the aggregate status; the helper keeps the exit code accurate (e.g. a 429 stays exit 4). - Run `cargo fmt` on the changed files. - Trim verbose comments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Linear returns application-level failures (not-found, auth, rate-limit) as HTTP 200 with a GraphQL `errors` array, so the transport-level status mapping in `http_error` never saw them and every such failure collapsed to exit code 1. The most common case — `i get <bad-id>` — returned 1 instead of the documented 2 (not found), defeating agents that branch on exit codes. Classify the first GraphQL error via its `extensions.statusCode` / `extensions.code` / message text into NotFound (2) / Auth (3) / RateLimited (4), falling back to General (1). Note "Entity not found" arrives with statusCode 400 (not 404), so message-text is the fallback that catches it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
from_graphql_errors only ran on the 2xx response branch, so any application error returned with a non-2xx status went through http_error, which maps only HTTP 429 to RateLimited. Per Linear's docs GraphQL rate limits arrive as HTTP 400 + extensions.code RATELIMITED, so they exited 1 (General) instead of 4 (RateLimited) — defeating agent backoff that keys on exit code 4. Add refine_http_error: when a non-2xx body carries a GraphQL `errors` array that classifies more specifically than the transport status, prefer the payload kind (keeping the status message and merging retry_after). Generic payloads leave the status kind intact, so a 401 with an unclassifiable body still exits 3. Unit-tested without HTTP mocking. Found via Codex PR review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Update after the latest push: Good: the main structural blocker from the prior review is addressed. Still blocked: Current local validation from the updated head:
GitHub CI is also still red on this PR, so I am keeping the requested-changes state until formatting is clean and CI is green. |
Finesssee
left a comment
There was a problem hiding this comment.
Approving the updated head.
The earlier structural blocker is fixed by error::rewrap_with_message, and the remaining formatter issue is fixed in the latest formatting commit.
Local validation on the final head:
cargo fmt --checkpassedcargo check -qpassedcargo test -qpassed: 331 bin tests + 200 integration tests
CI is queued/red because repository Actions billing is disabled, so I am treating the local validation above as the merge gate.
Problem
Some commands print an error but exit
0, so a script or agent that trusts$?treats failures as success. Examples:bulk label <missing> -i LIN-1 --output jsonemitted an{"error": …}bodyyet returned exit code
0.i update <issue> -l <missing-label>printed an error, exit0.bulk …,i get A B,comments list A B)returned
0even when individual items failed.Fix
The central dispatch in
main.rsalready maps a handlerErrto a non-zeroexit code and (under
--output json) emits the{"error":true,…}body onstderr — the bug was specific handlers swallowing failures into
Ok(()).This change stops the swallowing:
bulkuser/label resolution) now returnErrinstead ofprinting an inline error and returning
Ok, and they preserve the underlyingCliErrorkind — so e.g. a rate-limited (429) resolution stays exit4withits
retry_after.bulksubcommands,issuesmulti-get,commentsmulti-issue listing) return non-zero if any item failed, whilestill printing every per-item result on stdout. The aggregate error goes to
stderr; stdout remains a single valid JSON value.
keeps its
ErrorKindinstead of being collapsed toNotFound. Previously a429 inside a multi-get would surface as exit
2(NotFound); it now correctlystays exit
4(RateLimited), which is the retryable code an orchestratorbranches on.
Intentionally left non-fatal (unchanged): the interactive REPL loop, the
webhook-server signature loop, and "nothing to import" no-ops.
Tests
tests/cli_tests.rs: a failing command exitsnon-zero; under
--output jsonthe{"error":true}body is on stderr whilestdout stays clean; a successful command exits
0.bulk_exit_status,multi_get_status,comments_status), including that a rate-limited itemyields exit
4rather than NotFound/2.No change to the existing exit-code convention in
error.rs(1=General, 2=NotFound, 3=Auth, 4=RateLimited) — this just makes the handlers
honor it.
cargo testandcargo clippy -- -D warningspass.🤖 Generated with Claude Code