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,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
@@ -0,0 +1,218 @@
1
+ """Orphaned ticket detection - tickets without parent epic/project.
2
+
3
+ This module identifies tickets that are not properly organized in the hierarchy:
4
+ - Tickets without parent epic/milestone
5
+ - Tickets not assigned to any project/team
6
+ - Standalone issues that should be part of larger initiatives
7
+
8
+ Proper hierarchy ensures better organization and tracking of work.
9
+ """
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from pydantic import BaseModel
14
+
15
+ if TYPE_CHECKING:
16
+ from ..core.models import Task
17
+
18
+
19
+ class OrphanedResult(BaseModel):
20
+ """Result of orphaned ticket analysis.
21
+
22
+ Attributes:
23
+ ticket_id: ID of the orphaned ticket
24
+ ticket_title: Title of the orphaned ticket
25
+ ticket_type: Type of ticket (task, issue, epic)
26
+ orphan_type: Type of orphan condition (no_parent, no_epic, no_project)
27
+ suggested_action: Recommended action (assign_epic, assign_project, review)
28
+ reason: Human-readable explanation
29
+
30
+ """
31
+
32
+ ticket_id: str
33
+ ticket_title: str
34
+ ticket_type: str # "task", "issue", "epic"
35
+ orphan_type: str # "no_parent", "no_epic", "no_project"
36
+ suggested_action: str # "assign_epic", "assign_project", "review"
37
+ reason: str
38
+
39
+
40
+ class OrphanedTicketDetector:
41
+ """Detects orphaned tickets without proper hierarchy.
42
+
43
+ Analyzes tickets to find those missing proper parent relationships:
44
+ - Tasks without parent issues
45
+ - Issues without parent epics
46
+ - Tickets without project/team assignments
47
+
48
+ This helps identify organizational gaps in ticket management.
49
+ """
50
+
51
+ def find_orphaned_tickets(
52
+ self,
53
+ tickets: list["Task"],
54
+ epics: list["Task"] | None = None,
55
+ ) -> list[OrphanedResult]:
56
+ """Find tickets without parent epic/project associations.
57
+
58
+ Args:
59
+ tickets: List of tickets to analyze
60
+ epics: Optional list of epics for validation
61
+
62
+ Returns:
63
+ List of orphaned tickets with suggested actions
64
+
65
+ """
66
+ results = []
67
+
68
+ for ticket in tickets:
69
+ orphan_types = self._check_orphaned(ticket)
70
+
71
+ for orphan_type in orphan_types:
72
+ result = OrphanedResult(
73
+ ticket_id=ticket.id or "unknown",
74
+ ticket_title=ticket.title,
75
+ ticket_type=self._get_ticket_type(ticket),
76
+ orphan_type=orphan_type,
77
+ suggested_action=self._suggest_action(orphan_type),
78
+ reason=self._build_reason(orphan_type, ticket),
79
+ )
80
+ results.append(result)
81
+
82
+ return results
83
+
84
+ def _check_orphaned(self, ticket: "Task") -> list[str]:
85
+ """Check if ticket is orphaned in various ways.
86
+
87
+ Args:
88
+ ticket: Ticket to check
89
+
90
+ Returns:
91
+ List of orphan type strings
92
+
93
+ """
94
+ orphan_types = []
95
+ metadata = ticket.metadata or {}
96
+
97
+ # Check ticket type
98
+ ticket_type = self._get_ticket_type(ticket)
99
+
100
+ # For tasks, check parent_issue
101
+ if ticket_type == "task":
102
+ if not getattr(ticket, "parent_issue", None):
103
+ orphan_types.append("no_parent")
104
+ return orphan_types
105
+
106
+ # For issues, check parent_epic and project
107
+ if ticket_type == "issue":
108
+ # Check for parent epic
109
+ has_epic = any(
110
+ [
111
+ getattr(ticket, "parent_epic", None),
112
+ metadata.get("parent_id"),
113
+ metadata.get("parentId"),
114
+ metadata.get("epic_id"),
115
+ metadata.get("epicId"),
116
+ metadata.get("milestone_id"), # GitHub milestones
117
+ metadata.get("epic"), # JIRA epics
118
+ ]
119
+ )
120
+
121
+ if not has_epic:
122
+ orphan_types.append("no_epic")
123
+
124
+ # Check for project assignment
125
+ has_project = any(
126
+ [
127
+ metadata.get("project_id"),
128
+ metadata.get("projectId"),
129
+ metadata.get("team_id"),
130
+ metadata.get("teamId"),
131
+ metadata.get("board_id"), # JIRA boards
132
+ metadata.get("workspace_id"), # Asana workspaces
133
+ ]
134
+ )
135
+
136
+ if not has_project:
137
+ orphan_types.append("no_project")
138
+
139
+ # If neither epic nor project
140
+ if not has_epic and not has_project:
141
+ orphan_types.append("no_parent")
142
+
143
+ return orphan_types
144
+
145
+ def _get_ticket_type(self, ticket: "Task") -> str:
146
+ """Determine ticket type from metadata.
147
+
148
+ Args:
149
+ ticket: Ticket to analyze
150
+
151
+ Returns:
152
+ Ticket type string (task, issue, epic)
153
+
154
+ """
155
+ from ..core.models import TicketType
156
+
157
+ # Check explicit ticket_type field
158
+ ticket_type = getattr(ticket, "ticket_type", None)
159
+ if ticket_type:
160
+ if ticket_type == TicketType.EPIC:
161
+ return "epic"
162
+ elif ticket_type in (TicketType.TASK, TicketType.SUBTASK):
163
+ return "task"
164
+ elif ticket_type == TicketType.ISSUE:
165
+ return "issue"
166
+
167
+ # Fallback to metadata inspection
168
+ metadata = ticket.metadata or {}
169
+
170
+ if metadata.get("type") == "epic":
171
+ return "epic"
172
+ elif metadata.get("issue_type") == "Epic":
173
+ return "epic"
174
+ elif metadata.get("type") == "task":
175
+ return "task"
176
+ elif getattr(ticket, "parent_issue", None):
177
+ return "task" # Has parent issue, so it's a task
178
+ else:
179
+ return "issue" # Default to issue
180
+
181
+ def _suggest_action(self, orphan_type: str) -> str:
182
+ """Suggest action for orphaned ticket.
183
+
184
+ Args:
185
+ orphan_type: Type of orphan condition
186
+
187
+ Returns:
188
+ Suggested action string
189
+
190
+ """
191
+ if orphan_type == "no_parent":
192
+ return "review" # Needs manual review
193
+ elif orphan_type == "no_epic":
194
+ return "assign_epic"
195
+ elif orphan_type == "no_project":
196
+ return "assign_project"
197
+ else:
198
+ return "review"
199
+
200
+ def _build_reason(self, orphan_type: str, ticket: "Task") -> str:
201
+ """Build human-readable reason.
202
+
203
+ Args:
204
+ orphan_type: Type of orphan condition
205
+ ticket: The ticket being analyzed
206
+
207
+ Returns:
208
+ Human-readable explanation
209
+
210
+ """
211
+ ticket_type = self._get_ticket_type(ticket)
212
+
213
+ reasons = {
214
+ "no_parent": f"{ticket_type.capitalize()} has no parent epic or project assigned",
215
+ "no_epic": f"{ticket_type.capitalize()} is missing parent epic/milestone",
216
+ "no_project": f"{ticket_type.capitalize()} is not assigned to any project/team",
217
+ }
218
+ return reasons.get(orphan_type, "Orphaned ticket")