mcp-ticketer 0.3.5__py3-none-any.whl → 0.12.0__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 +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +263 -14
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1308 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +334 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +326 -109
- mcp_ticketer/adapters/hybrid.py +11 -11
- mcp_ticketer/adapters/jira.py +271 -25
- mcp_ticketer/adapters/linear/adapter.py +693 -39
- mcp_ticketer/adapters/linear/client.py +61 -9
- mcp_ticketer/adapters/linear/mappers.py +9 -3
- mcp_ticketer/adapters/linear/queries.py +9 -7
- mcp_ticketer/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +1 -1
- mcp_ticketer/cli/auggie_configure.py +104 -15
- mcp_ticketer/cli/codex_configure.py +188 -32
- mcp_ticketer/cli/configure.py +37 -48
- mcp_ticketer/cli/diagnostics.py +20 -18
- mcp_ticketer/cli/discover.py +292 -26
- mcp_ticketer/cli/gemini_configure.py +107 -26
- mcp_ticketer/cli/instruction_commands.py +429 -0
- mcp_ticketer/cli/linear_commands.py +105 -22
- mcp_ticketer/cli/main.py +1830 -435
- mcp_ticketer/cli/mcp_configure.py +296 -89
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +412 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/simple_health.py +1 -1
- mcp_ticketer/cli/ticket_commands.py +773 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +67 -62
- mcp_ticketer/core/__init__.py +14 -1
- mcp_ticketer/core/adapter.py +84 -15
- mcp_ticketer/core/config.py +44 -39
- mcp_ticketer/core/env_discovery.py +42 -12
- mcp_ticketer/core/env_loader.py +15 -14
- mcp_ticketer/core/exceptions.py +3 -3
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/mappers.py +11 -11
- mcp_ticketer/core/models.py +50 -20
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/project_config.py +57 -35
- mcp_ticketer/core/registry.py +3 -3
- mcp_ticketer/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +29 -1
- mcp_ticketer/mcp/__main__.py +60 -0
- mcp_ticketer/mcp/server/__init__.py +25 -0
- mcp_ticketer/mcp/server/__main__.py +60 -0
- mcp_ticketer/mcp/{dto.py → server/dto.py} +32 -32
- mcp_ticketer/mcp/{server.py → server/main.py} +127 -74
- mcp_ticketer/mcp/{response_builder.py → server/response_builder.py} +2 -2
- mcp_ticketer/mcp/server/server_sdk.py +93 -0
- mcp_ticketer/mcp/server/tools/__init__.py +47 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +90 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +532 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +154 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +206 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +430 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +5 -4
- mcp_ticketer/queue/manager.py +15 -51
- mcp_ticketer/queue/queue.py +19 -19
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +14 -14
- mcp_ticketer/queue/worker.py +16 -14
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +168 -32
- mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
- mcp_ticketer-0.3.5.dist-info/RECORD +0 -62
- /mcp_ticketer/mcp/{constants.py → server/constants.py} +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/top_level.txt +0 -0
mcp_ticketer/__version__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Version information for mcp-ticketer package."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.
|
|
3
|
+
__version__ = "0.12.0"
|
|
4
4
|
__version_info__ = tuple(int(part) for part in __version__.split("."))
|
|
5
5
|
|
|
6
6
|
# Package metadata
|
|
@@ -27,7 +27,7 @@ __features__ = {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
def get_version():
|
|
30
|
+
def get_version() -> str:
|
|
31
31
|
"""Return the full version string with build metadata if available."""
|
|
32
32
|
version = __version__
|
|
33
33
|
if __build__:
|
|
@@ -37,6 +37,6 @@ def get_version():
|
|
|
37
37
|
return version
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def get_user_agent():
|
|
40
|
+
def get_user_agent() -> str:
|
|
41
41
|
"""Return a user agent string for API requests."""
|
|
42
42
|
return f"{__title__}/{__version__}"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Adapter implementations for various ticket systems."""
|
|
2
2
|
|
|
3
3
|
from .aitrackdown import AITrackdownAdapter
|
|
4
|
+
from .asana import AsanaAdapter
|
|
4
5
|
from .github import GitHubAdapter
|
|
5
6
|
from .hybrid import HybridAdapter
|
|
6
7
|
from .jira import JiraAdapter
|
|
@@ -8,6 +9,7 @@ from .linear import LinearAdapter
|
|
|
8
9
|
|
|
9
10
|
__all__ = [
|
|
10
11
|
"AITrackdownAdapter",
|
|
12
|
+
"AsanaAdapter",
|
|
11
13
|
"LinearAdapter",
|
|
12
14
|
"JiraAdapter",
|
|
13
15
|
"GitHubAdapter",
|
|
@@ -2,14 +2,25 @@
|
|
|
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
|
-
from ..core.models import
|
|
11
|
+
from ..core.models import (
|
|
12
|
+
Attachment,
|
|
13
|
+
Comment,
|
|
14
|
+
Epic,
|
|
15
|
+
Priority,
|
|
16
|
+
SearchQuery,
|
|
17
|
+
Task,
|
|
18
|
+
TicketState,
|
|
19
|
+
)
|
|
11
20
|
from ..core.registry import AdapterRegistry
|
|
12
21
|
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
13
24
|
# Import ai-trackdown-pytools when available
|
|
14
25
|
try:
|
|
15
26
|
from ai_trackdown_pytools import AITrackdown
|
|
@@ -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:
|
|
@@ -269,7 +280,9 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
269
280
|
|
|
270
281
|
return ticket
|
|
271
282
|
|
|
272
|
-
async def create_epic(
|
|
283
|
+
async def create_epic(
|
|
284
|
+
self, title: str, description: str = None, **kwargs: Any
|
|
285
|
+
) -> Epic:
|
|
273
286
|
"""Create a new epic.
|
|
274
287
|
|
|
275
288
|
Args:
|
|
@@ -285,7 +298,11 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
285
298
|
return await self.create(epic)
|
|
286
299
|
|
|
287
300
|
async def create_issue(
|
|
288
|
-
self,
|
|
301
|
+
self,
|
|
302
|
+
title: str,
|
|
303
|
+
parent_epic: str = None,
|
|
304
|
+
description: str = None,
|
|
305
|
+
**kwargs: Any,
|
|
289
306
|
) -> Task:
|
|
290
307
|
"""Create a new issue.
|
|
291
308
|
|
|
@@ -305,7 +322,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
305
322
|
return await self.create(task)
|
|
306
323
|
|
|
307
324
|
async def create_task(
|
|
308
|
-
self, title: str, parent_id: str, description: str = None, **kwargs
|
|
325
|
+
self, title: str, parent_id: str, description: str = None, **kwargs: Any
|
|
309
326
|
) -> Task:
|
|
310
327
|
"""Create a new task under an issue.
|
|
311
328
|
|
|
@@ -324,7 +341,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
324
341
|
)
|
|
325
342
|
return await self.create(task)
|
|
326
343
|
|
|
327
|
-
async def read(self, ticket_id: str) ->
|
|
344
|
+
async def read(self, ticket_id: str) -> Task | Epic | None:
|
|
328
345
|
"""Read a task by ID."""
|
|
329
346
|
if self.tracker:
|
|
330
347
|
ai_ticket = self.tracker.get_ticket(ticket_id)
|
|
@@ -340,8 +357,8 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
340
357
|
return None
|
|
341
358
|
|
|
342
359
|
async def update(
|
|
343
|
-
self, ticket_id: str, updates:
|
|
344
|
-
) ->
|
|
360
|
+
self, ticket_id: str, updates: dict[str, Any] | Task
|
|
361
|
+
) -> Task | Epic | None:
|
|
345
362
|
"""Update a task or epic.
|
|
346
363
|
|
|
347
364
|
Args:
|
|
@@ -403,7 +420,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
403
420
|
return False
|
|
404
421
|
|
|
405
422
|
async def list(
|
|
406
|
-
self, limit: int = 10, offset: int = 0, filters:
|
|
423
|
+
self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
|
|
407
424
|
) -> list[Task]:
|
|
408
425
|
"""List tasks with pagination."""
|
|
409
426
|
tasks = []
|
|
@@ -487,7 +504,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
487
504
|
|
|
488
505
|
async def transition_state(
|
|
489
506
|
self, ticket_id: str, target_state: TicketState
|
|
490
|
-
) ->
|
|
507
|
+
) -> Task | None:
|
|
491
508
|
"""Transition task to new state."""
|
|
492
509
|
# Validate transition
|
|
493
510
|
if not await self.validate_transition(ticket_id, target_state):
|
|
@@ -534,7 +551,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
534
551
|
# Apply limit and offset AFTER filtering
|
|
535
552
|
return comments[offset : offset + limit]
|
|
536
553
|
|
|
537
|
-
async def get_epic(self, epic_id: str) ->
|
|
554
|
+
async def get_epic(self, epic_id: str) -> Epic | None:
|
|
538
555
|
"""Get epic by ID.
|
|
539
556
|
|
|
540
557
|
Args:
|
|
@@ -612,6 +629,238 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
612
629
|
tasks.append(ticket)
|
|
613
630
|
return tasks
|
|
614
631
|
|
|
632
|
+
def _sanitize_filename(self, filename: str) -> str:
|
|
633
|
+
"""Sanitize filename to prevent security issues.
|
|
634
|
+
|
|
635
|
+
Args:
|
|
636
|
+
filename: Original filename
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
Sanitized filename safe for filesystem
|
|
640
|
+
|
|
641
|
+
"""
|
|
642
|
+
# Remove path separators and other dangerous characters
|
|
643
|
+
safe_chars = set(
|
|
644
|
+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._- "
|
|
645
|
+
)
|
|
646
|
+
sanitized = "".join(c if c in safe_chars else "_" for c in filename)
|
|
647
|
+
|
|
648
|
+
# Ensure filename is not empty
|
|
649
|
+
if not sanitized.strip():
|
|
650
|
+
return "unnamed_file"
|
|
651
|
+
|
|
652
|
+
return sanitized.strip()
|
|
653
|
+
|
|
654
|
+
def _guess_content_type(self, file_path: Path) -> str:
|
|
655
|
+
"""Guess MIME type from file extension.
|
|
656
|
+
|
|
657
|
+
Args:
|
|
658
|
+
file_path: Path to file
|
|
659
|
+
|
|
660
|
+
Returns:
|
|
661
|
+
MIME type string
|
|
662
|
+
|
|
663
|
+
"""
|
|
664
|
+
import mimetypes
|
|
665
|
+
|
|
666
|
+
content_type, _ = mimetypes.guess_type(str(file_path))
|
|
667
|
+
return content_type or "application/octet-stream"
|
|
668
|
+
|
|
669
|
+
def _calculate_checksum(self, file_path: Path) -> str:
|
|
670
|
+
"""Calculate SHA256 checksum of file.
|
|
671
|
+
|
|
672
|
+
Args:
|
|
673
|
+
file_path: Path to file
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
Hexadecimal checksum string
|
|
677
|
+
|
|
678
|
+
"""
|
|
679
|
+
import hashlib
|
|
680
|
+
|
|
681
|
+
sha256 = hashlib.sha256()
|
|
682
|
+
with open(file_path, "rb") as f:
|
|
683
|
+
# Read in chunks to handle large files
|
|
684
|
+
for chunk in iter(lambda: f.read(4096), b""):
|
|
685
|
+
sha256.update(chunk)
|
|
686
|
+
|
|
687
|
+
return sha256.hexdigest()
|
|
688
|
+
|
|
689
|
+
async def add_attachment(
|
|
690
|
+
self,
|
|
691
|
+
ticket_id: str,
|
|
692
|
+
file_path: str,
|
|
693
|
+
description: str | None = None,
|
|
694
|
+
) -> Attachment:
|
|
695
|
+
"""Attach a file to a ticket (local filesystem storage).
|
|
696
|
+
|
|
697
|
+
Args:
|
|
698
|
+
ticket_id: Ticket identifier
|
|
699
|
+
file_path: Local file path to attach
|
|
700
|
+
description: Optional attachment description
|
|
701
|
+
|
|
702
|
+
Returns:
|
|
703
|
+
Attachment metadata
|
|
704
|
+
|
|
705
|
+
Raises:
|
|
706
|
+
ValueError: If ticket doesn't exist
|
|
707
|
+
FileNotFoundError: If file doesn't exist
|
|
708
|
+
|
|
709
|
+
"""
|
|
710
|
+
import shutil
|
|
711
|
+
|
|
712
|
+
# Validate ticket exists
|
|
713
|
+
ticket = await self.read(ticket_id)
|
|
714
|
+
if not ticket:
|
|
715
|
+
raise ValueError(f"Ticket {ticket_id} not found")
|
|
716
|
+
|
|
717
|
+
# Validate file exists
|
|
718
|
+
source_path = Path(file_path).resolve()
|
|
719
|
+
if not source_path.exists():
|
|
720
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
721
|
+
|
|
722
|
+
# Check file size (max 100MB for local storage)
|
|
723
|
+
size_mb = source_path.stat().st_size / (1024 * 1024)
|
|
724
|
+
if size_mb > 100:
|
|
725
|
+
raise ValueError(f"File too large: {size_mb:.2f}MB (max: 100MB)")
|
|
726
|
+
|
|
727
|
+
# Create attachments directory for this ticket
|
|
728
|
+
attachments_dir = self.base_path / "attachments" / ticket_id
|
|
729
|
+
attachments_dir.mkdir(parents=True, exist_ok=True)
|
|
730
|
+
|
|
731
|
+
# Generate unique filename with timestamp
|
|
732
|
+
timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
|
|
733
|
+
safe_filename = self._sanitize_filename(source_path.name)
|
|
734
|
+
attachment_id = f"{timestamp}-{safe_filename}"
|
|
735
|
+
dest_path = attachments_dir / attachment_id
|
|
736
|
+
|
|
737
|
+
# Copy file to attachments directory
|
|
738
|
+
shutil.copy2(source_path, dest_path)
|
|
739
|
+
|
|
740
|
+
# Create attachment metadata
|
|
741
|
+
attachment = Attachment(
|
|
742
|
+
id=attachment_id,
|
|
743
|
+
ticket_id=ticket_id,
|
|
744
|
+
filename=source_path.name,
|
|
745
|
+
url=f"file://{dest_path.absolute()}",
|
|
746
|
+
content_type=self._guess_content_type(source_path),
|
|
747
|
+
size_bytes=source_path.stat().st_size,
|
|
748
|
+
created_at=datetime.now(),
|
|
749
|
+
description=description,
|
|
750
|
+
metadata={
|
|
751
|
+
"original_path": str(source_path),
|
|
752
|
+
"storage_path": str(dest_path),
|
|
753
|
+
"checksum": self._calculate_checksum(dest_path),
|
|
754
|
+
},
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
# Save metadata to JSON file
|
|
758
|
+
metadata_file = attachments_dir / f"{attachment_id}.json"
|
|
759
|
+
with open(metadata_file, "w") as f:
|
|
760
|
+
# Convert to dict and handle datetime serialization
|
|
761
|
+
data = attachment.model_dump()
|
|
762
|
+
json.dump(data, f, indent=2, default=str)
|
|
763
|
+
|
|
764
|
+
return attachment
|
|
765
|
+
|
|
766
|
+
async def get_attachments(self, ticket_id: str) -> builtins.list[Attachment]:
|
|
767
|
+
"""Get all attachments for a ticket with path traversal protection.
|
|
768
|
+
|
|
769
|
+
Args:
|
|
770
|
+
ticket_id: Ticket identifier
|
|
771
|
+
|
|
772
|
+
Returns:
|
|
773
|
+
List of attachments (empty if none)
|
|
774
|
+
|
|
775
|
+
"""
|
|
776
|
+
# Resolve and validate attachments directory
|
|
777
|
+
attachments_dir = (self.base_path / "attachments" / ticket_id).resolve()
|
|
778
|
+
|
|
779
|
+
# CRITICAL SECURITY CHECK: Ensure ticket directory is within base attachments
|
|
780
|
+
base_attachments = (self.base_path / "attachments").resolve()
|
|
781
|
+
if not str(attachments_dir).startswith(str(base_attachments)):
|
|
782
|
+
raise ValueError("Invalid ticket_id: path traversal detected")
|
|
783
|
+
|
|
784
|
+
if not attachments_dir.exists():
|
|
785
|
+
return []
|
|
786
|
+
|
|
787
|
+
attachments = []
|
|
788
|
+
for metadata_file in attachments_dir.glob("*.json"):
|
|
789
|
+
try:
|
|
790
|
+
with open(metadata_file) as f:
|
|
791
|
+
data = json.load(f)
|
|
792
|
+
# Convert ISO datetime strings back to datetime objects
|
|
793
|
+
if isinstance(data.get("created_at"), str):
|
|
794
|
+
data["created_at"] = datetime.fromisoformat(
|
|
795
|
+
data["created_at"].replace("Z", "+00:00")
|
|
796
|
+
)
|
|
797
|
+
attachment = Attachment(**data)
|
|
798
|
+
attachments.append(attachment)
|
|
799
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
800
|
+
# Log error but continue processing other attachments
|
|
801
|
+
logger.warning(
|
|
802
|
+
"Failed to load attachment metadata from %s: %s",
|
|
803
|
+
metadata_file,
|
|
804
|
+
e,
|
|
805
|
+
)
|
|
806
|
+
continue
|
|
807
|
+
|
|
808
|
+
# Sort by creation time (newest first)
|
|
809
|
+
return sorted(
|
|
810
|
+
attachments,
|
|
811
|
+
key=lambda a: a.created_at or datetime.min,
|
|
812
|
+
reverse=True,
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
async def delete_attachment(
|
|
816
|
+
self,
|
|
817
|
+
ticket_id: str,
|
|
818
|
+
attachment_id: str,
|
|
819
|
+
) -> bool:
|
|
820
|
+
"""Delete an attachment and its metadata with path traversal protection.
|
|
821
|
+
|
|
822
|
+
Args:
|
|
823
|
+
ticket_id: Ticket identifier
|
|
824
|
+
attachment_id: Attachment identifier
|
|
825
|
+
|
|
826
|
+
Returns:
|
|
827
|
+
True if deleted, False if not found
|
|
828
|
+
|
|
829
|
+
"""
|
|
830
|
+
# Resolve base directory
|
|
831
|
+
attachments_dir = (self.base_path / "attachments" / ticket_id).resolve()
|
|
832
|
+
|
|
833
|
+
# Validate attachments directory exists
|
|
834
|
+
if not attachments_dir.exists():
|
|
835
|
+
return False
|
|
836
|
+
|
|
837
|
+
# Resolve file paths
|
|
838
|
+
attachment_file = (attachments_dir / attachment_id).resolve()
|
|
839
|
+
metadata_file = (attachments_dir / f"{attachment_id}.json").resolve()
|
|
840
|
+
|
|
841
|
+
# CRITICAL SECURITY CHECK: Ensure paths are within attachments_dir
|
|
842
|
+
base_resolved = attachments_dir.resolve()
|
|
843
|
+
if not str(attachment_file).startswith(str(base_resolved)):
|
|
844
|
+
raise ValueError(
|
|
845
|
+
"Invalid attachment path: path traversal detected in attachment_id"
|
|
846
|
+
)
|
|
847
|
+
if not str(metadata_file).startswith(str(base_resolved)):
|
|
848
|
+
raise ValueError(
|
|
849
|
+
"Invalid attachment path: path traversal detected in attachment_id"
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
# Delete files if they exist
|
|
853
|
+
deleted = False
|
|
854
|
+
if attachment_file.exists():
|
|
855
|
+
attachment_file.unlink()
|
|
856
|
+
deleted = True
|
|
857
|
+
|
|
858
|
+
if metadata_file.exists():
|
|
859
|
+
metadata_file.unlink()
|
|
860
|
+
deleted = True
|
|
861
|
+
|
|
862
|
+
return deleted
|
|
863
|
+
|
|
615
864
|
|
|
616
865
|
# Register the adapter
|
|
617
866
|
AdapterRegistry.register("aitrackdown", AITrackdownAdapter)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Asana adapter for mcp-ticketer.
|
|
2
|
+
|
|
3
|
+
This adapter provides comprehensive integration with Asana's REST API,
|
|
4
|
+
supporting ticket management operations including:
|
|
5
|
+
|
|
6
|
+
- CRUD operations for projects and tasks
|
|
7
|
+
- Hierarchical structure (Epic → Issue → Task)
|
|
8
|
+
- State transitions via custom fields
|
|
9
|
+
- User assignment and tag management
|
|
10
|
+
- Comment and attachment support
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .adapter import AsanaAdapter
|
|
14
|
+
|
|
15
|
+
__all__ = ["AsanaAdapter"]
|