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.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/_version_scm.py +1 -0
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +930 -52
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1537 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +348 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github/__init__.py +26 -0
- mcp_ticketer/adapters/github/adapter.py +3229 -0
- mcp_ticketer/adapters/github/client.py +335 -0
- mcp_ticketer/adapters/github/mappers.py +797 -0
- mcp_ticketer/adapters/github/queries.py +692 -0
- mcp_ticketer/adapters/github/types.py +460 -0
- mcp_ticketer/adapters/hybrid.py +58 -16
- mcp_ticketer/adapters/jira/__init__.py +35 -0
- mcp_ticketer/adapters/jira/adapter.py +1351 -0
- mcp_ticketer/adapters/jira/client.py +271 -0
- mcp_ticketer/adapters/jira/mappers.py +246 -0
- mcp_ticketer/adapters/jira/queries.py +216 -0
- mcp_ticketer/adapters/jira/types.py +304 -0
- mcp_ticketer/adapters/linear/__init__.py +1 -1
- mcp_ticketer/adapters/linear/adapter.py +3810 -462
- mcp_ticketer/adapters/linear/client.py +312 -69
- mcp_ticketer/adapters/linear/mappers.py +305 -85
- mcp_ticketer/adapters/linear/queries.py +317 -17
- mcp_ticketer/adapters/linear/types.py +187 -64
- mcp_ticketer/adapters/linear.py +2 -2
- mcp_ticketer/analysis/__init__.py +56 -0
- mcp_ticketer/analysis/dependency_graph.py +255 -0
- mcp_ticketer/analysis/health_assessment.py +304 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/project_status.py +594 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/automation/__init__.py +11 -0
- mcp_ticketer/automation/project_updates.py +378 -0
- mcp_ticketer/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +91 -54
- mcp_ticketer/cli/auggie_configure.py +116 -15
- mcp_ticketer/cli/codex_configure.py +274 -82
- mcp_ticketer/cli/configure.py +1323 -151
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +209 -114
- mcp_ticketer/cli/discover.py +297 -26
- mcp_ticketer/cli/gemini_configure.py +119 -26
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +256 -130
- mcp_ticketer/cli/main.py +140 -1544
- mcp_ticketer/cli/mcp_configure.py +1013 -100
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +545 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +794 -0
- mcp_ticketer/cli/simple_health.py +84 -59
- mcp_ticketer/cli/ticket_commands.py +1375 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +195 -72
- mcp_ticketer/core/__init__.py +64 -1
- mcp_ticketer/core/adapter.py +618 -18
- mcp_ticketer/core/config.py +77 -68
- mcp_ticketer/core/env_discovery.py +75 -16
- mcp_ticketer/core/env_loader.py +121 -97
- mcp_ticketer/core/exceptions.py +32 -24
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +42 -30
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +566 -19
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +189 -49
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/registry.py +3 -3
- mcp_ticketer/core/session_state.py +176 -0
- mcp_ticketer/core/state_matcher.py +592 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- 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/server/constants.py +58 -0
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/dto.py +195 -0
- mcp_ticketer/mcp/server/main.py +1343 -0
- mcp_ticketer/mcp/server/response_builder.py +206 -0
- mcp_ticketer/mcp/server/routing.py +723 -0
- mcp_ticketer/mcp/server/server_sdk.py +151 -0
- mcp_ticketer/mcp/server/tools/__init__.py +69 -0
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +224 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +330 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +1564 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +150 -0
- mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
- mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +318 -0
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1413 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +168 -136
- mcp_ticketer/queue/manager.py +78 -63
- mcp_ticketer/queue/queue.py +108 -21
- mcp_ticketer/queue/run_worker.py +2 -2
- mcp_ticketer/queue/ticket_registry.py +213 -155
- mcp_ticketer/queue/worker.py +96 -58
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.2.9.dist-info/METADATA +1396 -0
- mcp_ticketer-2.2.9.dist-info/RECORD +158 -0
- mcp_ticketer-2.2.9.dist-info/top_level.txt +2 -0
- py_mcp_installer/examples/phase3_demo.py +178 -0
- py_mcp_installer/scripts/manage_version.py +54 -0
- py_mcp_installer/setup.py +6 -0
- py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
- py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
- py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
- py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
- py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
- py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
- py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
- py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
- py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
- py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
- py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
- py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
- py_mcp_installer/src/py_mcp_installer/types.py +222 -0
- py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
- py_mcp_installer/tests/__init__.py +0 -0
- py_mcp_installer/tests/platforms/__init__.py +0 -0
- py_mcp_installer/tests/test_platform_detector.py +17 -0
- mcp_ticketer/adapters/github.py +0 -1354
- mcp_ticketer/adapters/jira.py +0 -1011
- mcp_ticketer/mcp/server.py +0 -2030
- mcp_ticketer-0.3.0.dist-info/METADATA +0 -414
- mcp_ticketer-0.3.0.dist-info/RECORD +0 -59
- mcp_ticketer-0.3.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Automatic project update posting on ticket transitions.
|
|
2
|
+
|
|
3
|
+
This module implements automatic project status updates that trigger when
|
|
4
|
+
tickets are transitioned, generating real-time epic/project status summaries.
|
|
5
|
+
|
|
6
|
+
Design Decision: Reuse StatusAnalyzer from 1M-316
|
|
7
|
+
------------------------------------------------
|
|
8
|
+
The project_status tool (1M-316) already provides comprehensive analysis via
|
|
9
|
+
StatusAnalyzer. This module focuses on:
|
|
10
|
+
1. Hooking into ticket transitions
|
|
11
|
+
2. Formatting analysis as concise updates
|
|
12
|
+
3. Posting to Linear automatically
|
|
13
|
+
|
|
14
|
+
Related Tickets:
|
|
15
|
+
- 1M-315: Automatic project update posting
|
|
16
|
+
- 1M-316: project_status tool (provides StatusAnalyzer)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from ..analysis.project_status import StatusAnalyzer
|
|
24
|
+
from ..core.adapter import BaseAdapter
|
|
25
|
+
from ..core.models import ProjectUpdateHealth, Task
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AutoProjectUpdateManager:
|
|
31
|
+
"""Manager for automatic project status updates on ticket transitions.
|
|
32
|
+
|
|
33
|
+
This class hooks into ticket transitions and automatically generates
|
|
34
|
+
project status updates using the StatusAnalyzer from 1M-316.
|
|
35
|
+
|
|
36
|
+
Design Principles:
|
|
37
|
+
- Fail gracefully: Update failures don't block ticket transitions
|
|
38
|
+
- Reuse existing analysis: Leverage StatusAnalyzer for consistency
|
|
39
|
+
- Concise summaries: Focus on key information for readability
|
|
40
|
+
- Health tracking: Include health status in all updates
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
config: Project configuration dictionary
|
|
44
|
+
adapter: Ticket adapter instance
|
|
45
|
+
analyzer: StatusAnalyzer for project analysis
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, config: dict[str, Any], adapter: BaseAdapter):
|
|
50
|
+
"""Initialize the auto project update manager.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
config: Project configuration dictionary
|
|
54
|
+
adapter: Ticket adapter instance
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
self.config = config
|
|
58
|
+
self.adapter = adapter
|
|
59
|
+
self.analyzer = StatusAnalyzer()
|
|
60
|
+
|
|
61
|
+
def is_enabled(self) -> bool:
|
|
62
|
+
"""Check if automatic project updates are enabled.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
True if auto_project_updates.enabled is True in config
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
auto_updates_config = self.config.get("auto_project_updates", {})
|
|
69
|
+
return auto_updates_config.get("enabled", False)
|
|
70
|
+
|
|
71
|
+
def get_update_frequency(self) -> str:
|
|
72
|
+
"""Get configured update frequency.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Update frequency: "on_transition", "on_completion", or "daily"
|
|
76
|
+
Default: "on_transition"
|
|
77
|
+
|
|
78
|
+
"""
|
|
79
|
+
auto_updates_config = self.config.get("auto_project_updates", {})
|
|
80
|
+
return auto_updates_config.get("update_frequency", "on_transition")
|
|
81
|
+
|
|
82
|
+
def get_health_tracking_enabled(self) -> bool:
|
|
83
|
+
"""Check if health tracking is enabled.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
True if auto_project_updates.health_tracking is True
|
|
87
|
+
Default: True
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
auto_updates_config = self.config.get("auto_project_updates", {})
|
|
91
|
+
return auto_updates_config.get("health_tracking", True)
|
|
92
|
+
|
|
93
|
+
async def create_transition_update(
|
|
94
|
+
self,
|
|
95
|
+
ticket_id: str,
|
|
96
|
+
ticket_title: str,
|
|
97
|
+
old_state: str,
|
|
98
|
+
new_state: str,
|
|
99
|
+
parent_epic: str,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
"""Create and post a project update for a ticket transition.
|
|
102
|
+
|
|
103
|
+
This is the main entry point called from ticket_transition.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
ticket_id: ID of the transitioned ticket
|
|
107
|
+
ticket_title: Title of the transitioned ticket
|
|
108
|
+
old_state: Previous state of the ticket
|
|
109
|
+
new_state: New state of the ticket
|
|
110
|
+
parent_epic: ID of the parent epic/project
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Dictionary containing:
|
|
114
|
+
- status: "completed" or "error"
|
|
115
|
+
- update_id: ID of created update (if successful)
|
|
116
|
+
- error: Error message (if failed)
|
|
117
|
+
|
|
118
|
+
Error Handling:
|
|
119
|
+
- Failures don't propagate to caller (ticket transition continues)
|
|
120
|
+
- Errors are logged for debugging
|
|
121
|
+
- Returns error status for observability
|
|
122
|
+
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
# Check if adapter supports project updates
|
|
126
|
+
if not hasattr(self.adapter, "create_project_update"):
|
|
127
|
+
logger.debug(
|
|
128
|
+
f"Adapter '{self.adapter.adapter_type}' does not support "
|
|
129
|
+
f"project updates, skipping auto-update"
|
|
130
|
+
)
|
|
131
|
+
return {
|
|
132
|
+
"status": "skipped",
|
|
133
|
+
"reason": "adapter_unsupported",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# Fetch epic/project data
|
|
137
|
+
epic_data = await self._fetch_epic_data(parent_epic)
|
|
138
|
+
if not epic_data:
|
|
139
|
+
logger.warning(
|
|
140
|
+
f"Could not fetch epic data for {parent_epic}, "
|
|
141
|
+
f"skipping auto-update"
|
|
142
|
+
)
|
|
143
|
+
return {
|
|
144
|
+
"status": "error",
|
|
145
|
+
"error": f"Epic {parent_epic} not found",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# Fetch all tickets in the epic
|
|
149
|
+
tickets = await self._fetch_epic_tickets(parent_epic)
|
|
150
|
+
if not tickets:
|
|
151
|
+
logger.debug(
|
|
152
|
+
f"No tickets found for epic {parent_epic}, "
|
|
153
|
+
f"creating minimal update"
|
|
154
|
+
)
|
|
155
|
+
# Still create update with just the transition info
|
|
156
|
+
tickets = []
|
|
157
|
+
|
|
158
|
+
# Perform analysis using StatusAnalyzer
|
|
159
|
+
project_name = epic_data.get("name", parent_epic)
|
|
160
|
+
analysis = self.analyzer.analyze(parent_epic, project_name, tickets)
|
|
161
|
+
|
|
162
|
+
# Format as markdown summary
|
|
163
|
+
summary = self._format_markdown_summary(
|
|
164
|
+
analysis=analysis,
|
|
165
|
+
ticket_id=ticket_id,
|
|
166
|
+
ticket_title=ticket_title,
|
|
167
|
+
old_state=old_state,
|
|
168
|
+
new_state=new_state,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Determine health status
|
|
172
|
+
health = None
|
|
173
|
+
if self.get_health_tracking_enabled():
|
|
174
|
+
health_value = analysis.health
|
|
175
|
+
# Map health string to ProjectUpdateHealth enum
|
|
176
|
+
if health_value:
|
|
177
|
+
try:
|
|
178
|
+
health = ProjectUpdateHealth(health_value.lower())
|
|
179
|
+
except ValueError:
|
|
180
|
+
logger.warning(
|
|
181
|
+
f"Invalid health value '{health_value}', "
|
|
182
|
+
f"defaulting to None"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Post update using project_update_create via adapter
|
|
186
|
+
update = await self.adapter.create_project_update(
|
|
187
|
+
project_id=parent_epic,
|
|
188
|
+
body=summary,
|
|
189
|
+
health=health,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
logger.info(
|
|
193
|
+
f"Created automatic project update for {parent_epic} "
|
|
194
|
+
f"after {ticket_id} transition"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"status": "completed",
|
|
199
|
+
"update_id": update.id if hasattr(update, "id") else None,
|
|
200
|
+
"project_id": parent_epic,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
except Exception as e:
|
|
204
|
+
# Log error but don't propagate (transition should still succeed)
|
|
205
|
+
logger.error(
|
|
206
|
+
f"Failed to create automatic project update: {e}",
|
|
207
|
+
exc_info=True,
|
|
208
|
+
)
|
|
209
|
+
return {
|
|
210
|
+
"status": "error",
|
|
211
|
+
"error": str(e),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async def _fetch_epic_data(self, epic_id: str) -> dict[str, Any] | None:
|
|
215
|
+
"""Fetch epic/project data from adapter.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
epic_id: Epic/project ID
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Epic data dictionary or None if not found
|
|
222
|
+
|
|
223
|
+
"""
|
|
224
|
+
try:
|
|
225
|
+
if hasattr(self.adapter, "get_epic"):
|
|
226
|
+
epic = await self.adapter.get_epic(epic_id)
|
|
227
|
+
if epic:
|
|
228
|
+
# Convert to dict for processing
|
|
229
|
+
if hasattr(epic, "model_dump"):
|
|
230
|
+
return epic.model_dump()
|
|
231
|
+
elif hasattr(epic, "dict"):
|
|
232
|
+
return epic.dict()
|
|
233
|
+
else:
|
|
234
|
+
return {"id": epic_id, "name": str(epic)}
|
|
235
|
+
return None
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.debug(f"Error fetching epic {epic_id}: {e}")
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
async def _fetch_epic_tickets(self, epic_id: str) -> list[Task]:
|
|
241
|
+
"""Fetch all tickets in an epic.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
epic_id: Epic/project ID
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
List of Task objects in the epic
|
|
248
|
+
|
|
249
|
+
"""
|
|
250
|
+
try:
|
|
251
|
+
if hasattr(self.adapter, "list_issues_by_epic"):
|
|
252
|
+
tickets = await self.adapter.list_issues_by_epic(epic_id)
|
|
253
|
+
return tickets
|
|
254
|
+
elif hasattr(self.adapter, "list"):
|
|
255
|
+
# Fallback to generic list with parent filter
|
|
256
|
+
tickets = await self.adapter.list(
|
|
257
|
+
limit=100,
|
|
258
|
+
offset=0,
|
|
259
|
+
filters={"parent_epic": epic_id},
|
|
260
|
+
)
|
|
261
|
+
return tickets
|
|
262
|
+
return []
|
|
263
|
+
except Exception as e:
|
|
264
|
+
logger.debug(f"Error fetching tickets for epic {epic_id}: {e}")
|
|
265
|
+
return []
|
|
266
|
+
|
|
267
|
+
def _format_markdown_summary(
|
|
268
|
+
self,
|
|
269
|
+
analysis: Any,
|
|
270
|
+
ticket_id: str,
|
|
271
|
+
ticket_title: str,
|
|
272
|
+
old_state: str,
|
|
273
|
+
new_state: str,
|
|
274
|
+
) -> str:
|
|
275
|
+
"""Format analysis as markdown summary.
|
|
276
|
+
|
|
277
|
+
This creates a concise, readable summary optimized for Linear's
|
|
278
|
+
project update interface.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
analysis: ProjectStatusResult from StatusAnalyzer
|
|
282
|
+
ticket_id: ID of transitioned ticket
|
|
283
|
+
ticket_title: Title of transitioned ticket
|
|
284
|
+
old_state: Previous state
|
|
285
|
+
new_state: New state
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Formatted markdown summary
|
|
289
|
+
|
|
290
|
+
"""
|
|
291
|
+
# Header: Transition trigger
|
|
292
|
+
lines = [
|
|
293
|
+
"## Progress Update (Automated)",
|
|
294
|
+
"",
|
|
295
|
+
f'**Ticket Transitioned**: {ticket_id} "{ticket_title}" '
|
|
296
|
+
f"→ **{new_state.upper()}**",
|
|
297
|
+
"",
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
# Epic status summary
|
|
301
|
+
summary = analysis.summary
|
|
302
|
+
total = summary.get("total", 0)
|
|
303
|
+
done_count = summary.get("done", 0) + summary.get("closed", 0)
|
|
304
|
+
in_progress = summary.get("in_progress", 0)
|
|
305
|
+
blocked = summary.get("blocked", 0)
|
|
306
|
+
|
|
307
|
+
completion_pct = int(analysis.health_metrics.completion_rate * 100)
|
|
308
|
+
|
|
309
|
+
lines.extend(
|
|
310
|
+
[
|
|
311
|
+
f"**Epic Status** ({analysis.project_name}):",
|
|
312
|
+
f"- Completed: {done_count}/{total} tickets ({completion_pct}%)",
|
|
313
|
+
f"- In Progress: {in_progress} ticket{'s' if in_progress != 1 else ''}",
|
|
314
|
+
f"- Blocked: {blocked} ticket{'s' if blocked != 1 else ''}",
|
|
315
|
+
"",
|
|
316
|
+
]
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Recent completions (if any)
|
|
320
|
+
if done_count > 0:
|
|
321
|
+
lines.append("**Recent Completions**:")
|
|
322
|
+
# Show up to 3 most recent completions
|
|
323
|
+
completed_tickets = [
|
|
324
|
+
rec for rec in analysis.recommended_next if hasattr(rec, "ticket_id")
|
|
325
|
+
][:3]
|
|
326
|
+
if completed_tickets:
|
|
327
|
+
for ticket in completed_tickets:
|
|
328
|
+
lines.append(f"- {ticket.ticket_id}: {ticket.title}")
|
|
329
|
+
else:
|
|
330
|
+
lines.append("- (Details not available)")
|
|
331
|
+
lines.append("")
|
|
332
|
+
|
|
333
|
+
# Next up: Recommendations
|
|
334
|
+
if analysis.recommended_next:
|
|
335
|
+
lines.append("**Next Up**:")
|
|
336
|
+
for rec in analysis.recommended_next[:3]:
|
|
337
|
+
priority_label = rec.priority.upper()
|
|
338
|
+
lines.append(
|
|
339
|
+
f"- {rec.ticket_id}: {rec.title} " f"(Priority: {priority_label})"
|
|
340
|
+
)
|
|
341
|
+
lines.append("")
|
|
342
|
+
|
|
343
|
+
# Health status
|
|
344
|
+
if self.get_health_tracking_enabled():
|
|
345
|
+
health_emoji = {
|
|
346
|
+
"on_track": "✅",
|
|
347
|
+
"at_risk": "⚠️",
|
|
348
|
+
"off_track": "🚨",
|
|
349
|
+
}
|
|
350
|
+
emoji = health_emoji.get(analysis.health, "ℹ️")
|
|
351
|
+
lines.append(
|
|
352
|
+
f"**Health**: {emoji} {analysis.health.replace('_', ' ').title()}"
|
|
353
|
+
)
|
|
354
|
+
lines.append("")
|
|
355
|
+
|
|
356
|
+
# Blockers (if any)
|
|
357
|
+
if analysis.blockers:
|
|
358
|
+
lines.append("**Blockers**:")
|
|
359
|
+
for blocker in analysis.blockers[:3]:
|
|
360
|
+
lines.append(
|
|
361
|
+
f"- {blocker['ticket_id']}: {blocker['title']} "
|
|
362
|
+
f"(blocks {blocker['blocks_count']} ticket{'s' if blocker['blocks_count'] > 1 else ''})"
|
|
363
|
+
)
|
|
364
|
+
lines.append("")
|
|
365
|
+
else:
|
|
366
|
+
lines.append("**Blockers**: None")
|
|
367
|
+
lines.append("")
|
|
368
|
+
|
|
369
|
+
# Footer
|
|
370
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
371
|
+
lines.extend(
|
|
372
|
+
[
|
|
373
|
+
"---",
|
|
374
|
+
f"*Auto-generated by mcp-ticketer on {timestamp}*",
|
|
375
|
+
]
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
return "\n".join(lines)
|
mcp_ticketer/cache/memory.py
CHANGED
|
@@ -4,8 +4,9 @@ import asyncio
|
|
|
4
4
|
import hashlib
|
|
5
5
|
import json
|
|
6
6
|
import time
|
|
7
|
+
from collections.abc import Callable
|
|
7
8
|
from functools import wraps
|
|
8
|
-
from typing import Any
|
|
9
|
+
from typing import Any
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class CacheEntry:
|
|
@@ -41,7 +42,7 @@ class MemoryCache:
|
|
|
41
42
|
self._default_ttl = default_ttl
|
|
42
43
|
self._lock = asyncio.Lock()
|
|
43
44
|
|
|
44
|
-
async def get(self, key: str) ->
|
|
45
|
+
async def get(self, key: str) -> Any | None:
|
|
45
46
|
"""Get value from cache.
|
|
46
47
|
|
|
47
48
|
Args:
|
|
@@ -60,7 +61,7 @@ class MemoryCache:
|
|
|
60
61
|
del self._cache[key]
|
|
61
62
|
return None
|
|
62
63
|
|
|
63
|
-
async def set(self, key: str, value: Any, ttl:
|
|
64
|
+
async def set(self, key: str, value: Any, ttl: float | None = None) -> None:
|
|
64
65
|
"""Set value in cache.
|
|
65
66
|
|
|
66
67
|
Args:
|
|
@@ -114,7 +115,7 @@ class MemoryCache:
|
|
|
114
115
|
return len(self._cache)
|
|
115
116
|
|
|
116
117
|
@staticmethod
|
|
117
|
-
def generate_key(*args, **kwargs) -> str:
|
|
118
|
+
def generate_key(*args: Any, **kwargs: Any) -> str:
|
|
118
119
|
"""Generate cache key from arguments.
|
|
119
120
|
|
|
120
121
|
Args:
|
|
@@ -134,11 +135,11 @@ class MemoryCache:
|
|
|
134
135
|
|
|
135
136
|
|
|
136
137
|
def cache_decorator(
|
|
137
|
-
ttl:
|
|
138
|
+
ttl: float | None = None,
|
|
138
139
|
key_prefix: str = "",
|
|
139
|
-
cache_instance:
|
|
140
|
+
cache_instance: MemoryCache | None = None,
|
|
140
141
|
) -> Callable:
|
|
141
|
-
"""
|
|
142
|
+
"""Decorate async function to cache its results.
|
|
142
143
|
|
|
143
144
|
Args:
|
|
144
145
|
ttl: TTL for cached results
|
|
@@ -154,7 +155,7 @@ def cache_decorator(
|
|
|
154
155
|
|
|
155
156
|
def decorator(func: Callable) -> Callable:
|
|
156
157
|
@wraps(func)
|
|
157
|
-
async def wrapper(*args, **kwargs):
|
|
158
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
158
159
|
# Generate cache key
|
|
159
160
|
base_key = MemoryCache.generate_key(*args, **kwargs)
|
|
160
161
|
cache_key = f"{key_prefix}:{func.__name__}:{base_key}"
|