@@ -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).
307309pub 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}
0 commit comments