Skip to content

Commit 84f5221

Browse files
committed
fix: resolve issues #16, #17, #18 and bump to v0.3.16
- #16: relations list returns empty because GraphQL query omitted state.id, causing WorkflowState deserialization to silently fail. Added id to all state fields in the relations query. - #17: project name lookup now tolerates filtered query failures and falls through to paginated lookup. Error message suggests using slugId. - #18: api query/mutate now auto-detect piped stdin without requiring '-' positional arg. Explicit '-' still works. - Fix pre-existing cargo fmt and clippy warnings. - Bump version to 0.3.16.
1 parent 54f7489 commit 84f5221

36 files changed

Lines changed: 877 additions & 432 deletions

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "linear-cli"
3-
version = "0.3.15"
3+
version = "0.3.16"
44
edition = "2021"
55
description = "A powerful CLI for Linear.app - manage issues, projects, cycles, and more from your terminal"
66
authors = ["scwrcd"]

‎src/api.rs‎

Lines changed: 110 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,8 @@ pub async fn resolve_label_id(
304304
}
305305

306306
/// Resolve a project name or slug to a UUID.
307+
/// Falls back to paginated lookup if the filtered query fails (some team/project
308+
/// combinations cause the Linear API to reject name-based lookups).
307309
pub async fn resolve_project_id(
308310
client: &LinearClient,
309311
project: &str,
@@ -313,34 +315,81 @@ pub async fn resolve_project_id(
313315
return Ok(project.to_string());
314316
}
315317

316-
let config = ResolverConfig {
317-
cache_type: CacheType::Projects,
318-
filtered_query: r#"
318+
// Check cache first
319+
if !cache_opts.no_cache {
320+
let cache = Cache::with_ttl(cache_opts.effective_ttl_seconds())?;
321+
if let Some(cached) = cache
322+
.get(CacheType::Projects)
323+
.and_then(|data| data.as_array().cloned())
324+
{
325+
if let Some(id) = find_project_id(&cached, project) {
326+
return Ok(id);
327+
}
328+
}
329+
}
330+
331+
// Try filtered query first (fast path) -- tolerate errors and fall through
332+
let filtered_result = client
333+
.query(
334+
r#"
319335
query($project: String!) {
320336
projects(first: 50, filter: { name: { eqIgnoreCase: $project } }) {
321337
nodes { id name slugId }
322338
}
323339
}
324-
"#,
325-
filtered_var_name: "project",
326-
filtered_nodes_path: &["data", "projects", "nodes"],
327-
paginated_query: r#"
328-
query($first: Int, $after: String) {
329-
projects(first: $first, after: $after) {
330-
nodes { id name slugId }
331-
pageInfo { hasNextPage endCursor }
332-
}
340+
"#,
341+
Some(json!({ "project": project })),
342+
)
343+
.await;
344+
345+
if let Ok(result) = filtered_result {
346+
let empty = vec![];
347+
let nodes = get_nested_array(&result, &["data", "projects", "nodes"]).unwrap_or(&empty);
348+
if let Some(id) = find_project_id(nodes, project) {
349+
return Ok(id);
350+
}
351+
}
352+
353+
// Fallback: paginate through all projects
354+
let pagination = PaginationOptions {
355+
all: true,
356+
page_size: Some(250),
357+
..Default::default()
358+
};
359+
let all_items = paginate_nodes(
360+
client,
361+
r#"
362+
query($first: Int, $after: String) {
363+
projects(first: $first, after: $after) {
364+
nodes { id name slugId }
365+
pageInfo { hasNextPage endCursor }
333366
}
367+
}
334368
"#,
335-
paginated_nodes_path: &["data", "projects", "nodes"],
336-
paginated_page_info_path: &["data", "projects", "pageInfo"],
337-
not_found_msg: &format!(
338-
"Project not found: {}. Use linear-cli p list to see available projects.",
339-
project
340-
),
341-
};
369+
serde_json::Map::new(),
370+
&["data", "projects", "nodes"],
371+
&["data", "projects", "pageInfo"],
372+
&pagination,
373+
250,
374+
)
375+
.await?;
342376

343-
resolve_id(client, project, cache_opts, &config, find_project_id).await
377+
if !cache_opts.no_cache {
378+
let cache = Cache::with_ttl(cache_opts.effective_ttl_seconds())?;
379+
let _ = cache.set(CacheType::Projects, json!(all_items));
380+
}
381+
382+
if let Some(id) = find_project_id(&all_items, project) {
383+
return Ok(id);
384+
}
385+
386+
anyhow::bail!(
387+
"Could not resolve project \"{}\". \
388+
Tip: try using the project's slugId (UUID) instead:\n \
389+
linear-cli issues update <ID> --project \"<slugId>\"\n\
390+
You can find project slugIds via: linear-cli projects list",
391+
project
392+
)
344393
}
345394

346395
/// Resolve a state name to a UUID for a given team.
@@ -531,7 +580,11 @@ impl AuthState {
531580
pub fn needs_refresh(&self) -> bool {
532581
match self {
533582
AuthState::ApiKey(_) => false,
534-
AuthState::OAuth { expires_at, refresh_token, .. } => {
583+
AuthState::OAuth {
584+
expires_at,
585+
refresh_token,
586+
..
587+
} => {
535588
if refresh_token.is_none() {
536589
return false;
537590
}
@@ -700,7 +753,8 @@ impl LinearClient {
700753
if let Ok(Some(oauth)) = config::get_oauth_config(&profile) {
701754
if !oauth.access_token.is_empty() {
702755
// If token has a refresh_token or isn't expired, use OAuth
703-
let is_expired = oauth.expires_at
756+
let is_expired = oauth
757+
.expires_at
704758
.map(|exp| chrono::Utc::now().timestamp() >= (exp - 300))
705759
.unwrap_or(false);
706760

@@ -740,8 +794,14 @@ impl LinearClient {
740794
}
741795

742796
match &*auth {
743-
AuthState::OAuth { refresh_token, client_id, profile, .. } => {
744-
let refresh_token = refresh_token.as_ref()
797+
AuthState::OAuth {
798+
refresh_token,
799+
client_id,
800+
profile,
801+
..
802+
} => {
803+
let refresh_token = refresh_token
804+
.as_ref()
745805
.context("OAuth token expired but no refresh token available")?;
746806

747807
let new_tokens = crate::oauth::refresh_tokens(client_id, refresh_token).await?;
@@ -762,7 +822,8 @@ impl LinearClient {
762822
scopes,
763823
};
764824
#[cfg(feature = "secure-storage")]
765-
let persist_result = if config::oauth_uses_secure_storage(profile).unwrap_or(false) {
825+
let persist_result = if config::oauth_uses_secure_storage(profile).unwrap_or(false)
826+
{
766827
config::save_oauth_config_secure(profile, &oauth_config)
767828
} else {
768829
config::save_oauth_config(profile, &oauth_config)
@@ -843,7 +904,10 @@ mod tests {
843904
expires_at: Some(chrono::Utc::now().timestamp() - 100), // expired
844905
profile: "default".to_string(),
845906
};
846-
assert!(!state.needs_refresh(), "OAuth without refresh token should not need refresh even if expired");
907+
assert!(
908+
!state.needs_refresh(),
909+
"OAuth without refresh token should not need refresh even if expired"
910+
);
847911
}
848912

849913
#[test]
@@ -855,7 +919,10 @@ mod tests {
855919
expires_at: Some(chrono::Utc::now().timestamp() - 100), // expired
856920
profile: "default".to_string(),
857921
};
858-
assert!(state.needs_refresh(), "OAuth with expired token and refresh token should need refresh");
922+
assert!(
923+
state.needs_refresh(),
924+
"OAuth with expired token and refresh token should need refresh"
925+
);
859926
}
860927

861928
#[test]
@@ -867,7 +934,10 @@ mod tests {
867934
expires_at: Some(chrono::Utc::now().timestamp() + 200), // within 5min buffer
868935
profile: "default".to_string(),
869936
};
870-
assert!(state.needs_refresh(), "OAuth expiring within buffer should need refresh");
937+
assert!(
938+
state.needs_refresh(),
939+
"OAuth expiring within buffer should need refresh"
940+
);
871941
}
872942

873943
#[test]
@@ -879,7 +949,10 @@ mod tests {
879949
expires_at: Some(chrono::Utc::now().timestamp() + 3600), // 1 hour from now
880950
profile: "default".to_string(),
881951
};
882-
assert!(!state.needs_refresh(), "OAuth with fresh token should not need refresh");
952+
assert!(
953+
!state.needs_refresh(),
954+
"OAuth with fresh token should not need refresh"
955+
);
883956
}
884957

885958
#[test]
@@ -891,7 +964,10 @@ mod tests {
891964
expires_at: None, // no expiry (legacy token)
892965
profile: "default".to_string(),
893966
};
894-
assert!(!state.needs_refresh(), "OAuth without expiry should not need refresh");
967+
assert!(
968+
!state.needs_refresh(),
969+
"OAuth without expiry should not need refresh"
970+
);
895971
}
896972

897973
#[test]
@@ -911,6 +987,9 @@ mod tests {
911987
fn test_auth_state_debug() {
912988
let state = AuthState::ApiKey("key".to_string());
913989
let debug = format!("{:?}", state);
914-
assert!(debug.contains("ApiKey"), "Debug output should contain variant name");
990+
assert!(
991+
debug.contains("ApiKey"),
992+
"Debug output should contain variant name"
993+
);
915994
}
916995
}

‎src/commands/api.rs‎

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use anyhow::Result;
22
use clap::Subcommand;
33
use serde_json::{json, Map, Value};
4-
use std::io::{self, BufRead};
4+
use std::io::{self, BufRead, IsTerminal};
55

66
use crate::api::LinearClient;
77
use crate::output::{print_json_owned, OutputOptions};
@@ -14,10 +14,11 @@ pub enum ApiCommands {
1414
linear api query '{ viewer { id name email } }'
1515
linear api query '{ teams { nodes { id key name } } }'
1616
linear api query -v teamId=abc123 'query($teamId: String!) { team(id: $teamId) { name } }'
17+
echo '{ viewer { id } }' | linear api query
1718
echo '{ viewer { id } }' | linear api query -"#)]
1819
Query {
19-
/// GraphQL query string. Use "-" to read from stdin.
20-
query: String,
20+
/// GraphQL query string. Use "-" to read from stdin, or omit when piping.
21+
query: Option<String>,
2122

2223
/// Variables as key=value pairs (e.g. -v id=abc123 -v name=test)
2324
#[arg(short = 'v', long = "variable", value_name = "KEY=VALUE")]
@@ -39,10 +40,11 @@ pub enum ApiCommands {
3940
#[command(after_help = r#"EXAMPLES:
4041
linear api mutate -v title="New Issue" -v teamId=abc123 \
4142
'mutation($title: String!, $teamId: String!) { issueCreate(input: { title: $title, teamId: $teamId }) { issue { id identifier } } }'
42-
cat mutation.graphql | linear api mutate -v id=abc123 -"#)]
43+
cat mutation.graphql | linear api mutate -
44+
echo '...' | linear api mutate"#)]
4345
Mutate {
44-
/// GraphQL mutation string. Use "-" to read from stdin.
45-
query: String,
46+
/// GraphQL mutation string. Use "-" to read from stdin, or omit when piping.
47+
query: Option<String>,
4648

4749
/// Variables as key=value pairs (e.g. -v id=abc123 -v name=test)
4850
#[arg(short = 'v', long = "variable", value_name = "KEY=VALUE")]
@@ -58,16 +60,57 @@ pub async fn handle(cmd: ApiCommands, output: &OutputOptions) -> Result<()> {
5860
paginate,
5961
nodes_path,
6062
page_info_path,
61-
} => run_query(&query, &variables, paginate, &nodes_path, &page_info_path, output).await,
62-
ApiCommands::Mutate { query, variables } => run_mutate(&query, &variables, output).await,
63+
} => {
64+
let resolved = resolve_query_source(query)?;
65+
run_query(
66+
&resolved,
67+
&variables,
68+
paginate,
69+
&nodes_path,
70+
&page_info_path,
71+
output,
72+
)
73+
.await
74+
}
75+
ApiCommands::Mutate { query, variables } => {
76+
let resolved = resolve_query_source(query)?;
77+
run_mutate(&resolved, &variables, output).await
78+
}
6379
}
6480
}
6581

82+
/// Resolve the GraphQL query source: explicit string, "-" for stdin, or auto-detect piped stdin.
83+
fn resolve_query_source(query: Option<String>) -> Result<String> {
84+
match query {
85+
Some(q) if q == "-" => read_stdin(),
86+
Some(q) => Ok(q),
87+
None => {
88+
if !io::stdin().is_terminal() {
89+
read_stdin()
90+
} else {
91+
anyhow::bail!(
92+
"No query provided. Pass a GraphQL query string as an argument, \
93+
or pipe one via stdin:\n \
94+
echo '{{ viewer {{ id }} }}' | linear-cli api query"
95+
)
96+
}
97+
}
98+
}
99+
}
100+
101+
fn read_stdin() -> Result<String> {
102+
let stdin = io::stdin();
103+
let lines: Vec<String> = stdin.lock().lines().map_while(Result::ok).collect();
104+
let query = lines.join("\n");
105+
if query.trim().is_empty() {
106+
anyhow::bail!("Empty query received from stdin");
107+
}
108+
Ok(query)
109+
}
110+
66111
fn read_query(input: &str) -> Result<String> {
67112
if input == "-" {
68-
let stdin = io::stdin();
69-
let lines: Vec<String> = stdin.lock().lines().map_while(Result::ok).collect();
70-
Ok(lines.join("\n"))
113+
read_stdin()
71114
} else {
72115
Ok(input.to_string())
73116
}
@@ -161,11 +204,7 @@ async fn run_query(
161204
Ok(())
162205
}
163206

164-
async fn run_mutate(
165-
query_str: &str,
166-
variables: &[String],
167-
output: &OutputOptions,
168-
) -> Result<()> {
207+
async fn run_mutate(query_str: &str, variables: &[String], output: &OutputOptions) -> Result<()> {
169208
let query = read_query(query_str)?;
170209
let vars = parse_variables(variables)?;
171210
let client = LinearClient::new()?;
@@ -238,4 +277,19 @@ mod tests {
238277
let q = read_query("{ viewer { id } }").unwrap();
239278
assert_eq!(q, "{ viewer { id } }");
240279
}
280+
281+
#[test]
282+
fn test_resolve_query_source_explicit() {
283+
let q = resolve_query_source(Some("{ viewer { id } }".to_string())).unwrap();
284+
assert_eq!(q, "{ viewer { id } }");
285+
}
286+
287+
#[test]
288+
fn test_resolve_query_source_none_tty() {
289+
// When stdin is a TTY and no query provided, should error
290+
// In test environment stdin is usually not a TTY, so this may read empty stdin
291+
// and return an error either way
292+
let result = resolve_query_source(None);
293+
assert!(result.is_err());
294+
}
241295
}

0 commit comments

Comments
 (0)