mcp-ticketer 0.3.0__py3-none-any.whl → 2.2.9__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.
Files changed (160) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/_version_scm.py +1 -0
  4. mcp_ticketer/adapters/__init__.py +2 -0
  5. mcp_ticketer/adapters/aitrackdown.py +930 -52
  6. mcp_ticketer/adapters/asana/__init__.py +15 -0
  7. mcp_ticketer/adapters/asana/adapter.py +1537 -0
  8. mcp_ticketer/adapters/asana/client.py +292 -0
  9. mcp_ticketer/adapters/asana/mappers.py +348 -0
  10. mcp_ticketer/adapters/asana/types.py +146 -0
  11. mcp_ticketer/adapters/github/__init__.py +26 -0
  12. mcp_ticketer/adapters/github/adapter.py +3229 -0
  13. mcp_ticketer/adapters/github/client.py +335 -0
  14. mcp_ticketer/adapters/github/mappers.py +797 -0
  15. mcp_ticketer/adapters/github/queries.py +692 -0
  16. mcp_ticketer/adapters/github/types.py +460 -0
  17. mcp_ticketer/adapters/hybrid.py +58 -16
  18. mcp_ticketer/adapters/jira/__init__.py +35 -0
  19. mcp_ticketer/adapters/jira/adapter.py +1351 -0
  20. mcp_ticketer/adapters/jira/client.py +271 -0
  21. mcp_ticketer/adapters/jira/mappers.py +246 -0
  22. mcp_ticketer/adapters/jira/queries.py +216 -0
  23. mcp_ticketer/adapters/jira/types.py +304 -0
  24. mcp_ticketer/adapters/linear/__init__.py +1 -1
  25. mcp_ticketer/adapters/linear/adapter.py +3810 -462
  26. mcp_ticketer/adapters/linear/client.py +312 -69
  27. mcp_ticketer/adapters/linear/mappers.py +305 -85
  28. mcp_ticketer/adapters/linear/queries.py +317 -17
  29. mcp_ticketer/adapters/linear/types.py +187 -64
  30. mcp_ticketer/adapters/linear.py +2 -2
  31. mcp_ticketer/analysis/__init__.py +56 -0
  32. mcp_ticketer/analysis/dependency_graph.py +255 -0
  33. mcp_ticketer/analysis/health_assessment.py +304 -0
  34. mcp_ticketer/analysis/orphaned.py +218 -0
  35. mcp_ticketer/analysis/project_status.py +594 -0
  36. mcp_ticketer/analysis/similarity.py +224 -0
  37. mcp_ticketer/analysis/staleness.py +266 -0
  38. mcp_ticketer/automation/__init__.py +11 -0
  39. mcp_ticketer/automation/project_updates.py +378 -0
  40. mcp_ticketer/cache/memory.py +9 -8
  41. mcp_ticketer/cli/adapter_diagnostics.py +91 -54
  42. mcp_ticketer/cli/auggie_configure.py +116 -15
  43. mcp_ticketer/cli/codex_configure.py +274 -82
  44. mcp_ticketer/cli/configure.py +1323 -151
  45. mcp_ticketer/cli/cursor_configure.py +314 -0
  46. mcp_ticketer/cli/diagnostics.py +209 -114
  47. mcp_ticketer/cli/discover.py +297 -26
  48. mcp_ticketer/cli/gemini_configure.py +119 -26
  49. mcp_ticketer/cli/init_command.py +880 -0
  50. mcp_ticketer/cli/install_mcp_server.py +418 -0
  51. mcp_ticketer/cli/instruction_commands.py +435 -0
  52. mcp_ticketer/cli/linear_commands.py +256 -130
  53. mcp_ticketer/cli/main.py +140 -1544
  54. mcp_ticketer/cli/mcp_configure.py +1013 -100
  55. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  56. mcp_ticketer/cli/migrate_config.py +12 -8
  57. mcp_ticketer/cli/platform_commands.py +123 -0
  58. mcp_ticketer/cli/platform_detection.py +477 -0
  59. mcp_ticketer/cli/platform_installer.py +545 -0
  60. mcp_ticketer/cli/project_update_commands.py +350 -0
  61. mcp_ticketer/cli/python_detection.py +126 -0
  62. mcp_ticketer/cli/queue_commands.py +15 -15
  63. mcp_ticketer/cli/setup_command.py +794 -0
  64. mcp_ticketer/cli/simple_health.py +84 -59
  65. mcp_ticketer/cli/ticket_commands.py +1375 -0
  66. mcp_ticketer/cli/update_checker.py +313 -0
  67. mcp_ticketer/cli/utils.py +195 -72
  68. mcp_ticketer/core/__init__.py +64 -1
  69. mcp_ticketer/core/adapter.py +618 -18
  70. mcp_ticketer/core/config.py +77 -68
  71. mcp_ticketer/core/env_discovery.py +75 -16
  72. mcp_ticketer/core/env_loader.py +121 -97
  73. mcp_ticketer/core/exceptions.py +32 -24
  74. mcp_ticketer/core/http_client.py +26 -26
  75. mcp_ticketer/core/instructions.py +405 -0
  76. mcp_ticketer/core/label_manager.py +732 -0
  77. mcp_ticketer/core/mappers.py +42 -30
  78. mcp_ticketer/core/milestone_manager.py +252 -0
  79. mcp_ticketer/core/models.py +566 -19
  80. mcp_ticketer/core/onepassword_secrets.py +379 -0
  81. mcp_ticketer/core/priority_matcher.py +463 -0
  82. mcp_ticketer/core/project_config.py +189 -49
  83. mcp_ticketer/core/project_utils.py +281 -0
  84. mcp_ticketer/core/project_validator.py +376 -0
  85. mcp_ticketer/core/registry.py +3 -3
  86. mcp_ticketer/core/session_state.py +176 -0
  87. mcp_ticketer/core/state_matcher.py +592 -0
  88. mcp_ticketer/core/url_parser.py +425 -0
  89. mcp_ticketer/core/validators.py +69 -0
  90. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  91. mcp_ticketer/mcp/__init__.py +29 -1
  92. mcp_ticketer/mcp/__main__.py +60 -0
  93. mcp_ticketer/mcp/server/__init__.py +25 -0
  94. mcp_ticketer/mcp/server/__main__.py +60 -0
  95. mcp_ticketer/mcp/server/constants.py +58 -0
  96. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  97. mcp_ticketer/mcp/server/dto.py +195 -0
  98. mcp_ticketer/mcp/server/main.py +1343 -0
  99. mcp_ticketer/mcp/server/response_builder.py +206 -0
  100. mcp_ticketer/mcp/server/routing.py +723 -0
  101. mcp_ticketer/mcp/server/server_sdk.py +151 -0
  102. mcp_ticketer/mcp/server/tools/__init__.py +69 -0
  103. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  104. mcp_ticketer/mcp/server/tools/attachment_tools.py +224 -0
  105. mcp_ticketer/mcp/server/tools/bulk_tools.py +330 -0
  106. mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
  107. mcp_ticketer/mcp/server/tools/config_tools.py +1564 -0
  108. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  109. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +942 -0
  110. mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
  111. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  112. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  113. mcp_ticketer/mcp/server/tools/pr_tools.py +150 -0
  114. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  115. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  116. mcp_ticketer/mcp/server/tools/search_tools.py +318 -0
  117. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  118. mcp_ticketer/mcp/server/tools/ticket_tools.py +1413 -0
  119. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
  120. mcp_ticketer/queue/__init__.py +1 -0
  121. mcp_ticketer/queue/health_monitor.py +168 -136
  122. mcp_ticketer/queue/manager.py +78 -63
  123. mcp_ticketer/queue/queue.py +108 -21
  124. mcp_ticketer/queue/run_worker.py +2 -2
  125. mcp_ticketer/queue/ticket_registry.py +213 -155
  126. mcp_ticketer/queue/worker.py +96 -58
  127. mcp_ticketer/utils/__init__.py +5 -0
  128. mcp_ticketer/utils/token_utils.py +246 -0
  129. mcp_ticketer-2.2.9.dist-info/METADATA +1396 -0
  130. mcp_ticketer-2.2.9.dist-info/RECORD +158 -0
  131. mcp_ticketer-2.2.9.dist-info/top_level.txt +2 -0
  132. py_mcp_installer/examples/phase3_demo.py +178 -0
  133. py_mcp_installer/scripts/manage_version.py +54 -0
  134. py_mcp_installer/setup.py +6 -0
  135. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  136. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  137. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  138. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  139. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  140. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  141. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  142. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  143. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  144. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  145. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  146. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  147. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  148. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  149. py_mcp_installer/tests/__init__.py +0 -0
  150. py_mcp_installer/tests/platforms/__init__.py +0 -0
  151. py_mcp_installer/tests/test_platform_detector.py +17 -0
  152. mcp_ticketer/adapters/github.py +0 -1354
  153. mcp_ticketer/adapters/jira.py +0 -1011
  154. mcp_ticketer/mcp/server.py +0 -2030
  155. mcp_ticketer-0.3.0.dist-info/METADATA +0 -414
  156. mcp_ticketer-0.3.0.dist-info/RECORD +0 -59
  157. mcp_ticketer-0.3.0.dist-info/top_level.txt +0 -1
  158. {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
  159. {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
  160. {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/licenses/LICENSE +0 -0
@@ -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, Optional, Union
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: Optional[list[int]] = None,
36
- retry_on_exceptions: Optional[list[type]] = None,
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: Optional[dict[str, str]] = None,
98
- auth: Optional[Union[httpx.Auth, tuple]] = None,
97
+ headers: dict[str, str] | None = None,
98
+ auth: httpx.Auth | tuple | None = None,
99
99
  timeout: float = 30.0,
100
- retry_config: Optional[RetryConfig] = None,
101
- rate_limiter: Optional[RateLimiter] = None,
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: Optional[AsyncClient] = None
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: Optional[httpx.Response] = None
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: Optional[httpx.Response] = None,
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: Union[HTTPMethod, str],
201
+ method: HTTPMethod | str,
202
202
  endpoint: str,
203
- data: Optional[dict[str, Any]] = None,
204
- json: Optional[dict[str, Any]] = None,
205
- params: Optional[dict[str, Any]] = None,
206
- headers: Optional[dict[str, str]] = None,
207
- timeout: Optional[float] = None,
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()