mcp-ticketer 0.4.11__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 (111) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +3 -3
  3. mcp_ticketer/adapters/__init__.py +2 -0
  4. mcp_ticketer/adapters/aitrackdown.py +394 -9
  5. mcp_ticketer/adapters/asana/__init__.py +15 -0
  6. mcp_ticketer/adapters/asana/adapter.py +1416 -0
  7. mcp_ticketer/adapters/asana/client.py +292 -0
  8. mcp_ticketer/adapters/asana/mappers.py +348 -0
  9. mcp_ticketer/adapters/asana/types.py +146 -0
  10. mcp_ticketer/adapters/github.py +836 -105
  11. mcp_ticketer/adapters/hybrid.py +47 -5
  12. mcp_ticketer/adapters/jira.py +772 -1
  13. mcp_ticketer/adapters/linear/adapter.py +2293 -108
  14. mcp_ticketer/adapters/linear/client.py +146 -12
  15. mcp_ticketer/adapters/linear/mappers.py +105 -11
  16. mcp_ticketer/adapters/linear/queries.py +168 -1
  17. mcp_ticketer/adapters/linear/types.py +80 -4
  18. mcp_ticketer/analysis/__init__.py +56 -0
  19. mcp_ticketer/analysis/dependency_graph.py +255 -0
  20. mcp_ticketer/analysis/health_assessment.py +304 -0
  21. mcp_ticketer/analysis/orphaned.py +218 -0
  22. mcp_ticketer/analysis/project_status.py +594 -0
  23. mcp_ticketer/analysis/similarity.py +224 -0
  24. mcp_ticketer/analysis/staleness.py +266 -0
  25. mcp_ticketer/automation/__init__.py +11 -0
  26. mcp_ticketer/automation/project_updates.py +378 -0
  27. mcp_ticketer/cache/memory.py +3 -3
  28. mcp_ticketer/cli/adapter_diagnostics.py +4 -2
  29. mcp_ticketer/cli/auggie_configure.py +18 -6
  30. mcp_ticketer/cli/codex_configure.py +175 -60
  31. mcp_ticketer/cli/configure.py +884 -146
  32. mcp_ticketer/cli/cursor_configure.py +314 -0
  33. mcp_ticketer/cli/diagnostics.py +31 -28
  34. mcp_ticketer/cli/discover.py +293 -21
  35. mcp_ticketer/cli/gemini_configure.py +18 -6
  36. mcp_ticketer/cli/init_command.py +880 -0
  37. mcp_ticketer/cli/instruction_commands.py +435 -0
  38. mcp_ticketer/cli/linear_commands.py +99 -15
  39. mcp_ticketer/cli/main.py +109 -2055
  40. mcp_ticketer/cli/mcp_configure.py +673 -99
  41. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  42. mcp_ticketer/cli/migrate_config.py +12 -8
  43. mcp_ticketer/cli/platform_commands.py +6 -6
  44. mcp_ticketer/cli/platform_detection.py +477 -0
  45. mcp_ticketer/cli/platform_installer.py +536 -0
  46. mcp_ticketer/cli/project_update_commands.py +350 -0
  47. mcp_ticketer/cli/queue_commands.py +15 -15
  48. mcp_ticketer/cli/setup_command.py +639 -0
  49. mcp_ticketer/cli/simple_health.py +13 -11
  50. mcp_ticketer/cli/ticket_commands.py +277 -36
  51. mcp_ticketer/cli/update_checker.py +313 -0
  52. mcp_ticketer/cli/utils.py +45 -41
  53. mcp_ticketer/core/__init__.py +35 -1
  54. mcp_ticketer/core/adapter.py +170 -5
  55. mcp_ticketer/core/config.py +38 -31
  56. mcp_ticketer/core/env_discovery.py +33 -3
  57. mcp_ticketer/core/env_loader.py +7 -6
  58. mcp_ticketer/core/exceptions.py +10 -4
  59. mcp_ticketer/core/http_client.py +10 -10
  60. mcp_ticketer/core/instructions.py +405 -0
  61. mcp_ticketer/core/label_manager.py +732 -0
  62. mcp_ticketer/core/mappers.py +32 -20
  63. mcp_ticketer/core/models.py +136 -1
  64. mcp_ticketer/core/onepassword_secrets.py +379 -0
  65. mcp_ticketer/core/priority_matcher.py +463 -0
  66. mcp_ticketer/core/project_config.py +148 -14
  67. mcp_ticketer/core/registry.py +1 -1
  68. mcp_ticketer/core/session_state.py +171 -0
  69. mcp_ticketer/core/state_matcher.py +592 -0
  70. mcp_ticketer/core/url_parser.py +425 -0
  71. mcp_ticketer/core/validators.py +69 -0
  72. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  73. mcp_ticketer/mcp/__init__.py +2 -2
  74. mcp_ticketer/mcp/server/__init__.py +2 -2
  75. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  76. mcp_ticketer/mcp/server/main.py +187 -93
  77. mcp_ticketer/mcp/server/routing.py +655 -0
  78. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  79. mcp_ticketer/mcp/server/tools/__init__.py +37 -9
  80. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  81. mcp_ticketer/mcp/server/tools/attachment_tools.py +65 -20
  82. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  83. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  84. mcp_ticketer/mcp/server/tools/config_tools.py +1429 -0
  85. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  86. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +878 -319
  87. mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
  88. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  89. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  90. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  91. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  92. mcp_ticketer/mcp/server/tools/search_tools.py +180 -97
  93. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  94. mcp_ticketer/mcp/server/tools/ticket_tools.py +1182 -82
  95. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
  96. mcp_ticketer/queue/health_monitor.py +1 -0
  97. mcp_ticketer/queue/manager.py +4 -4
  98. mcp_ticketer/queue/queue.py +3 -3
  99. mcp_ticketer/queue/run_worker.py +1 -1
  100. mcp_ticketer/queue/ticket_registry.py +2 -2
  101. mcp_ticketer/queue/worker.py +15 -13
  102. mcp_ticketer/utils/__init__.py +5 -0
  103. mcp_ticketer/utils/token_utils.py +246 -0
  104. mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
  105. mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
  106. mcp_ticketer-0.4.11.dist-info/METADATA +0 -496
  107. mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
  108. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
  109. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
  110. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
  111. {mcp_ticketer-0.4.11.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"]