mcp-ticketer 0.12.0__py3-none-any.whl → 2.2.13__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/__init__.py +10 -10
- mcp_ticketer/__version__.py +1 -1
- mcp_ticketer/_version_scm.py +1 -0
- mcp_ticketer/adapters/aitrackdown.py +507 -6
- mcp_ticketer/adapters/asana/adapter.py +229 -0
- mcp_ticketer/adapters/asana/mappers.py +14 -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 +47 -5
- 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/adapter.py +2730 -139
- mcp_ticketer/adapters/linear/client.py +175 -3
- mcp_ticketer/adapters/linear/mappers.py +203 -8
- mcp_ticketer/adapters/linear/queries.py +280 -3
- mcp_ticketer/adapters/linear/types.py +120 -4
- 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/cli/adapter_diagnostics.py +3 -1
- mcp_ticketer/cli/auggie_configure.py +17 -5
- mcp_ticketer/cli/codex_configure.py +97 -61
- mcp_ticketer/cli/configure.py +1288 -105
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +13 -12
- mcp_ticketer/cli/discover.py +5 -0
- mcp_ticketer/cli/gemini_configure.py +17 -5
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/instruction_commands.py +6 -0
- mcp_ticketer/cli/main.py +267 -3175
- mcp_ticketer/cli/mcp_configure.py +821 -119
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/platform_detection.py +77 -12
- mcp_ticketer/cli/platform_installer.py +545 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/setup_command.py +795 -0
- mcp_ticketer/cli/simple_health.py +12 -10
- mcp_ticketer/cli/ticket_commands.py +705 -103
- mcp_ticketer/cli/utils.py +113 -0
- mcp_ticketer/core/__init__.py +56 -6
- mcp_ticketer/core/adapter.py +533 -2
- mcp_ticketer/core/config.py +21 -21
- mcp_ticketer/core/exceptions.py +7 -1
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +31 -19
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +480 -0
- mcp_ticketer/core/onepassword_secrets.py +1 -1
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +132 -14
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/session_state.py +176 -0
- mcp_ticketer/core/state_matcher.py +625 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/mcp/server/__main__.py +2 -1
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +106 -25
- mcp_ticketer/mcp/server/routing.py +723 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +33 -11
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +5 -5
- mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
- mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
- mcp_ticketer/mcp/server/tools/config_tools.py +1391 -145
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
- mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
- 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 +3 -7
- 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 +209 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1107 -124
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
- mcp_ticketer/queue/queue.py +68 -0
- mcp_ticketer/queue/worker.py +1 -1
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.2.13.dist-info/METADATA +1396 -0
- mcp_ticketer-2.2.13.dist-info/RECORD +158 -0
- mcp_ticketer-2.2.13.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 -1574
- mcp_ticketer/adapters/jira.py +0 -1258
- mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
- mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
- mcp_ticketer-0.12.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.2.13.dist-info}/licenses/LICENSE +0 -0
mcp_ticketer/core/mappers.py
CHANGED
|
@@ -98,13 +98,13 @@ class StateMapper(BaseMapper):
|
|
|
98
98
|
self._mapping: BiDirectionalDict | None = None
|
|
99
99
|
|
|
100
100
|
@lru_cache(maxsize=1)
|
|
101
|
-
def get_mapping(self) -> BiDirectionalDict:
|
|
101
|
+
def get_mapping(self) -> BiDirectionalDict[TicketState, str]:
|
|
102
102
|
"""Get cached bidirectional state mapping."""
|
|
103
103
|
if self._mapping is not None:
|
|
104
104
|
return self._mapping
|
|
105
105
|
|
|
106
106
|
# Default mappings by adapter type
|
|
107
|
-
default_mappings = {
|
|
107
|
+
default_mappings: dict[str, dict[TicketState, str]] = {
|
|
108
108
|
"github": {
|
|
109
109
|
TicketState.OPEN: "open",
|
|
110
110
|
TicketState.IN_PROGRESS: "open", # Uses labels
|
|
@@ -147,13 +147,16 @@ class StateMapper(BaseMapper):
|
|
|
147
147
|
},
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
mapping = default_mappings.get(self.adapter_type, {})
|
|
150
|
+
mapping: dict[TicketState, str] = default_mappings.get(self.adapter_type, {})
|
|
151
151
|
|
|
152
|
-
# Apply custom mappings
|
|
152
|
+
# Apply custom mappings (cast to proper type)
|
|
153
153
|
if self.custom_mappings:
|
|
154
|
-
|
|
154
|
+
# custom_mappings might have str keys, need to convert to TicketState
|
|
155
|
+
for key, value in self.custom_mappings.items():
|
|
156
|
+
if isinstance(key, TicketState):
|
|
157
|
+
mapping[key] = value
|
|
155
158
|
|
|
156
|
-
self._mapping = BiDirectionalDict(mapping)
|
|
159
|
+
self._mapping = BiDirectionalDict[TicketState, str](mapping)
|
|
157
160
|
return self._mapping
|
|
158
161
|
|
|
159
162
|
def to_system_state(self, adapter_state: str) -> TicketState:
|
|
@@ -168,7 +171,9 @@ class StateMapper(BaseMapper):
|
|
|
168
171
|
"""
|
|
169
172
|
cache_key = f"to_system_{adapter_state}"
|
|
170
173
|
if cache_key in self._cache:
|
|
171
|
-
|
|
174
|
+
cached = self._cache[cache_key]
|
|
175
|
+
if isinstance(cached, TicketState):
|
|
176
|
+
return cached
|
|
172
177
|
|
|
173
178
|
mapping = self.get_mapping()
|
|
174
179
|
result = mapping.get_reverse(adapter_state)
|
|
@@ -205,7 +210,9 @@ class StateMapper(BaseMapper):
|
|
|
205
210
|
"""
|
|
206
211
|
cache_key = f"from_system_{system_state.value}"
|
|
207
212
|
if cache_key in self._cache:
|
|
208
|
-
|
|
213
|
+
cached = self._cache[cache_key]
|
|
214
|
+
if isinstance(cached, str):
|
|
215
|
+
return cached
|
|
209
216
|
|
|
210
217
|
mapping = self.get_mapping()
|
|
211
218
|
result = mapping.get_forward(system_state)
|
|
@@ -273,13 +280,13 @@ class PriorityMapper(BaseMapper):
|
|
|
273
280
|
self._mapping: BiDirectionalDict | None = None
|
|
274
281
|
|
|
275
282
|
@lru_cache(maxsize=1)
|
|
276
|
-
def get_mapping(self) -> BiDirectionalDict:
|
|
283
|
+
def get_mapping(self) -> BiDirectionalDict[Priority, Any]:
|
|
277
284
|
"""Get cached bidirectional priority mapping."""
|
|
278
285
|
if self._mapping is not None:
|
|
279
286
|
return self._mapping
|
|
280
287
|
|
|
281
288
|
# Default mappings by adapter type
|
|
282
|
-
default_mappings = {
|
|
289
|
+
default_mappings: dict[str, dict[Priority, Any]] = {
|
|
283
290
|
"github": {
|
|
284
291
|
Priority.CRITICAL: "P0",
|
|
285
292
|
Priority.HIGH: "P1",
|
|
@@ -306,13 +313,16 @@ class PriorityMapper(BaseMapper):
|
|
|
306
313
|
},
|
|
307
314
|
}
|
|
308
315
|
|
|
309
|
-
mapping = default_mappings.get(self.adapter_type, {})
|
|
316
|
+
mapping: dict[Priority, Any] = default_mappings.get(self.adapter_type, {})
|
|
310
317
|
|
|
311
|
-
# Apply custom mappings
|
|
318
|
+
# Apply custom mappings (cast to proper type)
|
|
312
319
|
if self.custom_mappings:
|
|
313
|
-
|
|
320
|
+
# custom_mappings might have str keys, need to convert to Priority
|
|
321
|
+
for key, value in self.custom_mappings.items():
|
|
322
|
+
if isinstance(key, Priority):
|
|
323
|
+
mapping[key] = value
|
|
314
324
|
|
|
315
|
-
self._mapping = BiDirectionalDict(mapping)
|
|
325
|
+
self._mapping = BiDirectionalDict[Priority, Any](mapping)
|
|
316
326
|
return self._mapping
|
|
317
327
|
|
|
318
328
|
def to_system_priority(self, adapter_priority: Any) -> Priority:
|
|
@@ -327,7 +337,9 @@ class PriorityMapper(BaseMapper):
|
|
|
327
337
|
"""
|
|
328
338
|
cache_key = f"to_system_{adapter_priority}"
|
|
329
339
|
if cache_key in self._cache:
|
|
330
|
-
|
|
340
|
+
cached = self._cache[cache_key]
|
|
341
|
+
if isinstance(cached, Priority):
|
|
342
|
+
return cached
|
|
331
343
|
|
|
332
344
|
mapping = self.get_mapping()
|
|
333
345
|
result = mapping.get_reverse(adapter_priority)
|
|
@@ -524,10 +536,10 @@ class MapperRegistry:
|
|
|
524
536
|
@classmethod
|
|
525
537
|
def clear_cache(cls) -> None:
|
|
526
538
|
"""Clear all mapper caches."""
|
|
527
|
-
for
|
|
528
|
-
|
|
529
|
-
for
|
|
530
|
-
|
|
539
|
+
for state_mapper in cls._state_mappers.values():
|
|
540
|
+
state_mapper.clear_cache()
|
|
541
|
+
for priority_mapper in cls._priority_mappers.values():
|
|
542
|
+
priority_mapper.clear_cache()
|
|
531
543
|
|
|
532
544
|
@classmethod
|
|
533
545
|
def reset(cls) -> None:
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Local milestone storage manager.
|
|
2
|
+
|
|
3
|
+
This module provides local persistent storage for milestones in the
|
|
4
|
+
.mcp-ticketer/milestones.json file. It handles CRUD operations for
|
|
5
|
+
milestones with automatic timestamp management and filtering support.
|
|
6
|
+
|
|
7
|
+
The storage format is JSON with the following structure:
|
|
8
|
+
{
|
|
9
|
+
"version": "1.0",
|
|
10
|
+
"milestones": {
|
|
11
|
+
"milestone-id-1": {...},
|
|
12
|
+
"milestone-id-2": {...}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
>>> from pathlib import Path
|
|
18
|
+
>>> from mcp_ticketer.core.milestone_manager import MilestoneManager
|
|
19
|
+
>>> from mcp_ticketer.core.models import Milestone
|
|
20
|
+
>>>
|
|
21
|
+
>>> config_dir = Path.home() / ".mcp-ticketer"
|
|
22
|
+
>>> manager = MilestoneManager(config_dir)
|
|
23
|
+
>>>
|
|
24
|
+
>>> milestone = Milestone(
|
|
25
|
+
... id="mile-001",
|
|
26
|
+
... name="v2.1.0 Release",
|
|
27
|
+
... labels=["v2.1", "release"]
|
|
28
|
+
... )
|
|
29
|
+
>>> saved = manager.save_milestone(milestone)
|
|
30
|
+
>>> retrieved = manager.get_milestone("mile-001")
|
|
31
|
+
|
|
32
|
+
Note:
|
|
33
|
+
Related to ticket 1M-607: Add milestone support (Phase 1 - Core Infrastructure)
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import logging
|
|
39
|
+
from datetime import datetime
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from .models import Milestone
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MilestoneManager:
|
|
48
|
+
"""Manages local milestone storage in .mcp-ticketer/milestones.json.
|
|
49
|
+
|
|
50
|
+
This class provides a simple file-based storage mechanism for milestones,
|
|
51
|
+
with automatic timestamp management and filtering capabilities. It is
|
|
52
|
+
designed to work alongside adapter-specific milestone implementations.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
config_dir: Path to .mcp-ticketer configuration directory
|
|
56
|
+
milestones_file: Path to milestones.json storage file
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, config_dir: Path):
|
|
61
|
+
"""Initialize milestone manager.
|
|
62
|
+
|
|
63
|
+
Creates the storage file if it doesn't exist. Ensures the config
|
|
64
|
+
directory exists before attempting to create the storage file.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
config_dir: Path to .mcp-ticketer directory
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
self.config_dir = config_dir
|
|
71
|
+
self.milestones_file = config_dir / "milestones.json"
|
|
72
|
+
self._ensure_storage()
|
|
73
|
+
|
|
74
|
+
def _ensure_storage(self) -> None:
|
|
75
|
+
"""Ensure milestone storage file exists.
|
|
76
|
+
|
|
77
|
+
Creates the config directory and initializes an empty storage file
|
|
78
|
+
if it doesn't exist. Uses atomic write to prevent corruption.
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
if not self.milestones_file.exists():
|
|
82
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
self._save_data({"milestones": {}, "version": "1.0"})
|
|
84
|
+
logger.info(f"Initialized milestone storage at {self.milestones_file}")
|
|
85
|
+
|
|
86
|
+
def _load_data(self) -> dict:
|
|
87
|
+
"""Load milestone data from file.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Dictionary containing milestones and version info
|
|
91
|
+
|
|
92
|
+
Note:
|
|
93
|
+
Returns empty structure if file doesn't exist or is corrupted
|
|
94
|
+
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
with open(self.milestones_file, encoding="utf-8") as f:
|
|
98
|
+
return json.load(f)
|
|
99
|
+
except (json.JSONDecodeError, FileNotFoundError) as e:
|
|
100
|
+
logger.warning(
|
|
101
|
+
f"Failed to load milestones from {self.milestones_file}: {e}"
|
|
102
|
+
)
|
|
103
|
+
return {"milestones": {}, "version": "1.0"}
|
|
104
|
+
|
|
105
|
+
def _save_data(self, data: dict) -> None:
|
|
106
|
+
"""Save milestone data to file.
|
|
107
|
+
|
|
108
|
+
Uses atomic write pattern to prevent corruption. Serializes datetime
|
|
109
|
+
objects to ISO format strings automatically.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
data: Dictionary containing milestones and version info
|
|
113
|
+
|
|
114
|
+
"""
|
|
115
|
+
with open(self.milestones_file, "w", encoding="utf-8") as f:
|
|
116
|
+
json.dump(data, f, indent=2, default=self._json_serializer)
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _json_serializer(obj):
|
|
120
|
+
"""Custom JSON serializer for datetime objects.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
obj: Object to serialize
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
ISO format string for datetime, original object otherwise
|
|
127
|
+
|
|
128
|
+
"""
|
|
129
|
+
if isinstance(obj, datetime):
|
|
130
|
+
return obj.isoformat()
|
|
131
|
+
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
|
|
132
|
+
|
|
133
|
+
def save_milestone(self, milestone: Milestone) -> Milestone:
|
|
134
|
+
"""Save or update milestone in local storage.
|
|
135
|
+
|
|
136
|
+
Automatically updates the updated_at timestamp before saving.
|
|
137
|
+
If the milestone doesn't have an ID, generates one based on name.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
milestone: Milestone to save
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Saved milestone with updated timestamp
|
|
144
|
+
|
|
145
|
+
"""
|
|
146
|
+
data = self._load_data()
|
|
147
|
+
|
|
148
|
+
# Generate ID if not present
|
|
149
|
+
if not milestone.id:
|
|
150
|
+
# Simple ID generation based on name
|
|
151
|
+
import uuid
|
|
152
|
+
|
|
153
|
+
milestone.id = str(uuid.uuid4())[:8]
|
|
154
|
+
|
|
155
|
+
# Update timestamp
|
|
156
|
+
milestone.updated_at = datetime.utcnow()
|
|
157
|
+
if not milestone.created_at:
|
|
158
|
+
milestone.created_at = datetime.utcnow()
|
|
159
|
+
|
|
160
|
+
# Convert to dict for storage
|
|
161
|
+
milestone_dict = milestone.model_dump(mode="json")
|
|
162
|
+
data["milestones"][milestone.id] = milestone_dict
|
|
163
|
+
|
|
164
|
+
self._save_data(data)
|
|
165
|
+
logger.debug(f"Saved milestone {milestone.id} ({milestone.name})")
|
|
166
|
+
return milestone
|
|
167
|
+
|
|
168
|
+
def get_milestone(self, milestone_id: str) -> Milestone | None:
|
|
169
|
+
"""Get milestone by ID.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
milestone_id: Milestone identifier
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Milestone object or None if not found
|
|
176
|
+
|
|
177
|
+
"""
|
|
178
|
+
data = self._load_data()
|
|
179
|
+
milestone_data = data["milestones"].get(milestone_id)
|
|
180
|
+
|
|
181
|
+
if not milestone_data:
|
|
182
|
+
logger.debug(f"Milestone {milestone_id} not found")
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
return Milestone(**milestone_data)
|
|
186
|
+
|
|
187
|
+
def list_milestones(
|
|
188
|
+
self,
|
|
189
|
+
project_id: str | None = None,
|
|
190
|
+
state: str | None = None,
|
|
191
|
+
) -> list[Milestone]:
|
|
192
|
+
"""List milestones with optional filters.
|
|
193
|
+
|
|
194
|
+
Filters are applied in sequence: project_id, then state.
|
|
195
|
+
Results are sorted by target_date (None values appear last).
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
project_id: Filter by project ID
|
|
199
|
+
state: Filter by state (open, active, completed, closed)
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
List of milestones matching filters, sorted by target_date
|
|
203
|
+
|
|
204
|
+
"""
|
|
205
|
+
data = self._load_data()
|
|
206
|
+
milestones = []
|
|
207
|
+
|
|
208
|
+
for milestone_data in data["milestones"].values():
|
|
209
|
+
milestone = Milestone(**milestone_data)
|
|
210
|
+
|
|
211
|
+
# Apply filters
|
|
212
|
+
if project_id and milestone.project_id != project_id:
|
|
213
|
+
continue
|
|
214
|
+
if state and milestone.state != state:
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
milestones.append(milestone)
|
|
218
|
+
|
|
219
|
+
# Sort by target_date (None values last)
|
|
220
|
+
milestones.sort(
|
|
221
|
+
key=lambda m: (
|
|
222
|
+
m.target_date is None,
|
|
223
|
+
m.target_date if m.target_date else datetime.max,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
logger.debug(
|
|
228
|
+
f"Listed {len(milestones)} milestones "
|
|
229
|
+
f"(project_id={project_id}, state={state})"
|
|
230
|
+
)
|
|
231
|
+
return milestones
|
|
232
|
+
|
|
233
|
+
def delete_milestone(self, milestone_id: str) -> bool:
|
|
234
|
+
"""Delete milestone from storage.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
milestone_id: Milestone identifier
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
True if deleted, False if not found
|
|
241
|
+
|
|
242
|
+
"""
|
|
243
|
+
data = self._load_data()
|
|
244
|
+
|
|
245
|
+
if milestone_id not in data["milestones"]:
|
|
246
|
+
logger.warning(f"Cannot delete milestone {milestone_id}: not found")
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
del data["milestones"][milestone_id]
|
|
250
|
+
self._save_data(data)
|
|
251
|
+
logger.info(f"Deleted milestone {milestone_id}")
|
|
252
|
+
return True
|