claude-mpm 4.1.4__py3-none-any.whl → 4.1.5__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.
- claude_mpm/VERSION +1 -1
- claude_mpm/cli/commands/tickets.py +365 -784
- claude_mpm/core/output_style_manager.py +24 -0
- claude_mpm/core/unified_agent_registry.py +46 -15
- claude_mpm/services/agents/deployment/agent_discovery_service.py +12 -3
- claude_mpm/services/agents/deployment/agent_lifecycle_manager.py +172 -233
- claude_mpm/services/agents/deployment/agent_lifecycle_manager_refactored.py +575 -0
- claude_mpm/services/agents/deployment/agent_operation_service.py +573 -0
- claude_mpm/services/agents/deployment/agent_record_service.py +419 -0
- claude_mpm/services/agents/deployment/agent_state_service.py +381 -0
- claude_mpm/services/agents/deployment/multi_source_deployment_service.py +4 -2
- claude_mpm/services/infrastructure/__init__.py +31 -5
- claude_mpm/services/infrastructure/monitoring/__init__.py +43 -0
- claude_mpm/services/infrastructure/monitoring/aggregator.py +437 -0
- claude_mpm/services/infrastructure/monitoring/base.py +130 -0
- claude_mpm/services/infrastructure/monitoring/legacy.py +203 -0
- claude_mpm/services/infrastructure/monitoring/network.py +218 -0
- claude_mpm/services/infrastructure/monitoring/process.py +342 -0
- claude_mpm/services/infrastructure/monitoring/resources.py +243 -0
- claude_mpm/services/infrastructure/monitoring/service.py +367 -0
- claude_mpm/services/infrastructure/monitoring.py +67 -1030
- claude_mpm/services/project/analyzer.py +13 -4
- claude_mpm/services/project/analyzer_refactored.py +450 -0
- claude_mpm/services/project/analyzer_v2.py +566 -0
- claude_mpm/services/project/architecture_analyzer.py +461 -0
- claude_mpm/services/project/dependency_analyzer.py +462 -0
- claude_mpm/services/project/language_analyzer.py +265 -0
- claude_mpm/services/project/metrics_collector.py +410 -0
- claude_mpm/services/ticket_manager.py +5 -1
- claude_mpm/services/ticket_services/__init__.py +26 -0
- claude_mpm/services/ticket_services/crud_service.py +328 -0
- claude_mpm/services/ticket_services/formatter_service.py +290 -0
- claude_mpm/services/ticket_services/search_service.py +324 -0
- claude_mpm/services/ticket_services/validation_service.py +303 -0
- claude_mpm/services/ticket_services/workflow_service.py +244 -0
- {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.5.dist-info}/METADATA +1 -1
- {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.5.dist-info}/RECORD +41 -17
- {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.5.dist-info}/WHEEL +0 -0
- {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.5.dist-info}/entry_points.txt +0 -0
- {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.5.dist-info}/licenses/LICENSE +0 -0
- {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.5.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Search service for tickets.
|
|
3
|
+
|
|
4
|
+
WHY: Extracts search logic into a dedicated service for better
|
|
5
|
+
organization and testability of search functionality.
|
|
6
|
+
|
|
7
|
+
DESIGN DECISIONS:
|
|
8
|
+
- Supports text-based search across multiple fields
|
|
9
|
+
- Handles filtering by type and status
|
|
10
|
+
- Provides relevance ranking for search results
|
|
11
|
+
- Abstracts search backend (can switch between different implementations)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from ...core.logger import get_logger
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TicketSearchService:
|
|
20
|
+
"""Service for searching tickets."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, ticket_manager=None):
|
|
23
|
+
"""
|
|
24
|
+
Initialize the search service.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
ticket_manager: Optional ticket manager instance for testing
|
|
28
|
+
"""
|
|
29
|
+
self.logger = get_logger("services.ticket_search")
|
|
30
|
+
self._ticket_manager = ticket_manager
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def ticket_manager(self):
|
|
34
|
+
"""Lazy load ticket manager."""
|
|
35
|
+
if self._ticket_manager is None:
|
|
36
|
+
try:
|
|
37
|
+
from ..ticket_manager import TicketManager
|
|
38
|
+
except ImportError:
|
|
39
|
+
from claude_mpm.services.ticket_manager import TicketManager
|
|
40
|
+
self._ticket_manager = TicketManager()
|
|
41
|
+
return self._ticket_manager
|
|
42
|
+
|
|
43
|
+
def search_tickets(
|
|
44
|
+
self,
|
|
45
|
+
query: str,
|
|
46
|
+
type_filter: str = "all",
|
|
47
|
+
status_filter: str = "all",
|
|
48
|
+
limit: int = 10,
|
|
49
|
+
search_fields: Optional[List[str]] = None,
|
|
50
|
+
) -> List[Dict[str, Any]]:
|
|
51
|
+
"""
|
|
52
|
+
Search tickets by query string.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
query: Search query
|
|
56
|
+
type_filter: Filter by ticket type
|
|
57
|
+
status_filter: Filter by status
|
|
58
|
+
limit: Maximum results to return
|
|
59
|
+
search_fields: Fields to search in (default: title, description, tags)
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of matching tickets
|
|
63
|
+
"""
|
|
64
|
+
if not search_fields:
|
|
65
|
+
search_fields = ["title", "description", "tags"]
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
# Get all available tickets
|
|
69
|
+
all_tickets = self.ticket_manager.list_recent_tickets(limit=100)
|
|
70
|
+
|
|
71
|
+
# Search and filter
|
|
72
|
+
matched_tickets = []
|
|
73
|
+
query_lower = query.lower()
|
|
74
|
+
|
|
75
|
+
for ticket in all_tickets:
|
|
76
|
+
# Check if query matches any search field
|
|
77
|
+
if self._matches_query(ticket, query_lower, search_fields):
|
|
78
|
+
# Apply type filter
|
|
79
|
+
if not self._passes_type_filter(ticket, type_filter):
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
# Apply status filter
|
|
83
|
+
if not self._passes_status_filter(ticket, status_filter):
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
matched_tickets.append(ticket)
|
|
87
|
+
|
|
88
|
+
if len(matched_tickets) >= limit:
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
# Sort by relevance
|
|
92
|
+
return self._sort_by_relevance(matched_tickets, query_lower)
|
|
93
|
+
|
|
94
|
+
except Exception as e:
|
|
95
|
+
self.logger.error(f"Error searching tickets: {e}")
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
def _matches_query(
|
|
99
|
+
self, ticket: Dict[str, Any], query: str, search_fields: List[str]
|
|
100
|
+
) -> bool:
|
|
101
|
+
"""
|
|
102
|
+
Check if ticket matches the search query.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
True if ticket matches query in any search field
|
|
106
|
+
"""
|
|
107
|
+
for field in search_fields:
|
|
108
|
+
if field == "title" and query in ticket.get("title", "").lower():
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
if (
|
|
112
|
+
field == "description"
|
|
113
|
+
and query in ticket.get("description", "").lower()
|
|
114
|
+
):
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
if field == "tags":
|
|
118
|
+
tags = ticket.get("tags", [])
|
|
119
|
+
if any(query in tag.lower() for tag in tags):
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
if field == "id" and query in ticket.get("id", "").lower():
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
# Search in metadata fields
|
|
126
|
+
if field == "metadata":
|
|
127
|
+
metadata = ticket.get("metadata", {})
|
|
128
|
+
for value in metadata.values():
|
|
129
|
+
if isinstance(value, str) and query in value.lower():
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
def _passes_type_filter(self, ticket: Dict[str, Any], type_filter: str) -> bool:
|
|
135
|
+
"""
|
|
136
|
+
Check if ticket passes type filter.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
True if ticket matches type filter
|
|
140
|
+
"""
|
|
141
|
+
if type_filter == "all":
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
ticket_type = ticket.get("metadata", {}).get("ticket_type", "unknown")
|
|
145
|
+
return ticket_type == type_filter
|
|
146
|
+
|
|
147
|
+
def _passes_status_filter(self, ticket: Dict[str, Any], status_filter: str) -> bool:
|
|
148
|
+
"""
|
|
149
|
+
Check if ticket passes status filter.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
True if ticket matches status filter
|
|
153
|
+
"""
|
|
154
|
+
if status_filter == "all":
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
return ticket.get("status") == status_filter
|
|
158
|
+
|
|
159
|
+
def _sort_by_relevance(
|
|
160
|
+
self, tickets: List[Dict[str, Any]], query: str
|
|
161
|
+
) -> List[Dict[str, Any]]:
|
|
162
|
+
"""
|
|
163
|
+
Sort tickets by relevance to search query.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Sorted list of tickets (most relevant first)
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def relevance_score(ticket):
|
|
170
|
+
score = 0
|
|
171
|
+
|
|
172
|
+
# Title matches are most relevant
|
|
173
|
+
if query in ticket.get("title", "").lower():
|
|
174
|
+
score += 10
|
|
175
|
+
|
|
176
|
+
# ID matches are very relevant
|
|
177
|
+
if query in ticket.get("id", "").lower():
|
|
178
|
+
score += 8
|
|
179
|
+
|
|
180
|
+
# Tag matches are moderately relevant
|
|
181
|
+
tags = ticket.get("tags", [])
|
|
182
|
+
for tag in tags:
|
|
183
|
+
if query in tag.lower():
|
|
184
|
+
score += 5
|
|
185
|
+
|
|
186
|
+
# Description matches are less relevant
|
|
187
|
+
if query in ticket.get("description", "").lower():
|
|
188
|
+
score += 2
|
|
189
|
+
|
|
190
|
+
# Boost recent tickets slightly
|
|
191
|
+
if ticket.get("status") in ["open", "in_progress"]:
|
|
192
|
+
score += 1
|
|
193
|
+
|
|
194
|
+
return score
|
|
195
|
+
|
|
196
|
+
return sorted(tickets, key=relevance_score, reverse=True)
|
|
197
|
+
|
|
198
|
+
def find_similar_tickets(
|
|
199
|
+
self, ticket_id: str, limit: int = 5
|
|
200
|
+
) -> List[Dict[str, Any]]:
|
|
201
|
+
"""
|
|
202
|
+
Find tickets similar to a given ticket.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
ticket_id: ID of the reference ticket
|
|
206
|
+
limit: Maximum similar tickets to return
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
List of similar tickets
|
|
210
|
+
"""
|
|
211
|
+
try:
|
|
212
|
+
# Get the reference ticket
|
|
213
|
+
reference = self.ticket_manager.get_ticket(ticket_id)
|
|
214
|
+
if not reference:
|
|
215
|
+
return []
|
|
216
|
+
|
|
217
|
+
# Extract keywords from title and tags
|
|
218
|
+
keywords = self._extract_keywords(reference)
|
|
219
|
+
|
|
220
|
+
# Search for similar tickets
|
|
221
|
+
similar = []
|
|
222
|
+
all_tickets = self.ticket_manager.list_recent_tickets(limit=50)
|
|
223
|
+
|
|
224
|
+
for ticket in all_tickets:
|
|
225
|
+
if ticket["id"] == ticket_id:
|
|
226
|
+
continue # Skip the reference ticket
|
|
227
|
+
|
|
228
|
+
# Calculate similarity score
|
|
229
|
+
score = self._calculate_similarity(reference, ticket, keywords)
|
|
230
|
+
|
|
231
|
+
if score > 0:
|
|
232
|
+
similar.append((score, ticket))
|
|
233
|
+
|
|
234
|
+
# Sort by similarity and return top results
|
|
235
|
+
similar.sort(key=lambda x: x[0], reverse=True)
|
|
236
|
+
return [ticket for _, ticket in similar[:limit]]
|
|
237
|
+
|
|
238
|
+
except Exception as e:
|
|
239
|
+
self.logger.error(f"Error finding similar tickets: {e}")
|
|
240
|
+
return []
|
|
241
|
+
|
|
242
|
+
def _extract_keywords(self, ticket: Dict[str, Any]) -> List[str]:
|
|
243
|
+
"""
|
|
244
|
+
Extract keywords from a ticket for similarity matching.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
List of keywords
|
|
248
|
+
"""
|
|
249
|
+
keywords = []
|
|
250
|
+
|
|
251
|
+
# Extract words from title
|
|
252
|
+
title = ticket.get("title", "")
|
|
253
|
+
keywords.extend(title.lower().split())
|
|
254
|
+
|
|
255
|
+
# Add tags as keywords
|
|
256
|
+
keywords.extend([tag.lower() for tag in ticket.get("tags", [])])
|
|
257
|
+
|
|
258
|
+
# Extract key words from description (first 100 chars)
|
|
259
|
+
desc = ticket.get("description", "")[:100]
|
|
260
|
+
keywords.extend(desc.lower().split()[:10])
|
|
261
|
+
|
|
262
|
+
# Remove common words
|
|
263
|
+
common_words = {
|
|
264
|
+
"the",
|
|
265
|
+
"a",
|
|
266
|
+
"an",
|
|
267
|
+
"and",
|
|
268
|
+
"or",
|
|
269
|
+
"but",
|
|
270
|
+
"in",
|
|
271
|
+
"on",
|
|
272
|
+
"at",
|
|
273
|
+
"to",
|
|
274
|
+
"for",
|
|
275
|
+
}
|
|
276
|
+
keywords = [w for w in keywords if w not in common_words and len(w) > 2]
|
|
277
|
+
|
|
278
|
+
return list(set(keywords)) # Remove duplicates
|
|
279
|
+
|
|
280
|
+
def _calculate_similarity(
|
|
281
|
+
self, ref_ticket: Dict[str, Any], ticket: Dict[str, Any], keywords: List[str]
|
|
282
|
+
) -> float:
|
|
283
|
+
"""
|
|
284
|
+
Calculate similarity score between two tickets.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Similarity score (0-100)
|
|
288
|
+
"""
|
|
289
|
+
score = 0.0
|
|
290
|
+
|
|
291
|
+
# Same type bonus
|
|
292
|
+
ref_type = ref_ticket.get("metadata", {}).get("ticket_type")
|
|
293
|
+
ticket_type = ticket.get("metadata", {}).get("ticket_type")
|
|
294
|
+
if ref_type and ticket_type and ref_type == ticket_type:
|
|
295
|
+
score += 10
|
|
296
|
+
|
|
297
|
+
# Same status bonus
|
|
298
|
+
if ref_ticket.get("status") == ticket.get("status"):
|
|
299
|
+
score += 5
|
|
300
|
+
|
|
301
|
+
# Same priority bonus
|
|
302
|
+
if ref_ticket.get("priority") == ticket.get("priority"):
|
|
303
|
+
score += 3
|
|
304
|
+
|
|
305
|
+
# Keyword matches
|
|
306
|
+
ticket_text = (
|
|
307
|
+
ticket.get("title", "").lower()
|
|
308
|
+
+ " "
|
|
309
|
+
+ ticket.get("description", "").lower()
|
|
310
|
+
+ " "
|
|
311
|
+
+ " ".join(ticket.get("tags", []))
|
|
312
|
+
).lower()
|
|
313
|
+
|
|
314
|
+
for keyword in keywords:
|
|
315
|
+
if keyword in ticket_text:
|
|
316
|
+
score += 2
|
|
317
|
+
|
|
318
|
+
# Common tags
|
|
319
|
+
ref_tags = set(ref_ticket.get("tags", []))
|
|
320
|
+
ticket_tags = set(ticket.get("tags", []))
|
|
321
|
+
common_tags = ref_tags.intersection(ticket_tags)
|
|
322
|
+
score += len(common_tags) * 5
|
|
323
|
+
|
|
324
|
+
return min(score, 100) # Cap at 100
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Input validation service for tickets.
|
|
3
|
+
|
|
4
|
+
WHY: Centralizes all validation logic to ensure data integrity and
|
|
5
|
+
provide consistent error messages across the ticket system.
|
|
6
|
+
|
|
7
|
+
DESIGN DECISIONS:
|
|
8
|
+
- Returns validation results with detailed error messages
|
|
9
|
+
- Validates ticket IDs, types, statuses, priorities
|
|
10
|
+
- Handles pagination parameter validation
|
|
11
|
+
- Provides sanitization for user inputs
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TicketValidationService:
|
|
18
|
+
"""Service for validating ticket inputs."""
|
|
19
|
+
|
|
20
|
+
# Valid ticket types
|
|
21
|
+
VALID_TYPES = ["task", "issue", "epic", "bug", "feature", "story"]
|
|
22
|
+
|
|
23
|
+
# Valid ticket statuses
|
|
24
|
+
VALID_STATUSES = [
|
|
25
|
+
"open",
|
|
26
|
+
"in_progress",
|
|
27
|
+
"ready",
|
|
28
|
+
"tested",
|
|
29
|
+
"done",
|
|
30
|
+
"waiting",
|
|
31
|
+
"closed",
|
|
32
|
+
"blocked",
|
|
33
|
+
"all",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
# Valid priorities
|
|
37
|
+
VALID_PRIORITIES = ["low", "medium", "high", "critical"]
|
|
38
|
+
|
|
39
|
+
# Valid workflow states
|
|
40
|
+
VALID_WORKFLOW_STATES = [
|
|
41
|
+
"todo",
|
|
42
|
+
"in_progress",
|
|
43
|
+
"ready",
|
|
44
|
+
"tested",
|
|
45
|
+
"done",
|
|
46
|
+
"blocked",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
def validate_ticket_id(self, ticket_id: Any) -> Tuple[bool, Optional[str]]:
|
|
50
|
+
"""
|
|
51
|
+
Validate a ticket ID.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Tuple of (is_valid, error_message)
|
|
55
|
+
"""
|
|
56
|
+
if not ticket_id:
|
|
57
|
+
return False, "No ticket ID provided"
|
|
58
|
+
|
|
59
|
+
if not isinstance(ticket_id, str):
|
|
60
|
+
return False, "Ticket ID must be a string"
|
|
61
|
+
|
|
62
|
+
if len(ticket_id) < 3:
|
|
63
|
+
return False, "Invalid ticket ID format"
|
|
64
|
+
|
|
65
|
+
return True, None
|
|
66
|
+
|
|
67
|
+
def validate_ticket_type(self, ticket_type: str) -> Tuple[bool, Optional[str]]:
|
|
68
|
+
"""
|
|
69
|
+
Validate a ticket type.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Tuple of (is_valid, error_message)
|
|
73
|
+
"""
|
|
74
|
+
if not ticket_type:
|
|
75
|
+
return True, None # Type is optional, default will be used
|
|
76
|
+
|
|
77
|
+
if ticket_type not in self.VALID_TYPES:
|
|
78
|
+
return (
|
|
79
|
+
False,
|
|
80
|
+
f"Invalid ticket type: {ticket_type}. Valid types: {', '.join(self.VALID_TYPES)}",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return True, None
|
|
84
|
+
|
|
85
|
+
def validate_status(self, status: str) -> Tuple[bool, Optional[str]]:
|
|
86
|
+
"""
|
|
87
|
+
Validate a ticket status.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Tuple of (is_valid, error_message)
|
|
91
|
+
"""
|
|
92
|
+
if not status:
|
|
93
|
+
return True, None # Status is optional
|
|
94
|
+
|
|
95
|
+
if status not in self.VALID_STATUSES:
|
|
96
|
+
return (
|
|
97
|
+
False,
|
|
98
|
+
f"Invalid status: {status}. Valid statuses: {', '.join(self.VALID_STATUSES)}",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return True, None
|
|
102
|
+
|
|
103
|
+
def validate_priority(self, priority: str) -> Tuple[bool, Optional[str]]:
|
|
104
|
+
"""
|
|
105
|
+
Validate a ticket priority.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Tuple of (is_valid, error_message)
|
|
109
|
+
"""
|
|
110
|
+
if not priority:
|
|
111
|
+
return True, None # Priority is optional, default will be used
|
|
112
|
+
|
|
113
|
+
if priority not in self.VALID_PRIORITIES:
|
|
114
|
+
return (
|
|
115
|
+
False,
|
|
116
|
+
f"Invalid priority: {priority}. Valid priorities: {', '.join(self.VALID_PRIORITIES)}",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return True, None
|
|
120
|
+
|
|
121
|
+
def validate_workflow_state(self, state: str) -> Tuple[bool, Optional[str]]:
|
|
122
|
+
"""
|
|
123
|
+
Validate a workflow state.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Tuple of (is_valid, error_message)
|
|
127
|
+
"""
|
|
128
|
+
if not state:
|
|
129
|
+
return False, "No workflow state provided"
|
|
130
|
+
|
|
131
|
+
if state not in self.VALID_WORKFLOW_STATES:
|
|
132
|
+
return (
|
|
133
|
+
False,
|
|
134
|
+
f"Invalid workflow state: {state}. Valid states: {', '.join(self.VALID_WORKFLOW_STATES)}",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return True, None
|
|
138
|
+
|
|
139
|
+
def validate_pagination(
|
|
140
|
+
self, page: int, page_size: int
|
|
141
|
+
) -> Tuple[bool, Optional[str]]:
|
|
142
|
+
"""
|
|
143
|
+
Validate pagination parameters.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Tuple of (is_valid, error_message)
|
|
147
|
+
"""
|
|
148
|
+
if page < 1:
|
|
149
|
+
return False, "Page number must be 1 or greater"
|
|
150
|
+
|
|
151
|
+
if page_size < 1:
|
|
152
|
+
return False, "Page size must be 1 or greater"
|
|
153
|
+
|
|
154
|
+
if page_size > 100:
|
|
155
|
+
return False, "Page size cannot exceed 100"
|
|
156
|
+
|
|
157
|
+
return True, None
|
|
158
|
+
|
|
159
|
+
def validate_create_params(
|
|
160
|
+
self, params: Dict[str, Any]
|
|
161
|
+
) -> Tuple[bool, Optional[str]]:
|
|
162
|
+
"""
|
|
163
|
+
Validate parameters for ticket creation.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Tuple of (is_valid, error_message)
|
|
167
|
+
"""
|
|
168
|
+
# Title is required
|
|
169
|
+
if not params.get("title"):
|
|
170
|
+
return False, "Title is required for ticket creation"
|
|
171
|
+
|
|
172
|
+
if len(params["title"]) < 3:
|
|
173
|
+
return False, "Title must be at least 3 characters long"
|
|
174
|
+
|
|
175
|
+
if len(params["title"]) > 200:
|
|
176
|
+
return False, "Title cannot exceed 200 characters"
|
|
177
|
+
|
|
178
|
+
# Validate optional fields if provided
|
|
179
|
+
if "type" in params:
|
|
180
|
+
valid, error = self.validate_ticket_type(params["type"])
|
|
181
|
+
if not valid:
|
|
182
|
+
return False, error
|
|
183
|
+
|
|
184
|
+
if "priority" in params:
|
|
185
|
+
valid, error = self.validate_priority(params["priority"])
|
|
186
|
+
if not valid:
|
|
187
|
+
return False, error
|
|
188
|
+
|
|
189
|
+
# Validate parent references
|
|
190
|
+
if params.get("parent_epic"):
|
|
191
|
+
valid, error = self.validate_ticket_id(params["parent_epic"])
|
|
192
|
+
if not valid:
|
|
193
|
+
return False, f"Invalid parent epic: {error}"
|
|
194
|
+
|
|
195
|
+
if params.get("parent_issue"):
|
|
196
|
+
valid, error = self.validate_ticket_id(params["parent_issue"])
|
|
197
|
+
if not valid:
|
|
198
|
+
return False, f"Invalid parent issue: {error}"
|
|
199
|
+
|
|
200
|
+
return True, None
|
|
201
|
+
|
|
202
|
+
def validate_update_params(
|
|
203
|
+
self, params: Dict[str, Any]
|
|
204
|
+
) -> Tuple[bool, Optional[str]]:
|
|
205
|
+
"""
|
|
206
|
+
Validate parameters for ticket update.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Tuple of (is_valid, error_message)
|
|
210
|
+
"""
|
|
211
|
+
# At least one update field must be provided
|
|
212
|
+
update_fields = ["status", "priority", "description", "tags", "assign"]
|
|
213
|
+
if not any(field in params for field in update_fields):
|
|
214
|
+
return False, "No update fields specified"
|
|
215
|
+
|
|
216
|
+
# Validate status if provided
|
|
217
|
+
if "status" in params:
|
|
218
|
+
valid, error = self.validate_status(params["status"])
|
|
219
|
+
if not valid:
|
|
220
|
+
return False, error
|
|
221
|
+
|
|
222
|
+
# Validate priority if provided
|
|
223
|
+
if "priority" in params:
|
|
224
|
+
valid, error = self.validate_priority(params["priority"])
|
|
225
|
+
if not valid:
|
|
226
|
+
return False, error
|
|
227
|
+
|
|
228
|
+
return True, None
|
|
229
|
+
|
|
230
|
+
def sanitize_tags(self, tags: Any) -> List[str]:
|
|
231
|
+
"""
|
|
232
|
+
Sanitize and parse tags input.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
List of sanitized tags
|
|
236
|
+
"""
|
|
237
|
+
if not tags:
|
|
238
|
+
return []
|
|
239
|
+
|
|
240
|
+
if isinstance(tags, str):
|
|
241
|
+
# Split comma-separated tags
|
|
242
|
+
tag_list = [tag.strip() for tag in tags.split(",")]
|
|
243
|
+
elif isinstance(tags, list):
|
|
244
|
+
tag_list = [str(tag).strip() for tag in tags]
|
|
245
|
+
else:
|
|
246
|
+
return []
|
|
247
|
+
|
|
248
|
+
# Remove empty tags and duplicates
|
|
249
|
+
return list(filter(None, dict.fromkeys(tag_list)))
|
|
250
|
+
|
|
251
|
+
def sanitize_description(self, description: Any) -> str:
|
|
252
|
+
"""
|
|
253
|
+
Sanitize description input.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Sanitized description string
|
|
257
|
+
"""
|
|
258
|
+
if not description:
|
|
259
|
+
return ""
|
|
260
|
+
|
|
261
|
+
if isinstance(description, list):
|
|
262
|
+
# Join list elements with spaces
|
|
263
|
+
return " ".join(str(item) for item in description)
|
|
264
|
+
|
|
265
|
+
return str(description).strip()
|
|
266
|
+
|
|
267
|
+
def validate_search_query(self, query: str) -> Tuple[bool, Optional[str]]:
|
|
268
|
+
"""
|
|
269
|
+
Validate a search query.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Tuple of (is_valid, error_message)
|
|
273
|
+
"""
|
|
274
|
+
if not query:
|
|
275
|
+
return False, "Search query cannot be empty"
|
|
276
|
+
|
|
277
|
+
if len(query) < 2:
|
|
278
|
+
return False, "Search query must be at least 2 characters"
|
|
279
|
+
|
|
280
|
+
if len(query) > 100:
|
|
281
|
+
return False, "Search query cannot exceed 100 characters"
|
|
282
|
+
|
|
283
|
+
return True, None
|
|
284
|
+
|
|
285
|
+
def validate_comment(self, comment: Any) -> Tuple[bool, Optional[str]]:
|
|
286
|
+
"""
|
|
287
|
+
Validate a comment.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Tuple of (is_valid, error_message)
|
|
291
|
+
"""
|
|
292
|
+
if not comment:
|
|
293
|
+
return False, "Comment cannot be empty"
|
|
294
|
+
|
|
295
|
+
comment_str = self.sanitize_description(comment)
|
|
296
|
+
|
|
297
|
+
if len(comment_str) < 1:
|
|
298
|
+
return False, "Comment cannot be empty"
|
|
299
|
+
|
|
300
|
+
if len(comment_str) > 5000:
|
|
301
|
+
return False, "Comment cannot exceed 5000 characters"
|
|
302
|
+
|
|
303
|
+
return True, None
|