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
62 changes: 57 additions & 5 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use tokio::sync::RwLock;

use crate::cache::{Cache, CacheOptions, CacheType};
use crate::config;
use crate::error::CliError;
use crate::error::{CliError, ErrorKind};
use crate::pagination::{paginate_nodes, PaginationOptions};
use crate::retry::{with_retry, RetryConfig};
use crate::text::is_uuid;
Expand Down Expand Up @@ -137,6 +137,25 @@ fn http_error(status: StatusCode, headers: &HeaderMap, context: &str) -> CliErro
err.with_details(details)
}

/// Refine a transport-level HTTP error using its GraphQL payload.
///
/// Linear can return application errors (notably RATELIMITED) as a non-2xx
/// status carrying a GraphQL `errors` array, so the transport status alone
/// under-classifies them. When the payload resolves to a more specific kind
/// than the status, prefer it so the exit code stays precise (e.g. 4 for a
/// rate limit behind HTTP 400). The status-derived message is kept.
fn refine_http_error(http_err: CliError, body: &Value) -> CliError {
let Some(errors) = body.get("errors") else {
return http_err;
};
let payload = CliError::from_graphql_errors(errors);
if payload.kind == ErrorKind::General {
return http_err;
}
CliError::new(payload.kind, http_err.message.clone())
.with_retry_after(http_err.retry_after.or(payload.retry_after))
}

