Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

### Upgrading from 2.x

3.0 is a rewrite in Rust. Commands, credentials, and config files carry over, but several 2.x quirks are gone, `--json` output has one consistent shape, and invalid input is rejected before anything is sent to Linear. Script authors should read the JSON output and command line sections.
3.0 is a rewrite in Rust. Commands, credentials, and config files carry over, but several 2.x quirks are gone, `--json` output has one consistent shape, invalid input is rejected before anything is sent to Linear, and the exit status tells failures apart. Script authors should read the JSON output and command line sections.

#### Installation and runtime

Expand Down Expand Up @@ -79,7 +79,7 @@ Every `--json` output follows one rule. Lists are a JSON array of entities, with
- terminal Markdown wraps long lines at spaces to the terminal width, keeping list and quote indentation on continuation lines. Tables wider than the terminal shrink their columns and wrap cell text, and print one `Header: value` record per row when the terminal is too narrow for a grid
- success messages share one form, `✓ Created issue ENG-123: Title` followed by the URL on its own line. Declining a confirmation prints `Canceled.` on stderr and exits 0
- errors read `✗ <what failed>: <why>`, often with a hint line underneath, and `LINEAR_DEBUG=1` adds the underlying causes. HTTP errors include a short excerpt of the response body. An ambiguous team, project, initiative, release, template, or user name lists the candidates. When a create's outcome is unknown, for example after a dropped connection, the error says the entity "may already exist"
- exit codes: `0` success, `1` error, `2` usage error (bad flags or values, a required value that is missing or empty, a confirmation or question that cannot be asked without a terminal, missing subcommand), `130` cancelled. A closed pipe (`linear issue list | head`) exits quietly
- exit codes: `0` success, `1` error, `2` usage error (bad flags or values, a required value that is missing or empty, a confirmation or question that cannot be asked without a terminal, missing subcommand), `3` not found (an issue, team, or other entity the command looked up), `4` authentication (no usable API key, or Linear rejected it), `5` unavailable (Linear could not be reached, timed out, rate limited the request, or failed with a server error; a create or update may still have taken effect), `130` cancelled. 2.x exited 1 for all of these, so a revoked key looked the same as a missing issue. `linear api` exits the same way for its response (GraphQL errors other than these stay `1`), and invalid input to it (no query, bad `--variables-json`) is now a usage error. A bulk command exits with the first of `4`, `5`, `1`, `3` among its failures, and says items "could not be found" only when they are all missing ("could not be looked up" otherwise). `linear --help` lists the statuses. A closed pipe (`linear issue list | head`) exits quietly ([#293](https://github.com/schpet/linear-cli/issues/293); thanks @sethfitz for the report)

#### Security

Expand All @@ -92,7 +92,7 @@ Every `--json` output follows one rule. Lists are a JSON array of entities, with

#### Bug fixes

- `issue start` takes the team from the issue itself, so a full ID or URL works without a configured team. An argument that is not an issue ID errors instead of opening the picker, and a failed state update exits 1 (the branch or jj change is still prepared) instead of reporting success
- `issue start` takes the team from the issue itself, so a full ID or URL works without a configured team. An argument that is not an issue ID errors instead of opening the picker, and a failed state update exits nonzero (the branch or jj change is still prepared) instead of reporting success
- finding the current issue from jj trailers reads one trailer per line. 2.x joined neighboring trailers (`Fixes A-1Fixes B-2`) and could pick the wrong issue. `issue commits` matches whole IDs, so `ENG-1` no longer matches `ENG-10`
- `issue view` shows every label, child, attachment, document, and comment instead of the first page. `milestone view`, `team states`, issue state lookups, `linear config`'s team list, milestone names, agent session activities, `initiative view`'s projects, and the relations `issue relation list` shows and `issue relation delete` searches also read every page
- `team delete --move-issues` reports a partial failure honestly and keeps the team, and bulk deletes skip items whose lookup failed instead of sending the mutation anyway
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,22 @@ settings are read from two config files, a project file and a global file. each

so the global file can hold defaults such as `issue_sort`, and a repository's `.linear.toml` overrides them for that project. every value is validated, even one a higher tier overrides, and an invalid value is an error naming its file and key.

## exit status

scripts can tell why a command failed from its exit status, without parsing the error message:

| status | meaning |
| ------ | ------- |
| `0` | success |
| `1` | any other failure, including GraphQL errors such as an invalid query in `linear api` |
| `2` | usage error: bad flags or values, rejected before anything is sent to Linear |
| `3` | not found: an issue, team, project, or other entity the command looked up does not exist |
| `4` | authentication: no usable API key, or Linear rejected it (revoked, mistyped, or lacking access) |
| `5` | unavailable: Linear could not be reached, timed out, rate limited the request, or failed with a server error. Retrying later may work, but a create or update may still have taken effect |
| `130` | cancelled at a prompt or in the editor |

a bulk command (`--bulk`) that fails for several reasons exits with the first of `4`, `5`, `1`, `3` among them, so `3` means every failure was a missing item. `linear --help` lists the statuses too.

## skills

linear-cli includes a skill that helps AI agents use the CLI effectively. for use cases outside the CLI, it includes instructions to interact directly with the graphql api, including authentication.
Expand Down
4 changes: 2 additions & 2 deletions crates/linear-cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ fn run(cli: Cli, settings: &mut DisplaySettings) -> Result<()> {
/// `LINEAR_DEBUG` the debug detail and source chain.
fn report(error: &Error, settings: DisplaySettings) {
let lines = match error.kind() {
ErrorKind::Exit(_) | ErrorKind::BrokenPipe => return,
ErrorKind::Reported(_) | ErrorKind::Exit(_) | ErrorKind::BrokenPipe => return,
// The same word as declining a confirmation, though the status differs.
ErrorKind::Cancelled => "Canceled.\n".to_owned(),
ErrorKind::Usage(usage) => usage.render().to_string(),
ErrorKind::Other | ErrorKind::Invalid => {
ErrorKind::Failed(_) | ErrorKind::Invalid => {
let terminal = Terminal::detect(settings.no_color);
let color = terminal.stderr_color();
// On a terminal, lines wrap at spaces with their indent kept.
Expand Down
3 changes: 2 additions & 1 deletion crates/linear-cli/src/cli/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ pub struct Api {
/// Follow the cursor of the one connection in the response and print every page
#[arg(long)]
pub paginate: bool,
/// Print nothing; the exit status still reports errors
/// Print nothing; the exit status still says whether and why it failed
/// (see `linear --help`)
#[arg(long)]
pub silent: bool,
}
16 changes: 15 additions & 1 deletion crates/linear-cli/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,21 @@ Environment:
LINEAR_IGNORE_ENV_FILE=1 Do not load .env files

Every .linear.toml setting can also be set with a LINEAR_* variable; run
`linear config` to write one for the current repository.";
`linear config` to write one for the current repository.

Exit status:
0 Success
1 Any other failure, including GraphQL errors such as an invalid query
2 Usage error: bad flags or values, rejected before anything is sent
3 Not found: an issue, team, or other entity the command looked up
4 Authentication: no usable API key, or Linear rejected it
5 Unavailable: Linear could not be reached, timed out, rate limited
the request, or failed with a server error. A create or update may
still have taken effect
130 Cancelled at a prompt or in the editor

A bulk command that fails for several reasons exits with the first of 4, 5,
1, 3 among them.";

/// Work with Linear from the command line
#[derive(Debug, Parser)]
Expand Down
2 changes: 1 addition & 1 deletion crates/linear-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use cynic::Operation;
use crate::graphql::envelope::{GraphQlRequest, ResponseError, parse_response};

pub use config::{ApiKey, ClientBuildError, ClientConfig, Deadline, EndpointUrl, ResponseCap};
pub use error::{HttpBodyShape, RawHttpResponse, RequestError};
pub use error::{HttpBodyShape, RawHttpResponse, RequestError, classify_failure};

use config::build_client;
use error::redact;
Expand Down
53 changes: 42 additions & 11 deletions crates/linear-cli/src/client/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ use reqwest::header::HeaderMap;

use super::config::{Deadline, ResponseCap};
use super::content_type;
use crate::error::Error;
use crate::error::{Error, Failure};
use crate::graphql::envelope::{
ResponseError, ResponseGraphQlError, graphql_message, is_not_found,
ResponseError, ResponseGraphQlError, graphql_failure, graphql_message,
};

/// A `reqwest::Error` with its URL removed before it is stored or chained.
Expand Down Expand Up @@ -313,6 +313,24 @@ impl StdError for RequestError {
}
}

/// The failure class of a response with `status` and GraphQL `errors`
/// (empty when the body had none or could not be read).
///
/// HTTP 401 and 403 are [`Failure::Auth`] and HTTP 408, 429 and 5xx are
/// [`Failure::Unavailable`], combined with the class of the errors; another
/// status, such as a 404 from a misconfigured endpoint, adds nothing beyond
/// [`Failure::General`]. A recognized missing entity under HTTP 400 is still
/// [`Failure::NotFound`].
pub fn classify_failure(status: StatusCode, errors: &[ResponseGraphQlError]) -> Failure {
let by_status = match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Some(Failure::Auth),
StatusCode::REQUEST_TIMEOUT | StatusCode::TOO_MANY_REQUESTS => Some(Failure::Unavailable),
status if status.is_server_error() => Some(Failure::Unavailable),
_ => None,
};
Failure::fold(by_status.into_iter().chain(graphql_failure(errors))).unwrap_or(Failure::General)
}

impl RequestError {
/// Whether the request may have reached Linear and taken effect anyway: a
/// timeout, a network failure after connecting (a reset after the request
Expand All @@ -327,9 +345,21 @@ impl RequestError {
}
}

/// Whether Linear answered that the requested entity does not exist.
/// The failure class this ends a command with.
pub fn failure(&self) -> Failure {
match self {
Self::GraphQl { status, errors, .. } => classify_failure(*status, errors),
Self::Http { response, .. } => classify_failure(response.status, &[]),
Self::ResponseTooLarge { status, .. } => classify_failure(*status, &[]),
Self::Timeout { .. } | Self::Network { .. } => Failure::Unavailable,
Self::RequestBody(_) | Self::Response(_) => Failure::General,
}
}

/// Whether Linear answered that the requested entity does not exist, and
/// nothing worse: a missing entity next to a rejected key is not "absent".
pub fn is_not_found(&self) -> bool {
matches!(self, Self::GraphQl { errors, .. } if is_not_found(errors))
self.failure() == Failure::NotFound
}

/// [`Error::not_found`] for `entity` `identifier` when Linear answered
Expand Down Expand Up @@ -357,8 +387,10 @@ impl RequestError {
impl From<RequestError> for Error {
fn from(failure: RequestError) -> Self {
let message = failure.to_string();
let class = failure.failure();
let error = |message| Error::failed(class, message);
match failure {
RequestError::RequestBody(source) => Error::new(message).with_source(source),
RequestError::RequestBody(source) => error(message).with_source(source),
RequestError::GraphQl {
status,
errors,
Expand All @@ -367,14 +399,15 @@ impl From<RequestError> for Error {
} => {
// The summary omits arbitrary response extensions, headers and
// the request URL.
Error::new(message).with_debug_detail(format!(
error(message).with_debug_detail(format!(
"GraphQL HTTP {status}; errors={}; partial_data={partial_data}",
errors.len()
))
}
// Only a 2xx body that is not usable data: always general.
RequestError::Response(source) => Error::from(source),
RequestError::Http { response, body } => {
let error = Error::new(message).with_debug_detail(format!(
let error = error(message).with_debug_detail(format!(
"HTTP {} body: {}",
response.status,
response.body_text()
Expand All @@ -384,10 +417,8 @@ impl From<RequestError> for Error {
HttpBodyShape::Data => error,
}
}
RequestError::ResponseTooLarge { .. } | RequestError::Timeout { .. } => {
Error::new(message)
}
RequestError::Network { source, .. } => Error::new(message).with_source(source),
RequestError::ResponseTooLarge { .. } | RequestError::Timeout { .. } => error(message),
RequestError::Network { source, .. } => error(message).with_source(source),
}
}
}
Loading
Loading