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
@@ -351,7 +351,8 @@ SEARCH_ISSUE_BY_IDENTIFIER_QUERY = """
351
351
  """
352
352
 
353
353
  LIST_PROJECTS_QUERY = (
354
- PROJECT_FRAGMENT
354
+ TEAM_FRAGMENT # Required by PROJECT_FRAGMENT which uses ...TeamFields
355
+ + PROJECT_FRAGMENT
355
356
  + """
356
357
  query ListProjects($filter: ProjectFilter, $first: Int!) {
357
358
  projects(filter: $filter, first: $first, orderBy: updatedAt) {
@@ -387,3 +388,169 @@ GET_CURRENT_USER_QUERY = (
387
388
  }
388
389
  """
389
390
  )
391
+
392
+ CREATE_LABEL_MUTATION = """
393
+ mutation CreateLabel($input: IssueLabelCreateInput!) {
394
+ issueLabelCreate(input: $input) {
395
+ success
396
+ issueLabel {
397
+ id
398
+ name
399
+ color
400
+ description
401
+ }
402
+ }
403
+ }
404
+ """
405
+
406
+ LIST_CYCLES_QUERY = """
407
+ query GetCycles($teamId: String!, $first: Int!, $after: String) {
408
+ team(id: $teamId) {
409
+ cycles(first: $first, after: $after) {
410
+ nodes {
411
+ id
412
+ name
413
+ number
414
+ startsAt
415
+ endsAt
416
+ completedAt
417
+ progress
418
+ }
419
+ pageInfo {
420
+ hasNextPage
421
+ endCursor
422
+ }
423
+ }
424
+ }
425
+ }
426
+ """
427
+
428
+ GET_ISSUE_STATUS_QUERY = """
429
+ query GetIssueStatus($issueId: String!) {
430
+ issue(id: $issueId) {
431
+ id
432
+ state {
433
+ id
434
+ name
435
+ type
436
+ color
437
+ description
438
+ position
439
+ }
440
+ }
441
+ }
442
+ """
443
+
444
+ LIST_ISSUE_STATUSES_QUERY = """
445
+ query GetWorkflowStates($teamId: String!) {
446
+ team(id: $teamId) {
447
+ states {
448
+ nodes {
449
+ id
450
+ name
451
+ type
452
+ color
453
+ description
454
+ position
455
+ }
456
+ }
457
+ }
458
+ }
459
+ """
460
+
461
+ GET_CUSTOM_VIEW_QUERY = (
462
+ ISSUE_LIST_FRAGMENTS
463
+ + """
464
+ query GetCustomView($viewId: String!, $first: Int!) {
465
+ customView(id: $viewId) {
466
+ id
467
+ name
468
+ description
469
+ issues(first: $first) {
470
+ nodes {
471
+ ...IssueCompactFields
472
+ }
473
+ pageInfo {
474
+ hasNextPage
475
+ }
476
+ }
477
+ }
478
+ }
479
+ """
480
+ )
481
+
482
+ # Project Update queries and mutations (1M-238)
483
+
484
+ PROJECT_UPDATE_FRAGMENT = """
485
+ fragment ProjectUpdateFields on ProjectUpdate {
486
+ id
487
+ body
488
+ health
489
+ createdAt
490
+ updatedAt
491
+ diffMarkdown
492
+ url
493
+ user {
494
+ id
495
+ name
496
+ email
497
+ }
498
+ project {
499
+ id
500
+ name
501
+ slugId
502
+ }
503
+ }
504
+ """
505
+
506
+ CREATE_PROJECT_UPDATE_MUTATION = (
507
+ PROJECT_UPDATE_FRAGMENT
508
+ + """
509
+ mutation ProjectUpdateCreate(
510
+ $projectId: String!
511
+ $body: String!
512
+ $health: ProjectUpdateHealthType
513
+ ) {
514
+ projectUpdateCreate(
515
+ input: {
516
+ projectId: $projectId
517
+ body: $body
518
+ health: $health
519
+ }
520
+ ) {
521
+ success
522
+ projectUpdate {
523
+ ...ProjectUpdateFields
524
+ }
525
+ }
526
+ }
527
+ """
528
+ )
529
+
530
+ LIST_PROJECT_UPDATES_QUERY = (
531
+ PROJECT_UPDATE_FRAGMENT
532
+ + """
533
+ query ProjectUpdates($projectId: String!, $first: Int) {
534
+ project(id: $projectId) {
535
+ id
536
+ name
537
+ projectUpdates(first: $first) {
538
+ nodes {
539
+ ...ProjectUpdateFields
540
+ }
541
+ }
542
+ }
543
+ }
544
+ """
545
+ )
546
+
547
+ GET_PROJECT_UPDATE_QUERY = (
548
+ PROJECT_UPDATE_FRAGMENT
549
+ + """
550
+ query ProjectUpdate($id: String!) {
551
+ projectUpdate(id: $id) {
552
+ ...ProjectUpdateFields
553
+ }
554
+ }
555
+ """
556
+ )
@@ -128,17 +128,87 @@ def get_linear_state_type(state: TicketState) -> str:
128
128
  return LinearStateMapping.TO_LINEAR.get(state, "unstarted")
129
129
 
130
130
 
131
- def get_universal_state(linear_state_type: str) -> TicketState:
132
- """Convert Linear workflow state type to universal TicketState.
131
+ def get_universal_state(
132
+ linear_state_type: str, state_name: str | None = None
133
+ ) -> TicketState:
134
+ """Convert Linear workflow state type to universal TicketState with synonym matching.
135
+
136
+ This function implements intelligent state mapping with fallback strategies:
137
+ 1. Try exact match on state type (backlog, unstarted, started, completed, canceled)
138
+ 2. Try synonym matching on state name (ToDo, In Review, Testing, etc.)
139
+ 3. Default to OPEN for unknown states
140
+
141
+ Synonym Matching Rules (ticket 1M-164):
142
+ - "Done", "Closed", "Cancelled", "Completed", "Won't Do" → CLOSED
143
+ - Everything else → OPEN
133
144
 
134
145
  Args:
135
- linear_state_type: Linear workflow state type string
146
+ linear_state_type: Linear workflow state type string (from state.type field)
147
+ state_name: Linear workflow state name (from state.name field, optional)
136
148
 
137
149
  Returns:
138
150
  Universal ticket state enum
139
151
 
140
152
  """
141
- return LinearStateMapping.FROM_LINEAR.get(linear_state_type, TicketState.OPEN)
153
+ # First try exact type match
154
+ if linear_state_type in LinearStateMapping.FROM_LINEAR:
155
+ return LinearStateMapping.FROM_LINEAR[linear_state_type]
156
+
157
+ # If no exact match and state_name provided, try synonym matching
158
+ if state_name:
159
+ state_name_lower = state_name.lower().strip()
160
+
161
+ # Check for "done/closed" synonyms - these become CLOSED
162
+ closed_synonyms = [
163
+ "done",
164
+ "closed",
165
+ "cancelled",
166
+ "canceled",
167
+ "completed",
168
+ "won't do",
169
+ "wont do",
170
+ "rejected",
171
+ "resolved",
172
+ "finished",
173
+ ]
174
+
175
+ if any(synonym in state_name_lower for synonym in closed_synonyms):
176
+ return (
177
+ TicketState.DONE if state_name_lower == "done" else TicketState.CLOSED
178
+ )
179
+
180
+ # Check for "in progress" synonyms
181
+ in_progress_synonyms = [
182
+ "in progress",
183
+ "in-progress",
184
+ "working",
185
+ "active",
186
+ "started",
187
+ "doing",
188
+ "in development",
189
+ "in dev",
190
+ ]
191
+
192
+ if any(synonym in state_name_lower for synonym in in_progress_synonyms):
193
+ return TicketState.IN_PROGRESS
194
+
195
+ # Check for "review/testing" synonyms
196
+ review_synonyms = [
197
+ "review",
198
+ "in review",
199
+ "in-review",
200
+ "testing",
201
+ "in test",
202
+ "in-test",
203
+ "qa",
204
+ "ready for review",
205
+ ]
206
+
207
+ if any(synonym in state_name_lower for synonym in review_synonyms):
208
+ return TicketState.READY
209
+
210
+ # Default: everything else is OPEN (including "ToDo", "Backlog", "To Do", etc.)
211
+ return TicketState.OPEN
142
212
 
143
213
 
144
214
  def build_issue_filter(
@@ -147,6 +217,7 @@ def build_issue_filter(
147
217
  priority: Priority | None = None,
148
218
  team_id: str | None = None,
149
219
  project_id: str | None = None,
220
+ parent_id: str | None = None,
150
221
  labels: list[str] | None = None,
151
222
  created_after: str | None = None,
152
223
  updated_after: str | None = None,
@@ -161,6 +232,7 @@ def build_issue_filter(
161
232
  priority: Filter by priority
162
233
  team_id: Filter by team ID
163
234
  project_id: Filter by project ID
235
+ parent_id: Filter by parent issue ID (for listing sub-issues)
164
236
  labels: Filter by label names
165
237
  created_after: Filter by creation date (ISO string)
166
238
  updated_after: Filter by update date (ISO string)
@@ -195,6 +267,10 @@ def build_issue_filter(
195
267
  if project_id:
196
268
  issue_filter["project"] = {"id": {"eq": project_id}}
197
269
 
270
+ # Parent filter (for listing children/sub-issues)
271
+ if parent_id:
272
+ issue_filter["parent"] = {"id": {"eq": parent_id}}
273
+
198
274
  # Labels filter
199
275
  if labels:
200
276
  issue_filter["labels"] = {"some": {"name": {"in": labels}}}
@@ -0,0 +1,56 @@
1
+ """Ticket analysis and cleanup tools for PM monitoring.
2
+
3
+ This module provides comprehensive analysis capabilities for ticket health:
4
+ - Similarity detection: Find duplicate or related tickets using TF-IDF
5
+ - Staleness detection: Identify old, inactive tickets
6
+ - Orphaned detection: Find tickets missing hierarchy (epic/project)
7
+ - Cleanup reports: Comprehensive analysis with recommendations
8
+ - Dependency graph: Build and analyze ticket dependency graphs
9
+ - Health assessment: Assess project health based on ticket metrics
10
+ - Project status: Comprehensive project status analysis and work planning
11
+
12
+ These tools help product managers maintain ticket health and development practices.
13
+
14
+ Note: Some analysis features require optional dependencies (scikit-learn, rapidfuzz).
15
+ Install with: pip install mcp-ticketer[analysis]
16
+ """
17
+
18
+ # Import dependency graph and health assessment (no optional deps required)
19
+ from .dependency_graph import DependencyGraph, DependencyNode
20
+ from .health_assessment import HealthAssessor, HealthMetrics, ProjectHealth
21
+ from .project_status import ProjectStatusResult, StatusAnalyzer, TicketRecommendation
22
+
23
+ # Import optional analysis modules (may fail if dependencies not installed)
24
+ try:
25
+ from .orphaned import OrphanedResult, OrphanedTicketDetector
26
+ from .similarity import SimilarityResult, TicketSimilarityAnalyzer
27
+ from .staleness import StalenessResult, StaleTicketDetector
28
+
29
+ ANALYSIS_AVAILABLE = True
30
+ except ImportError:
31
+ # Set placeholder values when optional deps not available
32
+ OrphanedResult = None # type: ignore
33
+ OrphanedTicketDetector = None # type: ignore
34
+ SimilarityResult = None # type: ignore
35
+ TicketSimilarityAnalyzer = None # type: ignore
36
+ StalenessResult = None # type: ignore
37
+ StaleTicketDetector = None # type: ignore
38
+ ANALYSIS_AVAILABLE = False
39
+
40
+ __all__ = [
41
+ "DependencyGraph",
42
+ "DependencyNode",
43
+ "HealthAssessor",
44
+ "HealthMetrics",
45
+ "ProjectHealth",
46
+ "ProjectStatusResult",
47
+ "StatusAnalyzer",
48
+ "TicketRecommendation",
49
+ "SimilarityResult",
50
+ "TicketSimilarityAnalyzer",
51
+ "StalenessResult",
52
+ "StaleTicketDetector",
53
+ "OrphanedResult",
54
+ "OrphanedTicketDetector",
55
+ "ANALYSIS_AVAILABLE",
56
+ ]
@@ -0,0 +1,255 @@
1
+ """Dependency graph analysis for tickets.
2
+
3
+ This module parses ticket descriptions and builds dependency graphs to:
4
+ - Identify ticket dependencies (blocks/depends on)
5
+ - Find critical paths (longest dependency chains)
6
+ - Detect circular dependencies
7
+ - Recommend optimal work order
8
+ """
9
+
10
+ import re
11
+ from collections import defaultdict
12
+ from typing import TYPE_CHECKING
13
+
14
+ from pydantic import BaseModel
15
+
16
+ if TYPE_CHECKING:
17
+ from ..core.models import Task
18
+
19
+
20
+ class DependencyNode(BaseModel):
21
+ """A node in the dependency graph.
22
+
23
+ Attributes:
24
+ ticket_id: ID of the ticket
25
+ blocks: List of ticket IDs that this ticket blocks
26
+ blocked_by: List of ticket IDs that block this ticket
27
+ depth: Depth in the dependency tree (0 = leaf, higher = more dependencies)
28
+
29
+ """
30
+
31
+ ticket_id: str
32
+ blocks: list[str] = []
33
+ blocked_by: list[str] = []
34
+ depth: int = 0
35
+
36
+
37
+ class DependencyGraph:
38
+ """Build and analyze ticket dependency graphs.
39
+
40
+ Parses ticket descriptions to identify references to other tickets
41
+ and builds a directed graph of dependencies.
42
+
43
+ Supported patterns:
44
+ - "Related to TICKET-123"
45
+ - "Depends on 1M-315"
46
+ - "Blocks #456"
47
+ - "1M-316: Feature name"
48
+ - "Blocked by PROJ-789"
49
+ """
50
+
51
+ # Regex patterns for dependency detection
52
+ DEPENDENCY_PATTERNS = [
53
+ (r"depends\s+on\s+([A-Z0-9]+-\d+)\b", "blocked_by"),
54
+ (r"depends\s+on\s+#(\d+)\b", "blocked_by"),
55
+ (r"blocked\s+by\s+([A-Z0-9]+-\d+)\b", "blocked_by"),
56
+ (r"blocked\s+by\s+#(\d+)\b", "blocked_by"),
57
+ (r"blocks\s+([A-Z0-9]+-\d+)\b", "blocks"),
58
+ (r"blocks\s+#(\d+)\b", "blocks"),
59
+ (r"related\s+to\s+([A-Z0-9]+-\d+)\b", "related"),
60
+ (r"related\s+to\s+#(\d+)\b", "related"),
61
+ # Inline references like "1M-316:" or "TICKET-123:"
62
+ (r"\b([A-Z0-9]+-\d+):", "related"),
63
+ ]
64
+
65
+ def __init__(self) -> None:
66
+ """Initialize the dependency graph."""
67
+ self.nodes: dict[str, DependencyNode] = {}
68
+ self.edges: dict[str, set[str]] = defaultdict(set) # ticket_id -> blocks set
69
+ self.reverse_edges: dict[str, set[str]] = defaultdict(
70
+ set
71
+ ) # ticket_id -> blocked_by set
72
+
73
+ def add_ticket(self, ticket: "Task") -> None:
74
+ """Add a ticket to the graph and extract its dependencies.
75
+
76
+ Args:
77
+ ticket: The ticket to add
78
+
79
+ """
80
+ ticket_id = ticket.id or ""
81
+ if not ticket_id:
82
+ return
83
+
84
+ # Initialize node if not exists
85
+ if ticket_id not in self.nodes:
86
+ self.nodes[ticket_id] = DependencyNode(ticket_id=ticket_id)
87
+
88
+ # Parse description and title for dependencies
89
+ text = f"{ticket.title or ''}\n{ticket.description or ''}"
90
+ dependencies = self._extract_dependencies(text, ticket_id)
91
+
92
+ # Update graph edges
93
+ for dep_type, dep_id in dependencies:
94
+ if dep_type == "blocks":
95
+ self.edges[ticket_id].add(dep_id)
96
+ self.reverse_edges[dep_id].add(ticket_id)
97
+ elif dep_type == "blocked_by":
98
+ self.edges[dep_id].add(ticket_id)
99
+ self.reverse_edges[ticket_id].add(dep_id)
100
+ elif dep_type == "related":
101
+ # For related, add bidirectional soft dependency
102
+ # (lower priority in recommendations)
103
+ pass
104
+
105
+ def _extract_dependencies(self, text: str, ticket_id: str) -> list[tuple[str, str]]:
106
+ """Extract dependencies from ticket text.
107
+
108
+ Args:
109
+ text: The text to parse (title + description)
110
+ ticket_id: ID of the ticket being parsed (to avoid self-references)
111
+
112
+ Returns:
113
+ List of (dependency_type, ticket_id) tuples
114
+
115
+ """
116
+ dependencies = []
117
+ text_lower = text.lower()
118
+
119
+ for pattern, dep_type in self.DEPENDENCY_PATTERNS:
120
+ matches = re.finditer(pattern, text_lower, re.IGNORECASE)
121
+ for match in matches:
122
+ dep_id = match.group(1)
123
+ # Normalize ticket ID (handle both "1M-123" and "123" formats)
124
+ if not re.match(r"[A-Z0-9]+-\d+", dep_id, re.IGNORECASE):
125
+ # If just a number, try to infer project prefix from current ticket
126
+ if "-" in ticket_id:
127
+ prefix = ticket_id.split("-")[0]
128
+ dep_id = f"{prefix}-{dep_id}"
129
+
130
+ # Avoid self-references
131
+ if dep_id.upper() != ticket_id.upper():
132
+ dependencies.append((dep_type, dep_id.upper()))
133
+
134
+ return dependencies
135
+
136
+ def calculate_depths(self) -> None:
137
+ """Calculate depth of each node in the dependency tree.
138
+
139
+ Depth is the longest path from this node to a leaf node.
140
+ Higher depth means more dependencies downstream.
141
+ """
142
+ # Build adjacency list from edges
143
+ visited = set()
144
+
145
+ def dfs(node_id: str) -> int:
146
+ """DFS to calculate max depth."""
147
+ if node_id in visited:
148
+ return 0 # Avoid cycles
149
+
150
+ visited.add(node_id)
151
+
152
+ # Get all tickets this one blocks
153
+ blocked_tickets = self.edges.get(node_id, set())
154
+ if not blocked_tickets:
155
+ depth = 0
156
+ else:
157
+ # Depth is 1 + max depth of any blocked ticket
158
+ depth = 1 + max(
159
+ (dfs(blocked) for blocked in blocked_tickets), default=0
160
+ )
161
+
162
+ if node_id in self.nodes:
163
+ self.nodes[node_id].depth = depth
164
+
165
+ visited.remove(node_id)
166
+ return depth
167
+
168
+ # Calculate depth for all nodes
169
+ for node_id in list(self.nodes.keys()):
170
+ if node_id not in visited:
171
+ dfs(node_id)
172
+
173
+ def get_critical_path(self) -> list[str]:
174
+ """Get the critical path (longest dependency chain).
175
+
176
+ Returns:
177
+ List of ticket IDs in the critical path, ordered from
178
+ start to end
179
+
180
+ """
181
+ if not self.nodes:
182
+ return []
183
+
184
+ # Find node with maximum depth
185
+ max_depth_node = max(self.nodes.values(), key=lambda n: n.depth)
186
+
187
+ if max_depth_node.depth == 0:
188
+ return [max_depth_node.ticket_id]
189
+
190
+ # Trace back the critical path
191
+ path = [max_depth_node.ticket_id]
192
+ current = max_depth_node.ticket_id
193
+
194
+ while True:
195
+ blocked_tickets = self.edges.get(current, set())
196
+ if not blocked_tickets:
197
+ break
198
+
199
+ # Find the blocked ticket with maximum depth
200
+ next_ticket = max(
201
+ blocked_tickets,
202
+ key=lambda tid: self.nodes[tid].depth if tid in self.nodes else 0,
203
+ default=None,
204
+ )
205
+
206
+ if next_ticket and next_ticket not in path:
207
+ path.append(next_ticket)
208
+ current = next_ticket
209
+ else:
210
+ break
211
+
212
+ return path
213
+
214
+ def get_blocked_tickets(self) -> dict[str, list[str]]:
215
+ """Get all blocked tickets and their blockers.
216
+
217
+ Returns:
218
+ Dictionary mapping ticket_id -> list of blocker ticket IDs
219
+
220
+ """
221
+ blocked = {}
222
+ for ticket_id, blockers in self.reverse_edges.items():
223
+ if blockers:
224
+ blocked[ticket_id] = list(blockers)
225
+ return blocked
226
+
227
+ def get_high_impact_tickets(self) -> list[tuple[str, int]]:
228
+ """Get tickets that block the most other tickets.
229
+
230
+ Returns:
231
+ List of (ticket_id, count) tuples sorted by impact (descending)
232
+
233
+ """
234
+ impact = []
235
+ for ticket_id, blocked_set in self.edges.items():
236
+ if blocked_set:
237
+ impact.append((ticket_id, len(blocked_set)))
238
+
239
+ return sorted(impact, key=lambda x: x[1], reverse=True)
240
+
241
+ def finalize(self) -> None:
242
+ """Finalize the graph by calculating all metrics.
243
+
244
+ Call this after adding all tickets to compute depths and
245
+ other derived metrics.
246
+ """
247
+ # Update nodes with edge information
248
+ for ticket_id in self.nodes:
249
+ self.nodes[ticket_id].blocks = list(self.edges.get(ticket_id, set()))
250
+ self.nodes[ticket_id].blocked_by = list(
251
+ self.reverse_edges.get(ticket_id, set())
252
+ )
253
+
254
+ # Calculate depths
255
+ self.calculate_depths()