mcp-ticketer 0.1.30__py3-none-any.whl → 1.2.11__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 +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +796 -46
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1416 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +348 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +879 -129
- mcp_ticketer/adapters/hybrid.py +11 -11
- mcp_ticketer/adapters/jira.py +973 -73
- mcp_ticketer/adapters/linear/__init__.py +24 -0
- mcp_ticketer/adapters/linear/adapter.py +2732 -0
- mcp_ticketer/adapters/linear/client.py +344 -0
- mcp_ticketer/adapters/linear/mappers.py +420 -0
- mcp_ticketer/adapters/linear/queries.py +479 -0
- mcp_ticketer/adapters/linear/types.py +360 -0
- mcp_ticketer/adapters/linear.py +10 -2315
- mcp_ticketer/analysis/__init__.py +23 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +421 -0
- mcp_ticketer/cli/auggie_configure.py +116 -15
- mcp_ticketer/cli/codex_configure.py +274 -82
- mcp_ticketer/cli/configure.py +888 -151
- mcp_ticketer/cli/diagnostics.py +400 -157
- mcp_ticketer/cli/discover.py +297 -26
- mcp_ticketer/cli/gemini_configure.py +119 -26
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +616 -0
- mcp_ticketer/cli/main.py +203 -1165
- mcp_ticketer/cli/mcp_configure.py +474 -90
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +418 -0
- mcp_ticketer/cli/platform_installer.py +513 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +90 -65
- mcp_ticketer/cli/ticket_commands.py +1013 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +114 -66
- mcp_ticketer/core/__init__.py +24 -1
- mcp_ticketer/core/adapter.py +250 -16
- mcp_ticketer/core/config.py +145 -37
- mcp_ticketer/core/env_discovery.py +101 -22
- mcp_ticketer/core/env_loader.py +349 -0
- mcp_ticketer/core/exceptions.py +160 -0
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +42 -30
- mcp_ticketer/core/models.py +280 -28
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/project_config.py +183 -49
- mcp_ticketer/core/registry.py +3 -3
- 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/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +29 -1
- mcp_ticketer/mcp/__main__.py +60 -0
- mcp_ticketer/mcp/server/__init__.py +25 -0
- mcp_ticketer/mcp/server/__main__.py +60 -0
- mcp_ticketer/mcp/server/constants.py +58 -0
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/dto.py +195 -0
- mcp_ticketer/mcp/server/main.py +1343 -0
- mcp_ticketer/mcp/server/response_builder.py +206 -0
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +151 -0
- mcp_ticketer/mcp/server/tools/__init__.py +56 -0
- mcp_ticketer/mcp/server/tools/analysis_tools.py +495 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +1439 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +921 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +300 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +948 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +215 -0
- mcp_ticketer/mcp/server/tools/session_tools.py +170 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1268 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +547 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +168 -136
- mcp_ticketer/queue/manager.py +95 -25
- mcp_ticketer/queue/queue.py +40 -21
- mcp_ticketer/queue/run_worker.py +6 -1
- mcp_ticketer/queue/ticket_registry.py +213 -155
- mcp_ticketer/queue/worker.py +109 -49
- mcp_ticketer-1.2.11.dist-info/METADATA +792 -0
- mcp_ticketer-1.2.11.dist-info/RECORD +110 -0
- mcp_ticketer/mcp/server.py +0 -1895
- mcp_ticketer-0.1.30.dist-info/METADATA +0 -413
- mcp_ticketer-0.1.30.dist-info/RECORD +0 -49
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Ticket analysis and cleanup tools for PM monitoring.
|
|
2
|
+
|
|
3
|
+
This module provides comprehensive analysis capabilities for ticket health:
|
|
4
|
+
- Similarity detection: Find duplicate or related tickets using TF-IDF
|
|
5
|
+
- Staleness detection: Identify old, inactive tickets
|
|
6
|
+
- Orphaned detection: Find tickets missing hierarchy (epic/project)
|
|
7
|
+
- Cleanup reports: Comprehensive analysis with recommendations
|
|
8
|
+
|
|
9
|
+
These tools help product managers maintain ticket health and development practices.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .orphaned import OrphanedResult, OrphanedTicketDetector
|
|
13
|
+
from .similarity import SimilarityResult, TicketSimilarityAnalyzer
|
|
14
|
+
from .staleness import StalenessResult, StaleTicketDetector
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"SimilarityResult",
|
|
18
|
+
"TicketSimilarityAnalyzer",
|
|
19
|
+
"StalenessResult",
|
|
20
|
+
"StaleTicketDetector",
|
|
21
|
+
"OrphanedResult",
|
|
22
|
+
"OrphanedTicketDetector",
|
|
23
|
+
]
|
|
@@ -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")
|
|
@@ -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
|
+
)
|