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.

Files changed (111) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/adapters/__init__.py +2 -0
  4. mcp_ticketer/adapters/aitrackdown.py +394 -9
  5. mcp_ticketer/adapters/asana/__init__.py +15 -0
  6. mcp_ticketer/adapters/asana/adapter.py +1416 -0
  7. mcp_ticketer/adapters/asana/client.py +292 -0
  8. mcp_ticketer/adapters/asana/mappers.py +348 -0
  9. mcp_ticketer/adapters/asana/types.py +146 -0
  10. mcp_ticketer/adapters/github.py +836 -105
  11. mcp_ticketer/adapters/hybrid.py +47 -5
  12. mcp_ticketer/adapters/jira.py +772 -1
  13. mcp_ticketer/adapters/linear/adapter.py +2293 -108
  14. mcp_ticketer/adapters/linear/client.py +146 -12
  15. mcp_ticketer/adapters/linear/mappers.py +105 -11
  16. mcp_ticketer/adapters/linear/queries.py +168 -1
  17. mcp_ticketer/adapters/linear/types.py +80 -4
  18. mcp_ticketer/analysis/__init__.py +56 -0
  19. mcp_ticketer/analysis/dependency_graph.py +255 -0
  20. mcp_ticketer/analysis/health_assessment.py +304 -0
  21. mcp_ticketer/analysis/orphaned.py +218 -0
  22. mcp_ticketer/analysis/project_status.py +594 -0
  23. mcp_ticketer/analysis/similarity.py +224 -0
  24. mcp_ticketer/analysis/staleness.py +266 -0
  25. mcp_ticketer/automation/__init__.py +11 -0
  26. mcp_ticketer/automation/project_updates.py +378 -0
  27. mcp_ticketer/cache/memory.py +3 -3
  28. mcp_ticketer/cli/adapter_diagnostics.py +4 -2
  29. mcp_ticketer/cli/auggie_configure.py +18 -6
  30. mcp_ticketer/cli/codex_configure.py +175 -60
  31. mcp_ticketer/cli/configure.py +884 -146
  32. mcp_ticketer/cli/cursor_configure.py +314 -0
  33. mcp_ticketer/cli/diagnostics.py +31 -28
  34. mcp_ticketer/cli/discover.py +293 -21
  35. mcp_ticketer/cli/gemini_configure.py +18 -6
  36. mcp_ticketer/cli/init_command.py +880 -0
  37. mcp_ticketer/cli/instruction_commands.py +435 -0
  38. mcp_ticketer/cli/linear_commands.py +99 -15
  39. mcp_ticketer/cli/main.py +109 -2055
  40. mcp_ticketer/cli/mcp_configure.py +673 -99
  41. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  42. mcp_ticketer/cli/migrate_config.py +12 -8
  43. mcp_ticketer/cli/platform_commands.py +6 -6
  44. mcp_ticketer/cli/platform_detection.py +477 -0
  45. mcp_ticketer/cli/platform_installer.py +536 -0
  46. mcp_ticketer/cli/project_update_commands.py +350 -0
  47. mcp_ticketer/cli/queue_commands.py +15 -15
  48. mcp_ticketer/cli/setup_command.py +639 -0
  49. mcp_ticketer/cli/simple_health.py +13 -11
  50. mcp_ticketer/cli/ticket_commands.py +277 -36
  51. mcp_ticketer/cli/update_checker.py +313 -0
  52. mcp_ticketer/cli/utils.py +45 -41
  53. mcp_ticketer/core/__init__.py +35 -1
  54. mcp_ticketer/core/adapter.py +170 -5
  55. mcp_ticketer/core/config.py +38 -31
  56. mcp_ticketer/core/env_discovery.py +33 -3
  57. mcp_ticketer/core/env_loader.py +7 -6
  58. mcp_ticketer/core/exceptions.py +10 -4
  59. mcp_ticketer/core/http_client.py +10 -10
  60. mcp_ticketer/core/instructions.py +405 -0
  61. mcp_ticketer/core/label_manager.py +732 -0
  62. mcp_ticketer/core/mappers.py +32 -20
  63. mcp_ticketer/core/models.py +136 -1
  64. mcp_ticketer/core/onepassword_secrets.py +379 -0
  65. mcp_ticketer/core/priority_matcher.py +463 -0
  66. mcp_ticketer/core/project_config.py +148 -14
  67. mcp_ticketer/core/registry.py +1 -1
  68. mcp_ticketer/core/session_state.py +171 -0
  69. mcp_ticketer/core/state_matcher.py +592 -0
  70. mcp_ticketer/core/url_parser.py +425 -0
  71. mcp_ticketer/core/validators.py +69 -0
  72. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  73. mcp_ticketer/mcp/__init__.py +2 -2
  74. mcp_ticketer/mcp/server/__init__.py +2 -2
  75. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  76. mcp_ticketer/mcp/server/main.py +187 -93
  77. mcp_ticketer/mcp/server/routing.py +655 -0
  78. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  79. mcp_ticketer/mcp/server/tools/__init__.py +37 -9
  80. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  81. mcp_ticketer/mcp/server/tools/attachment_tools.py +65 -20
  82. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  83. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  84. mcp_ticketer/mcp/server/tools/config_tools.py +1429 -0
  85. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  86. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +878 -319
  87. mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
  88. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  89. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  90. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  91. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  92. mcp_ticketer/mcp/server/tools/search_tools.py +180 -97
  93. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  94. mcp_ticketer/mcp/server/tools/ticket_tools.py +1182 -82
  95. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
  96. mcp_ticketer/queue/health_monitor.py +1 -0
  97. mcp_ticketer/queue/manager.py +4 -4
  98. mcp_ticketer/queue/queue.py +3 -3
  99. mcp_ticketer/queue/run_worker.py +1 -1
  100. mcp_ticketer/queue/ticket_registry.py +2 -2
  101. mcp_ticketer/queue/worker.py +15 -13
  102. mcp_ticketer/utils/__init__.py +5 -0
  103. mcp_ticketer/utils/token_utils.py +246 -0
  104. mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
  105. mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
  106. mcp_ticketer-0.4.11.dist-info/METADATA +0 -496
  107. mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
  108. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
  109. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
  110. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
  111. {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
- return {
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
- for field in required:
231
- if field not in config or not config[field]:
232
- return False, f"Linear config missing required field: {field}"
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
- # Require either team_key or team_id (team_id is preferred)
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 requires either team_key (short key like 'BTA') or team_id (UUID)",
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 field in required:
267
- if field not in config or not config[field]:
268
- return False, f"GitHub config missing required field: {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 field in required:
282
- if field not in config or not config[field]:
283
- return False, f"JIRA config missing required field: {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"]
@@ -115,7 +115,7 @@ class AdapterRegistry:
115
115
 
116
116
 
117
117
  def adapter_factory(adapter_type: str, config: dict[str, Any]) -> BaseAdapter:
118
- """Factory function for creating adapters.
118
+ """Create adapter instance using factory pattern.
119
119
 
120
120
  Args:
121
121
  adapter_type: Type of adapter to create