mcp-ticketer 0.3.1__py3-none-any.whl → 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcp-ticketer might be problematic. Click here for more details.

Files changed (37) hide show
  1. mcp_ticketer/__version__.py +1 -1
  2. mcp_ticketer/adapters/aitrackdown.py +12 -15
  3. mcp_ticketer/adapters/github.py +7 -4
  4. mcp_ticketer/adapters/jira.py +23 -22
  5. mcp_ticketer/adapters/linear/__init__.py +1 -1
  6. mcp_ticketer/adapters/linear/adapter.py +88 -89
  7. mcp_ticketer/adapters/linear/client.py +71 -52
  8. mcp_ticketer/adapters/linear/mappers.py +88 -68
  9. mcp_ticketer/adapters/linear/queries.py +28 -7
  10. mcp_ticketer/adapters/linear/types.py +57 -50
  11. mcp_ticketer/adapters/linear.py +2 -2
  12. mcp_ticketer/cli/adapter_diagnostics.py +86 -51
  13. mcp_ticketer/cli/diagnostics.py +165 -72
  14. mcp_ticketer/cli/linear_commands.py +156 -113
  15. mcp_ticketer/cli/main.py +153 -82
  16. mcp_ticketer/cli/simple_health.py +73 -45
  17. mcp_ticketer/cli/utils.py +15 -10
  18. mcp_ticketer/core/config.py +23 -19
  19. mcp_ticketer/core/env_discovery.py +5 -4
  20. mcp_ticketer/core/env_loader.py +109 -86
  21. mcp_ticketer/core/exceptions.py +20 -18
  22. mcp_ticketer/core/models.py +9 -0
  23. mcp_ticketer/core/project_config.py +1 -1
  24. mcp_ticketer/mcp/server.py +294 -139
  25. mcp_ticketer/queue/health_monitor.py +152 -121
  26. mcp_ticketer/queue/manager.py +11 -4
  27. mcp_ticketer/queue/queue.py +15 -3
  28. mcp_ticketer/queue/run_worker.py +1 -1
  29. mcp_ticketer/queue/ticket_registry.py +190 -132
  30. mcp_ticketer/queue/worker.py +54 -25
  31. {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.2.dist-info}/METADATA +1 -1
  32. mcp_ticketer-0.3.2.dist-info/RECORD +59 -0
  33. mcp_ticketer-0.3.1.dist-info/RECORD +0 -59
  34. {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.2.dist-info}/WHEEL +0 -0
  35. {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.2.dist-info}/entry_points.txt +0 -0
  36. {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.2.dist-info}/licenses/LICENSE +0 -0
  37. {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.2.dist-info}/top_level.txt +0 -0
@@ -7,13 +7,13 @@ from typing import Any, Dict, Optional
7
7
 
8
8
  try:
9
9
  from gql import Client, gql
10
- from gql.transport.aiohttp import AIOHTTPTransport
11
10
  from gql.transport.exceptions import TransportError
11
+ from gql.transport.httpx import HTTPXAsyncTransport
12
12
  except ImportError:
13
13
  # Handle missing gql dependency gracefully
14
14
  Client = None
15
15
  gql = None
16
- AIOHTTPTransport = None
16
+ HTTPXAsyncTransport = None
17
17
  TransportError = Exception
18
18
 
19
19
  from ...core.exceptions import AdapterError, AuthenticationError, RateLimitError
@@ -21,19 +21,20 @@ from ...core.exceptions import AdapterError, AuthenticationError, RateLimitError
21
21
 
22
22
  class LinearGraphQLClient:
23
23
  """GraphQL client for Linear API with error handling and retry logic."""
24
-
24
+
25
25
  def __init__(self, api_key: str, timeout: int = 30):
26
26
  """Initialize the Linear GraphQL client.
27
-
27
+
28
28
  Args:
29
29
  api_key: Linear API key
30
30
  timeout: Request timeout in seconds
31
+
31
32
  """
32
33
  self.api_key = api_key
33
34
  self.timeout = timeout
34
35
  self._base_url = "https://api.linear.app/graphql"
35
-
36
- def create_client(self) -> "Client":
36
+
37
+ def create_client(self) -> Client:
37
38
  """Create a new GraphQL client instance.
38
39
 
39
40
  Returns:
@@ -42,18 +43,24 @@ class LinearGraphQLClient:
42
43
  Raises:
43
44
  AuthenticationError: If API key is invalid
44
45
  AdapterError: If client creation fails
46
+
45
47
  """
