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.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +394 -9
- 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 +836 -105
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira.py +772 -1
- mcp_ticketer/adapters/linear/adapter.py +2293 -108
- mcp_ticketer/adapters/linear/client.py +146 -12
- mcp_ticketer/adapters/linear/mappers.py +105 -11
- 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/cache/memory.py +3 -3
- mcp_ticketer/cli/adapter_diagnostics.py +4 -2
- mcp_ticketer/cli/auggie_configure.py +18 -6
- mcp_ticketer/cli/codex_configure.py +175 -60
- mcp_ticketer/cli/configure.py +884 -146
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +31 -28
- mcp_ticketer/cli/discover.py +293 -21
- mcp_ticketer/cli/gemini_configure.py +18 -6
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +99 -15
- mcp_ticketer/cli/main.py +109 -2055
- mcp_ticketer/cli/mcp_configure.py +673 -99
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +6 -6
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +536 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +13 -11
- mcp_ticketer/cli/ticket_commands.py +277 -36
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +45 -41
- mcp_ticketer/core/__init__.py +35 -1
- mcp_ticketer/core/adapter.py +170 -5
- mcp_ticketer/core/config.py +38 -31
- mcp_ticketer/core/env_discovery.py +33 -3
- mcp_ticketer/core/env_loader.py +7 -6
- mcp_ticketer/core/exceptions.py +10 -4
- mcp_ticketer/core/http_client.py +10 -10
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +32 -20
- mcp_ticketer/core/models.py +136 -1
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +148 -14
- mcp_ticketer/core/registry.py +1 -1
- 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 +2 -2
- mcp_ticketer/mcp/server/__init__.py +2 -2
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +187 -93
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +37 -9
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +65 -20
- 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 +1429 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +878 -319
- mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
- 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 +1182 -82
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/health_monitor.py +1 -0
- mcp_ticketer/queue/manager.py +4 -4
- mcp_ticketer/queue/queue.py +3 -3
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +2 -2
- mcp_ticketer/queue/worker.py +15 -13
- 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.4.11.dist-info/METADATA +0 -496
- mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
|
@@ -351,7 +351,8 @@ SEARCH_ISSUE_BY_IDENTIFIER_QUERY = """
|
|
|
351
351
|
"""
|
|
352
352
|
|
|
353
353
|
LIST_PROJECTS_QUERY = (
|
|
354
|
-
PROJECT_FRAGMENT
|
|
354
|
+
TEAM_FRAGMENT # Required by PROJECT_FRAGMENT which uses ...TeamFields
|
|
355
|
+
+ PROJECT_FRAGMENT
|
|
355
356
|
+ """
|
|
356
357
|
query ListProjects($filter: ProjectFilter, $first: Int!) {
|
|
357
358
|
projects(filter: $filter, first: $first, orderBy: updatedAt) {
|
|
@@ -387,3 +388,169 @@ GET_CURRENT_USER_QUERY = (
|
|
|
387
388
|
}
|
|
388
389
|
"""
|
|
389
390
|
)
|
|
391
|
+
|
|
392
|
+
CREATE_LABEL_MUTATION = """
|
|
393
|
+
mutation CreateLabel($input: IssueLabelCreateInput!) {
|
|
394
|
+
issueLabelCreate(input: $input) {
|
|
395
|
+
success
|
|
396
|
+
issueLabel {
|
|
397
|
+
id
|
|
398
|
+
name
|
|
399
|
+
color
|
|
400
|
+
description
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
LIST_CYCLES_QUERY = """
|
|
407
|
+
query GetCycles($teamId: String!, $first: Int!, $after: String) {
|
|
408
|
+
team(id: $teamId) {
|
|
409
|
+
cycles(first: $first, after: $after) {
|
|
410
|
+
nodes {
|
|
411
|
+
id
|
|
412
|
+
name
|
|
413
|
+
number
|
|
414
|
+
startsAt
|
|
415
|
+
endsAt
|
|
416
|
+
completedAt
|
|
417
|
+
progress
|
|
418
|
+
}
|
|
419
|
+
pageInfo {
|
|
420
|
+
hasNextPage
|
|
421
|
+
endCursor
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
"""
|
|
427
|
+
|
|
428
|
+
GET_ISSUE_STATUS_QUERY = """
|
|
429
|
+
query GetIssueStatus($issueId: String!) {
|
|
430
|
+
issue(id: $issueId) {
|
|
431
|
+
id
|
|
432
|
+
state {
|
|
433
|
+
id
|
|
434
|
+
name
|
|
435
|
+
type
|
|
436
|
+
color
|
|
437
|
+
description
|
|
438
|
+
position
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
LIST_ISSUE_STATUSES_QUERY = """
|
|
445
|
+
query GetWorkflowStates($teamId: String!) {
|
|
446
|
+
team(id: $teamId) {
|
|
447
|
+
states {
|
|
448
|
+
nodes {
|
|
449
|
+
id
|
|
450
|
+
name
|
|
451
|
+
type
|
|
452
|
+
color
|
|
453
|
+
description
|
|
454
|
+
position
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
"""
|
|
460
|
+
|
|
461
|
+
GET_CUSTOM_VIEW_QUERY = (
|
|
462
|
+
ISSUE_LIST_FRAGMENTS
|
|
463
|
+
+ """
|
|
464
|
+
query GetCustomView($viewId: String!, $first: Int!) {
|
|
465
|
+
customView(id: $viewId) {
|
|
466
|
+
id
|
|
467
|
+
name
|
|
468
|
+
description
|
|
469
|
+
issues(first: $first) {
|
|
470
|
+
nodes {
|
|
471
|
+
...IssueCompactFields
|
|
472
|
+
}
|
|
473
|
+
pageInfo {
|
|
474
|
+
hasNextPage
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
"""
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# Project Update queries and mutations (1M-238)
|
|
483
|
+
|
|
484
|
+
PROJECT_UPDATE_FRAGMENT = """
|
|
485
|
+
fragment ProjectUpdateFields on ProjectUpdate {
|
|
486
|
+
id
|
|
487
|
+
body
|
|
488
|
+
health
|
|
489
|
+
createdAt
|
|
490
|
+
updatedAt
|
|
491
|
+
diffMarkdown
|
|
492
|
+
url
|
|
493
|
+
user {
|
|
494
|
+
id
|
|
495
|
+
name
|
|
496
|
+
email
|
|
497
|
+
}
|
|
498
|
+
project {
|
|
499
|
+
id
|
|
500
|
+
name
|
|
501
|
+
slugId
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
"""
|
|
505
|
+
|
|
506
|
+
CREATE_PROJECT_UPDATE_MUTATION = (
|
|
507
|
+
PROJECT_UPDATE_FRAGMENT
|
|
508
|
+
+ """
|
|
509
|
+
mutation ProjectUpdateCreate(
|
|
510
|
+
$projectId: String!
|
|
511
|
+
$body: String!
|
|
512
|
+
$health: ProjectUpdateHealthType
|
|
513
|
+
) {
|
|
514
|
+
projectUpdateCreate(
|
|
515
|
+
input: {
|
|
516
|
+
projectId: $projectId
|
|
517
|
+
body: $body
|
|
518
|
+
health: $health
|
|
519
|
+
}
|
|
520
|
+
) {
|
|
521
|
+
success
|
|
522
|
+
projectUpdate {
|
|
523
|
+
...ProjectUpdateFields
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
"""
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
LIST_PROJECT_UPDATES_QUERY = (
|
|
531
|
+
PROJECT_UPDATE_FRAGMENT
|
|
532
|
+
+ """
|
|
533
|
+
query ProjectUpdates($projectId: String!, $first: Int) {
|
|
534
|
+
project(id: $projectId) {
|
|
535
|
+
id
|
|
536
|
+
name
|
|
537
|
+
projectUpdates(first: $first) {
|
|
538
|
+
nodes {
|
|
539
|
+
...ProjectUpdateFields
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
"""
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
GET_PROJECT_UPDATE_QUERY = (
|
|
548
|
+
PROJECT_UPDATE_FRAGMENT
|
|
549
|
+
+ """
|
|
550
|
+
query ProjectUpdate($id: String!) {
|
|
551
|
+
projectUpdate(id: $id) {
|
|
552
|
+
...ProjectUpdateFields
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
"""
|
|
556
|
+
)
|
|
@@ -128,17 +128,87 @@ def get_linear_state_type(state: TicketState) -> str:
|
|
|
128
128
|
return LinearStateMapping.TO_LINEAR.get(state, "unstarted")
|
|
129
129
|
|
|
130
130
|
|
|
131
|
-
def get_universal_state(
|
|
132
|
-
|
|
131
|
+
def get_universal_state(
|
|
132
|
+
linear_state_type: str, state_name: str | None = None
|
|
133
|
+
) -> TicketState:
|
|
134
|
+
"""Convert Linear workflow state type to universal TicketState with synonym matching.
|
|
135
|
+
|
|
136
|
+
This function implements intelligent state mapping with fallback strategies:
|
|
137
|
+
1. Try exact match on state type (backlog, unstarted, started, completed, canceled)
|
|
138
|
+
2. Try synonym matching on state name (ToDo, In Review, Testing, etc.)
|
|
139
|
+
3. Default to OPEN for unknown states
|
|
140
|
+
|
|
141
|
+
Synonym Matching Rules (ticket 1M-164):
|
|
142
|
+
- "Done", "Closed", "Cancelled", "Completed", "Won't Do" → CLOSED
|
|
143
|
+
- Everything else → OPEN
|
|
133
144
|
|
|
134
145
|
Args:
|
|
135
|
-
linear_state_type: Linear workflow state type string
|
|
146
|
+
linear_state_type: Linear workflow state type string (from state.type field)
|
|
147
|
+
state_name: Linear workflow state name (from state.name field, optional)
|
|
136
148
|
|
|
137
149
|
Returns:
|
|
138
150
|
Universal ticket state enum
|
|
139
151
|
|
|
140
152
|
"""
|
|
141
|
-
|
|
153
|
+
# First try exact type match
|
|
154
|
+
if linear_state_type in LinearStateMapping.FROM_LINEAR:
|
|
155
|
+
return LinearStateMapping.FROM_LINEAR[linear_state_type]
|
|
156
|
+
|
|
157
|
+
# If no exact match and state_name provided, try synonym matching
|
|
158
|
+
if state_name:
|
|
159
|
+
state_name_lower = state_name.lower().strip()
|
|
160
|
+
|
|
161
|
+
# Check for "done/closed" synonyms - these become CLOSED
|
|
162
|
+
closed_synonyms = [
|
|
163
|
+
"done",
|
|
164
|
+
"closed",
|
|
165
|
+
"cancelled",
|
|
166
|
+
"canceled",
|
|
167
|
+
"completed",
|
|
168
|
+
"won't do",
|
|
169
|
+
"wont do",
|
|
170
|
+
"rejected",
|
|
171
|
+
"resolved",
|
|
172
|
+
"finished",
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
if any(synonym in state_name_lower for synonym in closed_synonyms):
|
|
176
|
+
return (
|
|
177
|
+
TicketState.DONE if state_name_lower == "done" else TicketState.CLOSED
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Check for "in progress" synonyms
|
|
181
|
+
in_progress_synonyms = [
|
|
182
|
+
"in progress",
|
|
183
|
+
"in-progress",
|
|
184
|
+
"working",
|
|
185
|
+
"active",
|
|
186
|
+
"started",
|
|
187
|
+
"doing",
|
|
188
|
+
"in development",
|
|
189
|
+
"in dev",
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
if any(synonym in state_name_lower for synonym in in_progress_synonyms):
|
|
193
|
+
return TicketState.IN_PROGRESS
|
|
194
|
+
|
|
195
|
+
# Check for "review/testing" synonyms
|
|
196
|
+
review_synonyms = [
|
|
197
|
+
"review",
|
|
198
|
+
"in review",
|
|
199
|
+
"in-review",
|
|
200
|
+
"testing",
|
|
201
|
+
"in test",
|
|
202
|
+
"in-test",
|
|
203
|
+
"qa",
|
|
204
|
+
"ready for review",
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
if any(synonym in state_name_lower for synonym in review_synonyms):
|
|
208
|
+
return TicketState.READY
|
|
209
|
+
|
|
210
|
+
# Default: everything else is OPEN (including "ToDo", "Backlog", "To Do", etc.)
|
|
211
|
+
return TicketState.OPEN
|
|
142
212
|
|
|
143
213
|
|
|
144
214
|
def build_issue_filter(
|
|
@@ -147,6 +217,7 @@ def build_issue_filter(
|
|
|
147
217
|
priority: Priority | None = None,
|
|
148
218
|
team_id: str | None = None,
|
|
149
219
|
project_id: str | None = None,
|
|
220
|
+
parent_id: str | None = None,
|
|
150
221
|
labels: list[str] | None = None,
|
|
151
222
|
created_after: str | None = None,
|
|
152
223
|
updated_after: str | None = None,
|
|
@@ -161,6 +232,7 @@ def build_issue_filter(
|
|
|
161
232
|
priority: Filter by priority
|
|
162
233
|
team_id: Filter by team ID
|
|
163
234
|
project_id: Filter by project ID
|
|
235
|
+
parent_id: Filter by parent issue ID (for listing sub-issues)
|
|
164
236
|
labels: Filter by label names
|
|
165
237
|
created_after: Filter by creation date (ISO string)
|
|
166
238
|
updated_after: Filter by update date (ISO string)
|
|
@@ -195,6 +267,10 @@ def build_issue_filter(
|
|
|
195
267
|
if project_id:
|
|
196
268
|
issue_filter["project"] = {"id": {"eq": project_id}}
|
|
197
269
|
|
|
270
|
+
# Parent filter (for listing children/sub-issues)
|
|
271
|
+
if parent_id:
|
|
272
|
+
issue_filter["parent"] = {"id": {"eq": parent_id}}
|
|
273
|
+
|
|
198
274
|
# Labels filter
|
|
199
275
|
if labels:
|
|
200
276
|
issue_filter["labels"] = {"some": {"name": {"in": labels}}}
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
- Dependency graph: Build and analyze ticket dependency graphs
|
|
9
|
+
- Health assessment: Assess project health based on ticket metrics
|
|
10
|
+
- Project status: Comprehensive project status analysis and work planning
|
|
11
|
+
|
|
12
|
+
These tools help product managers maintain ticket health and development practices.
|
|
13
|
+
|
|
14
|
+
Note: Some analysis features require optional dependencies (scikit-learn, rapidfuzz).
|
|
15
|
+
Install with: pip install mcp-ticketer[analysis]
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
# Import dependency graph and health assessment (no optional deps required)
|
|
19
|
+
from .dependency_graph import DependencyGraph, DependencyNode
|
|
20
|
+
from .health_assessment import HealthAssessor, HealthMetrics, ProjectHealth
|
|
21
|
+
from .project_status import ProjectStatusResult, StatusAnalyzer, TicketRecommendation
|
|
22
|
+
|
|
23
|
+
# Import optional analysis modules (may fail if dependencies not installed)
|
|
24
|
+
try:
|
|
25
|
+
from .orphaned import OrphanedResult, OrphanedTicketDetector
|
|
26
|
+
from .similarity import SimilarityResult, TicketSimilarityAnalyzer
|
|
27
|
+
from .staleness import StalenessResult, StaleTicketDetector
|
|
28
|
+
|
|
29
|
+
ANALYSIS_AVAILABLE = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
# Set placeholder values when optional deps not available
|
|
32
|
+
OrphanedResult = None # type: ignore
|
|
33
|
+
OrphanedTicketDetector = None # type: ignore
|
|
34
|
+
SimilarityResult = None # type: ignore
|
|
35
|
+
TicketSimilarityAnalyzer = None # type: ignore
|
|
36
|
+
StalenessResult = None # type: ignore
|
|
37
|
+
StaleTicketDetector = None # type: ignore
|
|
38
|
+
ANALYSIS_AVAILABLE = False
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"DependencyGraph",
|
|
42
|
+
"DependencyNode",
|
|
43
|
+
"HealthAssessor",
|
|
44
|
+
"HealthMetrics",
|
|
45
|
+
"ProjectHealth",
|
|
46
|
+
"ProjectStatusResult",
|
|
47
|
+
"StatusAnalyzer",
|
|
48
|
+
"TicketRecommendation",
|
|
49
|
+
"SimilarityResult",
|
|
50
|
+
"TicketSimilarityAnalyzer",
|
|
51
|
+
"StalenessResult",
|
|
52
|
+
"StaleTicketDetector",
|
|
53
|
+
"OrphanedResult",
|
|
54
|
+
"OrphanedTicketDetector",
|
|
55
|
+
"ANALYSIS_AVAILABLE",
|
|
56
|
+
]
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Dependency graph analysis for tickets.
|
|
2
|
+
|
|
3
|
+
This module parses ticket descriptions and builds dependency graphs to:
|
|
4
|
+
- Identify ticket dependencies (blocks/depends on)
|
|
5
|
+
- Find critical paths (longest dependency chains)
|
|
6
|
+
- Detect circular dependencies
|
|
7
|
+
- Recommend optimal work order
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections import defaultdict
|
|
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 DependencyNode(BaseModel):
|
|
21
|
+
"""A node in the dependency graph.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
ticket_id: ID of the ticket
|
|
25
|
+
blocks: List of ticket IDs that this ticket blocks
|
|
26
|
+
blocked_by: List of ticket IDs that block this ticket
|
|
27
|
+
depth: Depth in the dependency tree (0 = leaf, higher = more dependencies)
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
ticket_id: str
|
|
32
|
+
blocks: list[str] = []
|
|
33
|
+
blocked_by: list[str] = []
|
|
34
|
+
depth: int = 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DependencyGraph:
|
|
38
|
+
"""Build and analyze ticket dependency graphs.
|
|
39
|
+
|
|
40
|
+
Parses ticket descriptions to identify references to other tickets
|
|
41
|
+
and builds a directed graph of dependencies.
|
|
42
|
+
|
|
43
|
+
Supported patterns:
|
|
44
|
+
- "Related to TICKET-123"
|
|
45
|
+
- "Depends on 1M-315"
|
|
46
|
+
- "Blocks #456"
|
|
47
|
+
- "1M-316: Feature name"
|
|
48
|
+
- "Blocked by PROJ-789"
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
# Regex patterns for dependency detection
|
|
52
|
+
DEPENDENCY_PATTERNS = [
|
|
53
|
+
(r"depends\s+on\s+([A-Z0-9]+-\d+)\b", "blocked_by"),
|
|
54
|
+
(r"depends\s+on\s+#(\d+)\b", "blocked_by"),
|
|
55
|
+
(r"blocked\s+by\s+([A-Z0-9]+-\d+)\b", "blocked_by"),
|
|
56
|
+
(r"blocked\s+by\s+#(\d+)\b", "blocked_by"),
|
|
57
|
+
(r"blocks\s+([A-Z0-9]+-\d+)\b", "blocks"),
|
|
58
|
+
(r"blocks\s+#(\d+)\b", "blocks"),
|
|
59
|
+
(r"related\s+to\s+([A-Z0-9]+-\d+)\b", "related"),
|
|
60
|
+
(r"related\s+to\s+#(\d+)\b", "related"),
|
|
61
|
+
# Inline references like "1M-316:" or "TICKET-123:"
|
|
62
|
+
(r"\b([A-Z0-9]+-\d+):", "related"),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
"""Initialize the dependency graph."""
|
|
67
|
+
self.nodes: dict[str, DependencyNode] = {}
|
|
68
|
+
self.edges: dict[str, set[str]] = defaultdict(set) # ticket_id -> blocks set
|
|
69
|
+
self.reverse_edges: dict[str, set[str]] = defaultdict(
|
|
70
|
+
set
|
|
71
|
+
) # ticket_id -> blocked_by set
|
|
72
|
+
|
|
73
|
+
def add_ticket(self, ticket: "Task") -> None:
|
|
74
|
+
"""Add a ticket to the graph and extract its dependencies.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
ticket: The ticket to add
|
|
78
|
+
|
|
79
|
+
"""
|
|
80
|
+
ticket_id = ticket.id or ""
|
|
81
|
+
if not ticket_id:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
# Initialize node if not exists
|
|
85
|
+
if ticket_id not in self.nodes:
|
|
86
|
+
self.nodes[ticket_id] = DependencyNode(ticket_id=ticket_id)
|
|
87
|
+
|
|
88
|
+
# Parse description and title for dependencies
|
|
89
|
+
text = f"{ticket.title or ''}\n{ticket.description or ''}"
|
|
90
|
+
dependencies = self._extract_dependencies(text, ticket_id)
|
|
91
|
+
|
|
92
|
+
# Update graph edges
|
|
93
|
+
for dep_type, dep_id in dependencies:
|
|
94
|
+
if dep_type == "blocks":
|
|
95
|
+
self.edges[ticket_id].add(dep_id)
|
|
96
|
+
self.reverse_edges[dep_id].add(ticket_id)
|
|
97
|
+
elif dep_type == "blocked_by":
|
|
98
|
+
self.edges[dep_id].add(ticket_id)
|
|
99
|
+
self.reverse_edges[ticket_id].add(dep_id)
|
|
100
|
+
elif dep_type == "related":
|
|
101
|
+
# For related, add bidirectional soft dependency
|
|
102
|
+
# (lower priority in recommendations)
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
def _extract_dependencies(self, text: str, ticket_id: str) -> list[tuple[str, str]]:
|
|
106
|
+
"""Extract dependencies from ticket text.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
text: The text to parse (title + description)
|
|
110
|
+
ticket_id: ID of the ticket being parsed (to avoid self-references)
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
List of (dependency_type, ticket_id) tuples
|
|
114
|
+
|
|
115
|
+
"""
|
|
116
|
+
dependencies = []
|
|
117
|
+
text_lower = text.lower()
|
|
118
|
+
|
|
119
|
+
for pattern, dep_type in self.DEPENDENCY_PATTERNS:
|
|
120
|
+
matches = re.finditer(pattern, text_lower, re.IGNORECASE)
|
|
121
|
+
for match in matches:
|
|
122
|
+
dep_id = match.group(1)
|
|
123
|
+
# Normalize ticket ID (handle both "1M-123" and "123" formats)
|
|
124
|
+
if not re.match(r"[A-Z0-9]+-\d+", dep_id, re.IGNORECASE):
|
|
125
|
+
# If just a number, try to infer project prefix from current ticket
|
|
126
|
+
if "-" in ticket_id:
|
|
127
|
+
prefix = ticket_id.split("-")[0]
|
|
128
|
+
dep_id = f"{prefix}-{dep_id}"
|
|
129
|
+
|
|
130
|
+
# Avoid self-references
|
|
131
|
+
if dep_id.upper() != ticket_id.upper():
|
|
132
|
+
dependencies.append((dep_type, dep_id.upper()))
|
|
133
|
+
|
|
134
|
+
return dependencies
|
|
135
|
+
|
|
136
|
+
def calculate_depths(self) -> None:
|
|
137
|
+
"""Calculate depth of each node in the dependency tree.
|
|
138
|
+
|
|
139
|
+
Depth is the longest path from this node to a leaf node.
|
|
140
|
+
Higher depth means more dependencies downstream.
|
|
141
|
+
"""
|
|
142
|
+
# Build adjacency list from edges
|
|
143
|
+
visited = set()
|
|
144
|
+
|
|
145
|
+
def dfs(node_id: str) -> int:
|
|
146
|
+
"""DFS to calculate max depth."""
|
|
147
|
+
if node_id in visited:
|
|
148
|
+
return 0 # Avoid cycles
|
|
149
|
+
|
|
150
|
+
visited.add(node_id)
|
|
151
|
+
|
|
152
|
+
# Get all tickets this one blocks
|
|
153
|
+
blocked_tickets = self.edges.get(node_id, set())
|
|
154
|
+
if not blocked_tickets:
|
|
155
|
+
depth = 0
|
|
156
|
+
else:
|
|
157
|
+
# Depth is 1 + max depth of any blocked ticket
|
|
158
|
+
depth = 1 + max(
|
|
159
|
+
(dfs(blocked) for blocked in blocked_tickets), default=0
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if node_id in self.nodes:
|
|
163
|
+
self.nodes[node_id].depth = depth
|
|
164
|
+
|
|
165
|
+
visited.remove(node_id)
|
|
166
|
+
return depth
|
|
167
|
+
|
|
168
|
+
# Calculate depth for all nodes
|
|
169
|
+
for node_id in list(self.nodes.keys()):
|
|
170
|
+
if node_id not in visited:
|
|
171
|
+
dfs(node_id)
|
|
172
|
+
|
|
173
|
+
def get_critical_path(self) -> list[str]:
|
|
174
|
+
"""Get the critical path (longest dependency chain).
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
List of ticket IDs in the critical path, ordered from
|
|
178
|
+
start to end
|
|
179
|
+
|
|
180
|
+
"""
|
|
181
|
+
if not self.nodes:
|
|
182
|
+
return []
|
|
183
|
+
|
|
184
|
+
# Find node with maximum depth
|
|
185
|
+
max_depth_node = max(self.nodes.values(), key=lambda n: n.depth)
|
|
186
|
+
|
|
187
|
+
if max_depth_node.depth == 0:
|
|
188
|
+
return [max_depth_node.ticket_id]
|
|
189
|
+
|
|
190
|
+
# Trace back the critical path
|
|
191
|
+
path = [max_depth_node.ticket_id]
|
|
192
|
+
current = max_depth_node.ticket_id
|
|
193
|
+
|
|
194
|
+
while True:
|
|
195
|
+
blocked_tickets = self.edges.get(current, set())
|
|
196
|
+
if not blocked_tickets:
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
# Find the blocked ticket with maximum depth
|
|
200
|
+
next_ticket = max(
|
|
201
|
+
blocked_tickets,
|
|
202
|
+
key=lambda tid: self.nodes[tid].depth if tid in self.nodes else 0,
|
|
203
|
+
default=None,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if next_ticket and next_ticket not in path:
|
|
207
|
+
path.append(next_ticket)
|
|
208
|
+
current = next_ticket
|
|
209
|
+
else:
|
|
210
|
+
break
|
|
211
|
+
|
|
212
|
+
return path
|
|
213
|
+
|
|
214
|
+
def get_blocked_tickets(self) -> dict[str, list[str]]:
|
|
215
|
+
"""Get all blocked tickets and their blockers.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Dictionary mapping ticket_id -> list of blocker ticket IDs
|
|
219
|
+
|
|
220
|
+
"""
|
|
221
|
+
blocked = {}
|
|
222
|
+
for ticket_id, blockers in self.reverse_edges.items():
|
|
223
|
+
if blockers:
|
|
224
|
+
blocked[ticket_id] = list(blockers)
|
|
225
|
+
return blocked
|
|
226
|
+
|
|
227
|
+
def get_high_impact_tickets(self) -> list[tuple[str, int]]:
|
|
228
|
+
"""Get tickets that block the most other tickets.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
List of (ticket_id, count) tuples sorted by impact (descending)
|
|
232
|
+
|
|
233
|
+
"""
|
|
234
|
+
impact = []
|
|
235
|
+
for ticket_id, blocked_set in self.edges.items():
|
|
236
|
+
if blocked_set:
|
|
237
|
+
impact.append((ticket_id, len(blocked_set)))
|
|
238
|
+
|
|
239
|
+
return sorted(impact, key=lambda x: x[1], reverse=True)
|
|
240
|
+
|
|
241
|
+
def finalize(self) -> None:
|
|
242
|
+
"""Finalize the graph by calculating all metrics.
|
|
243
|
+
|
|
244
|
+
Call this after adding all tickets to compute depths and
|
|
245
|
+
other derived metrics.
|
|
246
|
+
"""
|
|
247
|
+
# Update nodes with edge information
|
|
248
|
+
for ticket_id in self.nodes:
|
|
249
|
+
self.nodes[ticket_id].blocks = list(self.edges.get(ticket_id, set()))
|
|
250
|
+
self.nodes[ticket_id].blocked_by = list(
|
|
251
|
+
self.reverse_edges.get(ticket_id, set())
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Calculate depths
|
|
255
|
+
self.calculate_depths()
|