mcp-ticketer 0.4.1__py3-none-any.whl → 0.4.3__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 (56) hide show
  1. mcp_ticketer/__init__.py +3 -12
  2. mcp_ticketer/__version__.py +1 -1
  3. mcp_ticketer/adapters/aitrackdown.py +243 -11
  4. mcp_ticketer/adapters/github.py +15 -14
  5. mcp_ticketer/adapters/hybrid.py +11 -11
  6. mcp_ticketer/adapters/jira.py +22 -25
  7. mcp_ticketer/adapters/linear/adapter.py +9 -21
  8. mcp_ticketer/adapters/linear/client.py +2 -1
  9. mcp_ticketer/adapters/linear/mappers.py +2 -1
  10. mcp_ticketer/cache/memory.py +6 -5
  11. mcp_ticketer/cli/adapter_diagnostics.py +4 -2
  12. mcp_ticketer/cli/auggie_configure.py +66 -0
  13. mcp_ticketer/cli/codex_configure.py +70 -2
  14. mcp_ticketer/cli/configure.py +7 -14
  15. mcp_ticketer/cli/diagnostics.py +2 -2
  16. mcp_ticketer/cli/discover.py +6 -11
  17. mcp_ticketer/cli/gemini_configure.py +68 -2
  18. mcp_ticketer/cli/linear_commands.py +6 -7
  19. mcp_ticketer/cli/main.py +341 -203
  20. mcp_ticketer/cli/mcp_configure.py +61 -2
  21. mcp_ticketer/cli/ticket_commands.py +27 -30
  22. mcp_ticketer/cli/utils.py +23 -22
  23. mcp_ticketer/core/__init__.py +3 -1
  24. mcp_ticketer/core/adapter.py +82 -13
  25. mcp_ticketer/core/config.py +27 -29
  26. mcp_ticketer/core/env_discovery.py +10 -10
  27. mcp_ticketer/core/env_loader.py +8 -8
  28. mcp_ticketer/core/http_client.py +16 -16
  29. mcp_ticketer/core/mappers.py +10 -10
  30. mcp_ticketer/core/models.py +50 -20
  31. mcp_ticketer/core/project_config.py +40 -34
  32. mcp_ticketer/core/registry.py +2 -2
  33. mcp_ticketer/mcp/dto.py +32 -32
  34. mcp_ticketer/mcp/response_builder.py +2 -2
  35. mcp_ticketer/mcp/server.py +17 -37
  36. mcp_ticketer/mcp/server_sdk.py +93 -0
  37. mcp_ticketer/mcp/tools/__init__.py +36 -0
  38. mcp_ticketer/mcp/tools/attachment_tools.py +179 -0
  39. mcp_ticketer/mcp/tools/bulk_tools.py +273 -0
  40. mcp_ticketer/mcp/tools/comment_tools.py +90 -0
  41. mcp_ticketer/mcp/tools/hierarchy_tools.py +383 -0
  42. mcp_ticketer/mcp/tools/pr_tools.py +154 -0
  43. mcp_ticketer/mcp/tools/search_tools.py +206 -0
  44. mcp_ticketer/mcp/tools/ticket_tools.py +277 -0
  45. mcp_ticketer/queue/health_monitor.py +4 -4
  46. mcp_ticketer/queue/manager.py +2 -2
  47. mcp_ticketer/queue/queue.py +16 -16
  48. mcp_ticketer/queue/ticket_registry.py +7 -7
  49. mcp_ticketer/queue/worker.py +2 -2
  50. {mcp_ticketer-0.4.1.dist-info → mcp_ticketer-0.4.3.dist-info}/METADATA +90 -17
  51. mcp_ticketer-0.4.3.dist-info/RECORD +73 -0
  52. mcp_ticketer-0.4.1.dist-info/RECORD +0 -64
  53. {mcp_ticketer-0.4.1.dist-info → mcp_ticketer-0.4.3.dist-info}/WHEEL +0 -0
  54. {mcp_ticketer-0.4.1.dist-info → mcp_ticketer-0.4.3.dist-info}/entry_points.txt +0 -0
  55. {mcp_ticketer-0.4.1.dist-info → mcp_ticketer-0.4.3.dist-info}/licenses/LICENSE +0 -0
  56. {mcp_ticketer-0.4.1.dist-info → mcp_ticketer-0.4.3.dist-info}/top_level.txt +0 -0