46
48
  if Client is None:
47
- raise AdapterError("gql library not installed. Install with: pip install gql[aiohttp]", "linear")
49
+ raise AdapterError(
50
+ "gql library not installed. Install with: pip install gql[httpx]",
51
+ "linear",
52
+ )
48
53
 
49
54
  if not self.api_key:
50
55
  raise AuthenticationError("Linear API key is required", "linear")
51
56
 
52
57
  try:
53
58
  # Create transport with authentication
54
- transport = AIOHTTPTransport(
59
+ # Linear API keys are passed directly (no Bearer prefix)
60
+ # Only OAuth tokens use Bearer scheme
61
+ transport = HTTPXAsyncTransport(
55
62
  url=self._base_url,
56
- headers={"Authorization": f"Bearer {self.api_key}"},
63
+ headers={"Authorization": self.api_key},
57
64
  timeout=self.timeout,
58
65
  )
59
66
 
@@ -63,7 +70,7 @@ class LinearGraphQLClient:
63
70
 
64
71
  except Exception as e:
65
72
  raise AdapterError(f"Failed to create Linear client: {e}", "linear")
66
-
73
+
67
74
  async def execute_query(
68
75
  self,
69
76
  query_string: str,
@@ -71,34 +78,37 @@ class LinearGraphQLClient:
71
78
  retries: int = 3,
72
79
  ) -> Dict[str, Any]:
73
80
  """Execute a GraphQL query with error handling and retries.
74
-
81
+
75
82
  Args:
76
83
  query_string: GraphQL query string
77
84
  variables: Query variables
78
85
  retries: Number of retry attempts
79
-
86
+
80
87
  Returns:
81
88
  Query result data
82
-
89
+
83
90
  Raises:
84
91
  AuthenticationError: If authentication fails
85
92
  RateLimitError: If rate limit is exceeded
86
93
  AdapterError: If query execution fails
94
+
87
95
  """
88
96
  query = gql(query_string)
89
-
97
+
90
98
  for attempt in range(retries + 1):
91
99
  try:
92
100
  client = self.create_client()
93
101
  async with client as session:
94
- result = await session.execute(query, variable_values=variables or {})
102
+ result = await session.execute(
103
+ query, variable_values=variables or {}
104
+ )
95
105
  return result
96
-
106
+
97
107
  except TransportError as e:
98
108
  # Handle HTTP errors
99
- if hasattr(e, 'response') and e.response:
109
+ if hasattr(e, "response") and e.response:
100
110
  status_code = e.response.status
101
-
111
+
102
112
  if status_code == 401:
103
113
  raise AuthenticationError("Invalid Linear API key", "linear")
104
114
  elif status_code == 403:
@@ -107,42 +117,47 @@ class LinearGraphQLClient:
107
117
  # Rate limit exceeded
108
118
  retry_after = e.response.headers.get("Retry-After", "60")
109
119
  raise RateLimitError(
110
- "Linear API rate limit exceeded",
111
- "linear",
112
- retry_after
120
+ "Linear API rate limit exceeded", "linear", retry_after
113
121
  )
114
122
  elif status_code >= 500:
115
123
  # Server error - retry
116
124
  if attempt < retries:
117
- await asyncio.sleep(2 ** attempt) # Exponential backoff
125
+ await asyncio.sleep(2**attempt) # Exponential backoff
118
126
  continue
119
- raise AdapterError(f"Linear API server error: {status_code}", "linear")
120
-
127
+ raise AdapterError(
128
+ f"Linear API server error: {status_code}", "linear"
129
+ )
130
+
121
131
  # Network or other transport error
122
132
  if attempt < retries:
123
- await asyncio.sleep(2 ** attempt)
133
+ await asyncio.sleep(2**attempt)
124
134
  continue
125
135
  raise AdapterError(f"Linear API transport error: {e}", "linear")
126
-
136
+
127
137
  except Exception as e:
128
138
  # GraphQL or other errors
129
139
  error_msg = str(e)
130
-
140
+
131
141
  # Check for specific GraphQL errors
132
- if "authentication" in error_msg.lower() or "unauthorized" in error_msg.lower():
133
- raise AuthenticationError(f"Linear authentication failed: {error_msg}", "linear")
142
+ if (
143
+ "authentication" in error_msg.lower()
144
+ or "unauthorized" in error_msg.lower()
145
+ ):
146
+ raise AuthenticationError(
147
+ f"Linear authentication failed: {error_msg}", "linear"
148
+ )
134
149
  elif "rate limit" in error_msg.lower():
135
150
  raise RateLimitError("Linear API rate limit exceeded", "linear")
136
-
151
+
137
152
  # Generic error
138
153
  if attempt < retries:
139
- await asyncio.sleep(2 ** attempt)
154
+ await asyncio.sleep(2**attempt)
140
155
  continue
141
156
  raise AdapterError(f"Linear GraphQL error: {error_msg}", "linear")
142
-
157
+
143
158
  # Should never reach here
144
159
  raise AdapterError("Maximum retries exceeded", "linear")
145
-
160
+
146
161
  async def execute_mutation(
147
162
  self,
148
163
  mutation_string: str,
@@ -150,27 +165,29 @@ class LinearGraphQLClient:
150
165
  retries: int = 3,
151
166
  ) -> Dict[str, Any]:
152
167
  """Execute a GraphQL mutation with error handling.
153
-
168
+
154
169
  Args:
155
170
  mutation_string: GraphQL mutation string
156
171
  variables: Mutation variables
157
172
  retries: Number of retry attempts
158
-
173
+
159
174
  Returns:
160
175
  Mutation result data
161
-
176
+
162
177
  Raises:
163
178
  AuthenticationError: If authentication fails
164
179
  RateLimitError: If rate limit is exceeded
165
180
  AdapterError: If mutation execution fails
181
+
166
182
  """
167
183
  return await self.execute_query(mutation_string, variables, retries)
168
-
184
+
169
185
  async def test_connection(self) -> bool:
170
186
  """Test the connection to Linear API.
171
-
187
+
172
188
  Returns:
173
189
  True if connection is successful, False otherwise
190
+
174
191
  """
175
192
  try:
176
193
  # Simple query to test authentication
@@ -182,21 +199,22 @@ class LinearGraphQLClient:
182
199
  }
