@@ -108,6 +108,136 @@ def _get_gql_client(self) -> Client:
108108
109109 return self ._gql_client
110110
111+ async def _handle_transport_error (
112+ self , error : TransportError , attempt : int
113+ ) -> tuple [bool , int ]:
114+ """
115+ Handle transport errors with appropriate retry logic.
116+
117+ Args:
118+ error: Transport error to handle
119+ attempt: Current attempt number
120+
121+ Returns:
122+ Tuple of (should_retry, wait_time)
123+
124+ Raises:
125+ AuthenticationError: If authentication fails
126+ RateLimitError: If rate limit exceeded
127+ LinearAPIError: For non-retryable errors
128+ """
129+ if not (hasattr (error , "response" ) and error .response ):
130+ raise LinearAPIError (f"Transport error: { error } " ) from error
131+
132+ status_code = error .response .status_code
133+
134+ if status_code == 401 :
135+ # Token expired, try to refresh
136+ try :
137+ self .authenticator .refresh_token ()
138+ # Reset client to use new token
139+ self ._gql_client = None
140+ self ._transport = None
141+ return True , 0 # Continue immediately
142+ except AuthenticationError as auth_err :
143+ raise AuthenticationError (
144+ "Authentication failed - please login again"
145+ ) from auth_err
146+
147+ elif status_code == 429 :
148+ # Rate limited
149+ wait_time = 60 # Default wait time
150+ if "Retry-After" in error .response .headers :
151+ wait_time = int (error .response .headers ["Retry-After" ])
152+
153+ if attempt < self .config .max_retries :
154+ logger .warning (
155+ f"Rate limited, waiting { wait_time } s (attempt { attempt + 1 } )"
156+ )
157+ return True , wait_time
158+ else :
159+ raise RateLimitError ("Rate limit exceeded" ) from None
160+
161+ elif 500 <= status_code < 600 :
162+ # Server error, retry
163+ if attempt < self .config .max_retries :
164+ wait_time = 2 ** attempt # Exponential backoff
165+ logger .warning (
166+ f"Server error { status_code } , retrying in { wait_time } s"
167+ )
168+ return True , wait_time
169+
170+ # Non-retryable error
171+ raise LinearAPIError (f"Transport error: { error } " ) from error
172+
173+ async def _handle_timeout_error (self , error : Exception , attempt : int ) -> tuple [bool , int ]:
174+ """
175+ Handle timeout errors with retry logic.
176+
177+ Args:
178+ error: Exception to handle
179+ attempt: Current attempt number
180+
181+ Returns:
182+ Tuple of (should_retry, wait_time)
183+
184+ Raises:
185+ LinearAPIError: If non-retryable or max retries exceeded
186+ """
187+ if attempt < self .config .max_retries and "timeout" in str (error ).lower ():
188+ wait_time = 2 ** attempt
189+ logger .warning (f"Timeout error, retrying in { wait_time } s" )
190+ return True , wait_time
191+
192+ # Non-retryable or max retries exceeded
193+ raise LinearAPIError (f"Query execution failed: { error } " ) from error
194+
195+ async def _execute_query_with_retries (
196+ self , query : str , variables : dict [str , Any ] | None , use_cache : bool
197+ ) -> dict [str , Any ]:
198+ """
199+ Execute GraphQL query with retry logic.
200+
201+ Args:
202+ query: GraphQL query string
203+ variables: Query variables
204+ use_cache: Whether to cache results
205+
206+ Returns:
207+ Query result data
208+ """
209+ client = self ._get_gql_client ()
210+
211+ for attempt in range (self .config .max_retries + 1 ):
212+ try :
213+ # Parse and execute query
214+ parsed_query = gql (query )
215+ result = await client .execute_async (
216+ parsed_query , variable_values = variables
217+ )
218+
219+ # Cache successful results
220+ if use_cache and self .cache and result :
221+ self .cache .set (query , variables , result )
222+
223+ return result
224+
225+ except TransportError as e :
226+ should_retry , wait_time = await self ._handle_transport_error (e , attempt )
227+ if should_retry :
228+ if wait_time > 0 :
229+ await asyncio .sleep (wait_time )
230+ continue
231+
232+ except Exception as e :
233+ should_retry , wait_time = await self ._handle_timeout_error (e , attempt )
234+ if should_retry :
235+ await asyncio .sleep (wait_time )
236+ continue
237+
238+ # Should not reach here
239+ raise LinearAPIError ("Max retries exceeded" )
240+
111241 async def execute_query (
112242 self ,
113243 query : str ,
@@ -140,90 +270,7 @@ async def execute_query(
140270 await self .rate_limiter .acquire ()
141271
142272 try :
143- # Get GraphQL client
144- client = self ._get_gql_client ()
145-
146- # Execute query with retry logic
147- for attempt in range (self .config .max_retries + 1 ):
148- try :
149- # Parse and execute query
150- parsed_query = gql (query )
151- result = await client .execute_async (
152- parsed_query , variable_values = variables
153- )
154-
155- # Cache successful results
156- if use_cache and self .cache and result :
157- self .cache .set (query , variables , result )
158-
159- return result
160-
161- except TransportError as e :
162- # Handle HTTP errors
163- if hasattr (e , "response" ) and e .response :
164- status_code = e .response .status_code
165-
166- if status_code == 401 :
167- # Token expired, try to refresh
168- try :
169- self .authenticator .refresh_token ()
170- # Reset client to use new token
171- self ._gql_client = None
172- self ._transport = None
173- continue
174- except AuthenticationError as auth_err :
175- raise AuthenticationError (
176- "Authentication failed - please login again"
177- ) from auth_err
178-
179- elif status_code == 429 :
180- # Rate limited
181- wait_time = 60 # Default wait time
182- if (
183- hasattr (e , "response" )
184- and "Retry-After" in e .response .headers
185- ):
186- wait_time = int (e .response .headers ["Retry-After" ])
187-
188- if attempt < self .config .max_retries :
189- logger .warning (
190- f"Rate limited, waiting { wait_time } s (attempt { attempt + 1 } )"
191- )
192- await asyncio .sleep (wait_time )
193- continue
194- else :
195- raise RateLimitError ("Rate limit exceeded" ) from None
196-
197- elif 500 <= status_code < 600 :
198- # Server error, retry
199- if attempt < self .config .max_retries :
200- wait_time = 2 ** attempt # Exponential backoff
201- logger .warning (
202- f"Server error { status_code } , retrying in { wait_time } s"
203- )
204- await asyncio .sleep (wait_time )
205- continue
206-
207- # Non-retryable error or max retries reached
208- raise LinearAPIError (f"Transport error: { e } " ) from e
209-
210- except Exception as e :
211- if (
212- attempt < self .config .max_retries
213- and "timeout" in str (e ).lower ()
214- ):
215- # Timeout error, retry
216- wait_time = 2 ** attempt
217- logger .warning (f"Timeout error, retrying in { wait_time } s" )
218- await asyncio .sleep (wait_time )
219- continue
220-
221- # Other errors
222- raise LinearAPIError (f"Query execution failed: { e } " ) from e
223-
224- # Should not reach here
225- raise LinearAPIError ("Max retries exceeded" )
226-
273+ return await self ._execute_query_with_retries (query , variables , use_cache )
227274 except AuthenticationError :
228275 # Re-raise authentication errors
229276 raise
@@ -268,8 +315,34 @@ async def get_teams(self) -> list[dict[str, Any]]:
268315 """
269316 Get list of teams accessible to the user.
270317
318+ CRITICAL FIX DOCUMENTATION:
319+ This query includes the 'states' field which was missing in previous versions,
320+ causing complete failure of all state update operations. Here's what happened:
321+
322+ PROBLEM:
323+ - The GraphQL query was missing the 'states { nodes { ... } }' field
324+ - When issue commands tried to resolve state names to IDs, they got empty state lists
325+ - This caused ALL state update operations to fail silently or with confusing errors
326+ - Users couldn't set states on issue creation or updates using either text or numeric inputs
327+
328+ ROOT CAUSE:
329+ - The Linear API requires explicit field selection in GraphQL queries
330+ - Team states are not returned by default - they must be explicitly requested
331+ - Without state data, the state resolution logic had no states to match against
332+
333+ SOLUTION:
334+ - Added complete 'states' field with nested 'nodes' containing id, name, type, color
335+ - This provides all necessary state information for the numeric state enum system
336+ - Now state resolution works for both numeric (0-6) and text-based state inputs
337+
338+ IMPACT:
339+ - Fixes the core state update functionality that was completely broken
340+ - Enables the numeric state enum feature (0=Canceled, 1=Backlog, etc.)
341+ - Restores backward compatibility with text-based state names
342+ - Essential for issue create and update commands to function properly
343+
271344 Returns:
272- List of team information
345+ List of team information including complete state data
273346 """
274347 query = """
275348 query {
@@ -286,6 +359,14 @@ async def get_teams(self) -> list[dict[str, Any]]:
286359 id
287360 }
288361 }
362+ states {
363+ nodes {
364+ id
365+ name
366+ type
367+ color
368+ }
369+ }
289370 }
290371 }
291372 }
0 commit comments