@@ -72,6 +72,71 @@ impl CliError {
7272 self . retry_after = retry_after;
7373 self
7474 }
75+
76+ /// Classify a GraphQL `errors` array into a `CliError` with a precise exit code.
77+ ///
78+ /// Linear reports application-level failures as HTTP 200 with an `errors`
79+ /// array, so the real signal lives in the first error's `extensions`
80+ /// (`statusCode` / `code`) or its message text rather than the transport
81+ /// status. The message stays "GraphQL error" so `Display` still appends the
82+ /// underlying error messages.
83+ pub fn from_graphql_errors ( errors : & Value ) -> Self {
84+ let first = errors. as_array ( ) . and_then ( |arr| arr. first ( ) ) ;
85+ let kind = first. map_or ( ErrorKind :: General , Self :: classify_graphql_error) ;
86+ let mut err = Self :: new ( kind, "GraphQL error" ) . with_details ( errors. clone ( ) ) ;
87+ if kind == ErrorKind :: RateLimited {
88+ let retry_after = first
89+ . and_then ( |e| e. get ( "extensions" ) )
90+ . and_then ( |ext| ext. get ( "retryAfter" ) )
91+ . and_then ( Value :: as_u64) ;
92+ err = err. with_retry_after ( retry_after) ;
93+ }
94+ err
95+ }
96+
97+ fn classify_graphql_error ( error : & Value ) -> ErrorKind {
98+ let ext = error. get ( "extensions" ) ;
99+
100+ // 1. Numeric status code carried in extensions, when present.
101+ if let Some ( status) = ext. and_then ( |e| e. get ( "statusCode" ) ) . and_then ( Value :: as_u64) {
102+ match status {
103+ 401 | 403 => return ErrorKind :: Auth ,
104+ 404 => return ErrorKind :: NotFound ,
105+ 429 => return ErrorKind :: RateLimited ,
106+ _ => { }
107+ }
108+ }
109+
110+ // 2. Symbolic code string (e.g. AUTHENTICATION_ERROR, RATELIMITED).
111+ if let Some ( code) = ext. and_then ( |e| e. get ( "code" ) ) . and_then ( Value :: as_str) {
112+ let code = code. to_ascii_uppercase ( ) ;
113+ if code. contains ( "AUTH" ) {
114+ return ErrorKind :: Auth ;
115+ }
116+ if code. contains ( "RATELIM" ) {
117+ return ErrorKind :: RateLimited ;
118+ }
119+ if code. contains ( "NOT_FOUND" ) {
120+ return ErrorKind :: NotFound ;
121+ }
122+ }
123+
124+ // 3. Message text — Linear reports "Entity not found" with statusCode 400.
125+ let msg = error
126+ . get ( "message" )
127+ . and_then ( Value :: as_str)
128+ . or_else ( || {
129+ ext. and_then ( |e| e. get ( "userPresentableMessage" ) )
130+ . and_then ( Value :: as_str)
131+ } )
132+ . unwrap_or ( "" ) ;
133+ let lower = msg. to_ascii_lowercase ( ) ;
134+ if lower. contains ( "not found" ) || lower. contains ( "could not find" ) {
135+ return ErrorKind :: NotFound ;
136+ }
137+
138+ ErrorKind :: General
139+ }
75140}
76141
77142impl fmt:: Display for CliError {
@@ -276,4 +341,72 @@ mod tests {
276341 3
277342 ) ;
278343 }
344+
345+ #[ test]
346+ fn graphql_entity_not_found_maps_to_not_found ( ) {
347+ // Linear returns "Entity not found" as HTTP 200 + errors array with
348+ // statusCode 400 (NOT 404), so classification must fall through to the
349+ // message-text check. This is the common `i get <bad-id>` path.
350+ let errors = json ! ( [ {
351+ "message" : "Entity not found: Issue" ,
352+ "extensions" : {
353+ "code" : "INPUT_ERROR" ,
354+ "statusCode" : 400 ,
355+ "type" : "invalid input" ,
356+ "userError" : true ,
357+ "userPresentableMessage" : "Could not find referenced Issue."
358+ }
359+ } ] ) ;
360+ let err = CliError :: from_graphql_errors ( & errors) ;
361+ assert_eq ! ( err. kind, ErrorKind :: NotFound ) ;
362+ assert_eq ! ( err. code( ) , 2 ) ;
363+ assert_eq ! ( err. to_string( ) , "GraphQL error: Entity not found: Issue" ) ;
364+ }
365+
366+ #[ test]
367+ fn graphql_auth_error_maps_to_auth ( ) {
368+ let errors = json ! ( [ {
369+ "message" : "Authentication required" ,
370+ "extensions" : { "code" : "AUTHENTICATION_ERROR" }
371+ } ] ) ;
372+ let err = CliError :: from_graphql_errors ( & errors) ;
373+ assert_eq ! ( err. kind, ErrorKind :: Auth ) ;
374+ assert_eq ! ( err. code( ) , 3 ) ;
375+ }
376+
377+ #[ test]
378+ fn graphql_rate_limit_maps_to_rate_limited_with_retry_after ( ) {
379+ let errors = json ! ( [ {
380+ "message" : "Too many requests" ,
381+ "extensions" : { "code" : "RATELIMITED" , "retryAfter" : 30 }
382+ } ] ) ;
383+ let err = CliError :: from_graphql_errors ( & errors) ;
384+ assert_eq ! ( err. kind, ErrorKind :: RateLimited ) ;
385+ assert_eq ! ( err. code( ) , 4 ) ;
386+ assert_eq ! ( err. retry_after, Some ( 30 ) ) ;
387+ }
388+
389+ #[ test]
390+ fn graphql_status_code_takes_precedence ( ) {
391+ // A 403 in extensions classifies as Auth even without a code string.
392+ let errors = json ! ( [ {
393+ "message" : "denied" ,
394+ "extensions" : { "statusCode" : 403 }
395+ } ] ) ;
396+ assert_eq ! ( CliError :: from_graphql_errors( & errors) . code( ) , 3 ) ;
397+ }
398+
399+ #[ test]
400+ fn graphql_generic_error_stays_general ( ) {
401+ let errors = json ! ( [ { "message" : "Field 'foo' is invalid" } ] ) ;
402+ let err = CliError :: from_graphql_errors ( & errors) ;
403+ assert_eq ! ( err. kind, ErrorKind :: General ) ;
404+ assert_eq ! ( err. code( ) , 1 ) ;
405+ }
406+
407+ #[ test]
408+ fn graphql_empty_errors_stays_general ( ) {
409+ let errors = json ! ( [ ] ) ;
410+ assert_eq ! ( CliError :: from_graphql_errors( & errors) . kind, ErrorKind :: General ) ;
411+ }
279412}
0 commit comments