183
200
  }
184
201
  """
185
-
202
+
186
203
  result = await self.execute_query(test_query)
187
204
  return bool(result.get("viewer"))
188
-
205
+
189
206
  except Exception:
190
207
  return False
191
-
208
+
192
209
  async def get_team_info(self, team_id: str) -> Optional[Dict[str, Any]]:
193
210
  """Get team information by ID.
194
-
211
+
195
212
  Args:
196
213
  team_id: Linear team ID
197
-
214
+
198
215
  Returns:
199
216
  Team information or None if not found
217
+
200
218
  """
201
219
  try:
202
220
  query = """
@@ -209,21 +227,22 @@ class LinearGraphQLClient:
209
227
  }
210
228
  }
211
229
  """
212
-
230
+
213
231
  result = await self.execute_query(query, {"teamId": team_id})
214
232
  return result.get("team")
215
-
233
+
216
234
  except Exception:
217
235
  return None
218
-
236
+
219
237
  async def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]:
220
238
  """Get user information by email.
221
-
239
+
222
240
  Args:
223
241
  email: User email address
224
-
242
+
225
243
  Returns:
226
244
  User information or None if not found
245
+
227
246
  """
228
247
  try:
229
248
  query = """
@@ -239,17 +258,17 @@ class LinearGraphQLClient:
239
258
  }
240
259
  }
241
260
  """
242
-
261
+
243
262
  result = await self.execute_query(query, {"email": email})
244
263
  users = result.get("users", {}).get("nodes", [])
245
264
  return users[0] if users else None
246
-
265
+
247
266
  except Exception:
248
267
  return None
249
-
268
+
250
269
  async def close(self) -> None:
251
270
  """Close the client connection.
252
-
271
+
253
272
  Since we create fresh clients for each operation, there's no persistent
254
273
  connection to close. Each client's transport is automatically closed when
255
274
  the async context manager exits.
@@ -3,79 +3,80 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from datetime import datetime
6
- from typing import Any, Dict, List, Optional
6
+ from typing import Any, Dict, List
7
7
 
8
8
  from ...core.models import Comment, Epic, Priority, Task, TicketState
