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.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/_version_scm.py +1 -0
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +930 -52
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1537 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +348 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github/__init__.py +26 -0
- mcp_ticketer/adapters/github/adapter.py +3229 -0
- mcp_ticketer/adapters/github/client.py +335 -0
- mcp_ticketer/adapters/github/mappers.py +797 -0
- mcp_ticketer/adapters/github/queries.py +692 -0
- mcp_ticketer/adapters/github/types.py +460 -0
- mcp_ticketer/adapters/hybrid.py +58 -16
- mcp_ticketer/adapters/jira/__init__.py +35 -0
- mcp_ticketer/adapters/jira/adapter.py +1351 -0
- mcp_ticketer/adapters/jira/client.py +271 -0
- mcp_ticketer/adapters/jira/mappers.py +246 -0
- mcp_ticketer/adapters/jira/queries.py +216 -0
- mcp_ticketer/adapters/jira/types.py +304 -0
- mcp_ticketer/adapters/linear/__init__.py +1 -1
- mcp_ticketer/adapters/linear/adapter.py +3810 -462
- mcp_ticketer/adapters/linear/client.py +312 -69
- mcp_ticketer/adapters/linear/mappers.py +305 -85
- mcp_ticketer/adapters/linear/queries.py +317 -17
- mcp_ticketer/adapters/linear/types.py +187 -64
- mcp_ticketer/adapters/linear.py +2 -2
- mcp_ticketer/analysis/__init__.py +56 -0
- mcp_ticketer/analysis/dependency_graph.py +255 -0
- mcp_ticketer/analysis/health_assessment.py +304 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/project_status.py +594 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/automation/__init__.py +11 -0
- mcp_ticketer/automation/project_updates.py +378 -0
- mcp_ticketer/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +91 -54
- mcp_ticketer/cli/auggie_configure.py +116 -15
- mcp_ticketer/cli/codex_configure.py +274 -82
- mcp_ticketer/cli/configure.py +1323 -151
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +209 -114
- mcp_ticketer/cli/discover.py +297 -26
- mcp_ticketer/cli/gemini_configure.py +119 -26
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +256 -130
- mcp_ticketer/cli/main.py +140 -1544
- mcp_ticketer/cli/mcp_configure.py +1013 -100
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +545 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +794 -0
- mcp_ticketer/cli/simple_health.py +84 -59
- mcp_ticketer/cli/ticket_commands.py +1375 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +195 -72
- mcp_ticketer/core/__init__.py +64 -1
- mcp_ticketer/core/adapter.py +618 -18
- mcp_ticketer/core/config.py +77 -68
- mcp_ticketer/core/env_discovery.py +75 -16
- mcp_ticketer/core/env_loader.py +121 -97
- mcp_ticketer/core/exceptions.py +32 -24
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +42 -30
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +566 -19
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +189 -49
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/registry.py +3 -3
- mcp_ticketer/core/session_state.py +176 -0
- mcp_ticketer/core/state_matcher.py +592 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +29 -1
- mcp_ticketer/mcp/__main__.py +60 -0
- mcp_ticketer/mcp/server/__init__.py +25 -0
- mcp_ticketer/mcp/server/__main__.py +60 -0
- mcp_ticketer/mcp/server/constants.py +58 -0
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/dto.py +195 -0
- mcp_ticketer/mcp/server/main.py +1343 -0
- mcp_ticketer/mcp/server/response_builder.py +206 -0
- mcp_ticketer/mcp/server/routing.py +723 -0
- mcp_ticketer/mcp/server/server_sdk.py +151 -0
- mcp_ticketer/mcp/server/tools/__init__.py +69 -0
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +224 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +330 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +1564 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +150 -0
- mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
- mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +318 -0
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1413 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +168 -136
- mcp_ticketer/queue/manager.py +78 -63
- mcp_ticketer/queue/queue.py +108 -21
- mcp_ticketer/queue/run_worker.py +2 -2
- mcp_ticketer/queue/ticket_registry.py +213 -155
- mcp_ticketer/queue/worker.py +96 -58
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.2.9.dist-info/METADATA +1396 -0
- mcp_ticketer-2.2.9.dist-info/RECORD +158 -0
- mcp_ticketer-2.2.9.dist-info/top_level.txt +2 -0
- py_mcp_installer/examples/phase3_demo.py +178 -0
- py_mcp_installer/scripts/manage_version.py +54 -0
- py_mcp_installer/setup.py +6 -0
- py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
- py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
- py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
- py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
- py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
- py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
- py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
- py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
- py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
- py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
- py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
- py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
- py_mcp_installer/src/py_mcp_installer/types.py +222 -0
- py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
- py_mcp_installer/tests/__init__.py +0 -0
- py_mcp_installer/tests/platforms/__init__.py +0 -0
- py_mcp_installer/tests/test_platform_detector.py +17 -0
- mcp_ticketer/adapters/github.py +0 -1354
- mcp_ticketer/adapters/jira.py +0 -1011
- mcp_ticketer/mcp/server.py +0 -2030
- mcp_ticketer-0.3.0.dist-info/METADATA +0 -414
- mcp_ticketer-0.3.0.dist-info/RECORD +0 -59
- mcp_ticketer-0.3.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.3.0.dist-info → mcp_ticketer-2.2.9.dist-info}/entry_points.txt +0 -0
- {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
|