mcp-ticketer 0.2.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 +421 -0
  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 -1284
  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 -1895
  155. mcp_ticketer-0.2.0.dist-info/METADATA +0 -414
  156. mcp_ticketer-0.2.0.dist-info/RECORD +0 -58
  157. mcp_ticketer-0.2.0.dist-info/top_level.txt +0 -1
  158. {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
  159. {mcp_ticketer-0.2.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
  160. {mcp_ticketer-0.2.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)
@@ -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, Callable, Optional
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) -> Optional[Any]:
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: Optional[float] = None) -> None:
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: Optional[float] = None,
138
+ ttl: float | None = None,
138
139
  key_prefix: str = "",
139
- cache_instance: Optional[MemoryCache] = None,
140
+ cache_instance: MemoryCache | None = None,
140
141
  ) -> Callable:
141
- """Decorator for caching async function results.
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}"