9
- from .types import (
10
- extract_linear_metadata,
11
- get_universal_priority,
12
- get_universal_state,
13
- )
9
+ from .types import extract_linear_metadata, get_universal_priority, get_universal_state
14
10
 
15
11
 
16
12
  def map_linear_issue_to_task(issue_data: Dict[str, Any]) -> Task:
17
13
  """Convert Linear issue data to universal Task model.
18
-
14
+
19
15
  Args:
20
16
  issue_data: Raw Linear issue data from GraphQL
21
-
17
+
22
18
  Returns:
23
19
  Universal Task model
20
+
24
21
  """
25
22
  # Extract basic fields
26
23
  task_id = issue_data["identifier"]
27
24
  title = issue_data["title"]
28
25
  description = issue_data.get("description", "")
29
-
26
+
30
27
  # Map priority
31
28
  linear_priority = issue_data.get("priority", 3)
32
29
  priority = get_universal_priority(linear_priority)
33
-
30
+
34
31
  # Map state
35
32
  state_data = issue_data.get("state", {})
36
33
  state_type = state_data.get("type", "unstarted")
37
34
  state = get_universal_state(state_type)
38
-
35
+
39
36
  # Extract assignee
40
37
  assignee = None
41
38
  if issue_data.get("assignee"):
42
39
  assignee_data = issue_data["assignee"]
43
40
  assignee = assignee_data.get("email") or assignee_data.get("displayName")
44
-
41
+
45
42
  # Extract creator
46
43
  creator = None
47
44
  if issue_data.get("creator"):
48
45
  creator_data = issue_data["creator"]
49
46
  creator = creator_data.get("email") or creator_data.get("displayName")
50
-
47
+
51
48
  # Extract tags (labels)
52
49
  tags = []
53
50
  if issue_data.get("labels", {}).get("nodes"):
54
51
  tags = [label["name"] for label in issue_data["labels"]["nodes"]]
55
-
52
+
56
53
  # Extract parent epic (project)
57
54
  parent_epic = None
58
55
  if issue_data.get("project"):
59
56
  parent_epic = issue_data["project"]["id"]
60
-
57
+
61
58
  # Extract parent issue
62
59
  parent_issue = None
63
60
  if issue_data.get("parent"):
64
61
  parent_issue = issue_data["parent"]["identifier"]
65
-
62
+
66
63
  # Extract dates
67
64
  created_at = None
68
65
  if issue_data.get("createdAt"):
69
- created_at = datetime.fromisoformat(issue_data["createdAt"].replace("Z", "+00:00"))
70
-
66
+ created_at = datetime.fromisoformat(
67
+ issue_data["createdAt"].replace("Z", "+00:00")
68
+ )
69
+
71
70
  updated_at = None
72
71
  if issue_data.get("updatedAt"):
73
- updated_at = datetime.fromisoformat(issue_data["updatedAt"].replace("Z", "+00:00"))
74
-
72
+ updated_at = datetime.fromisoformat(
73
+ issue_data["updatedAt"].replace("Z", "+00:00")
74
+ )
75
+
75
76
  # Extract Linear-specific metadata
76
77
  linear_metadata = extract_linear_metadata(issue_data)
