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.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +1 -1
- mcp_ticketer/adapters/aitrackdown.py +385 -6
- mcp_ticketer/adapters/asana/adapter.py +108 -0
- mcp_ticketer/adapters/asana/mappers.py +14 -0
- mcp_ticketer/adapters/github.py +525 -11
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira.py +521 -0
- mcp_ticketer/adapters/linear/adapter.py +1784 -101
- mcp_ticketer/adapters/linear/client.py +85 -3
- mcp_ticketer/adapters/linear/mappers.py +96 -8
- mcp_ticketer/adapters/linear/queries.py +168 -1
- mcp_ticketer/adapters/linear/types.py +80 -4
- 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/cli/adapter_diagnostics.py +3 -1
- mcp_ticketer/cli/auggie_configure.py +17 -5
- mcp_ticketer/cli/codex_configure.py +97 -61
- mcp_ticketer/cli/configure.py +851 -103
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +13 -12
- mcp_ticketer/cli/discover.py +5 -0
- mcp_ticketer/cli/gemini_configure.py +17 -5
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/instruction_commands.py +6 -0
- mcp_ticketer/cli/main.py +233 -3151
- mcp_ticketer/cli/mcp_configure.py +672 -98
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/platform_detection.py +77 -12
- mcp_ticketer/cli/platform_installer.py +536 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +12 -10
- mcp_ticketer/cli/ticket_commands.py +264 -24
- mcp_ticketer/core/__init__.py +28 -6
- mcp_ticketer/core/adapter.py +166 -1
- mcp_ticketer/core/config.py +21 -21
- mcp_ticketer/core/exceptions.py +7 -1
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +31 -19
- mcp_ticketer/core/models.py +135 -0
- mcp_ticketer/core/onepassword_secrets.py +1 -1
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +132 -14
- mcp_ticketer/core/session_state.py +171 -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/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +106 -25
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +31 -12
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +6 -8
- mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
- mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
- mcp_ticketer/mcp/server/tools/config_tools.py +1184 -136
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
- mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
- 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 +180 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1070 -123
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
- mcp_ticketer/queue/worker.py +1 -1
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
- mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
- mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
- mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {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")
|