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/core/exceptions.py
CHANGED
|
@@ -35,7 +35,7 @@ class AdapterError(MCPTicketerError):
|
|
|
35
35
|
self.original_error = original_error
|
|
36
36
|
|
|
37
37
|
def __str__(self) -> str:
|
|
38
|
-
"""
|
|
38
|
+
"""Return string representation of the error."""
|
|
39
39
|
base_msg = f"[{self.adapter_name}] {super().__str__()}"
|
|
40
40
|
if self.original_error:
|
|
41
41
|
base_msg += f" (caused by: {self.original_error})"
|
|
@@ -88,7 +88,7 @@ class ValidationError(MCPTicketerError):
|
|
|
88
88
|
self.value = value
|
|
89
89
|
|
|
90
90
|
def __str__(self) -> str:
|
|
91
|
-
"""
|
|
91
|
+
"""Return string representation of the error."""
|
|
92
92
|
base_msg = super().__str__()
|
|
93
93
|
if self.field:
|
|
94
94
|
base_msg += f" (field: {self.field})"
|
|
@@ -126,7 +126,7 @@ class StateTransitionError(MCPTicketerError):
|
|
|
126
126
|
self.to_state = to_state
|
|
127
127
|
|
|
128
128
|
def __str__(self) -> str:
|
|
129
|
-
"""
|
|
129
|
+
"""Return string representation of the error."""
|
|
130
130
|
return f"{super().__str__()} ({self.from_state} -> {self.to_state})"
|
|
131
131
|
|
|
132
132
|
|
mcp_ticketer/core/http_client.py
CHANGED
|
@@ -4,7 +4,7 @@ import asyncio
|
|
|
4
4
|
import logging
|
|
5
5
|
import time
|
|
6
6
|
from enum import Enum
|
|
7
|
-
from typing import Any
|
|
7
|
+
from typing import Any
|
|
8
8
|
|
|
9
9
|
import httpx
|
|
10
10
|
from httpx import AsyncClient, TimeoutException
|
|
@@ -32,8 +32,8 @@ class RetryConfig:
|
|
|
32
32
|
max_delay: float = 60.0,
|
|
33
33
|
exponential_base: float = 2.0,
|
|
34
34
|
jitter: bool = True,
|
|
35
|
-
retry_on_status:
|
|
36
|
-
retry_on_exceptions:
|
|
35
|
+
retry_on_status: list[int] | None = None,
|
|
36
|
+
retry_on_exceptions: list[type] | None = None,
|
|
37
37
|
):
|
|
38
38
|
self.max_retries = max_retries
|
|
39
39
|
self.initial_delay = initial_delay
|
|
@@ -94,11 +94,11 @@ class BaseHTTPClient:
|
|
|
94
94
|
def __init__(
|
|
95
95
|
self,
|
|
96
96
|
base_url: str,
|
|
97
|
-
headers:
|
|
98
|
-
auth:
|
|
97
|
+
headers: dict[str, str] | None = None,
|
|
98
|
+
auth: httpx.Auth | tuple | None = None,
|
|
99
99
|
timeout: float = 30.0,
|
|
100
|
-
retry_config:
|
|
101
|
-
rate_limiter:
|
|
100
|
+
retry_config: RetryConfig | None = None,
|
|
101
|
+
rate_limiter: RateLimiter | None = None,
|
|
102
102
|
verify_ssl: bool = True,
|
|
103
103
|
follow_redirects: bool = True,
|
|
104
104
|
):
|
|
@@ -132,7 +132,7 @@ class BaseHTTPClient:
|
|
|
132
132
|
"errors": 0,
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
self._client:
|
|
135
|
+
self._client: AsyncClient | None = None
|
|
136
136
|
|
|
137
137
|
async def _get_client(self) -> AsyncClient:
|
|
138
138
|
"""Get or create HTTP client instance."""
|
|
@@ -148,7 +148,7 @@ class BaseHTTPClient:
|
|
|
148
148
|
return self._client
|
|
149
149
|
|
|
150
150
|
async def _calculate_delay(
|
|
151
|
-
self, attempt: int, response:
|
|
151
|
+
self, attempt: int, response: httpx.Response | None = None
|
|
152
152
|
) -> float:
|
|
153
153
|
"""Calculate delay for retry attempt."""
|
|
154
154
|
if response and response.status_code == 429:
|
|
@@ -178,7 +178,7 @@ class BaseHTTPClient:
|
|
|
178
178
|
def _should_retry(
|
|
179
179
|
self,
|
|
180
180
|
exception: Exception,
|
|
181
|
-
response:
|
|
181
|
+
response: httpx.Response | None = None,
|
|
182
182
|
attempt: int = 1,
|
|
183
183
|
) -> bool:
|
|
184
184
|
"""Determine if request should be retried."""
|
|
@@ -198,15 +198,15 @@ class BaseHTTPClient:
|
|
|
198
198
|
|
|
199
199
|
async def request(
|
|
200
200
|
self,
|
|
201
|
-
method:
|
|
201
|
+
method: HTTPMethod | str,
|
|
202
202
|
endpoint: str,
|
|
203
|
-
data:
|
|
204
|
-
json:
|
|
205
|
-
params:
|
|
206
|
-
headers:
|
|
207
|
-
timeout:
|
|
203
|
+
data: dict[str, Any] | None = None,
|
|
204
|
+
json: dict[str, Any] | None = None,
|
|
205
|
+
params: dict[str, Any] | None = None,
|
|
206
|
+
headers: dict[str, str] | None = None,
|
|
207
|
+
timeout: float | None = None,
|
|
208
208
|
retry_count: int = 0,
|
|
209
|
-
**kwargs,
|
|
209
|
+
**kwargs: Any,
|
|
210
210
|
) -> httpx.Response:
|
|
211
211
|
"""Make HTTP request with retry and rate limiting.
|
|
212
212
|
|
|
@@ -293,27 +293,27 @@ class BaseHTTPClient:
|
|
|
293
293
|
# No more retries, re-raise the exception
|
|
294
294
|
raise
|
|
295
295
|
|
|
296
|
-
async def get(self, endpoint: str, **kwargs) -> httpx.Response:
|
|
296
|
+
async def get(self, endpoint: str, **kwargs: Any) -> httpx.Response:
|
|
297
297
|
"""Make GET request."""
|
|
298
298
|
return await self.request(HTTPMethod.GET, endpoint, **kwargs)
|
|
299
299
|
|
|
300
|
-
async def post(self, endpoint: str, **kwargs) -> httpx.Response:
|
|
300
|
+
async def post(self, endpoint: str, **kwargs: Any) -> httpx.Response:
|
|
301
301
|
"""Make POST request."""
|
|
302
302
|
return await self.request(HTTPMethod.POST, endpoint, **kwargs)
|
|
303
303
|
|
|
304
|
-
async def put(self, endpoint: str, **kwargs) -> httpx.Response:
|
|
304
|
+
async def put(self, endpoint: str, **kwargs: Any) -> httpx.Response:
|
|
305
305
|
"""Make PUT request."""
|
|
306
306
|
return await self.request(HTTPMethod.PUT, endpoint, **kwargs)
|
|
307
307
|
|
|
308
|
-
async def patch(self, endpoint: str, **kwargs) -> httpx.Response:
|
|
308
|
+
async def patch(self, endpoint: str, **kwargs: Any) -> httpx.Response:
|
|
309
309
|
"""Make PATCH request."""
|
|
310
310
|
return await self.request(HTTPMethod.PATCH, endpoint, **kwargs)
|
|
311
311
|
|
|
312
|
-
async def delete(self, endpoint: str, **kwargs) -> httpx.Response:
|
|
312
|
+
async def delete(self, endpoint: str, **kwargs: Any) -> httpx.Response:
|
|
313
313
|
"""Make DELETE request."""
|
|
314
314
|
return await self.request(HTTPMethod.DELETE, endpoint, **kwargs)
|
|
315
315
|
|
|
316
|
-
async def get_json(self, endpoint: str, **kwargs) -> dict[str, Any]:
|
|
316
|
+
async def get_json(self, endpoint: str, **kwargs: Any) -> dict[str, Any]:
|
|
317
317
|
"""Make GET request and return JSON response."""
|
|
318
318
|
response = await self.get(endpoint, **kwargs)
|
|
319
319
|
|
|
@@ -323,7 +323,7 @@ class BaseHTTPClient:
|
|
|
323
323
|
|
|
324
324
|
return response.json()
|
|
325
325
|
|
|
326
|
-
async def post_json(self, endpoint: str, **kwargs) -> dict[str, Any]:
|
|
326
|
+
async def post_json(self, endpoint: str, **kwargs: Any) -> dict[str, Any]:
|
|
327
327
|
"""Make POST request and return JSON response."""
|
|
328
328
|
response = await self.post(endpoint, **kwargs)
|
|
329
329
|
|
|
@@ -333,7 +333,7 @@ class BaseHTTPClient:
|
|
|
333
333
|
|
|
334
334
|
return response.json()
|
|
335
335
|
|
|
336
|
-
async def put_json(self, endpoint: str, **kwargs) -> dict[str, Any]:
|
|
336
|
+
async def put_json(self, endpoint: str, **kwargs: Any) -> dict[str, Any]:
|
|
337
337
|
"""Make PUT request and return JSON response."""
|
|
338
338
|
response = await self.put(endpoint, **kwargs)
|
|
339
339
|
|
|
@@ -343,7 +343,7 @@ class BaseHTTPClient:
|
|
|
343
343
|
|
|
344
344
|
return response.json()
|
|
345
345
|
|
|
346
|
-
async def patch_json(self, endpoint: str, **kwargs) -> dict[str, Any]:
|
|
346
|
+
async def patch_json(self, endpoint: str, **kwargs: Any) -> dict[str, Any]:
|
|
347
347
|
"""Make PATCH request and return JSON response."""
|
|
348
348
|
response = await self.patch(endpoint, **kwargs)
|
|
349
349
|
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Ticket writing instructions management.
|
|
2
|
+
|
|
3
|
+
This module provides a unified interface for managing ticket writing instructions
|
|
4
|
+
that guide AI agents and users in creating well-structured, consistent tickets.
|
|
5
|
+
|
|
6
|
+
The instructions can be:
|
|
7
|
+
- Default (embedded in the package)
|
|
8
|
+
- Custom (project-specific, stored in .mcp-ticketer/instructions.md)
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
>>> from mcp_ticketer.core.instructions import get_instructions
|
|
12
|
+
>>> instructions = get_instructions()
|
|
13
|
+
>>> print(instructions)
|
|
14
|
+
|
|
15
|
+
>>> # For project-specific management
|
|
16
|
+
>>> from mcp_ticketer.core.instructions import TicketInstructionsManager
|
|
17
|
+
>>> manager = TicketInstructionsManager(project_dir="/path/to/project")
|
|
18
|
+
>>> manager.set_instructions("Custom instructions here...")
|
|
19
|
+
>>> if manager.has_custom_instructions():
|
|
20
|
+
... print("Using custom instructions")
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from .exceptions import MCPTicketerError
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InstructionsError(MCPTicketerError):
|
|
35
|
+
"""Base exception for instructions-related errors.
|
|
36
|
+
|
|
37
|
+
Raised when there are issues loading, saving, or validating ticket
|
|
38
|
+
writing instructions.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class InstructionsNotFoundError(InstructionsError):
|
|
45
|
+
"""Exception raised when instructions file cannot be found.
|
|
46
|
+
|
|
47
|
+
This typically occurs when trying to load custom instructions from a
|
|
48
|
+
file path that doesn't exist, or when the default instructions file
|
|
49
|
+
is missing from the package.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class InstructionsValidationError(InstructionsError):
|
|
56
|
+
"""Exception raised when instructions content is invalid.
|
|
57
|
+
|
|
58
|
+
Raised when instructions content fails validation rules such as:
|
|
59
|
+
- Empty content
|
|
60
|
+
- Content too short (< 100 characters)
|
|
61
|
+
- Invalid encoding
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TicketInstructionsManager:
|
|
68
|
+
"""Manages ticket writing instructions for a project.
|
|
69
|
+
|
|
70
|
+
This class handles loading, saving, and validating ticket writing instructions.
|
|
71
|
+
It supports both default (embedded) instructions and custom project-specific
|
|
72
|
+
instructions.
|
|
73
|
+
|
|
74
|
+
The default instructions are embedded in the package at:
|
|
75
|
+
src/mcp_ticketer/defaults/ticket_instructions.md
|
|
76
|
+
|
|
77
|
+
Custom instructions are stored in the project directory at:
|
|
78
|
+
{project_dir}/.mcp-ticketer/instructions.md
|
|
79
|
+
|
|
80
|
+
Attributes:
|
|
81
|
+
project_dir: Path to the project root directory
|
|
82
|
+
|
|
83
|
+
Example:
|
|
84
|
+
>>> manager = TicketInstructionsManager("/path/to/project")
|
|
85
|
+
>>> instructions = manager.get_instructions()
|
|
86
|
+
>>> print(f"Using {'custom' if manager.has_custom_instructions() else 'default'}")
|
|
87
|
+
|
|
88
|
+
>>> # Set custom instructions
|
|
89
|
+
>>> manager.set_instructions("My custom guidelines...")
|
|
90
|
+
>>> assert manager.has_custom_instructions()
|
|
91
|
+
|
|
92
|
+
>>> # Revert to defaults
|
|
93
|
+
>>> manager.delete_instructions()
|
|
94
|
+
>>> assert not manager.has_custom_instructions()
|
|
95
|
+
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
# Class-level constant for custom instructions filename
|
|
99
|
+
INSTRUCTIONS_FILENAME = "instructions.md"
|
|
100
|
+
DEFAULT_INSTRUCTIONS_FILENAME = "ticket_instructions.md"
|
|
101
|
+
CONFIG_DIR = ".mcp-ticketer"
|
|
102
|
+
|
|
103
|
+
def __init__(self, project_dir: str | Path | None = None):
|
|
104
|
+
"""Initialize the instructions manager.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
project_dir: Path to the project root directory. If None, uses current
|
|
108
|
+
working directory. The project directory is where the .mcp-ticketer
|
|
109
|
+
folder will be created for custom instructions.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
InstructionsError: If project_dir is invalid or inaccessible
|
|
113
|
+
|
|
114
|
+
"""
|
|
115
|
+
if project_dir is None:
|
|
116
|
+
project_dir = Path.cwd()
|
|
117
|
+
else:
|
|
118
|
+
project_dir = Path(project_dir)
|
|
119
|
+
|
|
120
|
+
if not project_dir.exists():
|
|
121
|
+
raise InstructionsError(f"Project directory does not exist: {project_dir}")
|
|
122
|
+
|
|
123
|
+
if not project_dir.is_dir():
|
|
124
|
+
raise InstructionsError(f"Project path is not a directory: {project_dir}")
|
|
125
|
+
|
|
126
|
+
self.project_dir = project_dir.resolve()
|
|
127
|
+
logger.debug(f"Initialized TicketInstructionsManager for: {self.project_dir}")
|
|
128
|
+
|
|
129
|
+
def get_instructions(self) -> str:
|
|
130
|
+
"""Get the current ticket writing instructions.
|
|
131
|
+
|
|
132
|
+
Returns custom instructions if they exist, otherwise returns the default
|
|
133
|
+
embedded instructions.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
The ticket writing instructions as a string
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
InstructionsError: If instructions cannot be loaded
|
|
140
|
+
InstructionsNotFoundError: If default instructions are missing (package error)
|
|
141
|
+
|
|
142
|
+
Example:
|
|
143
|
+
>>> manager = TicketInstructionsManager()
|
|
144
|
+
>>> instructions = manager.get_instructions()
|
|
145
|
+
>>> # Use instructions to guide ticket creation
|
|
146
|
+
>>> print(instructions[:100])
|
|
147
|
+
|
|
148
|
+
"""
|
|
149
|
+
if self.has_custom_instructions():
|
|
150
|
+
logger.debug("Loading custom instructions")
|
|
151
|
+
custom_path = self.get_instructions_path()
|
|
152
|
+
try:
|
|
153
|
+
return custom_path.read_text(encoding="utf-8")
|
|
154
|
+
except Exception as e:
|
|
155
|
+
raise InstructionsError(
|
|
156
|
+
f"Failed to read custom instructions from {custom_path}: {e}"
|
|
157
|
+
) from e
|
|
158
|
+
else:
|
|
159
|
+
logger.debug("Loading default instructions")
|
|
160
|
+
return self.get_default_instructions()
|
|
161
|
+
|
|
162
|
+
def get_default_instructions(self) -> str:
|
|
163
|
+
"""Get the default embedded ticket writing instructions.
|
|
164
|
+
|
|
165
|
+
The default instructions are shipped with the package and provide
|
|
166
|
+
comprehensive guidelines for ticket creation.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
The default ticket writing instructions as a string
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
InstructionsNotFoundError: If the default instructions file is missing
|
|
173
|
+
from the package (indicates package corruption or installation issue)
|
|
174
|
+
|
|
175
|
+
Example:
|
|
176
|
+
>>> manager = TicketInstructionsManager()
|
|
177
|
+
>>> defaults = manager.get_default_instructions()
|
|
178
|
+
>>> # Always returns the same content regardless of project
|
|
179
|
+
|
|
180
|
+
"""
|
|
181
|
+
# Get the path to the defaults directory (sibling to core)
|
|
182
|
+
package_root = Path(__file__).parent.parent
|
|
183
|
+
default_path = package_root / "defaults" / self.DEFAULT_INSTRUCTIONS_FILENAME
|
|
184
|
+
|
|
185
|
+
if not default_path.exists():
|
|
186
|
+
raise InstructionsNotFoundError(
|
|
187
|
+
f"Default instructions file not found at: {default_path}. "
|
|
188
|
+
"This indicates a package installation issue."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
content = default_path.read_text(encoding="utf-8")
|
|
193
|
+
logger.debug(f"Loaded default instructions from {default_path}")
|
|
194
|
+
return content
|
|
195
|
+
except Exception as e:
|
|
196
|
+
raise InstructionsError(
|
|
197
|
+
f"Failed to read default instructions from {default_path}: {e}"
|
|
198
|
+
) from e
|
|
199
|
+
|
|
200
|
+
def set_instructions(self, content: str) -> None:
|
|
201
|
+
r"""Set custom ticket writing instructions for the project.
|
|
202
|
+
|
|
203
|
+
Creates or overwrites the custom instructions file in the project's
|
|
204
|
+
.mcp-ticketer directory. The content is validated before saving.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
content: The custom instructions content to save
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
InstructionsValidationError: If content fails validation
|
|
211
|
+
InstructionsError: If instructions cannot be written to disk
|
|
212
|
+
|
|
213
|
+
Example:
|
|
214
|
+
>>> manager = TicketInstructionsManager()
|
|
215
|
+
>>> custom = "# My Team's Ticket Guidelines\n\n..."
|
|
216
|
+
>>> manager.set_instructions(custom)
|
|
217
|
+
>>> assert manager.has_custom_instructions()
|
|
218
|
+
|
|
219
|
+
"""
|
|
220
|
+
# Validate content
|
|
221
|
+
self._validate_instructions(content)
|
|
222
|
+
|
|
223
|
+
# Ensure config directory exists
|
|
224
|
+
config_dir = self.project_dir / self.CONFIG_DIR
|
|
225
|
+
config_dir.mkdir(exist_ok=True, parents=True)
|
|
226
|
+
|
|
227
|
+
# Write instructions file
|
|
228
|
+
instructions_path = self.get_instructions_path()
|
|
229
|
+
try:
|
|
230
|
+
instructions_path.write_text(content, encoding="utf-8")
|
|
231
|
+
logger.info(f"Saved custom instructions to {instructions_path}")
|
|
232
|
+
except Exception as e:
|
|
233
|
+
raise InstructionsError(
|
|
234
|
+
f"Failed to write instructions to {instructions_path}: {e}"
|
|
235
|
+
) from e
|
|
236
|
+
|
|
237
|
+
def set_instructions_from_file(self, file_path: str | Path) -> None:
|
|
238
|
+
"""Load and set custom instructions from a file.
|
|
239
|
+
|
|
240
|
+
Reads instructions from the specified file and sets them as custom
|
|
241
|
+
instructions for the project. The file content is validated before saving.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
file_path: Path to the file containing instructions
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
InstructionsNotFoundError: If the source file doesn't exist
|
|
248
|
+
InstructionsValidationError: If file content fails validation
|
|
249
|
+
InstructionsError: If instructions cannot be loaded or saved
|
|
250
|
+
|
|
251
|
+
Example:
|
|
252
|
+
>>> manager = TicketInstructionsManager()
|
|
253
|
+
>>> manager.set_instructions_from_file("team_guidelines.md")
|
|
254
|
+
>>> assert manager.has_custom_instructions()
|
|
255
|
+
|
|
256
|
+
"""
|
|
257
|
+
source_path = Path(file_path)
|
|
258
|
+
|
|
259
|
+
if not source_path.exists():
|
|
260
|
+
raise InstructionsNotFoundError(
|
|
261
|
+
f"Instructions file not found: {source_path}"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if not source_path.is_file():
|
|
265
|
+
raise InstructionsError(f"Path is not a file: {source_path}")
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
content = source_path.read_text(encoding="utf-8")
|
|
269
|
+
logger.debug(f"Read instructions from {source_path}")
|
|
270
|
+
except Exception as e:
|
|
271
|
+
raise InstructionsError(
|
|
272
|
+
f"Failed to read instructions from {source_path}: {e}"
|
|
273
|
+
) from e
|
|
274
|
+
|
|
275
|
+
# Use set_instructions to validate and save
|
|
276
|
+
self.set_instructions(content)
|
|
277
|
+
|
|
278
|
+
def delete_instructions(self) -> bool:
|
|
279
|
+
"""Delete custom instructions and revert to defaults.
|
|
280
|
+
|
|
281
|
+
Removes the custom instructions file if it exists. After deletion,
|
|
282
|
+
get_instructions() will return the default instructions.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
True if custom instructions were deleted, False if they didn't exist
|
|
286
|
+
|
|
287
|
+
Raises:
|
|
288
|
+
InstructionsError: If instructions file cannot be deleted
|
|
289
|
+
|
|
290
|
+
Example:
|
|
291
|
+
>>> manager = TicketInstructionsManager()
|
|
292
|
+
>>> manager.set_instructions("Custom instructions")
|
|
293
|
+
>>> assert manager.delete_instructions() # Returns True
|
|
294
|
+
>>> assert not manager.has_custom_instructions()
|
|
295
|
+
>>> assert not manager.delete_instructions() # Returns False
|
|
296
|
+
|
|
297
|
+
"""
|
|
298
|
+
instructions_path = self.get_instructions_path()
|
|
299
|
+
|
|
300
|
+
if not instructions_path.exists():
|
|
301
|
+
logger.debug("No custom instructions to delete")
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
instructions_path.unlink()
|
|
306
|
+
logger.info(f"Deleted custom instructions at {instructions_path}")
|
|
307
|
+
return True
|
|
308
|
+
except Exception as e:
|
|
309
|
+
raise InstructionsError(
|
|
310
|
+
f"Failed to delete instructions at {instructions_path}: {e}"
|
|
311
|
+
) from e
|
|
312
|
+
|
|
313
|
+
def has_custom_instructions(self) -> bool:
|
|
314
|
+
"""Check if custom instructions exist for this project.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
True if custom instructions file exists, False otherwise
|
|
318
|
+
|
|
319
|
+
Example:
|
|
320
|
+
>>> manager = TicketInstructionsManager()
|
|
321
|
+
>>> if manager.has_custom_instructions():
|
|
322
|
+
... print("Using project-specific guidelines")
|
|
323
|
+
... else:
|
|
324
|
+
... print("Using default guidelines")
|
|
325
|
+
|
|
326
|
+
"""
|
|
327
|
+
return self.get_instructions_path().exists()
|
|
328
|
+
|
|
329
|
+
def get_instructions_path(self) -> Path:
|
|
330
|
+
"""Get the path to the custom instructions file.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
Path object pointing to where custom instructions are (or would be) stored
|
|
334
|
+
|
|
335
|
+
Note:
|
|
336
|
+
This returns the path even if the file doesn't exist yet. Use
|
|
337
|
+
has_custom_instructions() to check if the file exists.
|
|
338
|
+
|
|
339
|
+
Example:
|
|
340
|
+
>>> manager = TicketInstructionsManager("/path/to/project")
|
|
341
|
+
>>> path = manager.get_instructions_path()
|
|
342
|
+
>>> print(path) # /path/to/project/.mcp-ticketer/instructions.md
|
|
343
|
+
|
|
344
|
+
"""
|
|
345
|
+
return self.project_dir / self.CONFIG_DIR / self.INSTRUCTIONS_FILENAME
|
|
346
|
+
|
|
347
|
+
def _validate_instructions(self, content: str) -> None:
|
|
348
|
+
"""Validate instructions content.
|
|
349
|
+
|
|
350
|
+
Performs validation checks on instructions content:
|
|
351
|
+
- Not empty
|
|
352
|
+
- Minimum length (100 characters)
|
|
353
|
+
- Contains markdown headers (warning only)
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
content: The instructions content to validate
|
|
357
|
+
|
|
358
|
+
Raises:
|
|
359
|
+
InstructionsValidationError: If content fails validation
|
|
360
|
+
|
|
361
|
+
"""
|
|
362
|
+
if not content or not content.strip():
|
|
363
|
+
raise InstructionsValidationError("Instructions content cannot be empty")
|
|
364
|
+
|
|
365
|
+
if len(content.strip()) < 100:
|
|
366
|
+
raise InstructionsValidationError(
|
|
367
|
+
f"Instructions content too short ({len(content)} characters). "
|
|
368
|
+
"Minimum 100 characters required for meaningful guidelines."
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# Warn if no markdown headers (not an error, just a quality check)
|
|
372
|
+
if not any(line.strip().startswith("#") for line in content.split("\n")):
|
|
373
|
+
logger.warning(
|
|
374
|
+
"Instructions don't contain markdown headers. "
|
|
375
|
+
"Consider using headers for better structure."
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def get_instructions(project_dir: str | Path | None = None) -> str:
|
|
380
|
+
"""Get ticket writing instructions for a project.
|
|
381
|
+
|
|
382
|
+
This is a shorthand for creating a TicketInstructionsManager and calling
|
|
383
|
+
get_instructions(). Useful for simple cases where you just need the content.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
project_dir: Path to the project root directory. If None, uses current
|
|
387
|
+
working directory.
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
The ticket writing instructions (custom if available, otherwise default)
|
|
391
|
+
|
|
392
|
+
Raises:
|
|
393
|
+
InstructionsError: If instructions cannot be loaded
|
|
394
|
+
|
|
395
|
+
Example:
|
|
396
|
+
>>> from mcp_ticketer.core.instructions import get_instructions
|
|
397
|
+
>>> instructions = get_instructions()
|
|
398
|
+
>>> # Use instructions to guide ticket creation
|
|
399
|
+
|
|
400
|
+
>>> # For specific project
|
|
401
|
+
>>> instructions = get_instructions("/path/to/project")
|
|
402
|
+
|
|
403
|
+
"""
|
|
404
|
+
manager = TicketInstructionsManager(project_dir)
|
|
405
|
+
return manager.get_instructions()
|
mcp_ticketer/core/mappers.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import logging
|
|
4
4
|
from abc import ABC, abstractmethod
|
|
5
5
|
from functools import lru_cache
|
|
6
|
-
from typing import Any, Generic,
|
|
6
|
+
from typing import Any, Generic, TypeVar
|
|
7
7
|
|
|
8
8
|
from .models import Priority, TicketState
|
|
9
9
|
|
|
@@ -27,11 +27,11 @@ class BiDirectionalDict(Generic[T, U]):
|
|
|
27
27
|
self._reverse: dict[U, T] = {v: k for k, v in mapping.items()}
|
|
28
28
|
self._cache: dict[str, Any] = {}
|
|
29
29
|
|
|
30
|
-
def get_forward(self, key: T, default:
|
|
30
|
+
def get_forward(self, key: T, default: U | None = None) -> U | None:
|
|
31
31
|
"""Get value by forward key."""
|
|
32
32
|
return self._forward.get(key, default)
|
|
33
33
|
|
|
34
|
-
def get_reverse(self, key: U, default:
|
|
34
|
+
def get_reverse(self, key: U, default: T | None = None) -> T | None:
|
|
35
35
|
"""Get value by reverse key."""
|
|
36
36
|
return self._reverse.get(key, default)
|
|
37
37
|
|
|
@@ -83,7 +83,7 @@ class StateMapper(BaseMapper):
|
|
|
83
83
|
"""Universal state mapping utility."""
|
|
84
84
|
|
|
85
85
|
def __init__(
|
|
86
|
-
self, adapter_type: str, custom_mappings:
|
|
86
|
+
self, adapter_type: str, custom_mappings: dict[str, Any] | None = None
|
|
87
87
|
):
|
|
88
88
|
"""Initialize state mapper.
|
|
89
89
|
|
|
@@ -95,7 +95,7 @@ class StateMapper(BaseMapper):
|
|
|
95
95
|
super().__init__()
|
|
96
96
|
self.adapter_type = adapter_type
|
|
97
97
|
self.custom_mappings = custom_mappings or {}
|
|
98
|
-
self._mapping:
|
|
98
|
+
self._mapping: BiDirectionalDict | None = None
|
|
99
99
|
|
|
100
100
|
@lru_cache(maxsize=1)
|
|
101
101
|
def get_mapping(self) -> BiDirectionalDict:
|
|
@@ -229,7 +229,7 @@ class StateMapper(BaseMapper):
|
|
|
229
229
|
"""Check if adapter uses labels for extended states."""
|
|
230
230
|
return self.adapter_type in ["github", "linear"]
|
|
231
231
|
|
|
232
|
-
def get_state_label(self, state: TicketState) ->
|
|
232
|
+
def get_state_label(self, state: TicketState) -> str | None:
|
|
233
233
|
"""Get label name for extended states that require labels.
|
|
234
234
|
|
|
235
235
|
Args:
|
|
@@ -258,7 +258,7 @@ class PriorityMapper(BaseMapper):
|
|
|
258
258
|
"""Universal priority mapping utility."""
|
|
259
259
|
|
|
260
260
|
def __init__(
|
|
261
|
-
self, adapter_type: str, custom_mappings:
|
|
261
|
+
self, adapter_type: str, custom_mappings: dict[str, Any] | None = None
|
|
262
262
|
):
|
|
263
263
|
"""Initialize priority mapper.
|
|
264
264
|
|
|
@@ -270,7 +270,7 @@ class PriorityMapper(BaseMapper):
|
|
|
270
270
|
super().__init__()
|
|
271
271
|
self.adapter_type = adapter_type
|
|
272
272
|
self.custom_mappings = custom_mappings or {}
|
|
273
|
-
self._mapping:
|
|
273
|
+
self._mapping: BiDirectionalDict | None = None
|
|
274
274
|
|
|
275
275
|
@lru_cache(maxsize=1)
|
|
276
276
|
def get_mapping(self) -> BiDirectionalDict:
|
|
@@ -365,7 +365,7 @@ class PriorityMapper(BaseMapper):
|
|
|
365
365
|
]:
|
|
366
366
|
result = Priority.LOW
|
|
367
367
|
break
|
|
368
|
-
elif isinstance(adapter_priority,
|
|
368
|
+
elif isinstance(adapter_priority, int | float):
|
|
369
369
|
# Handle numeric priorities (Linear-style)
|
|
370
370
|
if adapter_priority <= 1:
|
|
371
371
|
result = Priority.CRITICAL
|
|
@@ -483,7 +483,7 @@ class MapperRegistry:
|
|
|
483
483
|
|
|
484
484
|
@classmethod
|
|
485
485
|
def get_state_mapper(
|
|
486
|
-
cls, adapter_type: str, custom_mappings:
|
|
486
|
+
cls, adapter_type: str, custom_mappings: dict[str, Any] | None = None
|
|
487
487
|
) -> StateMapper:
|
|
488
488
|
"""Get or create state mapper for adapter type.
|
|
489
489
|
|
|
@@ -502,7 +502,7 @@ class MapperRegistry:
|
|
|
502
502
|
|
|
503
503
|
@classmethod
|
|
504
504
|
def get_priority_mapper(
|
|
505
|
-
cls, adapter_type: str, custom_mappings:
|
|
505
|
+
cls, adapter_type: str, custom_mappings: dict[str, Any] | None = None
|
|
506
506
|
) -> PriorityMapper:
|
|
507
507
|
"""Get or create priority mapper for adapter type.
|
|
508
508
|
|