Skip to content

Commit c5ed49d

Browse files
committed
feat: implement numeric state enum system and fix critical state bugs
- Add numeric state enum system (0=Canceled, 1=Backlog, 2=Todo, 3=In Progress, 4=In Review, 5=Done, 6=Duplicate) - Add --state option to issue create command with dual input support (numeric + text) - Enhanced issue update command with improved state resolution - Fix GraphQL query missing states field causing all state updates to fail - Resolve state resolution logic that was completely broken - Fix type safety issues and reduce function complexity - Add STATE_MAPPINGS constants following priority system patterns - Implement dual input strategy supporting both numeric and text states - Extract complex state resolution logic into focused helper functions - Add comprehensive error handling with user-friendly messages - Reduce function complexity from 38/34 to <10 for maintainability - Refactored issue create/update functions for better maintainability
1 parent fddd23b commit c5ed49d

File tree

4 files changed

+494
-232
lines changed

4 files changed

+494
-232
lines changed

src/linear_cli/api/client/client.py

Lines changed: 166 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)