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,224 @@
|
|
|
1
|
+
"""Ticket similarity detection using TF-IDF and fuzzy matching.
|
|
2
|
+
|
|
3
|
+
This module provides similarity analysis between tickets to detect:
|
|
4
|
+
- Duplicate tickets that should be merged
|
|
5
|
+
- Related tickets that should be linked
|
|
6
|
+
- Similar work that could be consolidated
|
|
7
|
+
|
|
8
|
+
Uses TF-IDF vectorization with cosine similarity for content analysis,
|
|
9
|
+
and fuzzy string matching for title comparison.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
from rapidfuzz import fuzz
|
|
16
|
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
|
17
|
+
from sklearn.metrics.pairwise import cosine_similarity
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from ..core.models import Task
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SimilarityResult(BaseModel):
|
|
24
|
+
"""Result of similarity analysis between two tickets.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
ticket1_id: ID of first ticket
|
|
28
|
+
ticket1_title: Title of first ticket
|
|
29
|
+
ticket2_id: ID of second ticket
|
|
30
|
+
ticket2_title: Title of second ticket
|
|
31
|
+
similarity_score: Overall similarity score (0.0-1.0)
|
|
32
|
+
similarity_reasons: List of reasons for similarity
|
|
33
|
+
suggested_action: Recommended action (merge, link, ignore)
|
|
34
|
+
confidence: Confidence in the similarity (0.0-1.0)
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
ticket1_id: str
|
|
39
|
+
ticket1_title: str
|
|
40
|
+
ticket2_id: str
|
|
41
|
+
ticket2_title: str
|
|
42
|
+
similarity_score: float # 0.0-1.0
|
|
43
|
+
similarity_reasons: list[str]
|
|
44
|
+
suggested_action: str # "merge", "link", "ignore"
|
|
45
|
+
confidence: float
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TicketSimilarityAnalyzer:
|
|
49
|
+
"""Analyzes tickets to find similar/duplicate entries.
|
|
50
|
+
|
|
51
|
+
Uses a combination of TF-IDF vectorization on titles and descriptions,
|
|
52
|
+
plus fuzzy string matching on titles to identify similar tickets.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
threshold: Minimum similarity score to report (0.0-1.0)
|
|
56
|
+
title_weight: Weight given to title similarity (0.0-1.0)
|
|
57
|
+
description_weight: Weight given to description similarity (0.0-1.0)
|
|
58
|
+
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
threshold: float = 0.75,
|
|
64
|
+
title_weight: float = 0.7,
|
|
65
|
+
description_weight: float = 0.3,
|
|
66
|
+
):
|
|
67
|
+
"""Initialize the similarity analyzer.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
threshold: Minimum similarity score to report (default: 0.75)
|
|
71
|
+
title_weight: Weight for title similarity (default: 0.7)
|
|
72
|
+
description_weight: Weight for description similarity (default: 0.3)
|
|
73
|
+
|
|
74
|
+
"""
|
|
75
|
+
self.threshold = threshold
|
|
76
|
+
self.title_weight = title_weight
|
|
77
|
+
self.description_weight = description_weight
|
|
78
|
+
|
|
79
|
+
def find_similar_tickets(
|
|
80
|
+
self,
|
|
81
|
+
tickets: list["Task"],
|
|
82
|
+
target_ticket: "Task | None" = None,
|
|
83
|
+
limit: int = 10,
|
|
84
|
+
) -> list[SimilarityResult]:
|
|
85
|
+
"""Find similar tickets using TF-IDF + cosine similarity.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
tickets: List of tickets to analyze
|
|
89
|
+
target_ticket: Find similar to this ticket (if None, find all pairs)
|
|
90
|
+
limit: Maximum results to return
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
List of similarity results above threshold, sorted by score
|
|
94
|
+
|
|
95
|
+
"""
|
|
96
|
+
if len(tickets) < 2:
|
|
97
|
+
return []
|
|
98
|
+
|
|
99
|
+
# Build corpus for TF-IDF
|
|
100
|
+
titles = [t.title for t in tickets]
|
|
101
|
+
descriptions = [t.description or "" for t in tickets]
|
|
102
|
+
|
|
103
|
+
# TF-IDF on titles
|
|
104
|
+
title_vectorizer = TfidfVectorizer(
|
|
105
|
+
min_df=1, stop_words="english", lowercase=True, ngram_range=(1, 2)
|
|
106
|
+
)
|
|
107
|
+
title_matrix = title_vectorizer.fit_transform(titles)
|
|
108
|
+
|
|
109
|
+
# TF-IDF on descriptions (if available)
|
|
110
|
+
desc_matrix = None
|
|
111
|
+
if any(descriptions):
|
|
112
|
+
desc_vectorizer = TfidfVectorizer(
|
|
113
|
+
min_df=1, stop_words="english", lowercase=True, ngram_range=(1, 2)
|
|
114
|
+
)
|
|
115
|
+
desc_matrix = desc_vectorizer.fit_transform(descriptions)
|
|
116
|
+
|
|
117
|
+
# Compute similarity matrices
|
|
118
|
+
title_similarity = cosine_similarity(title_matrix)
|
|
119
|
+
|
|
120
|
+
if desc_matrix is not None:
|
|
121
|
+
desc_similarity = cosine_similarity(desc_matrix)
|
|
122
|
+
# Weighted combination
|
|
123
|
+
combined_similarity = (
|
|
124
|
+
self.title_weight * title_similarity
|
|
125
|
+
+ self.description_weight * desc_similarity
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
# Only use title similarity if no descriptions
|
|
129
|
+
combined_similarity = title_similarity
|
|
130
|
+
|
|
131
|
+
results = []
|
|
132
|
+
|
|
133
|
+
if target_ticket:
|
|
134
|
+
# Find similar to specific ticket
|
|
135
|
+
target_idx = next(
|
|
136
|
+
(i for i, t in enumerate(tickets) if t.id == target_ticket.id),
|
|
137
|
+
None,
|
|
138
|
+
)
|
|
139
|
+
if target_idx is None:
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
for i, ticket in enumerate(tickets):
|
|
143
|
+
if i == target_idx:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
score = float(combined_similarity[target_idx, i])
|
|
147
|
+
if score >= self.threshold:
|
|
148
|
+
results.append(self._create_result(target_ticket, ticket, score))
|
|
149
|
+
else:
|
|
150
|
+
# Find all similar pairs
|
|
151
|
+
for i in range(len(tickets)):
|
|
152
|
+
for j in range(i + 1, len(tickets)):
|
|
153
|
+
score = float(combined_similarity[i, j])
|
|
154
|
+
if score >= self.threshold:
|
|
155
|
+
results.append(
|
|
156
|
+
self._create_result(tickets[i], tickets[j], score)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Sort by score and limit
|
|
160
|
+
results.sort(key=lambda x: x.similarity_score, reverse=True)
|
|
161
|
+
return results[:limit]
|
|
162
|
+
|
|
163
|
+
def _create_result(
|
|
164
|
+
self,
|
|
165
|
+
ticket1: "Task",
|
|
166
|
+
ticket2: "Task",
|
|
167
|
+
score: float,
|
|
168
|
+
) -> SimilarityResult:
|
|
169
|
+
"""Create similarity result with analysis.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
ticket1: First ticket
|
|
173
|
+
ticket2: Second ticket
|
|
174
|
+
score: Similarity score
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
SimilarityResult with detailed analysis
|
|
178
|
+
|
|
179
|
+
"""
|
|
180
|
+
reasons = []
|
|
181
|
+
|
|
182
|
+
# Title similarity using fuzzy matching
|
|
183
|
+
title_sim = fuzz.ratio(ticket1.title, ticket2.title) / 100.0
|
|
184
|
+
if title_sim > 0.8:
|
|
185
|
+
reasons.append("very_similar_titles")
|
|
186
|
+
elif title_sim > 0.6:
|
|
187
|
+
reasons.append("similar_titles")
|
|
188
|
+
|
|
189
|
+
# Tag overlap
|
|
190
|
+
tags1 = set(ticket1.tags or [])
|
|
191
|
+
tags2 = set(ticket2.tags or [])
|
|
192
|
+
if tags1 and tags2:
|
|
193
|
+
overlap = len(tags1 & tags2) / len(tags1 | tags2)
|
|
194
|
+
if overlap > 0.5:
|
|
195
|
+
reasons.append(f"tag_overlap_{int(overlap*100)}%")
|
|
196
|
+
|
|
197
|
+
# Same state
|
|
198
|
+
if ticket1.state == ticket2.state:
|
|
199
|
+
reasons.append("same_state")
|
|
200
|
+
|
|
201
|
+
# Same assignee
|
|
202
|
+
assignee1 = getattr(ticket1, "assignee", None)
|
|
203
|
+
assignee2 = getattr(ticket2, "assignee", None)
|
|
204
|
+
if assignee1 and assignee2 and assignee1 == assignee2:
|
|
205
|
+
reasons.append("same_assignee")
|
|
206
|
+
|
|
207
|
+
# Determine action
|
|
208
|
+
if score > 0.9:
|
|
209
|
+
action = "merge" # Very likely duplicates
|
|
210
|
+
elif score > 0.75:
|
|
211
|
+
action = "link" # Related, should be linked
|
|
212
|
+
else:
|
|
213
|
+
action = "ignore" # Low confidence
|
|
214
|
+
|
|
215
|
+
return SimilarityResult(
|
|
216
|
+
ticket1_id=ticket1.id or "unknown",
|
|
217
|
+
ticket1_title=ticket1.title,
|
|
218
|
+
ticket2_id=ticket2.id or "unknown",
|
|
219
|
+
ticket2_title=ticket2.title,
|
|
220
|
+
similarity_score=score,
|
|
221
|
+
similarity_reasons=reasons,
|
|
222
|
+
suggested_action=action,
|
|
223
|
+
confidence=score,
|
|
224
|
+
)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Stale ticket detection based on age and activity.
|
|
2
|
+
|
|
3
|
+
This module identifies tickets that may need closing or review based on:
|
|
4
|
+
- Age: How long since the ticket was created
|
|
5
|
+
- Inactivity: How long since the last update
|
|
6
|
+
- State: Tickets in certain states (open, waiting, blocked)
|
|
7
|
+
- Priority: Lower priority tickets are more likely to be stale
|
|
8
|
+
|
|
9
|
+
The staleness score combines these factors to identify candidates for cleanup.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ..core.models import Task, TicketState
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StalenessResult(BaseModel):
|
|
22
|
+
"""Result of staleness analysis for a ticket.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
ticket_id: ID of the stale ticket
|
|
26
|
+
ticket_title: Title of the stale ticket
|
|
27
|
+
ticket_state: Current state of the ticket
|
|
28
|
+
age_days: Days since ticket was created
|
|
29
|
+
days_since_update: Days since last update
|
|
30
|
+
days_since_comment: Days since last comment (if available)
|
|
31
|
+
staleness_score: Overall staleness score (0.0-1.0, higher = staler)
|
|
32
|
+
suggested_action: Recommended action (close, review, keep)
|
|
33
|
+
reason: Human-readable explanation
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
ticket_id: str
|
|
38
|
+
ticket_title: str
|
|
39
|
+
ticket_state: str
|
|
40
|
+
age_days: int
|
|
41
|
+
days_since_update: int
|
|
42
|
+
days_since_comment: int | None
|
|
43
|
+
staleness_score: float # 0.0-1.0
|
|
44
|
+
suggested_action: str # "close", "review", "keep"
|
|
45
|
+
reason: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class StaleTicketDetector:
|
|
49
|
+
"""Detects stale tickets based on age and activity.
|
|
50
|
+
|
|
51
|
+
Analyzes tickets to find those that are old, inactive, and may be
|
|
52
|
+
candidates for closing or review. Uses configurable thresholds for
|
|
53
|
+
age and activity, along with state and priority factors.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
age_threshold: Minimum age in days to consider
|
|
57
|
+
activity_threshold: Days without activity to consider stale
|
|
58
|
+
check_states: List of states to check for staleness
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
age_threshold_days: int = 90,
|
|
65
|
+
activity_threshold_days: int = 30,
|
|
66
|
+
check_states: list["TicketState"] | None = None,
|
|
67
|
+
):
|
|
68
|
+
"""Initialize the stale ticket detector.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
age_threshold_days: Minimum age to consider (default: 90)
|
|
72
|
+
activity_threshold_days: Days without activity (default: 30)
|
|
73
|
+
check_states: Ticket states to check (default: open, waiting, blocked)
|
|
74
|
+
|
|
75
|
+
"""
|
|
76
|
+
from ..core.models import TicketState
|
|
77
|
+
|
|
78
|
+
self.age_threshold = age_threshold_days
|
|
79
|
+
self.activity_threshold = activity_threshold_days
|
|
80
|
+
self.check_states = check_states or [
|
|
81
|
+
TicketState.OPEN,
|
|
82
|
+
TicketState.WAITING,
|
|
83
|
+
TicketState.BLOCKED,
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
def find_stale_tickets(
|
|
87
|
+
self,
|
|
88
|
+
tickets: list["Task"],
|
|
89
|
+
limit: int = 50,
|
|
90
|
+
) -> list[StalenessResult]:
|
|
91
|
+
"""Find stale tickets that may need attention.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
tickets: List of tickets to analyze
|
|
95
|
+
limit: Maximum results
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
List of staleness results, sorted by staleness score
|
|
99
|
+
|
|
100
|
+
"""
|
|
101
|
+
now = datetime.now()
|
|
102
|
+
results = []
|
|
103
|
+
|
|
104
|
+
for ticket in tickets:
|
|
105
|
+
# Skip tickets not in check_states
|
|
106
|
+
if ticket.state not in self.check_states:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
# Calculate metrics
|
|
110
|
+
age_days = self._days_since(ticket.created_at, now)
|
|
111
|
+
days_since_update = self._days_since(ticket.updated_at, now)
|
|
112
|
+
|
|
113
|
+
# Check staleness criteria
|
|
114
|
+
is_old = age_days > self.age_threshold
|
|
115
|
+
is_inactive = days_since_update > self.activity_threshold
|
|
116
|
+
|
|
117
|
+
if is_old and is_inactive:
|
|
118
|
+
staleness_score = self._calculate_staleness_score(
|
|
119
|
+
age_days, days_since_update, ticket
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
result = StalenessResult(
|
|
123
|
+
ticket_id=ticket.id or "unknown",
|
|
124
|
+
ticket_title=ticket.title,
|
|
125
|
+
ticket_state=(
|
|
126
|
+
ticket.state.value
|
|
127
|
+
if hasattr(ticket.state, "value")
|
|
128
|
+
else str(ticket.state)
|
|
129
|
+
),
|
|
130
|
+
age_days=age_days,
|
|
131
|
+
days_since_update=days_since_update,
|
|
132
|
+
days_since_comment=None, # Can be enhanced with comment data
|
|
133
|
+
staleness_score=staleness_score,
|
|
134
|
+
suggested_action=self._suggest_action(staleness_score),
|
|
135
|
+
reason=self._build_reason(age_days, days_since_update, ticket),
|
|
136
|
+
)
|
|
137
|
+
results.append(result)
|
|
138
|
+
|
|
139
|
+
# Sort by staleness score
|
|
140
|
+
results.sort(key=lambda x: x.staleness_score, reverse=True)
|
|
141
|
+
return results[:limit]
|
|
142
|
+
|
|
143
|
+
def _days_since(self, dt: datetime | None, now: datetime) -> int:
|
|
144
|
+
"""Calculate days since a datetime.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
dt: Datetime to calculate from
|
|
148
|
+
now: Current datetime
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Number of days since dt (0 if dt is None)
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
if dt is None:
|
|
155
|
+
return 0
|
|
156
|
+
# Handle timezone-aware and naive datetimes
|
|
157
|
+
if dt.tzinfo is not None and now.tzinfo is None:
|
|
158
|
+
now = now.replace(tzinfo=dt.tzinfo)
|
|
159
|
+
elif dt.tzinfo is None and now.tzinfo is not None:
|
|
160
|
+
dt = dt.replace(tzinfo=now.tzinfo)
|
|
161
|
+
return (now - dt).days
|
|
162
|
+
|
|
163
|
+
def _calculate_staleness_score(
|
|
164
|
+
self,
|
|
165
|
+
age_days: int,
|
|
166
|
+
days_since_update: int,
|
|
167
|
+
ticket: "Task",
|
|
168
|
+
) -> float:
|
|
169
|
+
"""Calculate staleness score (0.0-1.0, higher = staler).
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
age_days: Days since creation
|
|
173
|
+
days_since_update: Days since last update
|
|
174
|
+
ticket: The ticket being analyzed
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Staleness score between 0.0 and 1.0
|
|
178
|
+
|
|
179
|
+
"""
|
|
180
|
+
from ..core.models import Priority, TicketState
|
|
181
|
+
|
|
182
|
+
# Base score from age and inactivity
|
|
183
|
+
age_factor = min(age_days / 365, 1.0) # Normalize to 1 year
|
|
184
|
+
activity_factor = min(days_since_update / 180, 1.0) # Normalize to 6 months
|
|
185
|
+
|
|
186
|
+
base_score = (age_factor + activity_factor) / 2
|
|
187
|
+
|
|
188
|
+
# Priority adjustment (low priority = more stale)
|
|
189
|
+
priority_weights = {
|
|
190
|
+
Priority.CRITICAL: 0.0,
|
|
191
|
+
Priority.HIGH: 0.3,
|
|
192
|
+
Priority.MEDIUM: 0.7,
|
|
193
|
+
Priority.LOW: 1.0,
|
|
194
|
+
}
|
|
195
|
+
priority_factor = priority_weights.get(ticket.priority, 0.5)
|
|
196
|
+
|
|
197
|
+
# State adjustment
|
|
198
|
+
state_weights = {
|
|
199
|
+
TicketState.BLOCKED: 0.8, # Blocked tickets are very stale
|
|
200
|
+
TicketState.WAITING: 0.9, # Waiting tickets are very stale
|
|
201
|
+
TicketState.OPEN: 0.6,
|
|
202
|
+
}
|
|
203
|
+
state_factor = state_weights.get(ticket.state, 0.5)
|
|
204
|
+
|
|
205
|
+
# Weighted combination
|
|
206
|
+
final_score = base_score * 0.5 + priority_factor * 0.3 + state_factor * 0.2
|
|
207
|
+
|
|
208
|
+
return min(final_score, 1.0)
|
|
209
|
+
|
|
210
|
+
def _suggest_action(self, score: float) -> str:
|
|
211
|
+
"""Suggest action based on staleness score.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
score: Staleness score
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Suggested action string
|
|
218
|
+
|
|
219
|
+
"""
|
|
220
|
+
if score > 0.8:
|
|
221
|
+
return "close" # Very stale, likely won't be done
|
|
222
|
+
elif score > 0.6:
|
|
223
|
+
return "review" # Moderately stale, needs review
|
|
224
|
+
else:
|
|
225
|
+
return "keep" # Still relevant
|
|
226
|
+
|
|
227
|
+
def _build_reason(
|
|
228
|
+
self,
|
|
229
|
+
age_days: int,
|
|
230
|
+
days_since_update: int,
|
|
231
|
+
ticket: "Task",
|
|
232
|
+
) -> str:
|
|
233
|
+
"""Build human-readable reason string.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
age_days: Days since creation
|
|
237
|
+
days_since_update: Days since last update
|
|
238
|
+
ticket: The ticket being analyzed
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Human-readable explanation
|
|
242
|
+
|
|
243
|
+
"""
|
|
244
|
+
from ..core.models import Priority, TicketState
|
|
245
|
+
|
|
246
|
+
reasons = []
|
|
247
|
+
|
|
248
|
+
if age_days > 365:
|
|
249
|
+
reasons.append(f"created {age_days} days ago")
|
|
250
|
+
elif age_days > self.age_threshold:
|
|
251
|
+
reasons.append(f"old ({age_days} days)")
|
|
252
|
+
|
|
253
|
+
if days_since_update > 180:
|
|
254
|
+
reasons.append(f"no updates for {days_since_update} days")
|
|
255
|
+
elif days_since_update > self.activity_threshold:
|
|
256
|
+
reasons.append(f"inactive for {days_since_update} days")
|
|
257
|
+
|
|
258
|
+
if ticket.state == TicketState.BLOCKED:
|
|
259
|
+
reasons.append("blocked state")
|
|
260
|
+
elif ticket.state == TicketState.WAITING:
|
|
261
|
+
reasons.append("waiting state")
|
|
262
|
+
|
|
263
|
+
if ticket.priority == Priority.LOW:
|
|
264
|
+
reasons.append("low priority")
|
|
265
|
+
|
|
266
|
+
return ", ".join(reasons)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Automation features for MCP Ticketer.
|
|
2
|
+
|
|
3
|
+
This module provides automated workflows including:
|
|
4
|
+
- Automatic project status updates on ticket transitions
|
|
5
|
+
- Real-time epic/project health monitoring
|
|
6
|
+
- Automated summaries and recommendations
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .project_updates import AutoProjectUpdateManager
|
|
10
|
+
|
|
11
|
+
__all__ = ["AutoProjectUpdateManager"]
|