mcp_ticketer/__init__.py CHANGED
@@ -1,17 +1,8 @@
1
1
  """MCP Ticketer - Universal ticket management interface."""
2
2
 
3
- from .__version__ import (
4
- __author__,
5
- __author_email__,
6
- __copyright__,
7
- __description__,
8
- __license__,
9
- __title__,
10
- __version__,
11
- __version_info__,
12
- get_user_agent,
13
- get_version,
14
- )
3
+ from .__version__ import (__author__, __author_email__, __copyright__,
4
+ __description__, __license__, __title__, __version__,
5
+ __version_info__, get_user_agent, get_version)
15
6
 
16
7
  __all__ = [
17
8
  "__version__",
@@ -1,6 +1,6 @@
1
1
  """Version information for mcp-ticketer package."""
2
2
 
3
- __version__ = "0.4.1"
3
+ __version__ = "0.4.3"
4
4
  __version_info__ = tuple(int(part) for part in __version__.split("."))
5
5
 
6
6
  # Package metadata
@@ -2,12 +2,16 @@
2
2
 
3
3
  import builtins
4
4
  import json
5
+ import logging
5
6
  from datetime import datetime
6
7
  from pathlib import Path
7
- from typing import Any, Optional, Union
8
+ from typing import Any
8
9
 
9
10
  from ..core.adapter import BaseAdapter
10
- from ..core.models import Comment, Epic, Priority, SearchQuery, Task, TicketState
11
+
12
+ logger = logging.getLogger(__name__)
13
+ from ..core.models import (Attachment, Comment, Epic, Priority, SearchQuery,
14
+ Task, TicketState)
11
15
  from ..core.registry import AdapterRegistry
12
16
 
13
17
  # Import ai-trackdown-pytools when available
@@ -80,7 +84,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
80
84
  TicketState.CLOSED: "closed",
81
85
  }
82
86
 
83
- def _priority_to_ai(self, priority: Union[Priority, str]) -> str:
87
+ def _priority_to_ai(self, priority: Priority | str) -> str:
84
88
  """Convert universal priority to AI-Trackdown priority."""
85
89
  if isinstance(priority, Priority):
86
90
  return priority.value
@@ -219,7 +223,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
219
223
  "type": "epic",
220
224
  }
221
225
 
222
- def _read_ticket_file(self, ticket_id: str) -> Optional[dict[str, Any]]:
226
+ def _read_ticket_file(self, ticket_id: str) -> dict[str, Any] | None:
223
227
  """Read ticket from file system."""
224
228
  ticket_file = self.tickets_dir / f"{ticket_id}.json"
225
229
  if ticket_file.exists():
@@ -233,7 +237,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
233
237
  with open(ticket_file, "w") as f:
234
238
  json.dump(data, f, indent=2, default=str)
235
239
 
236
- async def create(self, ticket: Union[Task, Epic]) -> Union[Task, Epic]:
240
+ async def create(self, ticket: Task | Epic) -> Task | Epic:
237
241
  """Create a new task."""
238
242
  # Generate ID if not provided
239
243
  if not ticket.id:
@@ -324,7 +328,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
324
328
  )
325
329
  return await self.create(task)
326
330
 
327
- async def read(self, ticket_id: str) -> Optional[Union[Task, Epic]]:
331
+ async def read(self, ticket_id: str) -> Task | Epic | None:
328
332
  """Read a task by ID."""
329
333
  if self.tracker:
330
334
  ai_ticket = self.tracker.get_ticket(ticket_id)
@@ -340,8 +344,8 @@ class AITrackdownAdapter(BaseAdapter[Task]):
340
344
  return None
341
345
 
342
346
  async def update(
343
- self, ticket_id: str, updates: Union[dict[str, Any], Task]
344
- ) -> Optional[Union[Task, Epic]]:
347
+ self, ticket_id: str, updates: dict[str, Any] | Task
348
+ ) -> Task | Epic | None:
345
349
  """Update a task or epic.
346
350
 
347
351
  Args:
@@ -403,7 +407,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
403
407
  return False
404
408
 
405
409
  async def list(
406
- self, limit: int = 10, offset: int = 0, filters: Optional[dict[str, Any]] = None
410
+ self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
407
411
  ) -> list[Task]:
408
412
  """List tasks with pagination."""
409
413
  tasks = []
@@ -487,7 +491,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
487
491
 
488
492
  async def transition_state(
489
493
  self, ticket_id: str, target_state: TicketState
490
- ) -> Optional[Task]:
494
+ ) -> Task | None:
491
495
  """Transition task to new state."""
492
496
  # Validate transition
493
497
  if not await self.validate_transition(ticket_id, target_state):
@@ -534,7 +538,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
534
538
  # Apply limit and offset AFTER filtering
535
539
  return comments[offset : offset + limit]
536
540
 
537
- async def get_epic(self, epic_id: str) -> Optional[Epic]:
541
+ async def get_epic(self, epic_id: str) -> Epic | None:
538
542
  """Get epic by ID.
539
543
 
540
544
  Args:
@@ -612,6 +616,234 @@ class AITrackdownAdapter(BaseAdapter[Task]):
612
616
  tasks.append(ticket)
613
617
  return tasks
614
618
 
619
+ def _sanitize_filename(self, filename: str) -> str:
620
+ """Sanitize filename to prevent security issues.
621
+
622
+ Args:
623
+ filename: Original filename
624
+
625
+ Returns:
626
+ Sanitized filename safe for filesystem
627
+
628
+ """
629
+ # Remove path separators and other dangerous characters
630
+ safe_chars = set(
631
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._- "
632
+ )
633
+ sanitized = "".join(c if c in safe_chars else "_" for c in filename)
634
+
635
+ # Ensure filename is not empty
636
+ if not sanitized.strip():
637
+ return "unnamed_file"
638
+
639
+ return sanitized.strip()
640
+
641
+ def _guess_content_type(self, file_path: Path) -> str:
642
+ """Guess MIME type from file extension.
643
+
644
+ Args:
645
+ file_path: Path to file
646
+
647
+ Returns:
648
+ MIME type string
649
+
650
+ """
651
+ import mimetypes
652
+
653
+ content_type, _ = mimetypes.guess_type(str(file_path))
654
+ return content_type or "application/octet-stream"
655
+
656
+ def _calculate_checksum(self, file_path: Path) -> str:
657
+ """Calculate SHA256 checksum of file.
658
+
659
+ Args:
660
+ file_path: Path to file
661
+
662
+ Returns:
663
+ Hexadecimal checksum string
664
+
665
+ """
666
+ import hashlib
667
+
668
+ sha256 = hashlib.sha256()
669
+ with open(file_path, "rb") as f:
670
+ # Read in chunks to handle large files
671
+ for chunk in iter(lambda: f.read(4096), b""):
672
+ sha256.update(chunk)
673
+
674
+ return sha256.hexdigest()
675
+
676
+ async def add_attachment(
677
+ self,
678
+ ticket_id: str,
679
+ file_path: str,
680
+ description: str | None = None,
681
+ ) -> Attachment:
682
+ """Attach a file to a ticket (local filesystem storage).
683
+
684
+ Args:
685
+ ticket_id: Ticket identifier
686
+ file_path: Local file path to attach
687
+ description: Optional attachment description
688
+
689
+ Returns:
690
+ Attachment metadata
691
+
692
+ Raises:
693
+ ValueError: If ticket doesn't exist
694
+ FileNotFoundError: If file doesn't exist
695
+
696
+ """
697
+ import shutil
698
+
699
+ # Validate ticket exists
700
+ ticket = await self.read(ticket_id)
701
+ if not ticket:
702
+ raise ValueError(f"Ticket {ticket_id} not found")
703
+
704
+ # Validate file exists
705
+ source_path = Path(file_path).resolve()
706
+ if not source_path.exists():
707
+ raise FileNotFoundError(f"File not found: {file_path}")
708
+
709
+ # Check file size (max 100MB for local storage)
710
+ size_mb = source_path.stat().st_size / (1024 * 1024)
711
+ if size_mb > 100:
712
+ raise ValueError(f"File too large: {size_mb:.2f}MB (max: 100MB)")
713
+
714
+ # Create attachments directory for this ticket
715
+ attachments_dir = self.base_path / "attachments" / ticket_id
716
+ attachments_dir.mkdir(parents=True, exist_ok=True)
717
+
718
+ # Generate unique filename with timestamp
719
+ timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
720
+ safe_filename = self._sanitize_filename(source_path.name)
721
+ attachment_id = f"{timestamp}-{safe_filename}"
722
+ dest_path = attachments_dir / attachment_id
723
+
724
+ # Copy file to attachments directory
725
+ shutil.copy2(source_path, dest_path)
726
+
727
+ # Create attachment metadata
728
+ attachment = Attachment(
729
+ id=attachment_id,
730
+ ticket_id=ticket_id,
731
+ filename=source_path.name,
732
+ url=f"file://{dest_path.absolute()}",
733
+ content_type=self._guess_content_type(source_path),
734
+ size_bytes=source_path.stat().st_size,
735
+ created_at=datetime.now(),
736
+ description=description,
737
+ metadata={
738
+ "original_path": str(source_path),
739
+ "storage_path": str(dest_path),
740
+ "checksum": self._calculate_checksum(dest_path),
741
+ },
742
+ )
743
+
744
+ # Save metadata to JSON file
745
+ metadata_file = attachments_dir / f"{attachment_id}.json"
746
+ with open(metadata_file, "w") as f:
747
+ # Convert to dict and handle datetime serialization
748
+ data = attachment.model_dump()
749
+ json.dump(data, f, indent=2, default=str)
750
+
751
+ return attachment
752
+
753
+ async def get_attachments(self, ticket_id: str) -> builtins.list[Attachment]:
754
+ """Get all attachments for a ticket with path traversal protection.
755
+
756
+ Args:
757
+ ticket_id: Ticket identifier
758
+
759
+ Returns:
760
+ List of attachments (empty if none)
761
+
762
+ """
763
+ # Resolve and validate attachments directory
764
+ attachments_dir = (self.base_path / "attachments" / ticket_id).resolve()
765
+
766
+ # CRITICAL SECURITY CHECK: Ensure ticket directory is within base attachments
767
+ base_attachments = (self.base_path / "attachments").resolve()
768
+ if not str(attachments_dir).startswith(str(base_attachments)):
769
+ raise ValueError(f"Invalid ticket_id: path traversal detected")
770
+
771
+ if not attachments_dir.exists():
772
+ return []
773
+
774
+ attachments = []
775
+ for metadata_file in attachments_dir.glob("*.json"):
776
+ try:
777
+ with open(metadata_file) as f:
778
+ data = json.load(f)
779
+ # Convert ISO datetime strings back to datetime objects
780
+ if isinstance(data.get("created_at"), str):
781
+ data["created_at"] = datetime.fromisoformat(
782
+ data["created_at"].replace("Z", "+00:00")
783
+ )
784
+ attachment = Attachment(**data)
785
+ attachments.append(attachment)
786
+ except (json.JSONDecodeError, ValueError) as e:
787
+ # Log error but continue processing other attachments
788
+ logger.warning(
789
+ "Failed to load attachment metadata from %s: %s",
790
+ metadata_file,
791
+ e,
792
+ )
793
+ continue
794
+
795
+ # Sort by creation time (newest first)
796
+ return sorted(
797
+ attachments,
798
+ key=lambda a: a.created_at or datetime.min,
799
+ reverse=True,
800
+ )
801
+
802
+ async def delete_attachment(
803
+ self,
804
+ ticket_id: str,
805
+ attachment_id: str,
806
+ ) -> bool:
807
+ """Delete an attachment and its metadata with path traversal protection.
808
+
809
+ Args:
810
+ ticket_id: Ticket identifier
811
+ attachment_id: Attachment identifier
812
+
813
+ Returns:
814
+ True if deleted, False if not found
815
+
816
+ """
817
+ # Resolve base directory
818
+ attachments_dir = (self.base_path / "attachments" / ticket_id).resolve()
819
+
820
+ # Validate attachments directory exists
821
+ if not attachments_dir.exists():
822
+ return False
823
+
824
+ # Resolve file paths
825
+ attachment_file = (attachments_dir / attachment_id).resolve()
826
+ metadata_file = (attachments_dir / f"{attachment_id}.json").resolve()
827
+
828
+ # CRITICAL SECURITY CHECK: Ensure paths are within attachments_dir
829
+ base_resolved = attachments_dir.resolve()
830
+ if not str(attachment_file).startswith(str(base_resolved)):
831
+ raise ValueError(f"Invalid attachment path: path traversal detected in attachment_id")
832
+ if not str(metadata_file).startswith(str(base_resolved)):
833
+ raise ValueError(f"Invalid attachment path: path traversal detected in attachment_id")
834
+
835
+ # Delete files if they exist
836
+ deleted = False
837
+ if attachment_file.exists():
838
+ attachment_file.unlink()
839
+ deleted = True
840
+
841
+ if metadata_file.exists():
842
+ metadata_file.unlink()
843
+ deleted = True
844
+
845
+ return deleted
846
+
615
847
 
616
848
  # Register the adapter
617
849
  AdapterRegistry.register("aitrackdown", AITrackdownAdapter)
@@ -3,13 +3,14 @@
3
3
  import builtins
4
4
  import re
5
5
  from datetime import datetime
6
- from typing import Any, Optional
6
+ from typing import Any
7
7
 
8
8
  import httpx
9
9
 
10
10
  from ..core.adapter import BaseAdapter
11
11
  from ..core.env_loader import load_adapter_config, validate_adapter_config
12
- from ..core.models import Comment, Epic, Priority, SearchQuery, Task, TicketState
12
+ from ..core.models import (Comment, Epic, Priority, SearchQuery, Task,
13
+ TicketState)
13
14
  from ..core.registry import AdapterRegistry
14
15
 
15
16
 
@@ -198,8 +199,8 @@ class GitHubAdapter(BaseAdapter[Task]):
198
199
  )
199
200
 
200
201
  # Cache for labels and milestones
201
- self._labels_cache: Optional[list[dict[str, Any]]] = None
202
- self._milestones_cache: Optional[list[dict[str, Any]]] = None
202
+ self._labels_cache: list[dict[str, Any]] | None = None
203
+ self._milestones_cache: list[dict[str, Any]] | None = None
203
204
  self._rate_limit: dict[str, Any] = {}
204
205
 
205
206
  def validate_credentials(self) -> tuple[bool, str]:
@@ -239,7 +240,7 @@ class GitHubAdapter(BaseAdapter[Task]):
239
240
  TicketState.CLOSED: GitHubStateMapping.CLOSED,
240
241
  }
241
242
 
242
- def _get_state_label(self, state: TicketState) -> Optional[str]:
243
+ def _get_state_label(self, state: TicketState) -> str | None:
243
244
  """Get the label name for extended states."""
244
245
  return GitHubStateMapping.STATE_LABELS.get(state)
245
246
 
@@ -508,7 +509,7 @@ class GitHubAdapter(BaseAdapter[Task]):
508
509
 
509
510
  return self._task_from_github_issue(created_issue)
510
511
 
511
- async def read(self, ticket_id: str) -> Optional[Task]:
512
+ async def read(self, ticket_id: str) -> Task | None:
512
513
  """Read a GitHub issue by number."""
513
514
  # Validate credentials before attempting operation
514
515
  is_valid, error_message = self.validate_credentials()
@@ -533,7 +534,7 @@ class GitHubAdapter(BaseAdapter[Task]):
533
534
  except httpx.HTTPError:
534
535
  return None
535
536
 
536
- async def update(self, ticket_id: str, updates: dict[str, Any]) -> Optional[Task]:
537
+ async def update(self, ticket_id: str, updates: dict[str, Any]) -> Task | None:
537
538
  """Update a GitHub issue."""
538
539
  # Validate credentials before attempting operation
539
540
  is_valid, error_message = self.validate_credentials()
@@ -685,7 +686,7 @@ class GitHubAdapter(BaseAdapter[Task]):
685
686
  return False
686
687
 
687
688
  async def list(
688
- self, limit: int = 10, offset: int = 0, filters: Optional[dict[str, Any]] = None
689
+ self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
689
690
  ) -> list[Task]:
690
691
  """List GitHub issues with filters."""
691
692
  # Build query parameters
@@ -837,7 +838,7 @@ class GitHubAdapter(BaseAdapter[Task]):
837
838
 
838
839
  async def transition_state(
839
840
  self, ticket_id: str, target_state: TicketState
840
- ) -> Optional[Task]:
841
+ ) -> Task | None:
841
842
  """Transition GitHub issue to a new state."""
842
843
  # Validate transition
843
844
  if not await self.validate_transition(ticket_id, target_state):
@@ -971,7 +972,7 @@ class GitHubAdapter(BaseAdapter[Task]):
971
972
  },
972
973
  )
973
974
 
974
- async def get_milestone(self, milestone_number: int) -> Optional[Epic]:
975
+ async def get_milestone(self, milestone_number: int) -> Epic | None:
975
976
  """Get a GitHub milestone as an Epic."""
976
977
  try:
977
978
  response = await self.client.get(
@@ -1073,9 +1074,9 @@ class GitHubAdapter(BaseAdapter[Task]):
1073
1074
  self,
1074
1075
  ticket_id: str,
1075
1076
  base_branch: str = "main",
1076
- head_branch: Optional[str] = None,
1077
- title: Optional[str] = None,
1078
- body: Optional[str] = None,
1077
+ head_branch: str | None = None,
1078
+ title: str | None = None,
1079
+ body: str | None = None,
1079
1080
  draft: bool = False,
1080
1081
  ) -> dict[str, Any]:
1081
1082
  """Create a pull request linked to an issue.
@@ -1342,7 +1343,7 @@ Fixes #{issue_number}
1342
1343
  response.raise_for_status()
1343
1344
  return response.json()
1344
1345
 
1345
- async def get_current_user(self) -> Optional[dict[str, Any]]:
1346
+ async def get_current_user(self) -> dict[str, Any] | None:
1346
1347
  """Get current authenticated user information."""
1347
1348
  response = await self.client.get("/user")
1348
1349
  response.raise_for_status()
@@ -8,7 +8,7 @@ import builtins
8
8
  import json
9
9
  import logging
10
10
  from pathlib import Path
11
- from typing import Any, Optional, Union
11
+ from typing import Any
12
12
 
13
13
  from ..core.adapter import BaseAdapter
14
14
  from ..core.models import Comment, Epic, SearchQuery, Task, TicketState
@@ -129,7 +129,7 @@ class HybridAdapter(BaseAdapter):
129
129
 
130
130
  def _get_adapter_ticket_id(
131
131
  self, universal_id: str, adapter_name: str
132
- ) -> Optional[str]:
132
+ ) -> str | None:
133
133
  """Get adapter-specific ticket ID from universal ID.
