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.
- mcp_ticketer/__version__.py +1 -1
- mcp_ticketer/adapters/aitrackdown.py +254 -11
- mcp_ticketer/adapters/github.py +13 -13
- mcp_ticketer/adapters/hybrid.py +11 -11
- mcp_ticketer/adapters/jira.py +20 -24
- mcp_ticketer/cache/memory.py +6 -5
- mcp_ticketer/cli/codex_configure.py +2 -2
- mcp_ticketer/cli/configure.py +4 -5
- mcp_ticketer/cli/diagnostics.py +2 -2
- mcp_ticketer/cli/discover.py +4 -5
- mcp_ticketer/cli/gemini_configure.py +2 -2
- mcp_ticketer/cli/linear_commands.py +6 -7
- mcp_ticketer/cli/main.py +341 -250
- mcp_ticketer/cli/mcp_configure.py +1 -2
- mcp_ticketer/cli/ticket_commands.py +27 -30
- mcp_ticketer/cli/utils.py +23 -22
- mcp_ticketer/core/__init__.py +2 -1
- mcp_ticketer/core/adapter.py +82 -13
- mcp_ticketer/core/config.py +27 -29
- mcp_ticketer/core/env_discovery.py +10 -10
- mcp_ticketer/core/env_loader.py +8 -8
- mcp_ticketer/core/http_client.py +16 -16
- mcp_ticketer/core/mappers.py +10 -10
- mcp_ticketer/core/models.py +50 -20
- mcp_ticketer/core/project_config.py +40 -34
- mcp_ticketer/core/registry.py +2 -2
- mcp_ticketer/mcp/dto.py +32 -32
- mcp_ticketer/mcp/response_builder.py +2 -2
- mcp_ticketer/mcp/server.py +3 -3
- mcp_ticketer/mcp/server_sdk.py +2 -2
- mcp_ticketer/mcp/tools/attachment_tools.py +3 -4
- mcp_ticketer/mcp/tools/comment_tools.py +2 -2
- mcp_ticketer/mcp/tools/hierarchy_tools.py +8 -8
- mcp_ticketer/mcp/tools/pr_tools.py +2 -2
- mcp_ticketer/mcp/tools/search_tools.py +6 -6
- mcp_ticketer/mcp/tools/ticket_tools.py +12 -12
- mcp_ticketer/queue/health_monitor.py +4 -4
- mcp_ticketer/queue/manager.py +2 -2
- mcp_ticketer/queue/queue.py +16 -16
- mcp_ticketer/queue/ticket_registry.py +7 -7
- mcp_ticketer/queue/worker.py +2 -2
- {mcp_ticketer-0.4.2.dist-info → mcp_ticketer-0.4.4.dist-info}/METADATA +61 -2
- mcp_ticketer-0.4.4.dist-info/RECORD +73 -0
- mcp_ticketer-0.4.2.dist-info/RECORD +0 -73
- {mcp_ticketer-0.4.2.dist-info → mcp_ticketer-0.4.4.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.4.2.dist-info → mcp_ticketer-0.4.4.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.4.2.dist-info → mcp_ticketer-0.4.4.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.4.2.dist-info → mcp_ticketer-0.4.4.dist-info}/top_level.txt +0 -0
mcp_ticketer/__version__.py
CHANGED
|
@@ -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
|
|
8
|
+
from typing import Any
|
|
8
9
|
|
|
9
10
|
from ..core.adapter import BaseAdapter
|
|
10
|
-
|
|
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:
|
|
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) ->
|
|
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:
|
|
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) ->
|
|
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:
|
|
344
|
-
) ->
|
|
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:
|
|
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
|
-
) ->
|
|
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) ->
|
|
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)
|
mcp_ticketer/adapters/github.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import builtins
|
|
4
4
|
import re
|
|
5
5
|
from datetime import datetime
|
|
6
|
-
from typing import Any
|
|
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:
|
|
202
|
-
self._milestones_cache:
|
|
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) ->
|
|
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) ->
|
|
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]) ->
|
|
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:
|
|
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
|
-
) ->
|
|
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) ->
|
|
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:
|
|
1077
|
-
title:
|
|
1078
|
-
body:
|
|
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) ->
|
|
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()
|
mcp_ticketer/adapters/hybrid.py
CHANGED
|
@@ -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
|
|
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
|
-
) ->
|
|
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:
|
|
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:
|
|
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) ->
|
|
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
|
-
) ->
|
|
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) ->
|
|
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:
|
|
363
|
-
) -> list[
|
|
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[
|
|
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
|
-
) ->
|
|
393
|
+
) -> Task | Epic | None:
|
|
394
394
|
"""Transition ticket state across all adapters.
|
|
395
395
|
|
|
396
396
|
Args:
|
mcp_ticketer/adapters/jira.py
CHANGED
|
@@ -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,
|
|
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) ->
|
|
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:
|
|
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:
|
|
225
|
-
params:
|
|
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:
|
|
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]) ->
|
|
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:
|
|
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:
|
|
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) ->
|
|
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
|
-
) ->
|
|
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:
|
|
656
|
-
) -> list[
|
|
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[
|
|
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
|
-
) ->
|
|
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[
|
|
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:
|
|
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) ->
|
|
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")
|