mcp-ticketer 0.12.0__py3-none-any.whl → 2.0.1__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/adapters/aitrackdown.py +385 -6
- mcp_ticketer/adapters/asana/adapter.py +108 -0
- mcp_ticketer/adapters/asana/mappers.py +14 -0
- mcp_ticketer/adapters/github.py +525 -11
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira.py +521 -0
- mcp_ticketer/adapters/linear/adapter.py +1784 -101
- mcp_ticketer/adapters/linear/client.py +85 -3
- mcp_ticketer/adapters/linear/mappers.py +96 -8
- mcp_ticketer/adapters/linear/queries.py +168 -1
- mcp_ticketer/adapters/linear/types.py +80 -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 +851 -103
- 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/instruction_commands.py +6 -0
- mcp_ticketer/cli/main.py +233 -3151
- mcp_ticketer/cli/mcp_configure.py +672 -98
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/platform_detection.py +77 -12
- mcp_ticketer/cli/platform_installer.py +536 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +12 -10
- mcp_ticketer/cli/ticket_commands.py +264 -24
- mcp_ticketer/core/__init__.py +28 -6
- mcp_ticketer/core/adapter.py +166 -1
- 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/models.py +135 -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/session_state.py +171 -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/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +106 -25
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +31 -12
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +6 -8
- 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 +1184 -136
- 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/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 +180 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1070 -123
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
- 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.0.1.dist-info/METADATA +1366 -0
- mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
- 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 → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""Semantic priority matcher for natural language ticket priority inputs.
|
|
2
|
+
|
|
3
|
+
This module provides intelligent priority matching that accepts natural language inputs
|
|
4
|
+
and resolves them to universal Priority values with confidence scoring.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Comprehensive synonym dictionary (20+ synonyms per priority)
|
|
8
|
+
- Multi-stage matching pipeline (exact → synonym → fuzzy)
|
|
9
|
+
- Confidence scoring with thresholds
|
|
10
|
+
- Support for all 4 universal priorities
|
|
11
|
+
- Natural language understanding
|
|
12
|
+
|
|
13
|
+
Design Decision: Multi-Stage Matching Pipeline
|
|
14
|
+
----------------------------------------------
|
|
15
|
+
The matcher uses a cascading approach to maximize accuracy while maintaining
|
|
16
|
+
flexibility:
|
|
17
|
+
|
|
18
|
+
1. Exact Match: Direct priority name match (confidence: 1.0)
|
|
19
|
+
2. Synonym Match: Pre-defined synonym lookup (confidence: 0.95)
|
|
20
|
+
3. Fuzzy Match: Levenshtein distance with thresholds (confidence: 0.70-0.95)
|
|
21
|
+
|
|
22
|
+
This approach ensures high confidence for common inputs while gracefully handling
|
|
23
|
+
typos and variations.
|
|
24
|
+
|
|
25
|
+
Performance Considerations:
|
|
26
|
+
- Average match time: <5ms (target: <10ms)
|
|
27
|
+
- Synonym lookup: O(1) with dict hashing
|
|
28
|
+
- Fuzzy matching: O(n) where n = number of priorities (4)
|
|
29
|
+
- Memory footprint: <500KB for matcher instance
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
>>> matcher = SemanticPriorityMatcher()
|
|
33
|
+
>>> result = matcher.match_priority("urgent")
|
|
34
|
+
>>> print(f"{result.priority.value} (confidence: {result.confidence})")
|
|
35
|
+
critical (confidence: 0.95)
|
|
36
|
+
|
|
37
|
+
>>> result = matcher.match_priority("asap")
|
|
38
|
+
>>> print(f"{result.priority.value} (confidence: {result.confidence})")
|
|
39
|
+
critical (confidence: 0.95)
|
|
40
|
+
|
|
41
|
+
>>> suggestions = matcher.suggest_priorities("important", top_n=3)
|
|
42
|
+
>>> for s in suggestions:
|
|
43
|
+
... print(f"{s.priority.value}: {s.confidence}")
|
|
44
|
+
high: 0.95
|
|
45
|
+
critical: 0.65
|
|
46
|
+
|
|
47
|
+
Ticket Reference: ISS-0002 - Add semantic priority matching for natural language inputs
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
from dataclasses import dataclass
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
from rapidfuzz import fuzz
|
|
56
|
+
|
|
57
|
+
FUZZY_AVAILABLE = True
|
|
58
|
+
except ImportError:
|
|
59
|
+
FUZZY_AVAILABLE = False
|
|
60
|
+
|
|
61
|
+
from .models import Priority
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class PriorityMatchResult:
|
|
66
|
+
"""Result of a priority matching operation.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
priority: Matched Priority
|
|
70
|
+
confidence: Confidence score (0.0-1.0)
|
|
71
|
+
match_type: Type of match used (exact, synonym, fuzzy)
|
|
72
|
+
original_input: Original user input string
|
|
73
|
+
suggestions: Alternative matches for ambiguous inputs
|
|
74
|
+
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
priority: Priority
|
|
78
|
+
confidence: float
|
|
79
|
+
match_type: str
|
|
80
|
+
original_input: str
|
|
81
|
+
suggestions: list[PriorityMatchResult] | None = None
|
|
82
|
+
|
|
83
|
+
def is_high_confidence(self) -> bool:
|
|
84
|
+
"""Check if confidence is high enough for auto-apply."""
|
|
85
|
+
return self.confidence >= 0.90
|
|
86
|
+
|
|
87
|
+
def is_medium_confidence(self) -> bool:
|
|
88
|
+
"""Check if confidence is medium (needs confirmation)."""
|
|
89
|
+
return 0.70 <= self.confidence < 0.90
|
|
90
|
+
|
|
91
|
+
def is_low_confidence(self) -> bool:
|
|
92
|
+
"""Check if confidence is too low (ambiguous)."""
|
|
93
|
+
return self.confidence < 0.70
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SemanticPriorityMatcher:
|
|
97
|
+
"""Intelligent priority matcher with natural language support.
|
|
98
|
+
|
|
99
|
+
Provides comprehensive synonym matching, fuzzy matching, and confidence
|
|
100
|
+
scoring for ticket priority assignment.
|
|
101
|
+
|
|
102
|
+
The synonym dictionary includes 20+ synonyms across all 4 universal priorities,
|
|
103
|
+
covering common variations, typos, and platform-specific terminology.
|
|
104
|
+
|
|
105
|
+
Ticket Reference: ISS-0002
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
# Comprehensive synonym dictionary for all universal priorities
|
|
109
|
+
PRIORITY_SYNONYMS: dict[Priority, list[str]] = {
|
|
110
|
+
Priority.CRITICAL: [
|
|
111
|
+
"critical",
|
|
112
|
+
"urgent",
|
|
113
|
+
"asap",
|
|
114
|
+
"as soon as possible",
|
|
115
|
+
"emergency",
|
|
116
|
+
"blocker",
|
|
117
|
+
"blocking",
|
|
118
|
+
"show stopper",
|
|
119
|
+
"show-stopper",
|
|
120
|
+
"showstopper",
|
|
121
|
+
"highest",
|
|
122
|
+
"p0",
|
|
123
|
+
"p-0",
|
|
124
|
+
"priority 0",
|
|
125
|
+
"needs immediate attention",
|
|
126
|
+
"immediate attention",
|
|
127
|
+
"very urgent",
|
|
128
|
+
"right now",
|
|
129
|
+
"drop everything",
|
|
130
|
+
"top priority",
|
|
131
|
+
"mission critical",
|
|
132
|
+
"business critical",
|
|
133
|
+
"sev 0",
|
|
134
|
+
"sev0",
|
|
135
|
+
"severity 0",
|
|
136
|
+
"must have",
|
|
137
|
+
],
|
|
138
|
+
Priority.HIGH: [
|
|
139
|
+
"high",
|
|
140
|
+
"important",
|
|
141
|
+
"soon",
|
|
142
|
+
"needs attention",
|
|
143
|
+
"p1",
|
|
144
|
+
"p-1",
|
|
145
|
+
"priority 1",
|
|
146
|
+
"high priority",
|
|
147
|
+
"should do",
|
|
148
|
+
"should have",
|
|
149
|
+
"significant",
|
|
150
|
+
"pressing",
|
|
151
|
+
"time sensitive",
|
|
152
|
+
"time-sensitive",
|
|
153
|
+
"sev 1",
|
|
154
|
+
"sev1",
|
|
155
|
+
"severity 1",
|
|
156
|
+
"major",
|
|
157
|
+
"escalated",
|
|
158
|
+
"higher",
|
|
159
|
+
"elevated",
|
|
160
|
+
],
|
|
161
|
+
Priority.MEDIUM: [
|
|
162
|
+
"medium",
|
|
163
|
+
"normal",
|
|
164
|
+
"standard",
|
|
165
|
+
"regular",
|
|
166
|
+
"moderate",
|
|
167
|
+
"average",
|
|
168
|
+
"default",
|
|
169
|
+
"typical",
|
|
170
|
+
"p2",
|
|
171
|
+
"p-2",
|
|
172
|
+
"priority 2",
|
|
173
|
+
"medium priority",
|
|
174
|
+
"could have",
|
|
175
|
+
"sev 2",
|
|
176
|
+
"sev2",
|
|
177
|
+
"severity 2",
|
|
178
|
+
"routine",
|
|
179
|
+
"ordinary",
|
|
180
|
+
],
|
|
181
|
+
Priority.LOW: [
|
|
182
|
+
"low",
|
|
183
|
+
"minor",
|
|
184
|
+
"whenever",
|
|
185
|
+
"low priority",
|
|
186
|
+
"not urgent",
|
|
187
|
+
"nice to have",
|
|
188
|
+
"nice-to-have",
|
|
189
|
+
"backlog",
|
|
190
|
+
"someday",
|
|
191
|
+
"if time permits",
|
|
192
|
+
"when possible",
|
|
193
|
+
"optional",
|
|
194
|
+
"can wait",
|
|
195
|
+
"lowest",
|
|
196
|
+
"p3",
|
|
197
|
+
"p-3",
|
|
198
|
+
"priority 3",
|
|
199
|
+
"trivial",
|
|
200
|
+
"cosmetic",
|
|
201
|
+
"sev 3",
|
|
202
|
+
"sev3",
|
|
203
|
+
"severity 3",
|
|
204
|
+
"won't have",
|
|
205
|
+
"wont have",
|
|
206
|
+
],
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# Confidence thresholds
|
|
210
|
+
CONFIDENCE_HIGH = 0.90
|
|
211
|
+
CONFIDENCE_MEDIUM = 0.70
|
|
212
|
+
FUZZY_THRESHOLD_HIGH = 90
|
|
213
|
+
FUZZY_THRESHOLD_MEDIUM = 70
|
|
214
|
+
|
|
215
|
+
def __init__(self) -> None:
|
|
216
|
+
"""Initialize the semantic priority matcher.
|
|
217
|
+
|
|
218
|
+
Creates reverse lookup dictionary for O(1) synonym matching.
|
|
219
|
+
"""
|
|
220
|
+
# Build reverse lookup: synonym -> (priority, is_exact)
|
|
221
|
+
self._synonym_to_priority: dict[str, tuple[Priority, bool]] = {}
|
|
222
|
+
|
|
223
|
+
for priority in Priority:
|
|
224
|
+
# Add exact priority value
|
|
225
|
+
self._synonym_to_priority[priority.value.lower()] = (priority, True)
|
|
226
|
+
|
|
227
|
+
# Add all synonyms
|
|
228
|
+
for synonym in self.PRIORITY_SYNONYMS.get(priority, []):
|
|
229
|
+
self._synonym_to_priority[synonym.lower()] = (priority, False)
|
|
230
|
+
|
|
231
|
+
def match_priority(
|
|
232
|
+
self,
|
|
233
|
+
user_input: str,
|
|
234
|
+
adapter_priorities: list[str] | None = None,
|
|
235
|
+
) -> PriorityMatchResult:
|
|
236
|
+
"""Match user input to universal priority with confidence score.
|
|
237
|
+
|
|
238
|
+
Uses multi-stage matching pipeline:
|
|
239
|
+
1. Exact match against priority values
|
|
240
|
+
2. Synonym lookup
|
|
241
|
+
3. Fuzzy matching with Levenshtein distance
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
user_input: Natural language priority input from user
|
|
245
|
+
adapter_priorities: Optional list of adapter-specific priority names
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
PriorityMatchResult with matched priority and confidence score
|
|
249
|
+
|
|
250
|
+
Example:
|
|
251
|
+
>>> matcher = SemanticPriorityMatcher()
|
|
252
|
+
>>> result = matcher.match_priority("urgent")
|
|
253
|
+
>>> print(f"{result.priority.value}: {result.confidence}")
|
|
254
|
+
critical: 0.95
|
|
255
|
+
|
|
256
|
+
>>> result = matcher.match_priority("criticl") # typo
|
|
257
|
+
>>> print(f"{result.priority.value}: {result.confidence}")
|
|
258
|
+
critical: 0.85
|
|
259
|
+
|
|
260
|
+
Ticket Reference: ISS-0002
|
|
261
|
+
"""
|
|
262
|
+
if not user_input:
|
|
263
|
+
# Default to MEDIUM for empty input
|
|
264
|
+
return PriorityMatchResult(
|
|
265
|
+
priority=Priority.MEDIUM,
|
|
266
|
+
confidence=0.5,
|
|
267
|
+
match_type="default",
|
|
268
|
+
original_input=user_input,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# Normalize input
|
|
272
|
+
normalized = user_input.strip().lower()
|
|
273
|
+
|
|
274
|
+
# Handle whitespace-only input (after normalization)
|
|
275
|
+
if not normalized:
|
|
276
|
+
return PriorityMatchResult(
|
|
277
|
+
priority=Priority.MEDIUM,
|
|
278
|
+
confidence=0.5,
|
|
279
|
+
match_type="default",
|
|
280
|
+
original_input=user_input,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Stage 1: Exact match
|
|
284
|
+
exact_result = self._exact_match(normalized)
|
|
285
|
+
if exact_result:
|
|
286
|
+
return exact_result
|
|
287
|
+
|
|
288
|
+
# Stage 2: Synonym match
|
|
289
|
+
synonym_result = self._synonym_match(normalized)
|
|
290
|
+
if synonym_result:
|
|
291
|
+
return synonym_result
|
|
292
|
+
|
|
293
|
+
# Stage 3: Fuzzy match
|
|
294
|
+
fuzzy_result = self._fuzzy_match(normalized)
|
|
295
|
+
if fuzzy_result:
|
|
296
|
+
return fuzzy_result
|
|
297
|
+
|
|
298
|
+
# No good match found - return suggestions
|
|
299
|
+
suggestions = self.suggest_priorities(user_input, top_n=3)
|
|
300
|
+
return PriorityMatchResult(
|
|
301
|
+
priority=suggestions[0].priority if suggestions else Priority.MEDIUM,
|
|
302
|
+
confidence=suggestions[0].confidence if suggestions else 0.5,
|
|
303
|
+
match_type="fallback",
|
|
304
|
+
original_input=user_input,
|
|
305
|
+
suggestions=suggestions,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def suggest_priorities(
|
|
309
|
+
self,
|
|
310
|
+
user_input: str,
|
|
311
|
+
top_n: int = 3,
|
|
312
|
+
) -> list[PriorityMatchResult]:
|
|
313
|
+
"""Return top N priority suggestions for ambiguous inputs.
|
|
314
|
+
|
|
315
|
+
Uses fuzzy matching to rank all possible priorities by similarity.
|
|
316
|
+
Useful for providing user with multiple options when confidence is low.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
user_input: Natural language priority input
|
|
320
|
+
top_n: Number of suggestions to return (default: 3)
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
List of PriorityMatchResult sorted by confidence (highest first)
|
|
324
|
+
|
|
325
|
+
Example:
|
|
326
|
+
>>> matcher = SemanticPriorityMatcher()
|
|
327
|
+
>>> suggestions = matcher.suggest_priorities("importnt", top_n=3)
|
|
328
|
+
>>> for s in suggestions:
|
|
329
|
+
... print(f"{s.priority.value}: {s.confidence:.2f}")
|
|
330
|
+
high: 0.85
|
|
331
|
+
medium: 0.45
|
|
332
|
+
critical: 0.42
|
|
333
|
+
|
|
334
|
+
Ticket Reference: ISS-0002
|
|
335
|
+
"""
|
|
336
|
+
if not FUZZY_AVAILABLE:
|
|
337
|
+
# Without fuzzy matching, return all priorities with low confidence
|
|
338
|
+
return [
|
|
339
|
+
PriorityMatchResult(
|
|
340
|
+
priority=priority,
|
|
341
|
+
confidence=0.5,
|
|
342
|
+
match_type="suggestion",
|
|
343
|
+
original_input=user_input,
|
|
344
|
+
)
|
|
345
|
+
for priority in Priority
|
|
346
|
+
][:top_n]
|
|
347
|
+
|
|
348
|
+
normalized = user_input.strip().lower()
|
|
349
|
+
suggestions: list[tuple[Priority, float, str]] = []
|
|
350
|
+
|
|
351
|
+
# Calculate similarity for each priority and its synonyms
|
|
352
|
+
for priority in Priority:
|
|
353
|
+
# Check priority value
|
|
354
|
+
priority_similarity = fuzz.ratio(normalized, priority.value.lower())
|
|
355
|
+
max_similarity = priority_similarity
|
|
356
|
+
match_text = priority.value
|
|
357
|
+
|
|
358
|
+
# Check synonyms
|
|
359
|
+
for synonym in self.PRIORITY_SYNONYMS.get(priority, []):
|
|
360
|
+
similarity = fuzz.ratio(normalized, synonym.lower())
|
|
361
|
+
if similarity > max_similarity:
|
|
362
|
+
max_similarity = similarity
|
|
363
|
+
match_text = synonym
|
|
364
|
+
|
|
365
|
+
# Convert similarity to confidence (0-100 → 0.0-1.0)
|
|
366
|
+
confidence = max_similarity / 100.0
|
|
367
|
+
suggestions.append((priority, confidence, match_text))
|
|
368
|
+
|
|
369
|
+
# Sort by confidence descending
|
|
370
|
+
suggestions.sort(key=lambda x: x[1], reverse=True)
|
|
371
|
+
|
|
372
|
+
# Convert to PriorityMatchResult
|
|
373
|
+
return [
|
|
374
|
+
PriorityMatchResult(
|
|
375
|
+
priority=priority,
|
|
376
|
+
confidence=conf,
|
|
377
|
+
match_type="suggestion",
|
|
378
|
+
original_input=user_input,
|
|
379
|
+
)
|
|
380
|
+
for priority, conf, _ in suggestions[:top_n]
|
|
381
|
+
]
|
|
382
|
+
|
|
383
|
+
def _exact_match(self, normalized_input: str) -> PriorityMatchResult | None:
|
|
384
|
+
"""Match exact priority value."""
|
|
385
|
+
for priority in Priority:
|
|
386
|
+
if normalized_input == priority.value.lower():
|
|
387
|
+
return PriorityMatchResult(
|
|
388
|
+
priority=priority,
|
|
389
|
+
confidence=1.0,
|
|
390
|
+
match_type="exact",
|
|
391
|
+
original_input=normalized_input,
|
|
392
|
+
)
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
def _synonym_match(self, normalized_input: str) -> PriorityMatchResult | None:
|
|
396
|
+
"""Match using synonym dictionary."""
|
|
397
|
+
if normalized_input in self._synonym_to_priority:
|
|
398
|
+
priority, is_exact = self._synonym_to_priority[normalized_input]
|
|
399
|
+
return PriorityMatchResult(
|
|
400
|
+
priority=priority,
|
|
401
|
+
confidence=1.0 if is_exact else 0.95,
|
|
402
|
+
match_type="exact" if is_exact else "synonym",
|
|
403
|
+
original_input=normalized_input,
|
|
404
|
+
)
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
def _fuzzy_match(self, normalized_input: str) -> PriorityMatchResult | None:
|
|
408
|
+
"""Match using fuzzy string matching."""
|
|
409
|
+
if not FUZZY_AVAILABLE:
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
best_match: tuple[Priority, float, str] | None = None
|
|
413
|
+
|
|
414
|
+
for priority in Priority:
|
|
415
|
+
# Check priority value
|
|
416
|
+
priority_similarity = fuzz.ratio(normalized_input, priority.value.lower())
|
|
417
|
+
|
|
418
|
+
if priority_similarity >= self.FUZZY_THRESHOLD_MEDIUM:
|
|
419
|
+
if best_match is None or priority_similarity > best_match[1]:
|
|
420
|
+
best_match = (priority, priority_similarity, "priority_value")
|
|
421
|
+
|
|
422
|
+
# Check synonyms
|
|
423
|
+
for synonym in self.PRIORITY_SYNONYMS.get(priority, []):
|
|
424
|
+
similarity = fuzz.ratio(normalized_input, synonym.lower())
|
|
425
|
+
if similarity >= self.FUZZY_THRESHOLD_MEDIUM:
|
|
426
|
+
if best_match is None or similarity > best_match[1]:
|
|
427
|
+
best_match = (priority, similarity, "synonym")
|
|
428
|
+
|
|
429
|
+
if best_match:
|
|
430
|
+
priority, similarity, match_source = best_match
|
|
431
|
+
|
|
432
|
+
# Calculate confidence based on similarity
|
|
433
|
+
if similarity >= self.FUZZY_THRESHOLD_HIGH:
|
|
434
|
+
confidence = 0.85 + (similarity - self.FUZZY_THRESHOLD_HIGH) / 100.0
|
|
435
|
+
else:
|
|
436
|
+
confidence = 0.70 + (similarity - self.FUZZY_THRESHOLD_MEDIUM) / 200.0
|
|
437
|
+
|
|
438
|
+
return PriorityMatchResult(
|
|
439
|
+
priority=priority,
|
|
440
|
+
confidence=min(confidence, 0.95), # Cap at 0.95
|
|
441
|
+
match_type="fuzzy",
|
|
442
|
+
original_input=normalized_input,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# Singleton instance for convenience
|
|
449
|
+
_default_matcher: SemanticPriorityMatcher | None = None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def get_priority_matcher() -> SemanticPriorityMatcher:
|
|
453
|
+
"""Get the default priority matcher instance.
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
Singleton SemanticPriorityMatcher instance
|
|
457
|
+
|
|
458
|
+
Ticket Reference: ISS-0002
|
|
459
|
+
"""
|
|
460
|
+
global _default_matcher
|
|
461
|
+
if _default_matcher is None:
|
|
462
|
+
_default_matcher = SemanticPriorityMatcher()
|
|
463
|
+
return _default_matcher
|
|
@@ -167,7 +167,14 @@ class HybridConfig:
|
|
|
167
167
|
|
|
168
168
|
@dataclass
|
|
169
169
|
class TicketerConfig:
|
|
170
|
-
"""Complete ticketer configuration with hierarchical resolution.
|
|
170
|
+
"""Complete ticketer configuration with hierarchical resolution.
|
|
171
|
+
|
|
172
|
+
Supports URL parsing for default_project field:
|
|
173
|
+
- Linear URLs: https://linear.app/workspace/project/project-slug-abc123
|
|
174
|
+
- JIRA URLs: https://company.atlassian.net/browse/PROJ-123
|
|
175
|
+
- GitHub URLs: https://github.com/owner/repo/projects/1
|
|
176
|
+
- Plain IDs: PROJ-123, abc-123, 1 (backward compatible)
|
|
177
|
+
"""
|
|
171
178
|
|
|
172
179
|
default_adapter: str = "aitrackdown"
|
|
173
180
|
project_configs: dict[str, ProjectConfig] = field(default_factory=dict)
|
|
@@ -176,8 +183,56 @@ class TicketerConfig:
|
|
|
176
183
|
|
|
177
184
|
# Default values for ticket operations
|
|
178
185
|
default_user: str | None = None # Default assignee (user_id or email)
|
|
179
|
-
default_project: str | None = None # Default project/epic ID
|
|
186
|
+
default_project: str | None = None # Default project/epic ID (supports URLs)
|
|
180
187
|
default_epic: str | None = None # Alias for default_project (backward compat)
|
|
188
|
+
default_tags: list[str] | None = None # Default tags for new tickets
|
|
189
|
+
default_team: str | None = None # Default team ID/key for multi-team platforms
|
|
190
|
+
default_cycle: str | None = None # Default sprint/cycle ID for timeline scoping
|
|
191
|
+
assignment_labels: list[str] | None = None # Labels indicating ticket assignment
|
|
192
|
+
|
|
193
|
+
# Automatic project updates configuration (1M-315)
|
|
194
|
+
auto_project_updates: dict[str, Any] | None = None # Auto update settings
|
|
195
|
+
|
|
196
|
+
def __post_init__(self):
|
|
197
|
+
"""Normalize default_project if it's a URL."""
|
|
198
|
+
if self.default_project:
|
|
199
|
+
self.default_project = self._normalize_project_id(self.default_project)
|
|
200
|
+
if self.default_epic:
|
|
201
|
+
self.default_epic = self._normalize_project_id(self.default_epic)
|
|
202
|
+
|
|
203
|
+
def _normalize_project_id(self, value: str) -> str:
|
|
204
|
+
"""Normalize project ID by extracting from URL if needed.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
value: Project ID or URL
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Normalized project ID (plain ID, not URL)
|
|
211
|
+
|
|
212
|
+
Examples:
|
|
213
|
+
>>> config._normalize_project_id("PROJ-123")
|
|
214
|
+
'PROJ-123'
|
|
215
|
+
>>> config._normalize_project_id("https://linear.app/team/project/abc-123")
|
|
216
|
+
'abc-123'
|
|
217
|
+
|
|
218
|
+
"""
|
|
219
|
+
from .url_parser import is_url, normalize_project_id
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
# If it's a URL, use auto-detection (don't rely on default_adapter)
|
|
223
|
+
# This allows users to paste URLs from any platform
|
|
224
|
+
if is_url(value):
|
|
225
|
+
normalized = normalize_project_id(value, adapter_type=None)
|
|
226
|
+
else:
|
|
227
|
+
# For plain IDs, just return as-is
|
|
228
|
+
normalized = normalize_project_id(value, self.default_adapter)
|
|
229
|
+
|
|
230
|
+
logger.debug(f"Normalized '{value}' to '{normalized}'")
|
|
231
|
+
return normalized
|
|
232
|
+
except Exception as e:
|
|
233
|
+
# If normalization fails, log warning but keep original value
|
|
234
|
+
logger.warning(f"Failed to normalize project ID '{value}': {e}")
|
|
235
|
+
return value
|
|
181
236
|
|
|
182
237
|
def to_dict(self) -> dict[str, Any]:
|
|
183
238
|
"""Convert to dictionary for JSON serialization."""
|
|
@@ -198,6 +253,16 @@ class TicketerConfig:
|
|
|
198
253
|
result["default_project"] = self.default_project
|
|
199
254
|
if self.default_epic is not None:
|
|
200
255
|
result["default_epic"] = self.default_epic
|
|
256
|
+
if self.default_tags is not None:
|
|
257
|
+
result["default_tags"] = self.default_tags
|
|
258
|
+
if self.default_team is not None:
|
|
259
|
+
result["default_team"] = self.default_team
|
|
260
|
+
if self.default_cycle is not None:
|
|
261
|
+
result["default_cycle"] = self.default_cycle
|
|
262
|
+
if self.assignment_labels is not None:
|
|
263
|
+
result["assignment_labels"] = self.assignment_labels
|
|
264
|
+
if self.auto_project_updates is not None:
|
|
265
|
+
result["auto_project_updates"] = self.auto_project_updates
|
|
201
266
|
return result
|
|
202
267
|
|
|
203
268
|
@classmethod
|
|
@@ -228,6 +293,11 @@ class TicketerConfig:
|
|
|
228
293
|
default_user=data.get("default_user"),
|
|
229
294
|
default_project=data.get("default_project"),
|
|
230
295
|
default_epic=data.get("default_epic"),
|
|
296
|
+
default_tags=data.get("default_tags"),
|
|
297
|
+
default_team=data.get("default_team"),
|
|
298
|
+
default_cycle=data.get("default_cycle"),
|
|
299
|
+
assignment_labels=data.get("assignment_labels"),
|
|
300
|
+
auto_project_updates=data.get("auto_project_updates"),
|
|
231
301
|
)
|
|
232
302
|
|
|
233
303
|
|
|
@@ -238,21 +308,69 @@ class ConfigValidator:
|
|
|
238
308
|
def validate_linear_config(config: dict[str, Any]) -> tuple[bool, str | None]:
|
|
239
309
|
"""Validate Linear adapter configuration.
|
|
240
310
|
|
|
311
|
+
Args:
|
|
312
|
+
config: Linear configuration dictionary
|
|
313
|
+
|
|
241
314
|
Returns:
|
|
242
315
|
Tuple of (is_valid, error_message)
|
|
243
316
|
|
|
244
317
|
"""
|
|
318
|
+
import logging
|
|
319
|
+
import re
|
|
320
|
+
|
|
321
|
+
logger = logging.getLogger(__name__)
|
|
322
|
+
|
|
245
323
|
required = ["api_key"]
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
324
|
+
missing_fields = []
|
|
325
|
+
|
|
326
|
+
for field_name in required:
|
|
327
|
+
if field_name not in config or not config[field_name]:
|
|
328
|
+
missing_fields.append(field_name)
|
|
329
|
+
|
|
330
|
+
if missing_fields:
|
|
331
|
+
return (
|
|
332
|
+
False,
|
|
333
|
+
f"Linear config missing required fields: {', '.join(missing_fields)}",
|
|
334
|
+
)
|
|
249
335
|
|
|
250
|
-
# Require either team_key or team_id (
|
|
251
|
-
|
|
336
|
+
# Require either team_key or team_id (team_key is preferred)
|
|
337
|
+
has_team_key = config.get("team_key") and config["team_key"].strip()
|
|
338
|
+
has_team_id = config.get("team_id") and config["team_id"].strip()
|
|
339
|
+
|
|
340
|
+
if not has_team_key and not has_team_id:
|
|
252
341
|
return (
|
|
253
342
|
False,
|
|
254
|
-
"Linear config requires either team_key (short key like '
|
|
343
|
+
"Linear config requires either team_key (short key like 'ENG') or team_id (UUID)",
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Validate team_id format if provided (should be UUID)
|
|
347
|
+
if has_team_id:
|
|
348
|
+
team_id = config["team_id"]
|
|
349
|
+
uuid_pattern = re.compile(
|
|
350
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
|
351
|
+
re.IGNORECASE,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
if not uuid_pattern.match(team_id):
|
|
355
|
+
# Not a UUID - could be a team_key mistakenly stored as team_id
|
|
356
|
+
logger.warning(
|
|
357
|
+
f"team_id '{team_id}' is not a UUID format. "
|
|
358
|
+
f"It will be treated as team_key and resolved at runtime."
|
|
359
|
+
)
|
|
360
|
+
# Move it to team_key if team_key is empty
|
|
361
|
+
if not has_team_key:
|
|
362
|
+
config["team_key"] = team_id
|
|
363
|
+
del config["team_id"]
|
|
364
|
+
logger.info(f"Moved non-UUID team_id to team_key: {team_id}")
|
|
365
|
+
|
|
366
|
+
# Validate user_email format if provided
|
|
367
|
+
if config.get("user_email"):
|
|
368
|
+
email = config["user_email"]
|
|
369
|
+
email_pattern = re.compile(
|
|
370
|
+
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
|
255
371
|
)
|
|
372
|
+
if not email_pattern.match(email):
|
|
373
|
+
return False, f"Invalid email format for user_email: {email}"
|
|
256
374
|
|
|
257
375
|
return True, None
|
|
258
376
|
|
|
@@ -279,9 +397,9 @@ class ConfigValidator:
|
|
|
279
397
|
|
|
280
398
|
# Otherwise need explicit owner and repo
|
|
281
399
|
required = ["owner", "repo"]
|
|
282
|
-
for
|
|
283
|
-
if
|
|
284
|
-
return False, f"GitHub config missing required field: {
|
|
400
|
+
for field_name in required:
|
|
401
|
+
if field_name not in config or not config[field_name]:
|
|
402
|
+
return False, f"GitHub config missing required field: {field_name}"
|
|
285
403
|
|
|
286
404
|
return True, None
|
|
287
405
|
|
|
@@ -294,9 +412,9 @@ class ConfigValidator:
|
|
|
294
412
|
|
|
295
413
|
"""
|
|
296
414
|
required = ["server", "email", "api_token"]
|
|
297
|
-
for
|
|
298
|
-
if
|
|
299
|
-
return False, f"JIRA config missing required field: {
|
|
415
|
+
for field_name in required:
|
|
416
|
+
if field_name not in config or not config[field_name]:
|
|
417
|
+
return False, f"JIRA config missing required field: {field_name}"
|
|
300
418
|
|
|
301
419
|
# Validate server URL format
|
|
302
420
|
server = config["server"]
|