Skip to content

fix(cli): return non-zero exit code on command errors#31

Merged
Finesssee merged 5 commits into
Finesssee:masterfrom
hojinyoo:fix/exit-codes
Jun 11, 2026
Merged

fix(cli): return non-zero exit code on command errors#31
Finesssee merged 5 commits into
Finesssee:masterfrom
hojinyoo:fix/exit-codes

Conversation

@hojinyoo

@hojinyoo hojinyoo commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

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 json emitted an {"error": …} body
    yet returned exit code 0.
  • i update <issue> -l <missing-label> printed an error, exit 0.
  • Batch / multi-item operations (bulk …, i get A B, comments list A B)
    returned 0 even when individual items failed.

Fix

The central dispatch in main.rs already maps a handler Err to a non-zero
exit code and (under --output json) emits the {"error":true,…} body on
stderr — the bug was specific handlers swallowing failures into Ok(()).
This change stops the swallowing:

  • Hard failures (bulk user/label resolution) now return Err instead of
    printing an inline error and returning Ok, and they preserve the underlying
    CliError kind — so e.g. a rate-limited (429) resolution stays exit 4 with
    its retry_after.
  • Batch operations (the four bulk subcommands, issues multi-get,
    comments multi-issue listing) return non-zero if any item failed, while
    still printing every per-item result on stdout. The aggregate error goes to
    stderr; stdout remains a single valid JSON value.
  • Error-kind fidelity: a real fetch error (network / auth / rate-limit)
    keeps its ErrorKind instead of being collapsed to NotFound. Previously a
    429 inside a multi-get would surface as exit 2 (NotFound); it now correctly
    stays exit 4 (RateLimited), which is the retryable code an orchestrator
    branches on.

Intentionally left non-fatal (unchanged): the interactive REPL loop, the
webhook-server signature loop, and "nothing to import" no-ops.

Tests

  • Offline CLI regression tests in tests/cli_tests.rs: a failing command exits
    non-zero; under --output json the {"error":true} body is on stderr while
    stdout stays clean; a successful command exits 0.
  • Unit tests for the pure aggregation helpers (bulk_exit_status,
    multi_get_status, comments_status), including that a rate-limited item
    yields exit 4 rather 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 test and cargo clippy -- -D warnings pass.

🤖 Generated with Claude Code

@Finesssee

Copy link
Copy Markdown
Owner

Hey, thanks for the PR, I will review this and give u some feedback.

@Finesssee Finesssee left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Requesting changes.

This is directionally the right behavioral fix, but it is not merge-ready yet.

  1. cargo fmt --check fails on this PR's changed files. That alone blocks merge for Rust code. The formatter wants changes in src/commands/bulk.rs, src/commands/comments.rs, src/commands/issues.rs, and tests/cli_tests.rs.

  2. The exit-status policy is being implemented as scattered per-command machinery. bulk_exit_status, comments_status, multi_get_status, wrap_resolve_error, and wrap_fetch_error all encode variants of the same new contract: print successful per-item output, then return one aggregate error while preserving CliError kind/retry metadata. That pushes more local policy into already-large command files, especially src/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_status passed
  • cargo test -q multi_get_status passed
  • cargo test -q comments_status passed
  • cargo test -q --test cli_tests test_failing_command_exits_nonzero passed
  • cargo fmt --check failed

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.

@Finesssee

Copy link
Copy Markdown
Owner

Concrete changes needed before this can merge:

  1. Run cargo fmt and push the formatted diff. cargo fmt --check currently fails on files changed by this PR:

    • src/commands/bulk.rs
    • src/commands/comments.rs
    • src/commands/issues.rs
    • tests/cli_tests.rs
  2. Pull the duplicated CliError preservation/wrapping logic into one canonical helper instead of reimplementing it in command modules. Right now wrap_resolve_error in bulk.rs and wrap_fetch_error in issues.rs both downcast anyhow::Error, copy kind, details, and retry_after, and rewrite the message. That behavior belongs near the error boundary, likely in error.rs, so commands do not each grow their own version of the same policy.

  3. Keep each command module focused on its own flow: collect per-item results, print the successful/failed item output, then return the aggregate status. The new shared helper should make the aggregate status code preserve the original CliError kind/retry metadata without adding more local branching to already-large files like src/commands/issues.rs.

After that, rerun at least:

  • cargo fmt --check
  • cargo test -q bulk_exit_status
  • cargo test -q multi_get_status
  • cargo test -q comments_status
  • cargo test -q --test cli_tests test_failing_command_exits_nonzero

hojinyoo added a commit to hojinyoo/linear-cli that referenced this pull request Jun 8, 2026
…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>
@hojinyoo

hojinyoo commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review in e46ea79:

  1. cargo fmt — applied; cargo fmt --check is clean.
  2. Canonical error helper — deleted bulk.rs::wrap_resolve_error and issues.rs::wrap_fetch_error; the "same kind/details/retry_after, new message" policy now lives once at the error boundary as error::rewrap_with_message(source, msg) (it also recovers a CliError sitting behind an anyhow context layer). Call sites just compose the message.
  3. Focused command modules — each handler is back to collect → print → return aggregate status; no downcast_ref::<CliError>() + field-copying scattered through bulk.rs/issues.rs.

hojinyoo and others added 2 commits June 9, 2026 16:32
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>
hojinyoo added a commit to hojinyoo/linear-cli that referenced this pull request Jun 9, 2026
…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>
hojinyoo and others added 2 commits June 9, 2026 23:32
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>
@Finesssee

Copy link
Copy Markdown
Owner

Update after the latest push:

Good: the main structural blocker from the prior review is addressed. CliError re-message/preservation now goes through the shared error::rewrap_with_message helper instead of duplicating the downcast/copy logic in command modules.

Still blocked: cargo fmt --check fails on the current head, specifically in src/error.rs. Please run cargo fmt and push the formatting-only diff.

Current local validation from the updated head:

  • cargo fmt --check fails
  • cargo check -q passes
  • cargo test -q passes: 331 bin tests + 200 integration tests
  • cargo test -q bulk_exit_status passes
  • cargo test -q multi_get_status passes
  • cargo test -q comments_status passes
  • cargo test -q rewrap passes
  • cargo test -q graphql_ passes
  • cargo test -q --test cli_tests test_failing_command_exits_nonzero passes

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 Finesssee left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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 --check passed
  • cargo check -q passed
  • cargo test -q passed: 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.

@Finesssee Finesssee merged commit 42780fb into Finesssee:master Jun 11, 2026
2 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants