mcp-ticketer 0.4.2__py3-none-any.whl → 0.4.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 (48) hide show
  1. mcp_ticketer/__version__.py +1 -1
  2. mcp_ticketer/adapters/aitrackdown.py +254 -11
  3. mcp_ticketer/adapters/github.py +13 -13
  4. mcp_ticketer/adapters/hybrid.py +11 -11
  5. mcp_ticketer/adapters/jira.py +20 -24
  6. mcp_ticketer/cache/memory.py +6 -5
  7. mcp_ticketer/cli/codex_configure.py +2 -2
  8. mcp_ticketer/cli/configure.py +4 -5
  9. mcp_ticketer/cli/diagnostics.py +2 -2
  10. mcp_ticketer/cli/discover.py +4 -5
  11. mcp_ticketer/cli/gemini_configure.py +2 -2
  12. mcp_ticketer/cli/linear_commands.py +6 -7
  13. mcp_ticketer/cli/main.py +341 -250
  14. mcp_ticketer/cli/mcp_configure.py +1 -2
  15. mcp_ticketer/cli/ticket_commands.py +27 -30
  16. mcp_ticketer/cli/utils.py +23 -22
  17. mcp_ticketer/core/__init__.py +2 -1
  18. mcp_ticketer/core/adapter.py +82 -13
  19. mcp_ticketer/core/config.py +27 -29
  20. mcp_ticketer/core/env_discovery.py +10 -10
  21. mcp_ticketer/core/env_loader.py +8 -8
  22. mcp_ticketer/core/http_client.py +16 -16
  23. mcp_ticketer/core/mappers.py +10 -10
  24. mcp_ticketer/core/models.py +50 -20
  25. mcp_ticketer/core/project_config.py +40 -34
  26. mcp_ticketer/core/registry.py +2 -2
  27. mcp_ticketer/mcp/dto.py +32 -32
  28. mcp_ticketer/mcp/response_builder.py +2 -2
  29. mcp_ticketer/mcp/server.py +3 -3
  30. mcp_ticketer/mcp/server_sdk.py +2 -2
  31. mcp_ticketer/mcp/tools/attachment_tools.py +3 -4
  32. mcp_ticketer/mcp/tools/comment_tools.py +2 -2
  33. mcp_ticketer/mcp/tools/hierarchy_tools.py +8 -8
  34. mcp_ticketer/mcp/tools/pr_tools.py +2 -2
  35. mcp_ticketer/mcp/tools/search_tools.py +6 -6
  36. mcp_ticketer/mcp/tools/ticket_tools.py +12 -12
  37. mcp_ticketer/queue/health_monitor.py +4 -4
  38. mcp_ticketer/queue/manager.py +2 -2
  39. mcp_ticketer/queue/queue.py +16 -16
  40. mcp_ticketer/queue/ticket_registry.py +7 -7
  41. mcp_ticketer/queue/worker.py +2 -2
  42. {mcp_ticketer-0.4.2.dist-info → mcp_ticketer-0.4.4.dist-info}/METADATA +61 -2
  43. mcp_ticketer-0.4.4.dist-info/RECORD +73 -0
  44. mcp_ticketer-0.4.2.dist-info/RECORD +0 -73
  45. {mcp_ticketer-0.4.2.dist-info → mcp_ticketer-0.4.4.dist-info}/WHEEL +0 -0
  46. {mcp_ticketer-0.4.2.dist-info → mcp_ticketer-0.4.4.dist-info}/entry_points.txt +0 -0
  47. {mcp_ticketer-0.4.2.dist-info → mcp_ticketer-0.4.4.dist-info}/licenses/LICENSE +0 -0
  48. {mcp_ticketer-0.4.2.dist-info → mcp_ticketer-0.4.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.4.2"
3
+ __version__ = "0.4.4"
4
4
  __version_info__ = tuple(int(part) for part in __version__.split("."))
5
5
 
6
6
  # Package metadata
@@ -2,12 +2,23 @@
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 (
14
+ Attachment,
15
+ Comment,
16
+ Epic,
17
+ Priority,
18
+ SearchQuery,
19
+ Task,
20
+ TicketState,
21
+ )
11
22
  from ..core.registry import AdapterRegistry
12
23
 
13
24
  # Import ai-trackdown-pytools when available
@@ -80,7 +91,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
80
91
  TicketState.CLOSED: "closed",
81
92
  }
82
93
 
83
- def _priority_to_ai(self, priority: Union[Priority, str]) -> str:
94
+ def _priority_to_ai(self, priority: Priority | str) -> str:
84
95
  """Convert universal priority to AI-Trackdown priority."""
85
96
  if isinstance(priority, Priority):
86
97
  return priority.value
@@ -219,7 +230,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
219
230
  "type": "epic",
220
231
  }
221
232
 
222
- def _read_ticket_file(self, ticket_id: str) -> Optional[dict[str, Any]]:
233
+ def _read_ticket_file(self, ticket_id: str) -> dict[str, Any] | None:
223
234
  """Read ticket from file system."""
224
235
  ticket_file = self.tickets_dir / f"{ticket_id}.json"
225
236
  if ticket_file.exists():
@@ -233,7 +244,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
233
244
  with open(ticket_file, "w") as f:
234
245
  json.dump(data, f, indent=2, default=str)
235
246
 
236
- async def create(self, ticket: Union[Task, Epic]) -> Union[Task, Epic]:
247
+ async def create(self, ticket: Task | Epic) -> Task | Epic:
237
248
  """Create a new task."""
238
249
  # Generate ID if not provided
239
250
  if not ticket.id:
@@ -324,7 +335,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
324
335
  )
325
336
  return await self.create(task)
326
337
 
327
- async def read(self, ticket_id: str) -> Optional[Union[Task, Epic]]:
338
+ async def read(self, ticket_id: str) -> Task | Epic | None:
328
339
  """Read a task by ID."""
329
340
  if self.tracker:
330
341
  ai_ticket = self.tracker.get_ticket(ticket_id)
@@ -340,8 +351,8 @@ class AITrackdownAdapter(BaseAdapter[Task]):
340
351
  return None
341
352
 
342
353
  async def update(
343
- self, ticket_id: str, updates: Union[dict[str, Any], Task]
344
- ) -> Optional[Union[Task, Epic]]:
354
+ self, ticket_id: str, updates: dict[str, Any] | Task
355
+ ) -> Task | Epic | None:
345
356
  """Update a task or epic.
346
357
 
347
358
  Args:
@@ -403,7 +414,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
403
414
  return False
404
415
 
405
416
  async def list(
406
- self, limit: int = 10, offset: int = 0, filters: Optional[dict[str, Any]] = None
417
+ self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
407
418
  ) -> list[Task]:
408
419
  """List tasks with pagination."""
409
420
  tasks = []
@@ -487,7 +498,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
487
498
 
488
499
  async def transition_state(
489
500
  self, ticket_id: str, target_state: TicketState
490
- ) -> Optional[Task]:
501
+ ) -> Task | None:
491
502
  """Transition task to new state."""
492
503
  # Validate transition
493
504
  if not await self.validate_transition(ticket_id, target_state):
@@ -534,7 +545,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
534
545
  # Apply limit and offset AFTER filtering
535
546
  return comments[offset : offset + limit]
536
547
 
537
- async def get_epic(self, epic_id: str) -> Optional[Epic]:
548
+ async def get_epic(self, epic_id: str) -> Epic | None:
538
549
  """Get epic by ID.
539
550
 
540
551
  Args:
@@ -612,6 +623,238 @@ class AITrackdownAdapter(BaseAdapter[Task]):
612
623
  tasks.append(ticket)
613
624
  return tasks
614
625
 
626
+ def _sanitize_filename(self, filename: str) -> str:
627
+ """Sanitize filename to prevent security issues.
628
+
629
+ Args:
630
+ filename: Original filename
631
+
632
+ Returns:
633
+ Sanitized filename safe for filesystem
634
+
635
+ """
636
+ # Remove path separators and other dangerous characters
637
+ safe_chars = set(
638
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._- "
639
+ )
640
+ sanitized = "".join(c if c in safe_chars else "_" for c in filename)
641
+
642
+ # Ensure filename is not empty
643
+ if not sanitized.strip():
644
+ return "unnamed_file"
645
+
646
+ return sanitized.strip()
647
+
648
+ def _guess_content_type(self, file_path: Path) -> str:
649
+ """Guess MIME type from file extension.
650
+
651
+ Args:
652
+ file_path: Path to file
653
+
654
+ Returns:
655
+ MIME type string
656
+
657
+ """
658
+ import mimetypes
659
+
660
+ content_type, _ = mimetypes.guess_type(str(file_path))
661
+ return content_type or "application/octet-stream"
662
+
663
+ def _calculate_checksum(self, file_path: Path) -> str:
664
+ """Calculate SHA256 checksum of file.
665
+
666
+ Args:
667
+ file_path: Path to file
668
+
669
+ Returns:
670
+ Hexadecimal checksum string
671
+
672
+ """
673
+ import hashlib
674
+
675
+ sha256 = hashlib.sha256()
676
+ with open(file_path, "rb") as f:
677
+ # Read in chunks to handle large files
678
+ for chunk in iter(lambda: f.read(4096), b""):
679
+ sha256.update(chunk)
680
+
681
+ return sha256.hexdigest()
682
+
683
+ async def add_attachment(
684
+ self,
685
+ ticket_id: str,
686
+ file_path: str,
687
+ description: str | None = None,
688
+ ) -> Attachment:
689
+ """Attach a file to a ticket (local filesystem storage).
690
+
691
+ Args:
692
+ ticket_id: Ticket identifier
693
+ file_path: Local file path to attach
694
+ description: Optional attachment description
695
+
696
+ Returns:
697
+ Attachment metadata
698
+
699
+ Raises:
700
+ ValueError: If ticket doesn't exist
701
+ FileNotFoundError: If file doesn't exist
702
+
703
+ """
704
+ import shutil
705
+
706
+ # Validate ticket exists
707
+ ticket = await self.read(ticket_id)
708
+ if not ticket:
709
+ raise ValueError(f"Ticket {ticket_id} not found")
710
+
711
+ # Validate file exists
712
+ source_path = Path(file_path).resolve()
713
+ if not source_path.exists():
714
+ raise FileNotFoundError(f"File not found: {file_path}")
715
+
716
+ # Check file size (max 100MB for local storage)
717
+ size_mb = source_path.stat().st_size / (1024 * 1024)
718
+ if size_mb > 100:
719
+ raise ValueError(f"File too large: {size_mb:.2f}MB (max: 100MB)")
720
+
721
+ # Create attachments directory for this ticket
722
+ attachments_dir = self.base_path / "attachments" / ticket_id
723
+ attachments_dir.mkdir(parents=True, exist_ok=True)
724
+
725
+ # Generate unique filename with timestamp
726
+ timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
727
+ safe_filename = self._sanitize_filename(source_path.name)
728
+ attachment_id = f"{timestamp}-{safe_filename}"
729
+ dest_path = attachments_dir / attachment_id
730
+
731
+ # Copy file to attachments directory
732
+ shutil.copy2(source_path, dest_path)
733
+
734
+ # Create attachment metadata
735
+ attachment = Attachment(
736
+ id=attachment_id,
737
+ ticket_id=ticket_id,
738
+ filename=source_path.name,
739
+ url=f"file://{dest_path.absolute()}",
740
+ content_type=self._guess_content_type(source_path),
741
+ size_bytes=source_path.stat().st_size,
742
+ created_at=datetime.now(),
743
+ description=description,
744
+ metadata={
745
+ "original_path": str(source_path),
746
+ "storage_path": str(dest_path),
747
+ "checksum": self._calculate_checksum(dest_path),
748
+ },
749
+ )
750
+
751
+ # Save metadata to JSON file
752
+ metadata_file = attachments_dir / f"{attachment_id}.json"
753
+ with open(metadata_file, "w") as f:
754
+ # Convert to dict and handle datetime serialization
755
+ data = attachment.model_dump()
756
+ json.dump(data, f, indent=2, default=str)
757
+
758
+ return attachment
759
+
760
+ async def get_attachments(self, ticket_id: str) -> builtins.list[Attachment]:
761
+ """Get all attachments for a ticket with path traversal protection.
762
+
763
+ Args:
764
+ ticket_id: Ticket identifier
765
+
766
+ Returns:
767
+ List of attachments (empty if none)
768
+
769
+ """
770
+ # Resolve and validate attachments directory
771
+ attachments_dir = (self.base_path / "attachments" / ticket_id).resolve()
772
+
773
+ # CRITICAL SECURITY CHECK: Ensure ticket directory is within base attachments
774
+ base_attachments = (self.base_path / "attachments").resolve()
775
+ if not str(attachments_dir).startswith(str(base_attachments)):
776
+ raise ValueError("Invalid ticket_id: path traversal detected")
777
+
778
+ if not attachments_dir.exists():
779
+ return []
780
+
781
+ attachments = []
782
+ for metadata_file in attachments_dir.glob("*.json"):
783
+ try:
784
+ with open(metadata_file) as f:
785
+ data = json.load(f)
786
+ # Convert ISO datetime strings back to datetime objects
787
+ if isinstance(data.get("created_at"), str):
788
+ data["created_at"] = datetime.fromisoformat(
789
+ data["created_at"].replace("Z", "+00:00")
790
+ )
791
+ attachment = Attachment(**data)
792
+ attachments.append(attachment)
793
+ except (json.JSONDecodeError, ValueError) as e:
794
+ # Log error but continue processing other attachments
795
+ logger.warning(
796
+ "Failed to load attachment metadata from %s: %s",
797
+ metadata_file,
798
+ e,
799
+ )
800
+ continue
801
+
802
+ # Sort by creation time (newest first)
803
+ return sorted(
804
+ attachments,
805
+ key=lambda a: a.created_at or datetime.min,
806
+ reverse=True,
807
+ )
808
+
809
+ async def delete_attachment(
810
+ self,
811
+ ticket_id: str,
812
+ attachment_id: str,
813
+ ) -> bool:
814
+ """Delete an attachment and its metadata with path traversal protection.
815
+
816
+ Args:
817
+ ticket_id: Ticket identifier
818
+ attachment_id: Attachment identifier
819
+
820
+ Returns:
821
+ True if deleted, False if not found
822
+
823
+ """
824
+ # Resolve base directory
825
+ attachments_dir = (self.base_path / "attachments" / ticket_id).resolve()
826
+
827
+ # Validate attachments directory exists
828
+ if not attachments_dir.exists():
829
+ return False
830
+
831
+ # Resolve file paths
832
+ attachment_file = (attachments_dir / attachment_id).resolve()
833
+ metadata_file = (attachments_dir / f"{attachment_id}.json").resolve()
834
+
835
+ # CRITICAL SECURITY CHECK: Ensure paths are within attachments_dir
836
+ base_resolved = attachments_dir.resolve()
837
+ if not str(attachment_file).startswith(str(base_resolved)):
838
+ raise ValueError(
839
+ "Invalid attachment path: path traversal detected in attachment_id"
840
+ )
841
+ if not str(metadata_file).startswith(str(base_resolved)):
842
+ raise ValueError(
843
+ "Invalid attachment path: path traversal detected in attachment_id"
844
+ )
845
+
846
+ # Delete files if they exist
847
+ deleted = False
848
+ if attachment_file.exists():
849
+ attachment_file.unlink()
850
+ deleted = True
851
+
852
+ if metadata_file.exists():
853
+ metadata_file.unlink()
854
+ deleted = True
855
+
856
+ return deleted
857
+
615
858
 
616
859
  # Register the adapter
617
860
  AdapterRegistry.register("aitrackdown", AITrackdownAdapter)
@@ -3,7 +3,7 @@
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
 
@@ -198,8 +198,8 @@ class GitHubAdapter(BaseAdapter[Task]):
198
198
  )
199
199
 
200
200
  # 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
201
+ self._labels_cache: list[dict[str, Any]] | None = None
202
+ self._milestones_cache: list[dict[str, Any]] | None = None
203
203
  self._rate_limit: dict[str, Any] = {}
204
204
 
205
205
  def validate_credentials(self) -> tuple[bool, str]:
@@ -239,7 +239,7 @@ class GitHubAdapter(BaseAdapter[Task]):
239
239
  TicketState.CLOSED: GitHubStateMapping.CLOSED,
240
240
  }
241
241
 
242
- def _get_state_label(self, state: TicketState) -> Optional[str]:
242
+ def _get_state_label(self, state: TicketState) -> str | None:
243
243
  """Get the label name for extended states."""
244
244
  return GitHubStateMapping.STATE_LABELS.get(state)
245
245
 
@@ -508,7 +508,7 @@ class GitHubAdapter(BaseAdapter[Task]):
508
508
 
509
509
  return self._task_from_github_issue(created_issue)
510
510
 
511
- async def read(self, ticket_id: str) -> Optional[Task]:
511
+ async def read(self, ticket_id: str) -> Task | None:
512
512
  """Read a GitHub issue by number."""
513
513
  # Validate credentials before attempting operation
514
514
  is_valid, error_message = self.validate_credentials()
@@ -533,7 +533,7 @@ class GitHubAdapter(BaseAdapter[Task]):
533
533
  except httpx.HTTPError:
534
534
  return None
535
535
 
536
- async def update(self, ticket_id: str, updates: dict[str, Any]) -> Optional[Task]:
536
+ async def update(self, ticket_id: str, updates: dict[str, Any]) -> Task | None:
537
537
  """Update a GitHub issue."""
538
538
  # Validate credentials before attempting operation
539
539
  is_valid, error_message = self.validate_credentials()
@@ -685,7 +685,7 @@ class GitHubAdapter(BaseAdapter[Task]):
685
685
  return False
686
686
 
687
687
  async def list(
688
- self, limit: int = 10, offset: int = 0, filters: Optional[dict[str, Any]] = None
688
+ self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
689
689
  ) -> list[Task]:
690
690
  """List GitHub issues with filters."""
691
691
  # Build query parameters
@@ -837,7 +837,7 @@ class GitHubAdapter(BaseAdapter[Task]):
837
837
 
838
838
  async def transition_state(
839
839
  self, ticket_id: str, target_state: TicketState
840
- ) -> Optional[Task]:
840
+ ) -> Task | None:
841
841
  """Transition GitHub issue to a new state."""
842
842
  # Validate transition
843
843
  if not await self.validate_transition(ticket_id, target_state):
@@ -971,7 +971,7 @@ class GitHubAdapter(BaseAdapter[Task]):
971
971
  },
972
972
  )
973
973
 
974
- async def get_milestone(self, milestone_number: int) -> Optional[Epic]:
974
+ async def get_milestone(self, milestone_number: int) -> Epic | None:
975
975
  """Get a GitHub milestone as an Epic."""
976
976
  try:
