mcp-ticketer 0.3.0__py3-none-any.whl → 2.2.9__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.
Files changed (160) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/_version_scm.py +1 -0
  4. mcp_ticketer/adapters/__init__.py +2 -0
  5. mcp_ticketer/adapters/aitrackdown.py +930 -52
  6. mcp_ticketer/adapters/asana/__init__.py +15 -0
  7. mcp_ticketer/adapters/asana/adapter.py +1537 -0
  8. mcp_ticketer/adapters/asana/client.py +292 -0
  9. mcp_ticketer/adapters/asana/mappers.py +348 -0
  10. mcp_ticketer/adapters/asana/types.py +146 -0
  11. mcp_ticketer/adapters/github/__init__.py +26 -0
  12. mcp_ticketer/adapters/github/adapter.py +3229 -0
  13. mcp_ticketer/adapters/github/client.py +335 -0
  14. mcp_ticketer/adapters/github/mappers.py +797 -0
  15. mcp_ticketer/adapters/github/queries.py +692 -0
  16. mcp_ticketer/adapters/github/types.py +460 -0
  17. mcp_ticketer/adapters/hybrid.py +58 -16
  18. mcp_ticketer/adapters/jira/__init__.py +35 -0
  19. mcp_ticketer/adapters/jira/adapter.py +1351 -0
  20. mcp_ticketer/adapters/jira/client.py +271 -0
  21. mcp_ticketer/adapters/jira/mappers.py +246 -0
  22. mcp_ticketer/adapters/jira/queries.py +216 -0
  23. mcp_ticketer/adapters/jira/types.py +304 -0
  24. mcp_ticketer/adapters/linear/__init__.py +1 -1
  25. mcp_ticketer/adapters/linear/adapter.py +3810 -462
  26. mcp_ticketer/adapters/linear/client.py +312 -69
  27. mcp_ticketer/adapters/linear/mappers.py +305 -85
  28. mcp_ticketer/adapters/linear/queries.py +317 -17
  29. mcp_ticketer/adapters/linear/types.py +187 -64
  30. mcp_ticketer/adapters/linear.py +2 -2
  31. mcp_ticketer/analysis/__init__.py +56 -0
  32. mcp_ticketer/analysis/dependency_graph.py +255 -0
  33. mcp_ticketer/analysis/health_assessment.py +304 -0
  34. mcp_ticketer/analysis/orphaned.py +218 -0
  35. mcp_ticketer/analysis/project_status.py +594 -0
  36. mcp_ticketer/analysis/similarity.py +224 -0
  37. mcp_ticketer/analysis/staleness.py +266 -0
  38. mcp_ticketer/automation/__init__.py +11 -0
  39. mcp_ticketer/automation/project_updates.py +378 -0
  40. mcp_ticketer/cache/memory.py +9 -8
  41. mcp_ticketer/cli/adapter_diagnostics.py +91 -54
  42. mcp_ticketer/cli/auggie_configure.py +116 -15
  43. mcp_ticketer/cli/codex_configure.py +274 -82
  44. mcp_ticketer/cli/configure.py +1323 -151
  45. mcp_ticketer/cli/cursor_configure.py +314 -0
  46. mcp_ticketer/cli/diagnostics.py +209 -114
  47. mcp_ticketer/cli/discover.py +297 -26
  48. mcp_ticketer/cli/gemini_configure.py +119 -26
  49. mcp_ticketer/cli/init_command.py +880 -0
  50. mcp_ticketer/cli/install_mcp_server.py +418 -0
  51. mcp_ticketer/cli/instruction_commands.py +435 -0
  52. mcp_ticketer/cli/linear_commands.py +256 -130
  53. mcp_ticketer/cli/main.py +140 -1544
  54. mcp_ticketer/cli/mcp_configure.py +1013 -100
  55. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  56. mcp_ticketer/cli/migrate_config.py +12 -8
  57. mcp_ticketer/cli/platform_commands.py +123 -0
  58. mcp_ticketer/cli/platform_detection.py +477 -0
  59. mcp_ticketer/cli/platform_installer.py +545 -0
  60. mcp_ticketer/cli/project_update_commands.py +350 -0
  61. mcp_ticketer/cli/python_detection.py +126 -0
  62. mcp_ticketer/cli/queue_commands.py +15 -15
  63. mcp_ticketer/cli/setup_command.py +794 -0
  64. mcp_ticketer/cli/simple_health.py +84 -59
  65. mcp_ticketer/cli/ticket_commands.py +1375 -0
  66. mcp_ticketer/cli/update_checker.py +313 -0
  67. mcp_ticketer/cli/utils.py +195 -72
  68. mcp_ticketer/core/__init__.py +64 -1
  69. mcp_ticketer/core/adapter.py +618 -18
  70. mcp_ticketer/core/config.py +77 -68
  71. mcp_ticketer/core/env_discovery.py +75 -16
  72. mcp_ticketer/core/env_loader.py +121 -97
  73. mcp_ticketer/core/exceptions.py +32 -24
  74. mcp_ticketer/core/http_client.py +26 -26
  75. mcp_ticketer/core/instructions.py +405 -0
  76. mcp_ticketer/core/label_manager.py +732 -0
  77. mcp_ticketer/core/mappers.py +42 -30
  78. mcp_ticketer/core/milestone_manager.py +252 -0
  79. mcp_ticketer/core/models.py +566 -19
  80. mcp_ticketer/core/onepassword_secrets.py +379 -0
  81. mcp_ticketer/core/priority_matcher.py +463 -0
  82. mcp_ticketer/core/project_config.py +189 -49
  83. mcp_ticketer/core/project_utils.py +281 -0
  84. mcp_ticketer/core/project_validator.py +376 -0
  85. mcp_ticketer/core/registry.py +3 -3
  86. mcp_ticketer/core/session_state.py +176 -0
  87. mcp_ticketer/core/state_matcher.py +592 -0
  88. mcp_ticketer/core/url_parser.py +425 -0
  89. mcp_ticketer/core/validators.py +69 -0
  90. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  91. mcp_ticketer/mcp/__init__.py +29 -1
  92. mcp_ticketer/mcp/__main__.py +60 -0
  93. mcp_ticketer/mcp/server/__init__.py +25 -0
  94. mcp_ticketer/mcp/server/__main__.py +60 -0
  95. mcp_ticketer/mcp/server/constants.py +58 -0
  96. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  97. mcp_ticketer/mcp/server/dto.py +195 -0
  98. mcp_ticketer/mcp/server/main.py +1343 -0
  99. mcp_ticketer/mcp/server/response_builder.py +206 -0
  100. mcp_ticketer/mcp/server/routing.py +723 -0
  101. mcp_ticketer/mcp/server/server_sdk.py +151 -0
  102. mcp_ticketer/mcp/server/tools/__init__.py +69 -0
  103. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  104. mcp_ticketer/mcp/server/tools/attachment_tools.py +224 -0
  105. mcp_ticketer/mcp/server/tools/bulk_tools.py +330 -0
  106. mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
  107. mcp_ticketer/mcp/server/tools/config_tools.py +1564 -0
  108. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  109. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +942 -0
  110. mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
  111. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  112. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  113. mcp_ticketer/mcp/server/tools/pr_tools.py +150 -0
  114. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  115. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  116. mcp_ticketer/mcp/server/tools/search_tools.py +318 -0
  117. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  118. mcp_ticketer/mcp/server/tools/ticket_tools.py +1413 -0
  119. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
  120. mcp_ticketer/queue/__init__.py +1 -0
  121. mcp_ticketer/queue/health_monitor.py +168 -136
  122. mcp_ticketer/queue/manager.py +78 -63
  123. mcp_ticketer/queue/queue.py +108 -21
  124. mcp_ticketer/queue/run_worker.py +2 -2
  125. mcp_ticketer/queue/ticket_registry.py +213 -155
  126. mcp_ticketer/queue/worker.py +96 -58
  127. mcp_ticketer/utils/__init__.py +5 -0
  128. mcp_ticketer/utils/token_utils.py +246 -0
  129. mcp_ticketer-2.2.9.dist-info/METADATA +1396 -0
  130. mcp_ticketer-2.2.9.dist-info/RECORD +158 -0
  131. mcp_ticketer-2.2.9.dist-info/top_level.txt +2 -0
  132. py_mcp_installer/examples/phase3_demo.py +178 -0
  133. py_mcp_installer/scripts/manage_version.py +54 -0
  134. py_mcp_installer/setup.py +6 -0
  135. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  136. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  137. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  138. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  139. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  140. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  141. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  142. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  143. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  144. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  145. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  146. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  147. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  148. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  149. py_mcp_installer/tests/__init__.py +0 -0
  150. py_mcp_installer/tests/platforms/__init__.py +0 -0
  151. py_mcp_installer/tests/test_platform_detector.py +17 -0
  152. mcp_ticketer/adapters/github.py +0 -1354
  153. mcp_ticketer/adapters/jira.py +0 -1011
  154. mcp_ticketer/mcp/server.py +0 -2030
  155. mcp_ticketer-0.3.0.dist-info/METADATA +0 -414
  156. mcp_ticketer-0.3.0.dist-info/RECORD +0 -59
  157. mcp_ticketer-0.3.0.dist-info/top_level.txt +0 -1
  158. {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
  159. {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
  160. {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/licenses/LICENSE +0 -0
@@ -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()
@@ -0,0 +1,304 @@
1
+ """Project health assessment for epics and projects.
2
+
3
+ This module evaluates project health based on:
4
+ - Completion rate (% of tickets done)
5
+ - Progress rate (% of tickets in progress)
6
+ - Blocker rate (% of tickets blocked)
7
+ - Priority distribution
8
+ - Work distribution balance
9
+ """
10
+
11
+ from enum import Enum
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 ProjectHealth(str, Enum):
21
+ """Project health status levels.
22
+
23
+ Attributes:
24
+ ON_TRACK: Project is progressing well, no major issues
25
+ AT_RISK: Project has some concerns but still recoverable
26
+ OFF_TRACK: Project has serious issues requiring intervention
27
+
28
+ """
29
+
30
+ ON_TRACK = "on_track"
31
+ AT_RISK = "at_risk"
32
+ OFF_TRACK = "off_track"
33
+
34
+
35
+ class HealthMetrics(BaseModel):
36
+ """Calculated health metrics for a project.
37
+
38
+ Attributes:
39
+ total_tickets: Total number of tickets
40
+ completion_rate: Percentage of tickets done (0.0-1.0)
41
+ progress_rate: Percentage of tickets in progress (0.0-1.0)
42
+ blocked_rate: Percentage of tickets blocked (0.0-1.0)
43
+ critical_count: Number of critical priority tickets
44
+ high_count: Number of high priority tickets
45
+ health_score: Overall health score (0.0-1.0, higher is better)
46
+ health_status: Overall health status
47
+
48
+ """
49
+
50
+ total_tickets: int
51
+ completion_rate: float
52
+ progress_rate: float
53
+ blocked_rate: float
54
+ critical_count: int
55
+ high_count: int
56
+ health_score: float
57
+ health_status: ProjectHealth
58
+
59
+
60
+ class HealthAssessor:
61
+ """Assess project health based on ticket metrics.
62
+
63
+ Uses weighted scoring of multiple factors:
64
+ - Completion rate (30% weight): How many tickets are done
65
+ - Progress rate (25% weight): How many tickets are actively worked
66
+ - Blocker rate (30% weight): How many tickets are blocked (negative)
67
+ - Priority balance (15% weight): Critical/high priority completion
68
+ """
69
+
70
+ # Thresholds for health determination
71
+ HEALTHY_COMPLETION_THRESHOLD = 0.5
72
+ HEALTHY_PROGRESS_THRESHOLD = 0.2
73
+ RISKY_BLOCKED_THRESHOLD = 0.2
74
+ CRITICAL_BLOCKED_THRESHOLD = 0.4
75
+
76
+ # Weights for health score calculation
77
+ COMPLETION_WEIGHT = 0.30
78
+ PROGRESS_WEIGHT = 0.25
79
+ BLOCKER_WEIGHT = 0.30
80
+ PRIORITY_WEIGHT = 0.15
81
+
82
+ def __init__(self) -> None:
83
+ """Initialize the health assessor."""
84
+ pass
85
+
86
+ def assess(self, tickets: list["Task"]) -> HealthMetrics:
87
+ """Assess project health from a list of tickets.
88
+
89
+ Args:
90
+ tickets: List of tickets in the project
91
+
92
+ Returns:
93
+ Health metrics including status and score
94
+
95
+ """
96
+ if not tickets:
97
+ return HealthMetrics(
98
+ total_tickets=0,
99
+ completion_rate=0.0,
100
+ progress_rate=0.0,
101
+ blocked_rate=0.0,
102
+ critical_count=0,
103
+ high_count=0,
104
+ health_score=0.0,
105
+ health_status=ProjectHealth.OFF_TRACK,
106
+ )
107
+
108
+ total = len(tickets)
109
+
110
+ # Calculate state-based metrics
111
+ completion_rate = self._calculate_completion_rate(tickets)
112
+ progress_rate = self._calculate_progress_rate(tickets)
113
+ blocked_rate = self._calculate_blocked_rate(tickets)
114
+
115
+ # Count priority tickets
116
+ critical_count = self._count_by_priority(tickets, "critical")
117
+ high_count = self._count_by_priority(tickets, "high")
118
+
119
+ # Calculate overall health score
120
+ health_score = self._calculate_health_score(
121
+ completion_rate, progress_rate, blocked_rate, tickets
122
+ )
123
+
124
+ # Determine health status
125
+ health_status = self._determine_health_status(
126
+ completion_rate, progress_rate, blocked_rate, health_score
127
+ )
128
+
129
+ return HealthMetrics(
130
+ total_tickets=total,
131
+ completion_rate=completion_rate,
132
+ progress_rate=progress_rate,
133
+ blocked_rate=blocked_rate,
134
+ critical_count=critical_count,
135
+ high_count=high_count,
136
+ health_score=health_score,
137
+ health_status=health_status,
138
+ )
139
+
140
+ def _calculate_completion_rate(self, tickets: list["Task"]) -> float:
141
+ """Calculate percentage of completed tickets."""
142
+ from ..core.models import TicketState
143
+
144
+ if not tickets:
145
+ return 0.0
146
+
147
+ completed = sum(
148
+ 1
149
+ for t in tickets
150
+ if t.state in (TicketState.DONE, TicketState.CLOSED, TicketState.TESTED)
151
+ )
152
+ return completed / len(tickets)
153
+
154
+ def _calculate_progress_rate(self, tickets: list["Task"]) -> float:
155
+ """Calculate percentage of in-progress tickets."""
156
+ from ..core.models import TicketState
157
+
158
+ if not tickets:
159
+ return 0.0
160
+
161
+ in_progress = sum(
162
+ 1
163
+ for t in tickets
164
+ if t.state
165
+ in (TicketState.IN_PROGRESS, TicketState.READY, TicketState.TESTED)
166
+ )
167
+ return in_progress / len(tickets)
168
+
169
+ def _calculate_blocked_rate(self, tickets: list["Task"]) -> float:
170
+ """Calculate percentage of blocked tickets."""
171
+ from ..core.models import TicketState
172
+
173
+ if not tickets:
174
+ return 0.0
175
+
176
+ blocked = sum(
177
+ 1 for t in tickets if t.state in (TicketState.BLOCKED, TicketState.WAITING)
178
+ )
179
+ return blocked / len(tickets)
180
+
181
+ def _count_by_priority(self, tickets: list["Task"], priority: str) -> int:
182
+ """Count tickets with a specific priority."""
183
+ # Handle both enum and string values
184
+ return sum(
185
+ 1
186
+ for t in tickets
187
+ if t.priority
188
+ and (t.priority.value if hasattr(t.priority, "value") else t.priority)
189
+ == priority
190
+ )
191
+
192
+ def _calculate_health_score(
193
+ self,
194
+ completion_rate: float,
195
+ progress_rate: float,
196
+ blocked_rate: float,
197
+ tickets: list["Task"],
198
+ ) -> float:
199
+ """Calculate weighted health score.
200
+
201
+ Args:
202
+ completion_rate: Percentage of completed tickets
203
+ progress_rate: Percentage of in-progress tickets
204
+ blocked_rate: Percentage of blocked tickets
205
+ tickets: All tickets (for priority analysis)
206
+
207
+ Returns:
208
+ Health score from 0.0 (worst) to 1.0 (best)
209
+
210
+ """
211
+ # Completion score (0.0-1.0)
212
+ completion_score = completion_rate
213
+
214
+ # Progress score (0.0-1.0, capped at reasonable level)
215
+ # Having some progress is good, but 100% in progress isn't ideal
216
+ progress_score = min(progress_rate * 2, 1.0)
217
+
218
+ # Blocker score (0.0-1.0, inverted since blockers are bad)
219
+ blocker_score = max(0.0, 1.0 - (blocked_rate * 2.5))
220
+
221
+ # Priority score: Check if critical/high priority items are addressed
222
+ priority_score = self._calculate_priority_score(tickets)
223
+
224
+ # Weighted average
225
+ health_score = (
226
+ completion_score * self.COMPLETION_WEIGHT
227
+ + progress_score * self.PROGRESS_WEIGHT
228
+ + blocker_score * self.BLOCKER_WEIGHT
229
+ + priority_score * self.PRIORITY_WEIGHT
230
+ )
231
+
232
+ return min(1.0, max(0.0, health_score))
233
+
234
+ def _calculate_priority_score(self, tickets: list["Task"]) -> float:
235
+ """Calculate score based on critical/high priority completion.
236
+
237
+ Returns:
238
+ Score from 0.0-1.0 based on priority ticket completion
239
+
240
+ """
241
+ from ..core.models import Priority, TicketState
242
+
243
+ critical_tickets = [
244
+ t
245
+ for t in tickets
246
+ if t.priority == Priority.CRITICAL or t.priority == Priority.HIGH
247
+ ]
248
+
249
+ if not critical_tickets:
250
+ return 1.0 # No high priority items = good score
251
+
252
+ completed_critical = sum(
253
+ 1
254
+ for t in critical_tickets
255
+ if t.state in (TicketState.DONE, TicketState.CLOSED, TicketState.TESTED)
256
+ )
257
+
258
+ in_progress_critical = sum(
259
+ 1
260
+ for t in critical_tickets
261
+ if t.state in (TicketState.IN_PROGRESS, TicketState.READY)
262
+ )
263
+
264
+ # Score: 1.0 for completed, 0.5 for in progress, 0.0 for not started
265
+ score = (completed_critical + 0.5 * in_progress_critical) / len(
266
+ critical_tickets
267
+ )
268
+
269
+ return score
270
+
271
+ def _determine_health_status(
272
+ self,
273
+ completion_rate: float,
274
+ progress_rate: float,
275
+ blocked_rate: float,
276
+ health_score: float,
277
+ ) -> ProjectHealth:
278
+ """Determine overall health status from metrics.
279
+
280
+ Args:
281
+ completion_rate: Percentage of completed tickets
282
+ progress_rate: Percentage of in-progress tickets
283
+ blocked_rate: Percentage of blocked tickets
284
+ health_score: Overall health score
285
+
286
+ Returns:
287
+ Health status (ON_TRACK, AT_RISK, or OFF_TRACK)
288
+
289
+ """
290
+ # Critical thresholds take priority
291
+ if blocked_rate >= self.CRITICAL_BLOCKED_THRESHOLD:
292
+ return ProjectHealth.OFF_TRACK
293
+
294
+ # Check for on-track conditions
295
+ if completion_rate >= self.HEALTHY_COMPLETION_THRESHOLD and blocked_rate == 0.0:
296
+ return ProjectHealth.ON_TRACK
297
+
298
+ # Use health score as tie-breaker
299
+ if health_score >= 0.7:
300
+ return ProjectHealth.ON_TRACK
301
+ elif health_score >= 0.4:
302
+ return ProjectHealth.AT_RISK
303
+ else:
304
+ return ProjectHealth.OFF_TRACK