77
- metadata = {"linear": linear_metadata} if linear_metadata else None
78
-
78
+ metadata = {"linear": linear_metadata} if linear_metadata else {}
79
+
79
80
  return Task(
80
81
  id=task_id,
81
82
  title=title,
@@ -95,18 +96,19 @@ def map_linear_issue_to_task(issue_data: Dict[str, Any]) -> Task:
95
96
 
96
97
  def map_linear_project_to_epic(project_data: Dict[str, Any]) -> Epic:
97
98
  """Convert Linear project data to universal Epic model.
98
-
99
+
99
100
  Args:
100
101
  project_data: Raw Linear project data from GraphQL
101
-
102
+
102
103
  Returns:
103
104
  Universal Epic model
105
+
104
106
  """
105
107
  # Extract basic fields
106
108
  epic_id = project_data["id"]
107
109
  title = project_data["name"]
108
110
  description = project_data.get("description", "")
109
-
111
+
110
112
  # Map state based on project state
111
113
  project_state = project_data.get("state", "planned")
112
114
  if project_state == "completed":
@@ -117,16 +119,20 @@ def map_linear_project_to_epic(project_data: Dict[str, Any]) -> Epic:
117
119
  state = TicketState.CLOSED
118
120
  else:
119
121
  state = TicketState.OPEN
120
-
122
+
121
123
  # Extract dates
122
124
  created_at = None
123
125
  if project_data.get("createdAt"):
124
- created_at = datetime.fromisoformat(project_data["createdAt"].replace("Z", "+00:00"))
125
-
126
+ created_at = datetime.fromisoformat(
127
+ project_data["createdAt"].replace("Z", "+00:00")
128
+ )
129
+
126
130
  updated_at = None
127
131
  if project_data.get("updatedAt"):
128
- updated_at = datetime.fromisoformat(project_data["updatedAt"].replace("Z", "+00:00"))
129
-
132
+ updated_at = datetime.fromisoformat(
133
+ project_data["updatedAt"].replace("Z", "+00:00")
134
+ )
135
+
130
136
  # Extract Linear-specific metadata
131
137
  metadata = {"linear": {}}
132
138
  if project_data.get("url"):
@@ -137,7 +143,7 @@ def map_linear_project_to_epic(project_data: Dict[str, Any]) -> Epic:
137
143
  metadata["linear"]["color"] = project_data["color"]
138
144
  if project_data.get("targetDate"):
139
145
  metadata["linear"]["target_date"] = project_data["targetDate"]
140
-
146
+
141
147
  return Epic(
142
148
  id=epic_id,
143
149
  title=title,
@@ -146,92 +152,100 @@ def map_linear_project_to_epic(project_data: Dict[str, Any]) -> Epic:
146
152
  priority=Priority.MEDIUM, # Projects don't have priority in Linear
147
153
  created_at=created_at,
148
154
  updated_at=updated_at,
149
- metadata=metadata if metadata["linear"] else None,
155
+ metadata=metadata if metadata["linear"] else {},
150
156
  )
151
157
 
152
158
 
153
- def map_linear_comment_to_comment(comment_data: Dict[str, Any], ticket_id: str) -> Comment:
159
+ def map_linear_comment_to_comment(
160
+ comment_data: Dict[str, Any], ticket_id: str
161
+ ) -> Comment:
154
162
  """Convert Linear comment data to universal Comment model.
155
-
163
+
156
164
  Args:
157
165
  comment_data: Raw Linear comment data from GraphQL
158
166
  ticket_id: ID of the ticket this comment belongs to
159
-
167
+
160
168
  Returns:
161
169
  Universal Comment model
170
+
162
171
  """
163
172
  # Extract basic fields
164
173
  comment_id = comment_data["id"]
165
174
  body = comment_data.get("body", "")
166
-
175
+
167
176
  # Extract author
168
177
  author = None
169
178
  if comment_data.get("user"):
170
179
  user_data = comment_data["user"]
171
180
  author = user_data.get("email") or user_data.get("displayName")
172
-
181
+
173
182
  # Extract dates
174
183
  created_at = None
175
184
  if comment_data.get("createdAt"):
176
- created_at = datetime.fromisoformat(comment_data["createdAt"].replace("Z", "+00:00"))
177
-
178
- updated_at = None
185
+ created_at = datetime.fromisoformat(
186
+ comment_data["createdAt"].replace("Z", "+00:00")
187
+ )
188
+
189
+ # Note: Comment model doesn't have updated_at field
190
+ # Store it in metadata if needed
191
+ metadata = {}
179
192
  if comment_data.get("updatedAt"):
180
- updated_at = datetime.fromisoformat(comment_data["updatedAt"].replace("Z", "+00:00"))
181
-
193
+ metadata["updated_at"] = comment_data["updatedAt"]
194
+
182
195
  return Comment(
183
196
  id=comment_id,
184
197
  ticket_id=ticket_id,
185
- body=body,
198
+ content=body,
186
199
  author=author,
187
200
  created_at=created_at,
188
- updated_at=updated_at,
201
+ metadata=metadata,
189
202
  )
190
203
 
191
204
 
192
205
  def build_linear_issue_input(task: Task, team_id: str) -> Dict[str, Any]:
193
206
  """Build Linear issue input from universal Task model.
194
-
207
+
195
208
  Args:
196
209
  task: Universal Task model
197
210
  team_id: Linear team ID
198
-
211
+
199
212
  Returns:
200
213
  Linear issue input dictionary
214
+
201
215
  """
202
216
  from .types import get_linear_priority
203
-
217
+
204
218
  issue_input = {
205
219
  "title": task.title,
206
220
  "teamId": team_id,
207
221
  }
208
-
222
+
209
223
  # Add description if provided
210
224
  if task.description:
211
225
  issue_input["description"] = task.description
212
-
226
+
213
227
  # Add priority
214
228
  if task.priority:
215
229
  issue_input["priority"] = get_linear_priority(task.priority)
216
-
230
+
217
231
  # Add assignee if provided (assumes it's a user ID)
218
232
  if task.assignee:
219
233
  issue_input["assigneeId"] = task.assignee
220
-
234
+
221
235
  # Add parent issue if provided
222
236
  if task.parent_issue:
223
237
  issue_input["parentId"] = task.parent_issue
224
-
238
+
225
239
  # Add project (epic) if provided
226
240
  if task.parent_epic:
227
241
  issue_input["projectId"] = task.parent_epic
228
-
242
+
229
243
  # Add labels (tags) if provided
230
244
  if task.tags:
231
245
  # Note: Linear requires label IDs, not names
232
246
  # This would need to be resolved by the adapter
233
247
  pass
234
-
248
+
235
249
  # Add Linear-specific metadata
236
250
  if task.metadata and "linear" in task.metadata:
237
251
  linear_meta = task.metadata["linear"]
@@ -241,42 +255,47 @@ def build_linear_issue_input(task: Task, team_id: str) -> Dict[str, Any]:
241
255
  issue_input["cycleId"] = linear_meta["cycle_id"]
242
256
  if "estimate" in linear_meta:
243
257
  issue_input["estimate"] = linear_meta["estimate"]
244
-
258
+
245
259
  return issue_input
246
260
 
247
261
 
248
262
  def build_linear_issue_update_input(updates: Dict[str, Any]) -> Dict[str, Any]:
249
263
  """Build Linear issue update input from update dictionary.
250
-
264
+
251
265
  Args:
252
266
  updates: Dictionary of fields to update
253
-
267
+
254
268
  Returns:
255
269
  Linear issue update input dictionary
270
+
256
271
  """
257
272
  from .types import get_linear_priority
258
-
273
+
259
274
  update_input = {}
260
-
275
+
261
276
  # Map standard fields
262
277
  if "title" in updates:
263
278
  update_input["title"] = updates["title"]
264
-
279
+
265
280
  if "description" in updates:
266
281
  update_input["description"] = updates["description"]
267
-
282
+
268
283
  if "priority" in updates:
269
- priority = Priority(updates["priority"]) if isinstance(updates["priority"], str) else updates["priority"]
284
+ priority = (
285
+ Priority(updates["priority"])
286
+ if isinstance(updates["priority"], str)
287
+ else updates["priority"]
288
+ )
270
289
  update_input["priority"] = get_linear_priority(priority)
271
-
290
+
272
291
  if "assignee" in updates:
273
292
  update_input["assigneeId"] = updates["assignee"]
274
-
293
+
275
294
  # Handle state transitions (would need workflow state mapping)
276
295
  if "state" in updates:
277
296
  # This would need to be handled by the adapter with proper state mapping
278
297
  pass
279
-
298
+
280
299
  # Handle metadata updates
281
300
  if "metadata" in updates and "linear" in updates["metadata"]:
282
301
  linear_meta = updates["metadata"]["linear"]
@@ -288,18 +307,19 @@ def build_linear_issue_update_input(updates: Dict[str, Any]) -> Dict[str, Any]:
288
307
  update_input["projectId"] = linear_meta["project_id"]
289
308
  if "estimate" in linear_meta:
290
309
  update_input["estimate"] = linear_meta["estimate"]
291
-
310
+
292
311
  return update_input
293
312
 
294
313
 
295
314
  def extract_child_issue_ids(issue_data: Dict[str, Any]) -> List[str]:
296
315
  """Extract child issue IDs from Linear issue data.
297
-
316
+
298
317
  Args:
299
318
  issue_data: Raw Linear issue data from GraphQL
300
-
319
+
301
320
  Returns:
302
321
  List of child issue identifiers
322
+
303
323
  """
304
324
  child_ids = []
305
325
  if issue_data.get("children", {}).get("nodes"):