977
977
  response = await self.client.get(
@@ -1073,9 +1073,9 @@ class GitHubAdapter(BaseAdapter[Task]):
1073
1073
  self,
1074
1074
  ticket_id: str,
1075
1075
  base_branch: str = "main",
1076
- head_branch: Optional[str] = None,
1077
- title: Optional[str] = None,
1078
- body: Optional[str] = None,
1076
+ head_branch: str | None = None,
1077
+ title: str | None = None,
1078
+ body: str | None = None,
1079
1079
  draft: bool = False,
1080
1080
  ) -> dict[str, Any]:
1081
1081
  """Create a pull request linked to an issue.
@@ -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) -> dict[str, Any] | None:
1346
1346
  """Get current authenticated user information."""
1347
1347
  response = await self.client.get("/user")
1348
1348
  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:
@@ -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, Optional, Union
9
+ from typing import Any, Union
10
10
 
11
11
  import httpx
12
12
  from httpx import AsyncClient, HTTPStatusError, TimeoutException
@@ -19,7 +19,7 @@ from ..core.registry import AdapterRegistry
19
19
  logger = logging.getLogger(__name__)
20
20
 
21
21
 
22
- def parse_jira_datetime(date_str: str) -> Optional[datetime]:
22
+ def parse_jira_datetime(date_str: str) -> datetime | None:
23
23
  """Parse JIRA datetime strings which can be in various formats.
24
24
 
25
25
  JIRA can return dates in formats like:
@@ -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: str | dict[str, Any]) -> str:
51
51
  """Extract plain text from Atlassian Document Format (ADF).
52
52
 
53
53
  Args:
@@ -221,8 +221,8 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
221
221
  self,
222
222
  method: str,
223
223
  endpoint: str,
224
- data: Optional[dict[str, Any]] = None,
225
- params: Optional[dict[str, Any]] = None,
224
+ data: dict[str, Any] | None = None,
225
+ params: dict[str, Any] | None = None,
226
226
  retry_count: int = 0,
227
227
  ) -> dict[str, Any]:
228
228
  """Make HTTP request to JIRA API with retry logic.
@@ -287,7 +287,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
287
287
  return self._priority_cache
288
288
 
289
289
  async def _get_issue_types(
290
- self, project_key: Optional[str] = None
290
+ self, project_key: str | None = None
291
291
  ) -> list[dict[str, Any]]:
292
292
  """Get available issue types for a project."""
293
293
  key = project_key or self.project_key
@@ -380,9 +380,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
380
380
  }
381
381
  return mapping.get(priority, JiraPriority.MEDIUM)
382
382
 
383
- def _map_priority_from_jira(
384
- self, jira_priority: Optional[dict[str, Any]]
385
- ) -> Priority:
383
+ def _map_priority_from_jira(self, jira_priority: dict[str, Any] | None) -> Priority:
386
384
  """Map JIRA priority to universal priority."""
387
385
  if not jira_priority:
388
386
  return Priority.MEDIUM
@@ -432,7 +430,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
432
430
  else:
433
431
  return TicketState.OPEN
434
432
 
435
- def _issue_to_ticket(self, issue: dict[str, Any]) -> Union[Epic, Task]:
433
+ def _issue_to_ticket(self, issue: dict[str, Any]) -> Epic | Task:
436
434
  """Convert JIRA issue to universal ticket model."""
437
435
  fields = issue.get("fields", {})
438
436
 
@@ -507,7 +505,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
507
505
  )
508
506
 
509
507
  def _ticket_to_issue_fields(
510
- self, ticket: Union[Epic, Task], issue_type: Optional[str] = None
508
+ self, ticket: Epic | Task, issue_type: str | None = None
511
509
  ) -> dict[str, Any]:
512
510
  """Convert universal ticket to JIRA issue fields."""
513
511
  # Convert description to ADF format for JIRA Cloud
@@ -556,7 +554,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
556
554
 
557
555
  return fields
558
556
 
559
- async def create(self, ticket: Union[Epic, Task]) -> Union[Epic, Task]:
557
+ async def create(self, ticket: Epic | Task) -> Epic | Task:
560
558
  """Create a new JIRA issue."""
