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.

Files changed (87) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +1 -1
  3. mcp_ticketer/adapters/aitrackdown.py +385 -6
  4. mcp_ticketer/adapters/asana/adapter.py +108 -0
  5. mcp_ticketer/adapters/asana/mappers.py +14 -0
  6. mcp_ticketer/adapters/github.py +525 -11
  7. mcp_ticketer/adapters/hybrid.py +47 -5
  8. mcp_ticketer/adapters/jira.py +521 -0
  9. mcp_ticketer/adapters/linear/adapter.py +1784 -101
  10. mcp_ticketer/adapters/linear/client.py +85 -3
  11. mcp_ticketer/adapters/linear/mappers.py +96 -8
  12. mcp_ticketer/adapters/linear/queries.py +168 -1
  13. mcp_ticketer/adapters/linear/types.py +80 -4
  14. mcp_ticketer/analysis/__init__.py +56 -0
  15. mcp_ticketer/analysis/dependency_graph.py +255 -0
  16. mcp_ticketer/analysis/health_assessment.py +304 -0
  17. mcp_ticketer/analysis/orphaned.py +218 -0
  18. mcp_ticketer/analysis/project_status.py +594 -0
  19. mcp_ticketer/analysis/similarity.py +224 -0
  20. mcp_ticketer/analysis/staleness.py +266 -0
  21. mcp_ticketer/automation/__init__.py +11 -0
  22. mcp_ticketer/automation/project_updates.py +378 -0
  23. mcp_ticketer/cli/adapter_diagnostics.py +3 -1
  24. mcp_ticketer/cli/auggie_configure.py +17 -5
  25. mcp_ticketer/cli/codex_configure.py +97 -61
  26. mcp_ticketer/cli/configure.py +851 -103
  27. mcp_ticketer/cli/cursor_configure.py +314 -0
  28. mcp_ticketer/cli/diagnostics.py +13 -12
  29. mcp_ticketer/cli/discover.py +5 -0
  30. mcp_ticketer/cli/gemini_configure.py +17 -5
  31. mcp_ticketer/cli/init_command.py +880 -0
  32. mcp_ticketer/cli/instruction_commands.py +6 -0
  33. mcp_ticketer/cli/main.py +233 -3151
  34. mcp_ticketer/cli/mcp_configure.py +672 -98
  35. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  36. mcp_ticketer/cli/platform_detection.py +77 -12
  37. mcp_ticketer/cli/platform_installer.py +536 -0
  38. mcp_ticketer/cli/project_update_commands.py +350 -0
  39. mcp_ticketer/cli/setup_command.py +639 -0
  40. mcp_ticketer/cli/simple_health.py +12 -10
  41. mcp_ticketer/cli/ticket_commands.py +264 -24
  42. mcp_ticketer/core/__init__.py +28 -6
  43. mcp_ticketer/core/adapter.py +166 -1
  44. mcp_ticketer/core/config.py +21 -21
  45. mcp_ticketer/core/exceptions.py +7 -1
  46. mcp_ticketer/core/label_manager.py +732 -0
  47. mcp_ticketer/core/mappers.py +31 -19
  48. mcp_ticketer/core/models.py +135 -0
  49. mcp_ticketer/core/onepassword_secrets.py +1 -1
  50. mcp_ticketer/core/priority_matcher.py +463 -0
  51. mcp_ticketer/core/project_config.py +132 -14
  52. mcp_ticketer/core/session_state.py +171 -0
  53. mcp_ticketer/core/state_matcher.py +592 -0
  54. mcp_ticketer/core/url_parser.py +425 -0
  55. mcp_ticketer/core/validators.py +69 -0
  56. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  57. mcp_ticketer/mcp/server/main.py +106 -25
  58. mcp_ticketer/mcp/server/routing.py +655 -0
  59. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  60. mcp_ticketer/mcp/server/tools/__init__.py +31 -12
  61. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  62. mcp_ticketer/mcp/server/tools/attachment_tools.py +6 -8
  63. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  64. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  65. mcp_ticketer/mcp/server/tools/config_tools.py +1184 -136
  66. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  67. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
  68. mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
  69. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  70. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  71. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  72. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  73. mcp_ticketer/mcp/server/tools/search_tools.py +180 -97
  74. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  75. mcp_ticketer/mcp/server/tools/ticket_tools.py +1070 -123
  76. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
  77. mcp_ticketer/queue/worker.py +1 -1
  78. mcp_ticketer/utils/__init__.py +5 -0
  79. mcp_ticketer/utils/token_utils.py +246 -0
  80. mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
  81. mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
  82. mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
  83. mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
  84. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
  85. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
  86. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
  87. {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
- for field in required:
247
- if field not in config or not config[field]:
248
- 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)
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 (team_id is preferred)
251
- if not config.get("team_key") and not config.get("team_id"):
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 'BTA') or team_id (UUID)",
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 field in required:
283
- if field not in config or not config[field]:
284
- 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}"
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 field in required:
298
- if field not in config or not config[field]:
299
- 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}"
300
418
 
301
419
  # Validate server URL format
302
420
  server = config["server"]