mcp-ticketer 0.12.0__py3-none-any.whl → 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcp-ticketer might be problematic. Click here for more details.

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