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.
- mcp_ticketer/__init__.py +3 -12
- mcp_ticketer/__version__.py +1 -1
- mcp_ticketer/adapters/aitrackdown.py +243 -11
- mcp_ticketer/adapters/github.py +15 -14
- mcp_ticketer/adapters/hybrid.py +11 -11
- mcp_ticketer/adapters/jira.py +22 -25
- mcp_ticketer/adapters/linear/adapter.py +9 -21
- mcp_ticketer/adapters/linear/client.py +2 -1
- mcp_ticketer/adapters/linear/mappers.py +2 -1
- mcp_ticketer/cache/memory.py +6 -5
- mcp_ticketer/cli/adapter_diagnostics.py +4 -2
- mcp_ticketer/cli/auggie_configure.py +66 -0
- mcp_ticketer/cli/codex_configure.py +70 -2
- mcp_ticketer/cli/configure.py +7 -14
- mcp_ticketer/cli/diagnostics.py +2 -2
- mcp_ticketer/cli/discover.py +6 -11
- mcp_ticketer/cli/gemini_configure.py +68 -2
- mcp_ticketer/cli/linear_commands.py +6 -7
- mcp_ticketer/cli/main.py +341 -203
- mcp_ticketer/cli/mcp_configure.py +61 -2
- mcp_ticketer/cli/ticket_commands.py +27 -30
- mcp_ticketer/cli/utils.py +23 -22
- mcp_ticketer/core/__init__.py +3 -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 +17 -37
- mcp_ticketer/mcp/server_sdk.py +93 -0
- mcp_ticketer/mcp/tools/__init__.py +36 -0
- mcp_ticketer/mcp/tools/attachment_tools.py +179 -0
- mcp_ticketer/mcp/tools/bulk_tools.py +273 -0
- mcp_ticketer/mcp/tools/comment_tools.py +90 -0
- mcp_ticketer/mcp/tools/hierarchy_tools.py +383 -0
- mcp_ticketer/mcp/tools/pr_tools.py +154 -0
- mcp_ticketer/mcp/tools/search_tools.py +206 -0
- mcp_ticketer/mcp/tools/ticket_tools.py +277 -0
- 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.1.dist-info → mcp_ticketer-0.4.3.dist-info}/METADATA +90 -17
- mcp_ticketer-0.4.3.dist-info/RECORD +73 -0
- mcp_ticketer-0.4.1.dist-info/RECORD +0 -64
- {mcp_ticketer-0.4.1.dist-info → mcp_ticketer-0.4.3.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.4.1.dist-info → mcp_ticketer-0.4.3.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.4.1.dist-info → mcp_ticketer-0.4.3.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
5
|
-
|
|
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__",
|
mcp_ticketer/__version__.py
CHANGED
|
@@ -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
|
|
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 (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:
|
|
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) ->
|
|
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:
|
|
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) ->
|
|
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:
|
|
344
|
-
) ->
|
|
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:
|
|
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
|
-
) ->
|
|
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) ->
|
|
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)
|
mcp_ticketer/adapters/github.py
CHANGED
|
@@ -3,13 +3,14 @@
|
|
|
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
|
|
|
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,
|
|
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:
|
|
202
|
-
self._milestones_cache:
|
|
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) ->
|
|
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) ->
|
|
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]) ->
|
|
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:
|
|
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
|
-
) ->
|
|
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) ->
|
|
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:
|
|
1077
|
-
title:
|
|
1078
|
-
body:
|
|
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) ->
|
|
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()
|
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:
|