561
559
  # Validate credentials before attempting operation
562
560
  is_valid, error_message = self.validate_credentials()
@@ -576,7 +574,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
576
574
  created_issue = await self._make_request("GET", f"issue/{ticket.id}")
577
575
  return self._issue_to_ticket(created_issue)
578
576
 
579
- async def read(self, ticket_id: str) -> Optional[Union[Epic, Task]]:
577
+ async def read(self, ticket_id: str) -> Epic | Task | None:
580
578
  """Read a JIRA issue by key."""
581
579
  # Validate credentials before attempting operation
582
580
  is_valid, error_message = self.validate_credentials()
@@ -595,7 +593,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
595
593
 
596
594
  async def update(
597
595
  self, ticket_id: str, updates: dict[str, Any]
598
- ) -> Optional[Union[Epic, Task]]:
596
+ ) -> Epic | Task | None:
599
597
  """Update a JIRA issue."""
600
598
  # Validate credentials before attempting operation
601
599
  is_valid, error_message = self.validate_credentials()
@@ -652,8 +650,8 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
652
650
  raise
653
651
 
654
652
  async def list(
655
- self, limit: int = 10, offset: int = 0, filters: Optional[dict[str, Any]] = None
656
- ) -> list[Union[Epic, Task]]:
653
+ self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
654
+ ) -> list[Epic | Task]:
657
655
  """List JIRA issues with pagination."""
658
656
  # Build JQL query
659
657
  jql_parts = []
@@ -692,7 +690,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
692
690
  issues = data.get("issues", [])
693
691
  return [self._issue_to_ticket(issue) for issue in issues]
694
692
 
695
- async def search(self, query: SearchQuery) -> builtins.list[Union[Epic, Task]]:
693
+ async def search(self, query: SearchQuery) -> builtins.list[Epic | Task]:
696
694
  """Search JIRA issues using JQL."""
697
695
  # Build JQL query
698
696
  jql_parts = []
@@ -744,7 +742,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
744
742
 
745
743
  async def transition_state(
746
744
  self, ticket_id: str, target_state: TicketState
747
- ) -> Optional[Union[Epic, Task]]:
745
+ ) -> Epic | Task | None:
748
746
  """Transition JIRA issue to a new state."""
749
747
  # Get available transitions
750
748
  transitions = await self._get_transitions(ticket_id)
@@ -858,9 +856,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
858
856
 
859
857
  return comments
860
858
 
861
- async def get_project_info(
862
- self, project_key: Optional[str] = None
863
- ) -> dict[str, Any]:
859
+ async def get_project_info(self, project_key: str | None = None) -> dict[str, Any]:
864
860
  """Get JIRA project information including workflows and fields."""
865
861
  key = project_key or self.project_key
866
862
  if not key:
@@ -882,7 +878,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
882
878
 
883
879
  async def execute_jql(
884
880
  self, jql: str, limit: int = 50
885
- ) -> builtins.list[Union[Epic, Task]]:
881
+ ) -> builtins.list[Epic | Task]:
886
882
  """Execute a raw JQL query.
887
883
 
888
884
  Args:
@@ -908,7 +904,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
908
904
  return [self._issue_to_ticket(issue) for issue in issues]
909
905
 
910
906
  async def get_sprints(
911
- self, board_id: Optional[int] = None
907
+ self, board_id: int | None = None
912
908
  ) -> builtins.list[dict[str, Any]]:
913
909
  """Get active sprints for a board (requires JIRA Software).
914
910
 
@@ -992,7 +988,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
992
988
  except Exception:
993
989
  return []
994
990
 
995
- async def get_current_user(self) -> Optional[dict[str, Any]]:
991
+ async def get_current_user(self) -> dict[str, Any] | None:
996
992
  """Get current authenticated user information."""
997
993
  try:
998
994
  return await self._make_request("GET", "myself")