mcp-ticketer 0.3.2__py3-none-any.whl → 0.3.4__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 (31) hide show
  1. mcp_ticketer/__version__.py +1 -1
  2. mcp_ticketer/adapters/aitrackdown.py +152 -21
  3. mcp_ticketer/adapters/github.py +4 -4
  4. mcp_ticketer/adapters/jira.py +6 -6
  5. mcp_ticketer/adapters/linear/adapter.py +121 -17
  6. mcp_ticketer/adapters/linear/client.py +7 -7
  7. mcp_ticketer/adapters/linear/mappers.py +9 -10
  8. mcp_ticketer/adapters/linear/types.py +10 -10
  9. mcp_ticketer/cli/adapter_diagnostics.py +2 -2
  10. mcp_ticketer/cli/codex_configure.py +6 -6
  11. mcp_ticketer/cli/diagnostics.py +17 -18
  12. mcp_ticketer/cli/main.py +15 -0
  13. mcp_ticketer/cli/simple_health.py +5 -10
  14. mcp_ticketer/core/env_loader.py +13 -13
  15. mcp_ticketer/core/exceptions.py +5 -5
  16. mcp_ticketer/core/models.py +30 -2
  17. mcp_ticketer/mcp/constants.py +58 -0
  18. mcp_ticketer/mcp/dto.py +195 -0
  19. mcp_ticketer/mcp/response_builder.py +206 -0
  20. mcp_ticketer/mcp/server.py +311 -1287
  21. mcp_ticketer/queue/health_monitor.py +14 -14
  22. mcp_ticketer/queue/manager.py +59 -15
  23. mcp_ticketer/queue/queue.py +9 -2
  24. mcp_ticketer/queue/ticket_registry.py +15 -15
  25. mcp_ticketer/queue/worker.py +25 -18
  26. {mcp_ticketer-0.3.2.dist-info → mcp_ticketer-0.3.4.dist-info}/METADATA +1 -1
  27. {mcp_ticketer-0.3.2.dist-info → mcp_ticketer-0.3.4.dist-info}/RECORD +31 -28
  28. {mcp_ticketer-0.3.2.dist-info → mcp_ticketer-0.3.4.dist-info}/WHEEL +0 -0
  29. {mcp_ticketer-0.3.2.dist-info → mcp_ticketer-0.3.4.dist-info}/entry_points.txt +0 -0
  30. {mcp_ticketer-0.3.2.dist-info → mcp_ticketer-0.3.4.dist-info}/licenses/LICENSE +0 -0
  31. {mcp_ticketer-0.3.2.dist-info → mcp_ticketer-0.3.4.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  """Version information for mcp-ticketer package."""
2
2
 
3
- __version__ = "0.3.2"
3
+ __version__ = "0.3.4"
4
4
  __version_info__ = tuple(int(part) for part in __version__.split("."))
5
5
 
6
6
  # Package metadata
@@ -35,14 +35,17 @@ class AITrackdownAdapter(BaseAdapter[Task]):
35
35
  super().__init__(config)
36
36
  self.base_path = Path(config.get("base_path", ".aitrackdown"))
37
37
  self.tickets_dir = self.base_path / "tickets"
38
+ self._comment_counter = 0 # Counter for unique comment IDs
38
39
 
39
40
  # Initialize AI-Trackdown if available
41
+ # Always create tickets directory (needed for both modes)
42
+ self.tickets_dir.mkdir(parents=True, exist_ok=True)
43
+
40
44
  if HAS_AITRACKDOWN:
41
45
  self.tracker = AITrackdown(str(self.base_path))
42
46
  else:
43
47
  # Fallback to direct file operations
44
48
  self.tracker = None
45
- self.tickets_dir.mkdir(parents=True, exist_ok=True)
46
49
 
47
50
  def validate_credentials(self) -> tuple[bool, str]:
48
51
  """Validate that required credentials are present.
@@ -60,10 +63,15 @@ class AITrackdownAdapter(BaseAdapter[Task]):
60
63
  return True, ""
61
64
 
62
65
  def _get_state_mapping(self) -> dict[TicketState, str]:
63
- """Map universal states to AI-Trackdown states."""
66
+ """Map universal states to AI-Trackdown states.
67
+
68
+ Note: We use the exact enum values (snake_case) to match what
69
+ Pydantic's use_enum_values=True produces. This ensures consistency
70
+ between what's written to files and what's read back.
71
+ """
64
72
  return {
65
73
  TicketState.OPEN: "open",
66
- TicketState.IN_PROGRESS: "in-progress",
74
+ TicketState.IN_PROGRESS: "in_progress", # snake_case, not kebab-case
67
75
  TicketState.READY: "ready",
68
76
  TicketState.TESTED: "tested",
69
77
  TicketState.DONE: "done",
@@ -87,6 +95,18 @@ class AITrackdownAdapter(BaseAdapter[Task]):
87
95
 
88
96
  def _task_from_ai_ticket(self, ai_ticket: dict[str, Any]) -> Task:
89
97
  """Convert AI-Trackdown ticket to universal Task."""
98
+ # Get user metadata from ticket file
99
+ user_metadata = ai_ticket.get("metadata", {})
100
+
101
+ # Create adapter metadata
102
+ adapter_metadata = {
103
+ "ai_ticket_id": ai_ticket.get("id"),
104
+ "source": "aitrackdown",
105
+ }
106
+
107
+ # Merge user metadata with adapter metadata (user takes priority)
108
+ combined_metadata = {**adapter_metadata, **user_metadata}
109
+
90
110
  return Task(
91
111
  id=ai_ticket.get("id"),
92
112
  title=ai_ticket.get("title", ""),
@@ -107,11 +127,23 @@ class AITrackdownAdapter(BaseAdapter[Task]):
107
127
  if "updated_at" in ai_ticket
108
128
  else None
109
129
  ),
110
- metadata={"ai_trackdown": ai_ticket},
130
+ metadata=combined_metadata, # Use merged metadata
111
131
  )
112
132
 
113
133
  def _epic_from_ai_ticket(self, ai_ticket: dict[str, Any]) -> Epic:
114
134
  """Convert AI-Trackdown ticket to universal Epic."""
135
+ # Get user metadata from ticket file
136
+ user_metadata = ai_ticket.get("metadata", {})
137
+
138
+ # Create adapter metadata
139
+ adapter_metadata = {
140
+ "ai_ticket_id": ai_ticket.get("id"),
141
+ "source": "aitrackdown",
142
+ }
143
+
144
+ # Merge user metadata with adapter metadata (user takes priority)
145
+ combined_metadata = {**adapter_metadata, **user_metadata}
146
+
115
147
  return Epic(
116
148
  id=ai_ticket.get("id"),
117
149
  title=ai_ticket.get("title", ""),
@@ -130,20 +162,20 @@ class AITrackdownAdapter(BaseAdapter[Task]):
130
162
  if "updated_at" in ai_ticket and ai_ticket["updated_at"]
131
163
  else None
132
164
  ),
133
- metadata={"ai_trackdown": ai_ticket},
165
+ metadata=combined_metadata, # Use merged metadata
134
166
  )
135
167
 
136
168
  def _task_to_ai_ticket(self, task: Task) -> dict[str, Any]:
137
169
  """Convert universal Task to AI-Trackdown ticket."""
138
170
  # Handle enum values that may be stored as strings due to use_enum_values=True
171
+ # Note: task.state is always a string due to ConfigDict(use_enum_values=True)
139
172
  state_value = task.state
140
173
  if isinstance(task.state, TicketState):
141
174
  state_value = self._get_state_mapping()[task.state]
142
175
  elif isinstance(task.state, str):
143
- # Already a string, map to AI-Trackdown format if needed
144
- state_value = task.state.replace(
145
- "_", "-"
146
- ) # Convert snake_case to kebab-case
176
+ # Already a string - keep as-is (don't convert to kebab-case)
177
+ # The state is already in snake_case format from the enum value
178
+ state_value = task.state
147
179
 
148
180
  return {
149
181
  "id": task.id,
@@ -157,20 +189,21 @@ class AITrackdownAdapter(BaseAdapter[Task]):
157
189
  "assignee": task.assignee,
158
190
  "created_at": task.created_at.isoformat() if task.created_at else None,
159
191
  "updated_at": task.updated_at.isoformat() if task.updated_at else None,
192
+ "metadata": task.metadata or {}, # Serialize user metadata
160
193
  "type": "task",
161
194
  }
162
195
 
163
196
  def _epic_to_ai_ticket(self, epic: Epic) -> dict[str, Any]:
164
197
  """Convert universal Epic to AI-Trackdown ticket."""
165
198
  # Handle enum values that may be stored as strings due to use_enum_values=True
199
+ # Note: epic.state is always a string due to ConfigDict(use_enum_values=True)
166
200
  state_value = epic.state
167
201
  if isinstance(epic.state, TicketState):
168
202
  state_value = self._get_state_mapping()[epic.state]
169
203
  elif isinstance(epic.state, str):
170
- # Already a string, map to AI-Trackdown format if needed
171
- state_value = epic.state.replace(
172
- "_", "-"
173
- ) # Convert snake_case to kebab-case
204
+ # Already a string - keep as-is (don't convert to kebab-case)
205
+ # The state is already in snake_case format from the enum value
206
+ state_value = epic.state
174
207
 
175
208
  return {
176
209
  "id": epic.id,
@@ -182,6 +215,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
182
215
  "child_issues": epic.child_issues,
183
216
  "created_at": epic.created_at.isoformat() if epic.created_at else None,
184
217
  "updated_at": epic.updated_at.isoformat() if epic.updated_at else None,
218
+ "metadata": epic.metadata or {}, # Serialize user metadata
185
219
  "type": "epic",
186
220
  }
187
221
 
@@ -307,8 +341,20 @@ class AITrackdownAdapter(BaseAdapter[Task]):
307
341
 
308
342
  async def update(
309
343
  self, ticket_id: str, updates: Union[dict[str, Any], Task]
310
- ) -> Optional[Task]:
311
- """Update a task."""
344
+ ) -> Optional[Union[Task, Epic]]:
345
+ """Update a task or epic.
346
+
347
+ Args:
348
+ ticket_id: ID of ticket to update
349
+ updates: Dictionary of updates or Task object with new values
350
+
351
+ Returns:
352
+ Updated Task or Epic, or None if ticket not found
353
+
354
+ Raises:
355
+ AttributeError: If update fails due to invalid fields
356
+
357
+ """
312
358
  # Read existing ticket
313
359
  existing = await self.read(ticket_id)
314
360
  if not existing:
@@ -332,8 +378,12 @@ class AITrackdownAdapter(BaseAdapter[Task]):
332
378
 
333
379
  existing.updated_at = datetime.now()
334
380
 
335
- # Write back
336
- ai_ticket = self._task_to_ai_ticket(existing)
381
+ # Write back - use appropriate converter based on ticket type
382
+ if isinstance(existing, Epic):
383
+ ai_ticket = self._epic_to_ai_ticket(existing)
384
+ else:
385
+ ai_ticket = self._task_to_ai_ticket(existing)
386
+
337
387
  if self.tracker:
338
388
  self.tracker.update_ticket(ticket_id, **updates)
339
389
  else:
@@ -448,10 +498,11 @@ class AITrackdownAdapter(BaseAdapter[Task]):
448
498
 
449
499
  async def add_comment(self, comment: Comment) -> Comment:
450
500
  """Add comment to a task."""
451
- # Generate ID
501
+ # Generate ID with counter to ensure uniqueness
452
502
  if not comment.id:
453
503
  timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
454
- comment.id = f"comment-{timestamp}"
504
+ self._comment_counter += 1
505
+ comment.id = f"comment-{timestamp}-{self._comment_counter:04d}"
455
506
 
456
507
  comment.created_at = datetime.now()
457
508
 
@@ -472,14 +523,94 @@ class AITrackdownAdapter(BaseAdapter[Task]):
472
523
  comments_dir = self.base_path / "comments"
473
524
 
474
525
  if comments_dir.exists():
526
+ # Get all comment files and filter by ticket_id first
475
527
  comment_files = sorted(comments_dir.glob("*.json"))
476
- for comment_file in comment_files[offset : offset + limit]:
528
+ for comment_file in comment_files:
477
529
  with open(comment_file) as f:
478
530
  data = json.load(f)
479
531
  if data.get("ticket_id") == ticket_id:
480
532
  comments.append(Comment(**data))
481
533
 
482
- return comments[:limit]
534
+ # Apply limit and offset AFTER filtering
535
+ return comments[offset : offset + limit]
536
+
537
+ async def get_epic(self, epic_id: str) -> Optional[Epic]:
538
+ """Get epic by ID.
539
+
540
+ Args:
541
+ epic_id: Epic ID to retrieve
542
+
543
+ Returns:
544
+ Epic if found, None otherwise
545
+
546
+ """
547
+ ticket = await self.read(epic_id)
548
+ if ticket:
549
+ # Check if it's an Epic (can be Epic instance or have epic ticket_type)
550
+ if isinstance(ticket, Epic):
551
+ return ticket
552
+ # Check ticket_type (may be string or enum)
553
+ ticket_type_str = (
554
+ str(ticket.ticket_type).lower()
555
+ if hasattr(ticket, "ticket_type")
556
+ else None
557
+ )
558
+ if ticket_type_str and "epic" in ticket_type_str:
559
+ return Epic(**ticket.model_dump())
560
+ return None
561
+
562
+ async def list_epics(self, limit: int = 10, offset: int = 0) -> builtins.list[Epic]:
563
+ """List all epics.
564
+
565
+ Args:
566
+ limit: Maximum number of epics to return
567
+ offset: Number of epics to skip
568
+
569
+ Returns:
570
+ List of epics
571
+
572
+ """
573
+ all_tickets = await self.list(limit=100, offset=0, filters={"type": "epic"})
574
+ epics = []
575
+ for ticket in all_tickets:
576
+ if ticket.ticket_type == "epic":
577
+ epics.append(Epic(**ticket.model_dump()))
578
+ return epics[offset : offset + limit]
579
+
580
+ async def list_issues_by_epic(self, epic_id: str) -> builtins.list[Task]:
581
+ """List all issues belonging to an epic.
582
+
583
+ Args:
584
+ epic_id: Epic ID to get issues for
585
+
586
+ Returns:
587
+ List of issues (tasks with parent_epic set)
588
+
589
+ """
590
+ all_tickets = await self.list(limit=1000, offset=0, filters={})
591
+ issues = []
592
+ for ticket in all_tickets:
593
+ if hasattr(ticket, "parent_epic") and ticket.parent_epic == epic_id:
594
+ issues.append(ticket)
595
+ return issues
596
+
597
+ async def list_tasks_by_issue(self, issue_id: str) -> builtins.list[Task]:
598
+ """List all tasks belonging to an issue.
599
+
600
+ Args:
601
+ issue_id: Issue ID (parent task) to get child tasks for
602
+
603
+ Returns:
604
+ List of tasks
605
+
606
+ """
607
+ all_tickets = await self.list(limit=1000, offset=0, filters={})
608
+ tasks = []
609
+ for ticket in all_tickets:
610
+ # Check if this ticket has parent_issue matching the issue
611
+ if hasattr(ticket, "parent_issue") and ticket.parent_issue == issue_id:
612
+ tasks.append(ticket)
613
+ return tasks
483
614
 
484
615
 
485
616
  # Register the adapter
@@ -3,7 +3,7 @@
3
3
  import builtins
4
4
  import re
5
5
  from datetime import datetime
6
- from typing import Any, Dict, List, Optional
6
+ from typing import Any, Optional
7
7
 
8
8
  import httpx
9
9
 
@@ -603,7 +603,7 @@ class GitHubAdapter(BaseAdapter[Task]):
603
603
  labels_to_update = update_data.get("labels", current_labels)
604
604
  all_priority_labels = []
605
605
  for labels in GitHubStateMapping.PRIORITY_LABELS.values():
606
- all_priority_labels.extend([l.lower() for l in labels])
606
+ all_priority_labels.extend([label.lower() for label in labels])
607
607
 
608
608
  labels_to_update = [
609
609
  label
@@ -1334,7 +1334,7 @@ Fixes #{issue_number}
1334
1334
  "message": f"Successfully linked PR #{pr_number} to issue #{issue_number}",
1335
1335
  }
1336
1336
 
1337
- async def get_collaborators(self) -> List[Dict[str, Any]]:
1337
+ async def get_collaborators(self) -> builtins.list[dict[str, Any]]:
1338
1338
  """Get repository collaborators."""
1339
1339
  response = await self.client.get(
1340
1340
  f"/repos/{self.owner}/{self.repo}/collaborators"
@@ -1342,7 +1342,7 @@ Fixes #{issue_number}
1342
1342
  response.raise_for_status()
1343
1343
  return response.json()
1344
1344
 
1345
- async def get_current_user(self) -> Optional[Dict[str, Any]]:
1345
+ async def get_current_user(self) -> Optional[dict[str, Any]]:
1346
1346
  """Get current authenticated user information."""
1347
1347
  response = await self.client.get("/user")
1348
1348
  response.raise_for_status()
@@ -6,7 +6,7 @@ import logging
6
6
  import re
7
7
  from datetime import datetime
8
8
  from enum import Enum
9
- from typing import Any, Dict, List, Optional, Union
9
+ from typing import Any, Optional, Union
10
10
 
11
11
  import httpx
12
12
  from httpx import AsyncClient, HTTPStatusError, TimeoutException
@@ -47,7 +47,7 @@ def parse_jira_datetime(date_str: str) -> Optional[datetime]:
47
47
  return None
48
48
 
49
49
 
50
- def extract_text_from_adf(adf_content: Union[str, Dict[str, Any]]) -> str:
50
+ def extract_text_from_adf(adf_content: Union[str, dict[str, Any]]) -> str:
51
51
  """Extract plain text from Atlassian Document Format (ADF).
52
52
 
53
53
  Args:
@@ -63,7 +63,7 @@ def extract_text_from_adf(adf_content: Union[str, Dict[str, Any]]) -> str:
63
63
  if not isinstance(adf_content, dict):
64
64
  return str(adf_content) if adf_content else ""
65
65
 
66
- def extract_text_recursive(node: Dict[str, Any]) -> str:
66
+ def extract_text_recursive(node: dict[str, Any]) -> str:
67
67
  """Recursively extract text from ADF nodes."""
68
68
  if not isinstance(node, dict):
69
69
  return ""
@@ -940,7 +940,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
940
940
 
941
941
  return sprints_data.get("values", [])
942
942
 
943
- async def get_project_users(self) -> List[Dict[str, Any]]:
943
+ async def get_project_users(self) -> builtins.list[dict[str, Any]]:
944
944
  """Get users who have access to the project."""
945
945
  if not self.project_key:
946
946
  return []
@@ -954,7 +954,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
954
954
  # Get users from project roles
955
955
  users = []
956
956
  if "roles" in project_data:
957
- for role_name, role_url in project_data["roles"].items():
957
+ for _role_name, role_url in project_data["roles"].items():
958
958
  # Extract role ID from URL
959
959
  role_id = role_url.split("/")[-1]
960
960
  try:
@@ -992,7 +992,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
992
992
  except Exception:
993
993
  return []
994
994
 
995
- async def get_current_user(self) -> Optional[Dict[str, Any]]:
995
+ async def get_current_user(self) -> Optional[dict[str, Any]]:
996
996
  """Get current authenticated user information."""
997
997
  try:
998
998
  return await self._make_request("GET", "myself")
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
- from typing import Any, Dict, List, Optional, Union
6
+ from typing import Any
7
7
 
8
8
  try:
9
9
  from gql import gql
@@ -12,6 +12,8 @@ except ImportError:
12
12
  gql = None
13
13
  TransportQueryError = Exception
14
14
 
15
+ import builtins
16
+
15
17
  from ...core.adapter import BaseAdapter
16
18
  from ...core.models import Comment, Epic, SearchQuery, Task, TicketState
17
19
  from ...core.registry import AdapterRegistry
@@ -58,7 +60,7 @@ class LinearAdapter(BaseAdapter[Task]):
58
60
  - mappers.py: Data transformation logic
59
61
  """
60
62
 
61
- def __init__(self, config: Dict[str, Any]):
63
+ def __init__(self, config: dict[str, Any]):
62
64
  """Initialize Linear adapter.
63
65
 
64
66
  Args:
@@ -75,10 +77,10 @@ class LinearAdapter(BaseAdapter[Task]):
75
77
  """
76
78
  # Initialize instance variables before calling super().__init__
77
79
  # because parent constructor calls _get_state_mapping()
78
- self._team_data: Optional[Dict[str, Any]] = None
79
- self._workflow_states: Optional[Dict[str, Dict[str, Any]]] = None
80
- self._labels_cache: Optional[List[Dict[str, Any]]] = None
81
- self._users_cache: Optional[Dict[str, Dict[str, Any]]] = None
80
+ self._team_data: dict[str, Any] | None = None
81
+ self._workflow_states: dict[str, dict[str, Any]] | None = None
82
+ self._labels_cache: list[dict[str, Any]] | None = None
83
+ self._users_cache: dict[str, dict[str, Any]] | None = None
82
84
  self._initialized = False
83
85
 
84
86
  super().__init__(config)
@@ -135,8 +137,9 @@ class LinearAdapter(BaseAdapter[Task]):
135
137
  # Load team data and workflow states concurrently
136
138
  team_id = await self._ensure_team_id()
137
139
 
138
- # Load workflow states for the team
140
+ # Load workflow states and labels for the team
139
141
  await self._load_workflow_states(team_id)
142
+ await self._load_team_labels(team_id)
140
143
 
141
144
  self._initialized = True
142
145
 
@@ -214,7 +217,86 @@ class LinearAdapter(BaseAdapter[Task]):
214
217
  except Exception as e:
215
218
  raise ValueError(f"Failed to load workflow states: {e}")
216
219
 
217
- def _get_state_mapping(self) -> Dict[TicketState, str]:
220
+ async def _load_team_labels(self, team_id: str) -> None:
221
+ """Load and cache labels for the team.
222
+
223
+ Args:
224
+ team_id: Linear team ID
225
+
226
+ """
227
+ query = """
228
+ query GetTeamLabels($teamId: ID!) {
229
+ team(id: $teamId) {
230
+ labels {
231
+ nodes {
232
+ id
233
+ name
234
+ color
235
+ description
236
+ }
237
+ }
238
+ }
239
+ }
240
+ """
241
+
242
+ try:
243
+ result = await self.client.execute_query(query, {"teamId": team_id})
244
+ self._labels_cache = result["team"]["labels"]["nodes"]
245
+ except Exception:
246
+ # Log error but don't fail - labels are optional
247
+ self._labels_cache = []
248
+
249
+ async def _resolve_label_ids(self, label_names: list[str]) -> list[str]:
250
+ """Resolve label names to Linear label IDs.
251
+
252
+ Args:
253
+ label_names: List of label names
254
+
255
+ Returns:
256
+ List of Linear label IDs that exist
257
+
258
+ """
259
+ import logging
260
+
261
+ logger = logging.getLogger(__name__)
262
+
263
+ if not self._labels_cache:
264
+ team_id = await self._ensure_team_id()
265
+ await self._load_team_labels(team_id)
266
+
267
+ if not self._labels_cache:
268
+ logger.warning("No labels found in team cache")
269
+ return []
270
+
271
+ # Create name -> ID mapping (case-insensitive)
272
+ label_map = {label["name"].lower(): label["id"] for label in self._labels_cache}
273
+
274
+ logger.debug(f"Available labels in team: {list(label_map.keys())}")
275
+
276
+ # Resolve label names to IDs
277
+ label_ids = []
278
+ unmatched_labels = []
279
+
280
+ for name in label_names:
281
+ label_id = label_map.get(name.lower())
282
+ if label_id:
283
+ label_ids.append(label_id)
284
+ logger.debug(f"Resolved label '{name}' to ID: {label_id}")
285
+ else:
286
+ unmatched_labels.append(name)
287
+ logger.warning(
288
+ f"Label '{name}' not found in team. Available labels: {list(label_map.keys())}"
289
+ )
290
+
291
+ if unmatched_labels:
292
+ logger.warning(
293
+ f"Could not resolve labels: {unmatched_labels}. "
294
+ f"Create them in Linear first or check spelling."
295
+ )
296
+
297
+ return label_ids
298
+
299
+ def _get_state_mapping(self) -> dict[TicketState, str]:
218
300
  """Get mapping from universal states to Linear workflow state IDs.
219
301
 
220
302
  Returns:
@@ -245,7 +327,7 @@ class LinearAdapter(BaseAdapter[Task]):
245
327
 
246
328
  return mapping
247
329
 
248
- async def _get_user_id(self, user_identifier: str) -> Optional[str]:
330
+ async def _get_user_id(self, user_identifier: str) -> str | None:
249
331
  """Get Linear user ID from email or display name.
250
332
 
251
333
  Args:
@@ -266,7 +348,7 @@ class LinearAdapter(BaseAdapter[Task]):
266
348
 
267
349
  # CRUD Operations
268
350
 
269
- async def create(self, ticket: Union[Epic, Task]) -> Union[Epic, Task]:
351
+ async def create(self, ticket: Epic | Task) -> Epic | Task:
270
352
  """Create a new Linear issue or project with full field support.
271
353
 
272
354
  Args:
@@ -309,12 +391,28 @@ class LinearAdapter(BaseAdapter[Task]):
309
391
  # Build issue input using mapper
310
392
  issue_input = build_linear_issue_input(task, team_id)
311
393
 
394
+ # Set default state if not provided
395
+ # Map OPEN to "unstarted" state (typically "To-Do" in Linear)
396
+ if task.state == TicketState.OPEN and self._workflow_states:
397
+ state_mapping = self._get_state_mapping()
398
+ if TicketState.OPEN in state_mapping:
399
+ issue_input["stateId"] = state_mapping[TicketState.OPEN]
400
+
312
401
  # Resolve assignee to user ID if provided
313
402
  if task.assignee:
314
403
  user_id = await self._get_user_id(task.assignee)
315
404
  if user_id:
316
405
  issue_input["assigneeId"] = user_id
317
406
 
407
+ # Resolve label names to IDs if provided
408
+ if task.tags:
409
+ label_ids = await self._resolve_label_ids(task.tags)
410
+ if label_ids:
411
+ issue_input["labelIds"] = label_ids
412
+ else:
413
+ # Remove labelIds if no labels resolved
414
+ issue_input.pop("labelIds", None)
415
+
318
416
  try:
319
417
  result = await self.client.execute_mutation(
320
418
  CREATE_ISSUE_MUTATION, {"input": issue_input}
@@ -394,7 +492,7 @@ class LinearAdapter(BaseAdapter[Task]):
394
492
  except Exception as e:
395
493
  raise ValueError(f"Failed to create Linear project: {e}")
396
494
 
397
- async def read(self, ticket_id: str) -> Optional[Task]:
495
+ async def read(self, ticket_id: str) -> Task | None:
398
496
  """Read a Linear issue by identifier with full details.
399
497
 
400
498
  Args:
@@ -432,7 +530,7 @@ class LinearAdapter(BaseAdapter[Task]):
432
530
 
433
531
  return None
434
532
 
435
- async def update(self, ticket_id: str, updates: Dict[str, Any]) -> Optional[Task]:
533
+ async def update(self, ticket_id: str, updates: dict[str, Any]) -> Task | None:
436
534
  """Update a Linear issue with comprehensive field support.
437
535
 
438
536
  Args:
@@ -487,6 +585,12 @@ class LinearAdapter(BaseAdapter[Task]):
487
585
  if user_id:
488
586
  update_input["assigneeId"] = user_id
489
587
 
588
+ # Resolve label names to IDs if provided
589
+ if "tags" in updates and updates["tags"]:
590
+ label_ids = await self._resolve_label_ids(updates["tags"])
591
+ if label_ids:
592
+ update_input["labelIds"] = label_ids
593
+
490
594
  # Execute update
491
595
  result = await self.client.execute_mutation(
492
596
  UPDATE_ISSUE_MUTATION, {"id": linear_id, "input": update_input}
@@ -519,8 +623,8 @@ class LinearAdapter(BaseAdapter[Task]):
519
623
  return False
520
624
 
521
625
  async def list(
522
- self, limit: int = 10, offset: int = 0, filters: Optional[Dict[str, Any]] = None
523
- ) -> List[Task]:
626
+ self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
627
+ ) -> builtins.list[Task]:
524
628
  """List Linear issues with optional filtering.
525
629
 
526
630
  Args:
@@ -578,7 +682,7 @@ class LinearAdapter(BaseAdapter[Task]):
578
682
  except Exception as e:
579
683
  raise ValueError(f"Failed to list Linear issues: {e}")
580
684
 
581
- async def search(self, query: SearchQuery) -> List[Task]:
685
+ async def search(self, query: SearchQuery) -> builtins.list[Task]:
582
686
  """Search Linear issues using comprehensive filters.
583
687
 
584
688
  Args:
@@ -644,7 +748,7 @@ class LinearAdapter(BaseAdapter[Task]):
644
748
 
645
749
  async def transition_state(
646
750
  self, ticket_id: str, target_state: TicketState
647
- ) -> Optional[Task]:
751
+ ) -> Task | None:
648
752
  """Transition Linear issue to new state with workflow validation.
649
753
 
650
754
  Args:
@@ -749,7 +853,7 @@ class LinearAdapter(BaseAdapter[Task]):
749
853
 
750
854
  async def get_comments(
751
855
  self, ticket_id: str, limit: int = 10, offset: int = 0
752
- ) -> List[Comment]:
856
+ ) -> builtins.list[Comment]:
753
857
  """Get comments for a Linear issue.
754
858
 
755
859
  Args:
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- from typing import Any, Dict, Optional
6
+ from typing import Any
7
7
 
8
8
  try:
9
9
  from gql import Client, gql
@@ -74,9 +74,9 @@ class LinearGraphQLClient:
74
74
  async def execute_query(
75
75
  self,
76
76
  query_string: str,
77
- variables: Optional[Dict[str, Any]] = None,
77
+ variables: dict[str, Any] | None = None,
78
78
  retries: int = 3,
79
- ) -> Dict[str, Any]:
79
+ ) -> dict[str, Any]:
80
80
  """Execute a GraphQL query with error handling and retries.
81
81
 
82
82
  Args:
@@ -161,9 +161,9 @@ class LinearGraphQLClient:
161
161
  async def execute_mutation(
162
162
  self,
163
163
  mutation_string: str,
164
- variables: Optional[Dict[str, Any]] = None,
164
+ variables: dict[str, Any] | None = None,
165
165
  retries: int = 3,
166
- ) -> Dict[str, Any]:
166
+ ) -> dict[str, Any]:
167
167
  """Execute a GraphQL mutation with error handling.
168
168
 
169
169
  Args:
@@ -206,7 +206,7 @@ class LinearGraphQLClient:
206
206
  except Exception:
207
207
  return False
208
208
 
209
- async def get_team_info(self, team_id: str) -> Optional[Dict[str, Any]]:
209
+ async def get_team_info(self, team_id: str) -> dict[str, Any] | None:
210
210
  """Get team information by ID.
211
211
 
212
212
  Args:
@@ -234,7 +234,7 @@ class LinearGraphQLClient:
234
234
  except Exception:
235
235
  return None
236
236
 
237
- async def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]:
237
+ async def get_user_by_email(self, email: str) -> dict[str, Any] | None:
238
238
  """Get user information by email.
239
239
 
240
240
  Args: