mcp-ticketer 0.4.11__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 +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +394 -9
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1416 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +348 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +836 -105
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira.py +772 -1
- mcp_ticketer/adapters/linear/adapter.py +2293 -108
- mcp_ticketer/adapters/linear/client.py +146 -12
- mcp_ticketer/adapters/linear/mappers.py +105 -11
- 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/cache/memory.py +3 -3
- mcp_ticketer/cli/adapter_diagnostics.py +4 -2
- mcp_ticketer/cli/auggie_configure.py +18 -6
- mcp_ticketer/cli/codex_configure.py +175 -60
- mcp_ticketer/cli/configure.py +884 -146
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +31 -28
- mcp_ticketer/cli/discover.py +293 -21
- mcp_ticketer/cli/gemini_configure.py +18 -6
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +99 -15
- mcp_ticketer/cli/main.py +109 -2055
- mcp_ticketer/cli/mcp_configure.py +673 -99
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +6 -6
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +536 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +13 -11
- mcp_ticketer/cli/ticket_commands.py +277 -36
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +45 -41
- mcp_ticketer/core/__init__.py +35 -1
- mcp_ticketer/core/adapter.py +170 -5
- mcp_ticketer/core/config.py +38 -31
- mcp_ticketer/core/env_discovery.py +33 -3
- mcp_ticketer/core/env_loader.py +7 -6
- mcp_ticketer/core/exceptions.py +10 -4
- mcp_ticketer/core/http_client.py +10 -10
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +32 -20
- mcp_ticketer/core/models.py +136 -1
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +148 -14
- mcp_ticketer/core/registry.py +1 -1
- 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/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +2 -2
- mcp_ticketer/mcp/server/__init__.py +2 -2
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +187 -93
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +37 -9
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +65 -20
- 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 +1429 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +878 -319
- mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/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 +1182 -82
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/health_monitor.py +1 -0
- mcp_ticketer/queue/manager.py +4 -4
- mcp_ticketer/queue/queue.py +3 -3
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +2 -2
- mcp_ticketer/queue/worker.py +15 -13
- 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.4.11.dist-info/METADATA +0 -496
- mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.4.11.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,16 +167,76 @@ 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)
|
|
174
181
|
adapters: dict[str, AdapterConfig] = field(default_factory=dict)
|
|
175
182
|
hybrid_mode: HybridConfig | None = None
|
|
176
183
|
|
|
184
|
+
# Default values for ticket operations
|
|
185
|
+
default_user: str | None = None # Default assignee (user_id or email)
|
|
186
|
+
default_project: str | None = None # Default project/epic ID (supports URLs)
|
|
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
|
|
236
|
+
|
|
177
237
|
def to_dict(self) -> dict[str, Any]:
|
|
178
238
|
"""Convert to dictionary for JSON serialization."""
|
|
179
|
-
|
|
239
|
+
result = {
|
|
180
240
|
"default_adapter": self.default_adapter,
|
|
181
241
|
"project_configs": {
|
|
182
242
|
path: config.to_dict() for path, config in self.project_configs.items()
|
|
@@ -186,6 +246,24 @@ class TicketerConfig:
|
|
|
186
246
|
},
|
|
187
247
|
"hybrid_mode": self.hybrid_mode.to_dict() if self.hybrid_mode else None,
|
|
188
248
|
}
|
|
249
|
+
# Add optional fields if set
|
|
250
|
+
if self.default_user is not None:
|
|
251
|
+
result["default_user"] = self.default_user
|
|
252
|
+
if self.default_project is not None:
|
|
253
|
+
result["default_project"] = self.default_project
|
|
254
|
+
if self.default_epic is not None:
|
|
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
|
|
266
|
+
return result
|
|
189
267
|
|
|
190
268
|
@classmethod
|
|
191
269
|
def from_dict(cls, data: dict[str, Any]) -> "TicketerConfig":
|
|
@@ -212,6 +290,14 @@ class TicketerConfig:
|
|
|
212
290
|
project_configs=project_configs,
|
|
213
291
|
adapters=adapters,
|
|
214
292
|
hybrid_mode=hybrid_mode,
|
|
293
|
+
default_user=data.get("default_user"),
|
|
294
|
+
default_project=data.get("default_project"),
|
|
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"),
|
|
215
301
|
)
|
|
216
302
|
|
|
217
303
|
|
|
@@ -222,21 +308,69 @@ class ConfigValidator:
|
|
|
222
308
|
def validate_linear_config(config: dict[str, Any]) -> tuple[bool, str | None]:
|
|
223
309
|
"""Validate Linear adapter configuration.
|
|
224
310
|
|
|
311
|
+
Args:
|
|
312
|
+
config: Linear configuration dictionary
|
|
313
|
+
|
|
225
314
|
Returns:
|
|
226
315
|
Tuple of (is_valid, error_message)
|
|
227
316
|
|
|
228
317
|
"""
|
|
318
|
+
import logging
|
|
319
|
+
import re
|
|
320
|
+
|
|
321
|
+
logger = logging.getLogger(__name__)
|
|
322
|
+
|
|
229
323
|
required = ["api_key"]
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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)
|
|
233
329
|
|
|
234
|
-
|
|
235
|
-
if not config.get("team_key") and not config.get("team_id"):
|
|
330
|
+
if missing_fields:
|
|
236
331
|
return (
|
|
237
332
|
False,
|
|
238
|
-
"Linear config
|
|
333
|
+
f"Linear config missing required fields: {', '.join(missing_fields)}",
|
|
334
|
+
)
|
|
335
|
+
|
|
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:
|
|
341
|
+
return (
|
|
342
|
+
False,
|
|
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,}$"
|
|
239
371
|
)
|
|
372
|
+
if not email_pattern.match(email):
|
|
373
|
+
return False, f"Invalid email format for user_email: {email}"
|
|
240
374
|
|
|
241
375
|
return True, None
|
|
242
376
|
|
|
@@ -263,9 +397,9 @@ class ConfigValidator:
|
|
|
263
397
|
|
|
264
398
|
# Otherwise need explicit owner and repo
|
|
265
399
|
required = ["owner", "repo"]
|
|
266
|
-
for
|
|
267
|
-
if
|
|
268
|
-
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}"
|
|
269
403
|
|
|
270
404
|
return True, None
|
|
271
405
|
|
|
@@ -278,9 +412,9 @@ class ConfigValidator:
|
|
|
278
412
|
|
|
279
413
|
"""
|
|
280
414
|
required = ["server", "email", "api_token"]
|
|
281
|
-
for
|
|
282
|
-
if
|
|
283
|
-
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}"
|
|
284
418
|
|
|
285
419
|
# Validate server URL format
|
|
286
420
|
server = config["server"]
|
mcp_ticketer/core/registry.py
CHANGED
|
@@ -115,7 +115,7 @@ class AdapterRegistry:
|
|
|
115
115
|
|
|
116
116
|
|
|
117
117
|
def adapter_factory(adapter_type: str, config: dict[str, Any]) -> BaseAdapter:
|
|
118
|
-
"""
|
|
118
|
+
"""Create adapter instance using factory pattern.
|
|
119
119
|
|
|
120
120
|
Args:
|
|
121
121
|
adapter_type: Type of adapter to create
|