134
134
 
135
135
  Args:
@@ -153,7 +153,7 @@ class HybridAdapter(BaseAdapter):
153
153
 
154
154
  return f"hybrid-{uuid.uuid4().hex[:12]}"
155
155
 
156
- async def create(self, ticket: Union[Task, Epic]) -> Union[Task, Epic]:
156
+ async def create(self, ticket: Task | Epic) -> Task | Epic:
157
157
  """Create ticket in all configured adapters.
158
158
 
159
159
  Args:
@@ -208,7 +208,7 @@ class HybridAdapter(BaseAdapter):
208
208
  return primary_ticket
209
209
 
210
210
  def _add_cross_references(
211
- self, ticket: Union[Task, Epic], results: list[tuple[str, Union[Task, Epic]]]
211
+ self, ticket: Task | Epic, results: list[tuple[str, Task | Epic]]
212
212
  ) -> None:
213
213
  """Add cross-references to ticket description.
214
214
 
@@ -226,7 +226,7 @@ class HybridAdapter(BaseAdapter):
226
226
  else:
227
227
  ticket.description = cross_refs.strip()
228
228
 
229
- async def read(self, ticket_id: str) -> Optional[Union[Task, Epic]]:
229
+ async def read(self, ticket_id: str) -> Task | Epic | None:
230
230
  """Read ticket from primary adapter.
231
231
 
232
232
  Args:
@@ -255,7 +255,7 @@ class HybridAdapter(BaseAdapter):
255
255
 
256
256
  async def update(
257
257
  self, ticket_id: str, updates: dict[str, Any]
258
- ) -> Optional[Union[Task, Epic]]:
258
+ ) -> Task | Epic | None:
259
259
  """Update ticket across all adapters.
260
260
 
261
261
  Args:
@@ -300,7 +300,7 @@ class HybridAdapter(BaseAdapter):
300
300
 
301
301
  return None
302
302
 
303
- def _find_universal_id(self, adapter_ticket_id: str) -> Optional[str]:
303
+ def _find_universal_id(self, adapter_ticket_id: str) -> str | None:
304
304
  """Find universal ID for an adapter-specific ticket ID.
305
305
 
306
306
  Args:
@@ -359,8 +359,8 @@ class HybridAdapter(BaseAdapter):
359
359
  return success_count > 0
360
360
 
361
361
  async def list(
362
- self, limit: int = 10, offset: int = 0, filters: Optional[dict[str, Any]] = None
363
- ) -> list[Union[Task, Epic]]:
362
+ self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
363
+ ) -> list[Task | Epic]:
364
364
  """List tickets from primary adapter.
365
365
 
366
366
  Args:
@@ -375,7 +375,7 @@ class HybridAdapter(BaseAdapter):
375
375
  primary = self.adapters[self.primary_adapter_name]
376
376
  return await primary.list(limit, offset, filters)
377
377
 
378
- async def search(self, query: SearchQuery) -> builtins.list[Union[Task, Epic]]:
378
+ async def search(self, query: SearchQuery) -> builtins.list[Task | Epic]:
379
379
  """Search tickets in primary adapter.
380
380
 
381
381
  Args:
@@ -390,7 +390,7 @@ class HybridAdapter(BaseAdapter):
390
390
 
391
391
  async def transition_state(
392
392
  self, ticket_id: str, target_state: TicketState
393
- ) -> Optional[Union[Task, Epic]]:
393
+ ) -> Task | Epic | None:
394
394
  """Transition ticket state across all adapters.
395
395
 
396
396
  Args: