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,594 @@
|
|
|
1
|
+
"""Project status analysis and work plan generation.
|
|
2
|
+
|
|
3
|
+
This module provides comprehensive project/epic analysis including:
|
|
4
|
+
- Status breakdown by state, priority, assignee
|
|
5
|
+
- Dependency analysis and critical path
|
|
6
|
+
- Health assessment
|
|
7
|
+
- Next ticket recommendations
|
|
8
|
+
- Actionable recommendations for project managers
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from .dependency_graph import DependencyGraph
|
|
17
|
+
from .health_assessment import HealthAssessor, HealthMetrics, ProjectHealth
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from ..core.models import Task
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_value(enum_or_str: Any) -> str:
|
|
24
|
+
"""Safely get value from enum or string.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
enum_or_str: Either an enum with .value or a string
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
String value
|
|
31
|
+
"""
|
|
32
|
+
return enum_or_str.value if hasattr(enum_or_str, "value") else enum_or_str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TicketRecommendation(BaseModel):
|
|
36
|
+
"""Recommended ticket to work on next.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
ticket_id: ID of the recommended ticket
|
|
40
|
+
title: Title of the ticket
|
|
41
|
+
priority: Priority level
|
|
42
|
+
reason: Explanation of why this ticket is recommended
|
|
43
|
+
blocks: List of ticket IDs this ticket blocks (if any)
|
|
44
|
+
impact_score: Numeric score for impact (higher = more important)
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
ticket_id: str
|
|
49
|
+
title: str
|
|
50
|
+
priority: str
|
|
51
|
+
reason: str
|
|
52
|
+
blocks: list[str] = []
|
|
53
|
+
impact_score: float = 0.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ProjectStatusResult(BaseModel):
|
|
57
|
+
"""Complete project status analysis result.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
project_id: ID of the project/epic
|
|
61
|
+
project_name: Name of the project/epic
|
|
62
|
+
health: Overall project health status
|
|
63
|
+
health_metrics: Detailed health metrics
|
|
64
|
+
summary: Ticket count by state
|
|
65
|
+
priority_summary: Ticket count by priority
|
|
66
|
+
work_distribution: Ticket count by assignee
|
|
67
|
+
recommended_next: Top tickets to start next
|
|
68
|
+
blockers: Tickets that are blocking others
|
|
69
|
+
critical_path: Longest dependency chain
|
|
70
|
+
recommendations: Actionable recommendations for PMs
|
|
71
|
+
timeline_estimate: Timeline projections (if applicable)
|
|
72
|
+
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
project_id: str
|
|
76
|
+
project_name: str
|
|
77
|
+
health: str
|
|
78
|
+
health_metrics: HealthMetrics
|
|
79
|
+
summary: dict[str, int]
|
|
80
|
+
priority_summary: dict[str, int]
|
|
81
|
+
work_distribution: dict[str, dict[str, int]]
|
|
82
|
+
recommended_next: list[TicketRecommendation]
|
|
83
|
+
blockers: list[dict[str, Any]]
|
|
84
|
+
critical_path: list[str]
|
|
85
|
+
recommendations: list[str]
|
|
86
|
+
timeline_estimate: dict[str, Any]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class StatusAnalyzer:
|
|
90
|
+
"""Analyze project/epic status and generate work plans.
|
|
91
|
+
|
|
92
|
+
Combines multiple analysis techniques:
|
|
93
|
+
1. State and priority analysis
|
|
94
|
+
2. Dependency graph analysis
|
|
95
|
+
3. Health assessment
|
|
96
|
+
4. Work distribution analysis
|
|
97
|
+
5. Intelligent recommendations
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self) -> None:
|
|
101
|
+
"""Initialize the status analyzer."""
|
|
102
|
+
self.health_assessor = HealthAssessor()
|
|
103
|
+
|
|
104
|
+
def analyze(
|
|
105
|
+
self, project_id: str, project_name: str, tickets: list["Task"]
|
|
106
|
+
) -> ProjectStatusResult:
|
|
107
|
+
"""Perform comprehensive project status analysis.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
project_id: ID of the project/epic
|
|
111
|
+
project_name: Name of the project/epic
|
|
112
|
+
tickets: List of tickets in the project
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Complete project status analysis
|
|
116
|
+
|
|
117
|
+
"""
|
|
118
|
+
# Basic state and priority analysis
|
|
119
|
+
summary = self._build_state_summary(tickets)
|
|
120
|
+
priority_summary = self._build_priority_summary(tickets)
|
|
121
|
+
work_distribution = self._build_work_distribution(tickets)
|
|
122
|
+
|
|
123
|
+
# Dependency analysis
|
|
124
|
+
dep_graph = self._build_dependency_graph(tickets)
|
|
125
|
+
critical_path = dep_graph.get_critical_path()
|
|
126
|
+
blockers = self._identify_blockers(dep_graph, tickets)
|
|
127
|
+
|
|
128
|
+
# Health assessment
|
|
129
|
+
health_metrics = self.health_assessor.assess(tickets)
|
|
130
|
+
|
|
131
|
+
# Generate recommendations
|
|
132
|
+
recommended_next = self._recommend_next_tickets(
|
|
133
|
+
tickets, dep_graph, health_metrics
|
|
134
|
+
)
|
|
135
|
+
recommendations = self._generate_recommendations(
|
|
136
|
+
tickets, dep_graph, health_metrics, blockers
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Timeline estimation
|
|
140
|
+
timeline_estimate = self._estimate_timeline(tickets, dep_graph)
|
|
141
|
+
|
|
142
|
+
return ProjectStatusResult(
|
|
143
|
+
project_id=project_id,
|
|
144
|
+
project_name=project_name,
|
|
145
|
+
health=health_metrics.health_status.value,
|
|
146
|
+
health_metrics=health_metrics,
|
|
147
|
+
summary=summary,
|
|
148
|
+
priority_summary=priority_summary,
|
|
149
|
+
work_distribution=work_distribution,
|
|
150
|
+
recommended_next=recommended_next,
|
|
151
|
+
blockers=blockers,
|
|
152
|
+
critical_path=critical_path,
|
|
153
|
+
recommendations=recommendations,
|
|
154
|
+
timeline_estimate=timeline_estimate,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def _build_state_summary(self, tickets: list["Task"]) -> dict[str, int]:
|
|
158
|
+
"""Build summary of tickets by state.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
tickets: List of tickets
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Dictionary mapping state -> count
|
|
165
|
+
|
|
166
|
+
"""
|
|
167
|
+
summary: dict[str, int] = defaultdict(int)
|
|
168
|
+
summary["total"] = len(tickets)
|
|
169
|
+
|
|
170
|
+
for ticket in tickets:
|
|
171
|
+
if ticket.state:
|
|
172
|
+
state_value = _get_value(ticket.state)
|
|
173
|
+
summary[state_value] = summary.get(state_value, 0) + 1
|
|
174
|
+
|
|
175
|
+
return dict(summary)
|
|
176
|
+
|
|
177
|
+
def _build_priority_summary(self, tickets: list["Task"]) -> dict[str, int]:
|
|
178
|
+
"""Build summary of tickets by priority.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
tickets: List of tickets
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Dictionary mapping priority -> count
|
|
185
|
+
|
|
186
|
+
"""
|
|
187
|
+
summary: dict[str, int] = defaultdict(int)
|
|
188
|
+
|
|
189
|
+
for ticket in tickets:
|
|
190
|
+
if ticket.priority:
|
|
191
|
+
priority_value = _get_value(ticket.priority)
|
|
192
|
+
summary[priority_value] = summary.get(priority_value, 0) + 1
|
|
193
|
+
|
|
194
|
+
return dict(summary)
|
|
195
|
+
|
|
196
|
+
def _build_work_distribution(
|
|
197
|
+
self, tickets: list["Task"]
|
|
198
|
+
) -> dict[str, dict[str, int]]:
|
|
199
|
+
"""Build work distribution by assignee.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
tickets: List of tickets
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Dictionary mapping assignee -> {state: count}
|
|
206
|
+
|
|
207
|
+
"""
|
|
208
|
+
distribution: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
209
|
+
|
|
210
|
+
for ticket in tickets:
|
|
211
|
+
assignee = ticket.assignee or "unassigned"
|
|
212
|
+
state = _get_value(ticket.state) if ticket.state else "unknown"
|
|
213
|
+
|
|
214
|
+
distribution[assignee]["total"] = distribution[assignee].get("total", 0) + 1
|
|
215
|
+
distribution[assignee][state] = distribution[assignee].get(state, 0) + 1
|
|
216
|
+
|
|
217
|
+
return {k: dict(v) for k, v in distribution.items()}
|
|
218
|
+
|
|
219
|
+
def _build_dependency_graph(self, tickets: list["Task"]) -> DependencyGraph:
|
|
220
|
+
"""Build dependency graph from tickets.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
tickets: List of tickets
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Populated and finalized dependency graph
|
|
227
|
+
|
|
228
|
+
"""
|
|
229
|
+
graph = DependencyGraph()
|
|
230
|
+
|
|
231
|
+
for ticket in tickets:
|
|
232
|
+
graph.add_ticket(ticket)
|
|
233
|
+
|
|
234
|
+
graph.finalize()
|
|
235
|
+
return graph
|
|
236
|
+
|
|
237
|
+
def _identify_blockers(
|
|
238
|
+
self, dep_graph: DependencyGraph, tickets: list["Task"]
|
|
239
|
+
) -> list[dict[str, Any]]:
|
|
240
|
+
"""Identify tickets that are blocking others.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
dep_graph: Dependency graph
|
|
244
|
+
tickets: List of tickets
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
List of blocker information dicts
|
|
248
|
+
|
|
249
|
+
"""
|
|
250
|
+
from ..core.models import TicketState
|
|
251
|
+
|
|
252
|
+
blockers = []
|
|
253
|
+
high_impact = dep_graph.get_high_impact_tickets()
|
|
254
|
+
ticket_map = {t.id: t for t in tickets if t.id}
|
|
255
|
+
|
|
256
|
+
for ticket_id, blocked_count in high_impact:
|
|
257
|
+
ticket = ticket_map.get(ticket_id)
|
|
258
|
+
if not ticket:
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
# Only include if the blocker is not done
|
|
262
|
+
if ticket.state not in (
|
|
263
|
+
TicketState.DONE,
|
|
264
|
+
TicketState.CLOSED,
|
|
265
|
+
TicketState.TESTED,
|
|
266
|
+
):
|
|
267
|
+
blockers.append(
|
|
268
|
+
{
|
|
269
|
+
"ticket_id": ticket_id,
|
|
270
|
+
"title": ticket.title or "",
|
|
271
|
+
"state": (
|
|
272
|
+
_get_value(ticket.state) if ticket.state else "unknown"
|
|
273
|
+
),
|
|
274
|
+
"priority": (
|
|
275
|
+
_get_value(ticket.priority) if ticket.priority else "medium"
|
|
276
|
+
),
|
|
277
|
+
"blocks_count": blocked_count,
|
|
278
|
+
"blocks": list(dep_graph.edges.get(ticket_id, set())),
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# Sort by blocks_count descending
|
|
283
|
+
return sorted(blockers, key=lambda x: x["blocks_count"], reverse=True)
|
|
284
|
+
|
|
285
|
+
def _recommend_next_tickets(
|
|
286
|
+
self,
|
|
287
|
+
tickets: list["Task"],
|
|
288
|
+
dep_graph: DependencyGraph,
|
|
289
|
+
health_metrics: HealthMetrics,
|
|
290
|
+
) -> list[TicketRecommendation]:
|
|
291
|
+
"""Recommend top 3 tickets to work on next.
|
|
292
|
+
|
|
293
|
+
Scoring factors:
|
|
294
|
+
1. Priority (critical > high > medium > low)
|
|
295
|
+
2. Not blocked by others
|
|
296
|
+
3. Blocks other tickets (high impact)
|
|
297
|
+
4. On critical path
|
|
298
|
+
5. State (open > waiting)
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
tickets: List of tickets
|
|
302
|
+
dep_graph: Dependency graph
|
|
303
|
+
health_metrics: Health assessment results
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
List of top 3 recommended tickets
|
|
307
|
+
|
|
308
|
+
"""
|
|
309
|
+
from ..core.models import TicketState
|
|
310
|
+
|
|
311
|
+
# Filter to actionable tickets (not done, not in progress)
|
|
312
|
+
actionable = [
|
|
313
|
+
t
|
|
314
|
+
for t in tickets
|
|
315
|
+
if t.state
|
|
316
|
+
in (
|
|
317
|
+
TicketState.OPEN,
|
|
318
|
+
TicketState.WAITING,
|
|
319
|
+
TicketState.BLOCKED,
|
|
320
|
+
TicketState.READY,
|
|
321
|
+
)
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
if not actionable:
|
|
325
|
+
return []
|
|
326
|
+
|
|
327
|
+
# Score each ticket
|
|
328
|
+
scored_tickets = []
|
|
329
|
+
critical_path_set = set(dep_graph.get_critical_path())
|
|
330
|
+
|
|
331
|
+
for ticket in actionable:
|
|
332
|
+
ticket_id = ticket.id or ""
|
|
333
|
+
if not ticket_id:
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
score = self._calculate_ticket_score(
|
|
337
|
+
ticket, ticket_id, dep_graph, critical_path_set
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
reason = self._generate_recommendation_reason(
|
|
341
|
+
ticket, ticket_id, dep_graph, critical_path_set
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
blocks = list(dep_graph.edges.get(ticket_id, set()))
|
|
345
|
+
|
|
346
|
+
scored_tickets.append(
|
|
347
|
+
(
|
|
348
|
+
score,
|
|
349
|
+
TicketRecommendation(
|
|
350
|
+
ticket_id=ticket_id,
|
|
351
|
+
title=ticket.title or "",
|
|
352
|
+
priority=(
|
|
353
|
+
_get_value(ticket.priority) if ticket.priority else "medium"
|
|
354
|
+
),
|
|
355
|
+
reason=reason,
|
|
356
|
+
blocks=blocks,
|
|
357
|
+
impact_score=score,
|
|
358
|
+
),
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# Sort by score descending and return top 3
|
|
363
|
+
scored_tickets.sort(key=lambda x: x[0], reverse=True)
|
|
364
|
+
return [rec for _, rec in scored_tickets[:3]]
|
|
365
|
+
|
|
366
|
+
def _calculate_ticket_score(
|
|
367
|
+
self,
|
|
368
|
+
ticket: "Task",
|
|
369
|
+
ticket_id: str,
|
|
370
|
+
dep_graph: DependencyGraph,
|
|
371
|
+
critical_path_set: set[str],
|
|
372
|
+
) -> float:
|
|
373
|
+
"""Calculate recommendation score for a ticket.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
ticket: The ticket to score
|
|
377
|
+
ticket_id: Ticket ID
|
|
378
|
+
dep_graph: Dependency graph
|
|
379
|
+
critical_path_set: Set of ticket IDs on critical path
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
Score (higher = more recommended)
|
|
383
|
+
|
|
384
|
+
"""
|
|
385
|
+
from ..core.models import Priority, TicketState
|
|
386
|
+
|
|
387
|
+
score = 0.0
|
|
388
|
+
|
|
389
|
+
# Priority score (30 points max)
|
|
390
|
+
priority_scores = {
|
|
391
|
+
Priority.CRITICAL: 30.0,
|
|
392
|
+
Priority.HIGH: 20.0,
|
|
393
|
+
Priority.MEDIUM: 10.0,
|
|
394
|
+
Priority.LOW: 5.0,
|
|
395
|
+
}
|
|
396
|
+
score += priority_scores.get(ticket.priority, 10.0)
|
|
397
|
+
|
|
398
|
+
# Not blocked bonus (20 points)
|
|
399
|
+
blocked_by = dep_graph.reverse_edges.get(ticket_id, set())
|
|
400
|
+
if not blocked_by:
|
|
401
|
+
score += 20.0
|
|
402
|
+
else:
|
|
403
|
+
# Penalty for being blocked
|
|
404
|
+
score -= len(blocked_by) * 5.0
|
|
405
|
+
|
|
406
|
+
# Blocks others bonus (up to 25 points)
|
|
407
|
+
blocks_count = len(dep_graph.edges.get(ticket_id, set()))
|
|
408
|
+
score += min(blocks_count * 5.0, 25.0)
|
|
409
|
+
|
|
410
|
+
# Critical path bonus (15 points)
|
|
411
|
+
if ticket_id in critical_path_set:
|
|
412
|
+
score += 15.0
|
|
413
|
+
|
|
414
|
+
# State bonus (10 points for ready/open)
|
|
415
|
+
if ticket.state == TicketState.OPEN:
|
|
416
|
+
score += 10.0
|
|
417
|
+
elif ticket.state == TicketState.READY:
|
|
418
|
+
score += 8.0
|
|
419
|
+
elif ticket.state == TicketState.WAITING:
|
|
420
|
+
score += 5.0
|
|
421
|
+
|
|
422
|
+
return score
|
|
423
|
+
|
|
424
|
+
def _generate_recommendation_reason(
|
|
425
|
+
self,
|
|
426
|
+
ticket: "Task",
|
|
427
|
+
ticket_id: str,
|
|
428
|
+
dep_graph: DependencyGraph,
|
|
429
|
+
critical_path_set: set[str],
|
|
430
|
+
) -> str:
|
|
431
|
+
"""Generate human-readable reason for recommendation.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
ticket: The ticket
|
|
435
|
+
ticket_id: Ticket ID
|
|
436
|
+
dep_graph: Dependency graph
|
|
437
|
+
critical_path_set: Set of ticket IDs on critical path
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
Reason string
|
|
441
|
+
|
|
442
|
+
"""
|
|
443
|
+
from ..core.models import Priority
|
|
444
|
+
|
|
445
|
+
reasons = []
|
|
446
|
+
|
|
447
|
+
# Priority
|
|
448
|
+
if ticket.priority == Priority.CRITICAL:
|
|
449
|
+
reasons.append("Critical priority")
|
|
450
|
+
elif ticket.priority == Priority.HIGH:
|
|
451
|
+
reasons.append("High priority")
|
|
452
|
+
|
|
453
|
+
# Impact
|
|
454
|
+
blocks_count = len(dep_graph.edges.get(ticket_id, set()))
|
|
455
|
+
if blocks_count > 0:
|
|
456
|
+
reasons.append(
|
|
457
|
+
f"Unblocks {blocks_count} ticket{'s' if blocks_count > 1 else ''}"
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# Critical path
|
|
461
|
+
if ticket_id in critical_path_set:
|
|
462
|
+
reasons.append("On critical path")
|
|
463
|
+
|
|
464
|
+
# Not blocked
|
|
465
|
+
blocked_by = dep_graph.reverse_edges.get(ticket_id, set())
|
|
466
|
+
if not blocked_by:
|
|
467
|
+
reasons.append("No blockers")
|
|
468
|
+
else:
|
|
469
|
+
reasons.append(
|
|
470
|
+
f"Blocked by {len(blocked_by)} ticket{'s' if len(blocked_by) > 1 else ''}"
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
return ", ".join(reasons) if reasons else "Available to start"
|
|
474
|
+
|
|
475
|
+
def _generate_recommendations(
|
|
476
|
+
self,
|
|
477
|
+
tickets: list["Task"],
|
|
478
|
+
dep_graph: DependencyGraph,
|
|
479
|
+
health_metrics: HealthMetrics,
|
|
480
|
+
blockers: list[dict[str, Any]],
|
|
481
|
+
) -> list[str]:
|
|
482
|
+
"""Generate actionable recommendations for project managers.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
tickets: List of tickets
|
|
486
|
+
dep_graph: Dependency graph
|
|
487
|
+
health_metrics: Health metrics
|
|
488
|
+
blockers: Blocker information
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
List of recommendation strings
|
|
492
|
+
|
|
493
|
+
"""
|
|
494
|
+
recommendations = []
|
|
495
|
+
|
|
496
|
+
# Health-based recommendations
|
|
497
|
+
if health_metrics.health_status == ProjectHealth.OFF_TRACK:
|
|
498
|
+
recommendations.append("⚠️ Project is OFF TRACK - Immediate action required")
|
|
499
|
+
|
|
500
|
+
if health_metrics.blocked_rate > 0.3:
|
|
501
|
+
recommendations.append(
|
|
502
|
+
f"🚧 {int(health_metrics.blocked_rate * 100)}% of tickets are blocked - Focus on resolving blockers"
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
elif health_metrics.health_status == ProjectHealth.AT_RISK:
|
|
506
|
+
recommendations.append("⚡ Project is AT RISK - Monitor closely")
|
|
507
|
+
|
|
508
|
+
# Blocker recommendations
|
|
509
|
+
if blockers:
|
|
510
|
+
top_blocker = blockers[0]
|
|
511
|
+
recommendations.append(
|
|
512
|
+
f"🔓 Resolve {top_blocker['ticket_id']} first ({top_blocker['priority']}) - "
|
|
513
|
+
f"Unblocks {top_blocker['blocks_count']} ticket{'s' if top_blocker['blocks_count'] > 1 else ''}"
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
# Priority recommendations
|
|
517
|
+
if health_metrics.critical_count > 0:
|
|
518
|
+
from ..core.models import Priority, TicketState
|
|
519
|
+
|
|
520
|
+
critical_open = sum(
|
|
521
|
+
1
|
|
522
|
+
for t in tickets
|
|
523
|
+
if t.priority == Priority.CRITICAL
|
|
524
|
+
and t.state not in (TicketState.DONE, TicketState.CLOSED)
|
|
525
|
+
)
|
|
526
|
+
if critical_open > 0:
|
|
527
|
+
recommendations.append(
|
|
528
|
+
f"🔥 {critical_open} critical priority ticket{'s' if critical_open > 1 else ''} need{'s' if critical_open == 1 else ''} attention"
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# Progress recommendations
|
|
532
|
+
if health_metrics.completion_rate == 0.0 and len(tickets) > 0:
|
|
533
|
+
recommendations.append(
|
|
534
|
+
"🏁 No tickets completed yet - Focus on delivering first wins"
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# Work distribution recommendations
|
|
538
|
+
work_dist = self._build_work_distribution(tickets)
|
|
539
|
+
if len(work_dist) > 1:
|
|
540
|
+
# Check for imbalanced workload
|
|
541
|
+
ticket_counts = [info.get("total", 0) for info in work_dist.values()]
|
|
542
|
+
if ticket_counts:
|
|
543
|
+
max_tickets = max(ticket_counts)
|
|
544
|
+
min_tickets = min(ticket_counts)
|
|
545
|
+
if max_tickets > min_tickets * 2:
|
|
546
|
+
recommendations.append(
|
|
547
|
+
"⚖️ Workload is imbalanced - Consider redistributing tickets"
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
# Default positive message
|
|
551
|
+
if not recommendations:
|
|
552
|
+
recommendations.append("✅ Project is on track - Continue current momentum")
|
|
553
|
+
|
|
554
|
+
return recommendations
|
|
555
|
+
|
|
556
|
+
def _estimate_timeline(
|
|
557
|
+
self, tickets: list["Task"], dep_graph: DependencyGraph
|
|
558
|
+
) -> dict[str, Any]:
|
|
559
|
+
"""Estimate timeline for project completion.
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
tickets: List of tickets
|
|
563
|
+
dep_graph: Dependency graph
|
|
564
|
+
|
|
565
|
+
Returns:
|
|
566
|
+
Timeline estimation information
|
|
567
|
+
|
|
568
|
+
"""
|
|
569
|
+
# For now, return basic risk assessment
|
|
570
|
+
# Future: Could incorporate estimates if available in ticket data
|
|
571
|
+
risk_factors = []
|
|
572
|
+
|
|
573
|
+
if any(t.priority and _get_value(t.priority) == "critical" for t in tickets):
|
|
574
|
+
risk_factors.append("Multiple high-priority items")
|
|
575
|
+
|
|
576
|
+
from ..core.models import TicketState
|
|
577
|
+
|
|
578
|
+
completed = sum(
|
|
579
|
+
1
|
|
580
|
+
for t in tickets
|
|
581
|
+
if t.state in (TicketState.DONE, TicketState.CLOSED, TicketState.TESTED)
|
|
582
|
+
)
|
|
583
|
+
if completed == 0 and len(tickets) > 0:
|
|
584
|
+
risk_factors.append("No completions yet")
|
|
585
|
+
|
|
586
|
+
blockers = dep_graph.get_blocked_tickets()
|
|
587
|
+
if len(blockers) > len(tickets) * 0.3:
|
|
588
|
+
risk_factors.append("High number of blocked tickets")
|
|
589
|
+
|
|
590
|
+
return {
|
|
591
|
+
"days_to_completion": None, # Would need estimates
|
|
592
|
+
"critical_path_days": None, # Would need estimates
|
|
593
|
+
"risk": ", ".join(risk_factors) if risk_factors else "On track",
|
|
594
|
+
}
|