pub fn parse_linear_upload_url(raw: &str) -> Result<Url> {
let url = Url::parse(raw).context("Failed to parse upload URL")?;

Expand Down Expand Up @@ -685,7 +704,7 @@ impl LinearClient {
} else {
json!({ "body": body })
};
let mut err = http_error(status, &headers, "resource");
let mut err = refine_http_error(http_error(status, &headers, "resource"), &details);
if !body.is_empty() {
err = err.with_details(details);
}
Expand All @@ -695,9 +714,7 @@ impl LinearClient {
let result: Value = response.json().await?;

if let Some(errors) = result.get("errors") {
return Err(CliError::general("GraphQL error")
.with_details(errors.clone())
.into());
return Err(CliError::from_graphql_errors(errors).into());
}

Ok(result)
Expand Down Expand Up @@ -871,6 +888,41 @@ fn default_retry_config() -> RetryConfig {
mod tests {
use super::*;

#[test]
fn refine_http_error_prefers_ratelimited_payload_behind_http_400() {
// Linear returns GraphQL rate limits as HTTP 400 + extensions.code
// RATELIMITED; the transport status alone would yield General(1).
let http_err = CliError::general("HTTP 400 Bad Request");
let body = json!({
"errors": [{
"message": "Rate limit exceeded",
"extensions": { "code": "RATELIMITED", "retryAfter": 12 }
}]
});
let refined = refine_http_error(http_err, &body);
assert_eq!(refined.kind, ErrorKind::RateLimited);
assert_eq!(refined.code(), 4);
assert_eq!(refined.retry_after, Some(12));
}

#[test]
fn refine_http_error_keeps_status_kind_when_payload_is_generic() {
// A 401 whose payload doesn't classify must stay Auth(3), not regress to General.
let http_err = CliError::auth("Authentication failed");
let body = json!({ "errors": [{ "message": "Unauthorized" }] });
let refined = refine_http_error(http_err, &body);
assert_eq!(refined.kind, ErrorKind::Auth);
assert_eq!(refined.code(), 3);
}

#[test]
fn refine_http_error_passes_through_without_errors_array() {
let http_err = CliError::not_found("resource not found");
let body = json!({ "body": "<html>502</html>" });
let refined = refine_http_error(http_err, &body);
assert_eq!(refined.kind, ErrorKind::NotFound);
}

#[test]
fn test_auth_state_api_key_header() {
let state = AuthState::ApiKey("lin_api_key123".to_string());
Expand Down
104 changes: 66 additions & 38 deletions src/commands/bulk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use tokio::sync::Mutex;

use crate::api::{resolve_label_id, resolve_state_id, resolve_user_id, LinearClient};
use crate::display_options;
use crate::error::{self, CliError};
use crate::output::{print_json_owned, OutputOptions};
use crate::text::truncate;

Expand Down Expand Up @@ -148,8 +149,7 @@ async fn bulk_update_state(state: &str, issues: Vec<String>, output: &OutputOpti
.collect()
.await;
print_summary(&results, "state updated", output);

Ok(())
bulk_exit_status(&results)
}

async fn bulk_assign(user: &str, issues: Vec<String>, output: &OutputOptions) -> Result<()> {
Expand All @@ -168,21 +168,12 @@ async fn bulk_assign(user: &str, issues: Vec<String>, output: &OutputOptions) ->

let client = LinearClient::new()?;

// Resolve the user ID once upfront
let user_id = match resolve_user_id(&client, user, &output.cache).await {
Ok(id) => id,
Err(e) => {
if output.is_json() || output.has_template() {
print_json_owned(
json!({ "error": format!("Failed to resolve user '{}': {}", user, e), "results": [] }),
output,
)?;
} else {
println!("{} Failed to resolve user '{}': {}", "x".red(), user, e);
}
return Ok(());
}
};
// Resolution failure is a hard error: return Err, preserving the kind.
let user_id = resolve_user_id(&client, user, &output.cache)
.await
.map_err(|e| {
error::rewrap_with_message(&e, format!("Failed to resolve user '{user}': {e}"))
})?;

let results: Vec<_> = stream::iter(issues.iter())
.map(|issue_id| {
Expand All @@ -195,8 +186,7 @@ async fn bulk_assign(user: &str, issues: Vec<String>, output: &OutputOptions) ->
.collect()
.await;
print_summary(&results, "assigned", output);

Ok(())
bulk_exit_status(&results)
}

async fn bulk_label(label: &str, issues: Vec<String>, output: &OutputOptions) -> Result<()> {
Expand All @@ -215,21 +205,12 @@ async fn bulk_label(label: &str, issues: Vec<String>, output: &OutputOptions) ->

let client = LinearClient::new()?;

// Resolve the label ID once upfront
let label_id = match resolve_label_id(&client, label, &output.cache).await {
Ok(id) => id,
Err(e) => {
if output.is_json() || output.has_template() {
print_json_owned(
json!({ "error": format!("Failed to resolve label '{}': {}", label, e), "results": [] }),
output,
)?;
} else {
println!("{} Failed to resolve label '{}': {}", "x".red(), label, e);
}
return Ok(());
}
};
// Resolution failure is a hard error: return Err, preserving the kind.
let label_id = resolve_label_id(&client, label, &output.cache)
.await
.map_err(|e| {
error::rewrap_with_message(&e, format!("Failed to resolve label '{label}': {e}"))
})?;

let results: Vec<_> = stream::iter(issues.iter())
.map(|issue_id| {
Expand All @@ -242,8 +223,7 @@ async fn bulk_label(label: &str, issues: Vec<String>, output: &OutputOptions) ->
.collect()
.await;
print_summary(&results, "labeled", output);

Ok(())
bulk_exit_status(&results)
}

async fn bulk_unassign(issues: Vec<String>, output: &OutputOptions) -> Result<()> {
Expand All @@ -267,8 +247,7 @@ async fn bulk_unassign(issues: Vec<String>, output: &OutputOptions) -> Result<()
.collect()
.await;
print_summary(&results, "unassigned", output);

Ok(())
bulk_exit_status(&results)
}

fn missing_bulk_issues(example: &str) -> Result<()> {
Expand Down Expand Up @@ -620,3 +599,52 @@ fn print_summary(results: &[BulkResult], action: &str, output: &OutputOptions) {
}
);
}

/// `Err` if any item failed, so partial failures still exit non-zero.
fn bulk_exit_status(results: &[BulkResult]) -> Result<()> {
let failed = results.iter().filter(|r| !r.success).count();
if failed > 0 {
return Err(CliError::general(format!(
"{} of {} operations failed",
failed,
results.len()
))
.into());
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

fn result(success: bool) -> BulkResult {
BulkResult {
issue_id: "LIN-1".to_string(),
success,
identifier: None,
error: if success {
None
} else {
Some("boom".to_string())
},
}
}

#[test]
fn bulk_exit_status_ok_when_all_succeed_or_empty() {
assert!(bulk_exit_status(&[]).is_ok());
assert!(bulk_exit_status(&[result(true), result(true)]).is_ok());
}

#[test]
fn bulk_exit_status_errs_on_any_failure() {
let err = bulk_exit_status(&[result(true), result(false)]).unwrap_err();
assert_eq!(err.downcast_ref::<CliError>().expect("CliError").code(), 1);
}

#[test]
fn bulk_exit_status_errs_when_all_fail() {
assert!(bulk_exit_status(&[result(false), result(false)]).is_err());
}
}
71 changes: 66 additions & 5 deletions src/commands/comments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use tabled::{Table, Tabled};

use crate::api::LinearClient;
use crate::display_options;
use crate::error::{CliError, ErrorKind};
use crate::input::read_ids_from_stdin;
use crate::output::{
ensure_non_empty, filter_values, print_json, print_json_owned, sort_values, OutputOptions,
Expand Down Expand Up @@ -109,7 +110,10 @@ async fn list_comments(issue_ids: &[String], output: &OutputOptions) -> Result<(
Err(e) => Err((id, e)),
}
}
Ok(_) => Err((id, anyhow::anyhow!("Issue not found"))),
Ok(_) => Err((
id.clone(),
CliError::not_found(format!("Issue not found: {id}")).into(),
)),
Err(e) => Err((id, e)),
}
}
Expand All @@ -119,12 +123,28 @@ async fn list_comments(issue_ids: &[String], output: &OutputOptions) -> Result<(
.await;

let mut issues = Vec::new();
let mut missing: Vec<String> = Vec::new();
// Real fetch errors keep their kind; missing issues map to NotFound.
let mut first_error: Option<anyhow::Error> = None;
for result in fetched {
match result {
Ok((_id, issue)) => issues.push(issue),
Err((id, _e)) => {
Err((id, e)) => {
let is_missing = e
.downcast_ref::<CliError>()
.map(|c| c.kind == ErrorKind::NotFound)
.unwrap_or(false);
if !output.is_json() && !output.has_template() {
eprintln!("{} Issue not found: {}", "!".yellow(), id);
if is_missing {
eprintln!("{} Issue not found: {}", "!".yellow(), id);
} else {
eprintln!("{} Error fetching {}: {}", "!".red(), id, e);
}
}
if is_missing {
missing.push(id);
} else if first_error.is_none() {
first_error = Some(e);
}
}
}
Expand All @@ -137,7 +157,7 @@ async fn list_comments(issue_ids: &[String], output: &OutputOptions) -> Result<(
} else {
print_json_owned(serde_json::json!(issues), output)?;
}
return Ok(());
return comments_status(first_error, &missing);
}

for (idx, issue) in issues.iter().enumerate() {
Expand Down Expand Up @@ -202,7 +222,20 @@ async fn list_comments(issue_ids: &[String], output: &OutputOptions) -> Result<(
println!("\n{} comments", comments.len());
}

Ok(())
comments_status(first_error, &missing)
}

/// Aggregate exit status: a real fetch error (passed through unchanged) wins so
/// its kind survives; otherwise missing issues map to NotFound.
fn comments_status(first_error: Option<anyhow::Error>, missing: &[String]) -> Result<()> {
if let Some(e) = first_error {
return Err(e);
}
if missing.is_empty() {
Ok(())
} else {
Err(CliError::not_found(format!("Issue(s) not found: {}", missing.join(", "))).into())
}
}

async fn fetch_issue_meta(client: &LinearClient, issue_id: &str) -> Result<serde_json::Value> {
Expand Down Expand Up @@ -335,6 +368,34 @@ mod tests {
"badtitle"
);
}

#[test]
fn comments_status_ok_when_nothing_failed() {
assert!(comments_status(None, &[]).is_ok());
}

#[test]
fn comments_status_notfound_when_only_missing() {
let err = comments_status(None, &["LIN-9".to_string()]).unwrap_err();
assert_eq!(err.downcast_ref::<CliError>().expect("CliError").code(), 2);
}

#[test]
fn comments_status_preserves_real_error_over_missing() {
// A real fetch error keeps its kind (rate-limited => 4), not NotFound(2).
let err = comments_status(
Some(
CliError::rate_limited("429")
.with_retry_after(Some(5))
.into(),
),
&["LIN-9".to_string()],
)
.unwrap_err();
let cli = err.downcast_ref::<CliError>().expect("CliError");
assert_eq!(cli.code(), 4);
assert_eq!(cli.retry_after, Some(5));
}
}

async fn update_comment(id: &str, body: &str, output: &OutputOptions) -> Result<()> {
Expand Down
Loading
Loading