mcp-ticketer 0.3.2__py3-none-any.whl → 0.3.4__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/__version__.py +1 -1
- mcp_ticketer/adapters/aitrackdown.py +152 -21
- mcp_ticketer/adapters/github.py +4 -4
- mcp_ticketer/adapters/jira.py +6 -6
- mcp_ticketer/adapters/linear/adapter.py +121 -17
- mcp_ticketer/adapters/linear/client.py +7 -7
- mcp_ticketer/adapters/linear/mappers.py +9 -10
- mcp_ticketer/adapters/linear/types.py +10 -10
- mcp_ticketer/cli/adapter_diagnostics.py +2 -2
- mcp_ticketer/cli/codex_configure.py +6 -6
- mcp_ticketer/cli/diagnostics.py +17 -18
- mcp_ticketer/cli/main.py +15 -0
- mcp_ticketer/cli/simple_health.py +5 -10
- mcp_ticketer/core/env_loader.py +13 -13
- mcp_ticketer/core/exceptions.py +5 -5
- mcp_ticketer/core/models.py +30 -2
- mcp_ticketer/mcp/constants.py +58 -0
- mcp_ticketer/mcp/dto.py +195 -0
- mcp_ticketer/mcp/response_builder.py +206 -0
- mcp_ticketer/mcp/server.py +311 -1287
- mcp_ticketer/queue/health_monitor.py +14 -14
- mcp_ticketer/queue/manager.py +59 -15
- mcp_ticketer/queue/queue.py +9 -2
- mcp_ticketer/queue/ticket_registry.py +15 -15
- mcp_ticketer/queue/worker.py +25 -18
- {mcp_ticketer-0.3.2.dist-info → mcp_ticketer-0.3.4.dist-info}/METADATA +1 -1
- {mcp_ticketer-0.3.2.dist-info → mcp_ticketer-0.3.4.dist-info}/RECORD +31 -28
- {mcp_ticketer-0.3.2.dist-info → mcp_ticketer-0.3.4.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.3.2.dist-info → mcp_ticketer-0.3.4.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.3.2.dist-info → mcp_ticketer-0.3.4.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.3.2.dist-info → mcp_ticketer-0.3.4.dist-info}/top_level.txt +0 -0
mcp_ticketer/__version__.py
CHANGED
|
@@ -35,14 +35,17 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
35
35
|
super().__init__(config)
|
|
36
36
|
self.base_path = Path(config.get("base_path", ".aitrackdown"))
|
|
37
37
|
self.tickets_dir = self.base_path / "tickets"
|
|
38
|
+
self._comment_counter = 0 # Counter for unique comment IDs
|
|
38
39
|
|
|
39
40
|
# Initialize AI-Trackdown if available
|
|
41
|
+
# Always create tickets directory (needed for both modes)
|
|
42
|
+
self.tickets_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
40
44
|
if HAS_AITRACKDOWN:
|
|
41
45
|
self.tracker = AITrackdown(str(self.base_path))
|
|
42
46
|
else:
|
|
43
47
|
# Fallback to direct file operations
|
|
44
48
|
self.tracker = None
|
|
45
|
-
self.tickets_dir.mkdir(parents=True, exist_ok=True)
|
|
46
49
|
|
|
47
50
|
def validate_credentials(self) -> tuple[bool, str]:
|
|
48
51
|
"""Validate that required credentials are present.
|
|
@@ -60,10 +63,15 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
60
63
|
return True, ""
|
|
61
64
|
|
|
62
65
|
def _get_state_mapping(self) -> dict[TicketState, str]:
|
|
63
|
-
"""Map universal states to AI-Trackdown states.
|
|
66
|
+
"""Map universal states to AI-Trackdown states.
|
|
67
|
+
|
|
68
|
+
Note: We use the exact enum values (snake_case) to match what
|
|
69
|
+
Pydantic's use_enum_values=True produces. This ensures consistency
|
|
70
|
+
between what's written to files and what's read back.
|
|
71
|
+
"""
|
|
64
72
|
return {
|
|
65
73
|
TicketState.OPEN: "open",
|
|
66
|
-
TicketState.IN_PROGRESS: "
|
|
74
|
+
TicketState.IN_PROGRESS: "in_progress", # snake_case, not kebab-case
|
|
67
75
|
TicketState.READY: "ready",
|
|
68
76
|
TicketState.TESTED: "tested",
|
|
69
77
|
TicketState.DONE: "done",
|
|
@@ -87,6 +95,18 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
87
95
|
|
|
88
96
|
def _task_from_ai_ticket(self, ai_ticket: dict[str, Any]) -> Task:
|
|
89
97
|
"""Convert AI-Trackdown ticket to universal Task."""
|
|
98
|
+
# Get user metadata from ticket file
|
|
99
|
+
user_metadata = ai_ticket.get("metadata", {})
|
|
100
|
+
|
|
101
|
+
# Create adapter metadata
|
|
102
|
+
adapter_metadata = {
|
|
103
|
+
"ai_ticket_id": ai_ticket.get("id"),
|
|
104
|
+
"source": "aitrackdown",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# Merge user metadata with adapter metadata (user takes priority)
|
|
108
|
+
combined_metadata = {**adapter_metadata, **user_metadata}
|
|
109
|
+
|
|
90
110
|
return Task(
|
|
91
111
|
id=ai_ticket.get("id"),
|
|
92
112
|
title=ai_ticket.get("title", ""),
|
|
@@ -107,11 +127,23 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
107
127
|
if "updated_at" in ai_ticket
|
|
108
128
|
else None
|
|
109
129
|
),
|
|
110
|
-
metadata=
|
|
130
|
+
metadata=combined_metadata, # Use merged metadata
|
|
111
131
|
)
|
|
112
132
|
|
|
113
133
|
def _epic_from_ai_ticket(self, ai_ticket: dict[str, Any]) -> Epic:
|
|
114
134
|
"""Convert AI-Trackdown ticket to universal Epic."""
|
|
135
|
+
# Get user metadata from ticket file
|
|
136
|
+
user_metadata = ai_ticket.get("metadata", {})
|
|
137
|
+
|
|
138
|
+
# Create adapter metadata
|
|
139
|
+
adapter_metadata = {
|
|
140
|
+
"ai_ticket_id": ai_ticket.get("id"),
|
|
141
|
+
"source": "aitrackdown",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# Merge user metadata with adapter metadata (user takes priority)
|
|
145
|
+
combined_metadata = {**adapter_metadata, **user_metadata}
|
|
146
|
+
|
|
115
147
|
return Epic(
|
|
116
148
|
id=ai_ticket.get("id"),
|
|
117
149
|
title=ai_ticket.get("title", ""),
|
|
@@ -130,20 +162,20 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
130
162
|
if "updated_at" in ai_ticket and ai_ticket["updated_at"]
|
|
131
163
|
else None
|
|
132
164
|
),
|
|
133
|
-
metadata=
|
|
165
|
+
metadata=combined_metadata, # Use merged metadata
|
|
134
166
|
)
|
|
135
167
|
|
|
136
168
|
def _task_to_ai_ticket(self, task: Task) -> dict[str, Any]:
|
|
137
169
|
"""Convert universal Task to AI-Trackdown ticket."""
|
|
138
170
|
# Handle enum values that may be stored as strings due to use_enum_values=True
|
|
171
|
+
# Note: task.state is always a string due to ConfigDict(use_enum_values=True)
|
|
139
172
|
state_value = task.state
|
|
140
173
|
if isinstance(task.state, TicketState):
|
|
141
174
|
state_value = self._get_state_mapping()[task.state]
|
|
142
175
|
elif isinstance(task.state, str):
|
|
143
|
-
# Already a string
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
) # Convert snake_case to kebab-case
|
|
176
|
+
# Already a string - keep as-is (don't convert to kebab-case)
|
|
177
|
+
# The state is already in snake_case format from the enum value
|
|
178
|
+
state_value = task.state
|
|
147
179
|
|
|
148
180
|
return {
|
|
149
181
|
"id": task.id,
|
|
@@ -157,20 +189,21 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
157
189
|
"assignee": task.assignee,
|
|
158
190
|
"created_at": task.created_at.isoformat() if task.created_at else None,
|
|
159
191
|
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
|
|
192
|
+
"metadata": task.metadata or {}, # Serialize user metadata
|
|
160
193
|
"type": "task",
|
|
161
194
|
}
|
|
162
195
|
|
|
163
196
|
def _epic_to_ai_ticket(self, epic: Epic) -> dict[str, Any]:
|
|
164
197
|
"""Convert universal Epic to AI-Trackdown ticket."""
|
|
165
198
|
# Handle enum values that may be stored as strings due to use_enum_values=True
|
|
199
|
+
# Note: epic.state is always a string due to ConfigDict(use_enum_values=True)
|
|
166
200
|
state_value = epic.state
|
|
167
201
|
if isinstance(epic.state, TicketState):
|
|
168
202
|
state_value = self._get_state_mapping()[epic.state]
|
|
169
203
|
elif isinstance(epic.state, str):
|
|
170
|
-
# Already a string
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
) # Convert snake_case to kebab-case
|
|
204
|
+
# Already a string - keep as-is (don't convert to kebab-case)
|
|
205
|
+
# The state is already in snake_case format from the enum value
|
|
206
|
+
state_value = epic.state
|
|
174
207
|
|
|
175
208
|
return {
|
|
176
209
|
"id": epic.id,
|
|
@@ -182,6 +215,7 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
182
215
|
"child_issues": epic.child_issues,
|
|
183
216
|
"created_at": epic.created_at.isoformat() if epic.created_at else None,
|
|
184
217
|
"updated_at": epic.updated_at.isoformat() if epic.updated_at else None,
|
|
218
|
+
"metadata": epic.metadata or {}, # Serialize user metadata
|
|
185
219
|
"type": "epic",
|
|
186
220
|
}
|
|
187
221
|
|
|
@@ -307,8 +341,20 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
307
341
|
|
|
308
342
|
async def update(
|
|
309
343
|
self, ticket_id: str, updates: Union[dict[str, Any], Task]
|
|
310
|
-
) -> Optional[Task]:
|
|
311
|
-
"""Update a task.
|
|
344
|
+
) -> Optional[Union[Task, Epic]]:
|
|
345
|
+
"""Update a task or epic.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
ticket_id: ID of ticket to update
|
|
349
|
+
updates: Dictionary of updates or Task object with new values
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Updated Task or Epic, or None if ticket not found
|
|
353
|
+
|
|
354
|
+
Raises:
|
|
355
|
+
AttributeError: If update fails due to invalid fields
|
|
356
|
+
|
|
357
|
+
"""
|
|
312
358
|
# Read existing ticket
|
|
313
359
|
existing = await self.read(ticket_id)
|
|
314
360
|
if not existing:
|
|
@@ -332,8 +378,12 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
332
378
|
|
|
333
379
|
existing.updated_at = datetime.now()
|
|
334
380
|
|
|
335
|
-
# Write back
|
|
336
|
-
|
|
381
|
+
# Write back - use appropriate converter based on ticket type
|
|
382
|
+
if isinstance(existing, Epic):
|
|
383
|
+
ai_ticket = self._epic_to_ai_ticket(existing)
|
|
384
|
+
else:
|
|
385
|
+
ai_ticket = self._task_to_ai_ticket(existing)
|
|
386
|
+
|
|
337
387
|
if self.tracker:
|
|
338
388
|
self.tracker.update_ticket(ticket_id, **updates)
|
|
339
389
|
else:
|
|
@@ -448,10 +498,11 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
448
498
|
|
|
449
499
|
async def add_comment(self, comment: Comment) -> Comment:
|
|
450
500
|
"""Add comment to a task."""
|
|
451
|
-
# Generate ID
|
|
501
|
+
# Generate ID with counter to ensure uniqueness
|
|
452
502
|
if not comment.id:
|
|
453
503
|
timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
|
|
454
|
-
|
|
504
|
+
self._comment_counter += 1
|
|
505
|
+
comment.id = f"comment-{timestamp}-{self._comment_counter:04d}"
|
|
455
506
|
|
|
456
507
|
comment.created_at = datetime.now()
|
|
457
508
|
|
|
@@ -472,14 +523,94 @@ class AITrackdownAdapter(BaseAdapter[Task]):
|
|
|
472
523
|
comments_dir = self.base_path / "comments"
|
|
473
524
|
|
|
474
525
|
if comments_dir.exists():
|
|
526
|
+
# Get all comment files and filter by ticket_id first
|
|
475
527
|
comment_files = sorted(comments_dir.glob("*.json"))
|
|
476
|
-
for comment_file in comment_files
|
|
528
|
+
for comment_file in comment_files:
|
|
477
529
|
with open(comment_file) as f:
|
|
478
530
|
data = json.load(f)
|
|
479
531
|
if data.get("ticket_id") == ticket_id:
|
|
480
532
|
comments.append(Comment(**data))
|
|
481
533
|
|
|
482
|
-
|
|
534
|
+
# Apply limit and offset AFTER filtering
|
|
535
|
+
return comments[offset : offset + limit]
|
|
536
|
+
|
|
537
|
+
async def get_epic(self, epic_id: str) -> Optional[Epic]:
|
|
538
|
+
"""Get epic by ID.
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
epic_id: Epic ID to retrieve
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
Epic if found, None otherwise
|
|
545
|
+
|
|
546
|
+
"""
|
|
547
|
+
ticket = await self.read(epic_id)
|
|
548
|
+
if ticket:
|
|
549
|
+
# Check if it's an Epic (can be Epic instance or have epic ticket_type)
|
|
550
|
+
if isinstance(ticket, Epic):
|
|
551
|
+
return ticket
|
|
552
|
+
# Check ticket_type (may be string or enum)
|
|
553
|
+
ticket_type_str = (
|
|
554
|
+
str(ticket.ticket_type).lower()
|
|
555
|
+
if hasattr(ticket, "ticket_type")
|
|
556
|
+
else None
|
|
557
|
+
)
|
|
558
|
+
if ticket_type_str and "epic" in ticket_type_str:
|
|
559
|
+
return Epic(**ticket.model_dump())
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
async def list_epics(self, limit: int = 10, offset: int = 0) -> builtins.list[Epic]:
|
|
563
|
+
"""List all epics.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
limit: Maximum number of epics to return
|
|
567
|
+
offset: Number of epics to skip
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
List of epics
|
|
571
|
+
|
|
572
|
+
"""
|
|
573
|
+
all_tickets = await self.list(limit=100, offset=0, filters={"type": "epic"})
|
|
574
|
+
epics = []
|
|
575
|
+
for ticket in all_tickets:
|
|
576
|
+
if ticket.ticket_type == "epic":
|
|
577
|
+
epics.append(Epic(**ticket.model_dump()))
|
|
578
|
+
return epics[offset : offset + limit]
|
|
579
|
+
|
|
580
|
+
async def list_issues_by_epic(self, epic_id: str) -> builtins.list[Task]:
|
|
581
|
+
"""List all issues belonging to an epic.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
epic_id: Epic ID to get issues for
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
List of issues (tasks with parent_epic set)
|
|
588
|
+
|
|
589
|
+
"""
|
|
590
|
+
all_tickets = await self.list(limit=1000, offset=0, filters={})
|
|
591
|
+
issues = []
|
|
592
|
+
for ticket in all_tickets:
|
|
593
|
+
if hasattr(ticket, "parent_epic") and ticket.parent_epic == epic_id:
|
|
594
|
+
issues.append(ticket)
|
|
595
|
+
return issues
|
|
596
|
+
|
|
597
|
+
async def list_tasks_by_issue(self, issue_id: str) -> builtins.list[Task]:
|
|
598
|
+
"""List all tasks belonging to an issue.
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
issue_id: Issue ID (parent task) to get child tasks for
|
|
602
|
+
|
|
603
|
+
Returns:
|
|
604
|
+
List of tasks
|
|
605
|
+
|
|
606
|
+
"""
|
|
607
|
+
all_tickets = await self.list(limit=1000, offset=0, filters={})
|
|
608
|
+
tasks = []
|
|
609
|
+
for ticket in all_tickets:
|
|
610
|
+
# Check if this ticket has parent_issue matching the issue
|
|
611
|
+
if hasattr(ticket, "parent_issue") and ticket.parent_issue == issue_id:
|
|
612
|
+
tasks.append(ticket)
|
|
613
|
+
return tasks
|
|
483
614
|
|
|
484
615
|
|
|
485
616
|
# Register the adapter
|
mcp_ticketer/adapters/github.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import builtins
|
|
4
4
|
import re
|
|
5
5
|
from datetime import datetime
|
|
6
|
-
from typing import Any,
|
|
6
|
+
from typing import Any, Optional
|
|
7
7
|
|
|
8
8
|
import httpx
|
|
9
9
|
|
|
@@ -603,7 +603,7 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
603
603
|
labels_to_update = update_data.get("labels", current_labels)
|
|
604
604
|
all_priority_labels = []
|
|
605
605
|
for labels in GitHubStateMapping.PRIORITY_LABELS.values():
|
|
606
|
-
all_priority_labels.extend([
|
|
606
|
+
all_priority_labels.extend([label.lower() for label in labels])
|
|
607
607
|
|
|
608
608
|
labels_to_update = [
|
|
609
609
|
label
|
|
@@ -1334,7 +1334,7 @@ Fixes #{issue_number}
|
|
|
1334
1334
|
"message": f"Successfully linked PR #{pr_number} to issue #{issue_number}",
|
|
1335
1335
|
}
|
|
1336
1336
|
|
|
1337
|
-
async def get_collaborators(self) ->
|
|
1337
|
+
async def get_collaborators(self) -> builtins.list[dict[str, Any]]:
|
|
1338
1338
|
"""Get repository collaborators."""
|
|
1339
1339
|
response = await self.client.get(
|
|
1340
1340
|
f"/repos/{self.owner}/{self.repo}/collaborators"
|
|
@@ -1342,7 +1342,7 @@ Fixes #{issue_number}
|
|
|
1342
1342
|
response.raise_for_status()
|
|
1343
1343
|
return response.json()
|
|
1344
1344
|
|
|
1345
|
-
async def get_current_user(self) -> Optional[
|
|
1345
|
+
async def get_current_user(self) -> Optional[dict[str, Any]]:
|
|
1346
1346
|
"""Get current authenticated user information."""
|
|
1347
1347
|
response = await self.client.get("/user")
|
|
1348
1348
|
response.raise_for_status()
|
mcp_ticketer/adapters/jira.py
CHANGED
|
@@ -6,7 +6,7 @@ import logging
|
|
|
6
6
|
import re
|
|
7
7
|
from datetime import datetime
|
|
8
8
|
from enum import Enum
|
|
9
|
-
from typing import Any,
|
|
9
|
+
from typing import Any, Optional, Union
|
|
10
10
|
|
|
11
11
|
import httpx
|
|
12
12
|
from httpx import AsyncClient, HTTPStatusError, TimeoutException
|
|
@@ -47,7 +47,7 @@ def parse_jira_datetime(date_str: str) -> Optional[datetime]:
|
|
|
47
47
|
return None
|
|
48
48
|
|
|
49
49
|
|
|
50
|
-
def extract_text_from_adf(adf_content: Union[str,
|
|
50
|
+
def extract_text_from_adf(adf_content: Union[str, dict[str, Any]]) -> str:
|
|
51
51
|
"""Extract plain text from Atlassian Document Format (ADF).
|
|
52
52
|
|
|
53
53
|
Args:
|
|
@@ -63,7 +63,7 @@ def extract_text_from_adf(adf_content: Union[str, Dict[str, Any]]) -> str:
|
|
|
63
63
|
if not isinstance(adf_content, dict):
|
|
64
64
|
return str(adf_content) if adf_content else ""
|
|
65
65
|
|
|
66
|
-
def extract_text_recursive(node:
|
|
66
|
+
def extract_text_recursive(node: dict[str, Any]) -> str:
|
|
67
67
|
"""Recursively extract text from ADF nodes."""
|
|
68
68
|
if not isinstance(node, dict):
|
|
69
69
|
return ""
|
|
@@ -940,7 +940,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
940
940
|
|
|
941
941
|
return sprints_data.get("values", [])
|
|
942
942
|
|
|
943
|
-
async def get_project_users(self) ->
|
|
943
|
+
async def get_project_users(self) -> builtins.list[dict[str, Any]]:
|
|
944
944
|
"""Get users who have access to the project."""
|
|
945
945
|
if not self.project_key:
|
|
946
946
|
return []
|
|
@@ -954,7 +954,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
954
954
|
# Get users from project roles
|
|
955
955
|
users = []
|
|
956
956
|
if "roles" in project_data:
|
|
957
|
-
for
|
|
957
|
+
for _role_name, role_url in project_data["roles"].items():
|
|
958
958
|
# Extract role ID from URL
|
|
959
959
|
role_id = role_url.split("/")[-1]
|
|
960
960
|
try:
|
|
@@ -992,7 +992,7 @@ class JiraAdapter(BaseAdapter[Union[Epic, Task]]):
|
|
|
992
992
|
except Exception:
|
|
993
993
|
return []
|
|
994
994
|
|
|
995
|
-
async def get_current_user(self) -> Optional[
|
|
995
|
+
async def get_current_user(self) -> Optional[dict[str, Any]]:
|
|
996
996
|
"""Get current authenticated user information."""
|
|
997
997
|
try:
|
|
998
998
|
return await self._make_request("GET", "myself")
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
-
from typing import Any
|
|
6
|
+
from typing import Any
|
|
7
7
|
|
|
8
8
|
try:
|
|
9
9
|
from gql import gql
|
|
@@ -12,6 +12,8 @@ except ImportError:
|
|
|
12
12
|
gql = None
|
|
13
13
|
TransportQueryError = Exception
|
|
14
14
|
|
|
15
|
+
import builtins
|
|
16
|
+
|
|
15
17
|
from ...core.adapter import BaseAdapter
|
|
16
18
|
from ...core.models import Comment, Epic, SearchQuery, Task, TicketState
|
|
17
19
|
from ...core.registry import AdapterRegistry
|
|
@@ -58,7 +60,7 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
58
60
|
- mappers.py: Data transformation logic
|
|
59
61
|
"""
|
|
60
62
|
|
|
61
|
-
def __init__(self, config:
|
|
63
|
+
def __init__(self, config: dict[str, Any]):
|
|
62
64
|
"""Initialize Linear adapter.
|
|
63
65
|
|
|
64
66
|
Args:
|
|
@@ -75,10 +77,10 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
75
77
|
"""
|
|
76
78
|
# Initialize instance variables before calling super().__init__
|
|
77
79
|
# because parent constructor calls _get_state_mapping()
|
|
78
|
-
self._team_data:
|
|
79
|
-
self._workflow_states:
|
|
80
|
-
self._labels_cache:
|
|
81
|
-
self._users_cache:
|
|
80
|
+
self._team_data: dict[str, Any] | None = None
|
|
81
|
+
self._workflow_states: dict[str, dict[str, Any]] | None = None
|
|
82
|
+
self._labels_cache: list[dict[str, Any]] | None = None
|
|
83
|
+
self._users_cache: dict[str, dict[str, Any]] | None = None
|
|
82
84
|
self._initialized = False
|
|
83
85
|
|
|
84
86
|
super().__init__(config)
|
|
@@ -135,8 +137,9 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
135
137
|
# Load team data and workflow states concurrently
|
|
136
138
|
team_id = await self._ensure_team_id()
|
|
137
139
|
|
|
138
|
-
# Load workflow states for the team
|
|
140
|
+
# Load workflow states and labels for the team
|
|
139
141
|
await self._load_workflow_states(team_id)
|
|
142
|
+
await self._load_team_labels(team_id)
|
|
140
143
|
|
|
141
144
|
self._initialized = True
|
|
142
145
|
|
|
@@ -214,7 +217,86 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
214
217
|
except Exception as e:
|
|
215
218
|
raise ValueError(f"Failed to load workflow states: {e}")
|
|
216
219
|
|
|
217
|
-
def
|
|
220
|
+
async def _load_team_labels(self, team_id: str) -> None:
|
|
221
|
+
"""Load and cache labels for the team.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
team_id: Linear team ID
|
|
225
|
+
|
|
226
|
+
"""
|
|
227
|
+
query = """
|
|
228
|
+
query GetTeamLabels($teamId: ID!) {
|
|
229
|
+
team(id: $teamId) {
|
|
230
|
+
labels {
|
|
231
|
+
nodes {
|
|
232
|
+
id
|
|
233
|
+
name
|
|
234
|
+
color
|
|
235
|
+
description
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
result = await self.client.execute_query(query, {"teamId": team_id})
|
|
244
|
+
self._labels_cache = result["team"]["labels"]["nodes"]
|
|
245
|
+
except Exception:
|
|
246
|
+
# Log error but don't fail - labels are optional
|
|
247
|
+
self._labels_cache = []
|
|
248
|
+
|
|
249
|
+
async def _resolve_label_ids(self, label_names: list[str]) -> list[str]:
|
|
250
|
+
"""Resolve label names to Linear label IDs.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
label_names: List of label names
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
List of Linear label IDs that exist
|
|
257
|
+
|
|
258
|
+
"""
|
|
259
|
+
import logging
|
|
260
|
+
|
|
261
|
+
logger = logging.getLogger(__name__)
|
|
262
|
+
|
|
263
|
+
if not self._labels_cache:
|
|
264
|
+
team_id = await self._ensure_team_id()
|
|
265
|
+
await self._load_team_labels(team_id)
|
|
266
|
+
|
|
267
|
+
if not self._labels_cache:
|
|
268
|
+
logger.warning("No labels found in team cache")
|
|
269
|
+
return []
|
|
270
|
+
|
|
271
|
+
# Create name -> ID mapping (case-insensitive)
|
|
272
|
+
label_map = {label["name"].lower(): label["id"] for label in self._labels_cache}
|
|
273
|
+
|
|
274
|
+
logger.debug(f"Available labels in team: {list(label_map.keys())}")
|
|
275
|
+
|
|
276
|
+
# Resolve label names to IDs
|
|
277
|
+
label_ids = []
|
|
278
|
+
unmatched_labels = []
|
|
279
|
+
|
|
280
|
+
for name in label_names:
|
|
281
|
+
label_id = label_map.get(name.lower())
|
|
282
|
+
if label_id:
|
|
283
|
+
label_ids.append(label_id)
|
|
284
|
+
logger.debug(f"Resolved label '{name}' to ID: {label_id}")
|
|
285
|
+
else:
|
|
286
|
+
unmatched_labels.append(name)
|
|
287
|
+
logger.warning(
|
|
288
|
+
f"Label '{name}' not found in team. Available labels: {list(label_map.keys())}"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if unmatched_labels:
|
|
292
|
+
logger.warning(
|
|
293
|
+
f"Could not resolve labels: {unmatched_labels}. "
|
|
294
|
+
f"Create them in Linear first or check spelling."
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
return label_ids
|
|
298
|
+
|
|
299
|
+
def _get_state_mapping(self) -> dict[TicketState, str]:
|
|
218
300
|
"""Get mapping from universal states to Linear workflow state IDs.
|
|
219
301
|
|
|
220
302
|
Returns:
|
|
@@ -245,7 +327,7 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
245
327
|
|
|
246
328
|
return mapping
|
|
247
329
|
|
|
248
|
-
async def _get_user_id(self, user_identifier: str) ->
|
|
330
|
+
async def _get_user_id(self, user_identifier: str) -> str | None:
|
|
249
331
|
"""Get Linear user ID from email or display name.
|
|
250
332
|
|
|
251
333
|
Args:
|
|
@@ -266,7 +348,7 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
266
348
|
|
|
267
349
|
# CRUD Operations
|
|
268
350
|
|
|
269
|
-
async def create(self, ticket:
|
|
351
|
+
async def create(self, ticket: Epic | Task) -> Epic | Task:
|
|
270
352
|
"""Create a new Linear issue or project with full field support.
|
|
271
353
|
|
|
272
354
|
Args:
|
|
@@ -309,12 +391,28 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
309
391
|
# Build issue input using mapper
|
|
310
392
|
issue_input = build_linear_issue_input(task, team_id)
|
|
311
393
|
|
|
394
|
+
# Set default state if not provided
|
|
395
|
+
# Map OPEN to "unstarted" state (typically "To-Do" in Linear)
|
|
396
|
+
if task.state == TicketState.OPEN and self._workflow_states:
|
|
397
|
+
state_mapping = self._get_state_mapping()
|
|
398
|
+
if TicketState.OPEN in state_mapping:
|
|
399
|
+
issue_input["stateId"] = state_mapping[TicketState.OPEN]
|
|
400
|
+
|
|
312
401
|
# Resolve assignee to user ID if provided
|
|
313
402
|
if task.assignee:
|
|
314
403
|
user_id = await self._get_user_id(task.assignee)
|
|
315
404
|
if user_id:
|
|
316
405
|
issue_input["assigneeId"] = user_id
|
|
317
406
|
|
|
407
|
+
# Resolve label names to IDs if provided
|
|
408
|
+
if task.tags:
|
|
409
|
+
label_ids = await self._resolve_label_ids(task.tags)
|
|
410
|
+
if label_ids:
|
|
411
|
+
issue_input["labelIds"] = label_ids
|
|
412
|
+
else:
|
|
413
|
+
# Remove labelIds if no labels resolved
|
|
414
|
+
issue_input.pop("labelIds", None)
|
|
415
|
+
|
|
318
416
|
try:
|
|
319
417
|
result = await self.client.execute_mutation(
|
|
320
418
|
CREATE_ISSUE_MUTATION, {"input": issue_input}
|
|
@@ -394,7 +492,7 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
394
492
|
except Exception as e:
|
|
395
493
|
raise ValueError(f"Failed to create Linear project: {e}")
|
|
396
494
|
|
|
397
|
-
async def read(self, ticket_id: str) ->
|
|
495
|
+
async def read(self, ticket_id: str) -> Task | None:
|
|
398
496
|
"""Read a Linear issue by identifier with full details.
|
|
399
497
|
|
|
400
498
|
Args:
|
|
@@ -432,7 +530,7 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
432
530
|
|
|
433
531
|
return None
|
|
434
532
|
|
|
435
|
-
async def update(self, ticket_id: str, updates:
|
|
533
|
+
async def update(self, ticket_id: str, updates: dict[str, Any]) -> Task | None:
|
|
436
534
|
"""Update a Linear issue with comprehensive field support.
|
|
437
535
|
|
|
438
536
|
Args:
|
|
@@ -487,6 +585,12 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
487
585
|
if user_id:
|
|
488
586
|
update_input["assigneeId"] = user_id
|
|
489
587
|
|
|
588
|
+
# Resolve label names to IDs if provided
|
|
589
|
+
if "tags" in updates and updates["tags"]:
|
|
590
|
+
label_ids = await self._resolve_label_ids(updates["tags"])
|
|
591
|
+
if label_ids:
|
|
592
|
+
update_input["labelIds"] = label_ids
|
|
593
|
+
|
|
490
594
|
# Execute update
|
|
491
595
|
result = await self.client.execute_mutation(
|
|
492
596
|
UPDATE_ISSUE_MUTATION, {"id": linear_id, "input": update_input}
|
|
@@ -519,8 +623,8 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
519
623
|
return False
|
|
520
624
|
|
|
521
625
|
async def list(
|
|
522
|
-
self, limit: int = 10, offset: int = 0, filters:
|
|
523
|
-
) ->
|
|
626
|
+
self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
|
|
627
|
+
) -> builtins.list[Task]:
|
|
524
628
|
"""List Linear issues with optional filtering.
|
|
525
629
|
|
|
526
630
|
Args:
|
|
@@ -578,7 +682,7 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
578
682
|
except Exception as e:
|
|
579
683
|
raise ValueError(f"Failed to list Linear issues: {e}")
|
|
580
684
|
|
|
581
|
-
async def search(self, query: SearchQuery) ->
|
|
685
|
+
async def search(self, query: SearchQuery) -> builtins.list[Task]:
|
|
582
686
|
"""Search Linear issues using comprehensive filters.
|
|
583
687
|
|
|
584
688
|
Args:
|
|
@@ -644,7 +748,7 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
644
748
|
|
|
645
749
|
async def transition_state(
|
|
646
750
|
self, ticket_id: str, target_state: TicketState
|
|
647
|
-
) ->
|
|
751
|
+
) -> Task | None:
|
|
648
752
|
"""Transition Linear issue to new state with workflow validation.
|
|
649
753
|
|
|
650
754
|
Args:
|
|
@@ -749,7 +853,7 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
749
853
|
|
|
750
854
|
async def get_comments(
|
|
751
855
|
self, ticket_id: str, limit: int = 10, offset: int = 0
|
|
752
|
-
) ->
|
|
856
|
+
) -> builtins.list[Comment]:
|
|
753
857
|
"""Get comments for a Linear issue.
|
|
754
858
|
|
|
755
859
|
Args:
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
from typing import Any
|
|
6
|
+
from typing import Any
|
|
7
7
|
|
|
8
8
|
try:
|
|
9
9
|
from gql import Client, gql
|
|
@@ -74,9 +74,9 @@ class LinearGraphQLClient:
|
|
|
74
74
|
async def execute_query(
|
|
75
75
|
self,
|
|
76
76
|
query_string: str,
|
|
77
|
-
variables:
|
|
77
|
+
variables: dict[str, Any] | None = None,
|
|
78
78
|
retries: int = 3,
|
|
79
|
-
) ->
|
|
79
|
+
) -> dict[str, Any]:
|
|
80
80
|
"""Execute a GraphQL query with error handling and retries.
|
|
81
81
|
|
|
82
82
|
Args:
|
|
@@ -161,9 +161,9 @@ class LinearGraphQLClient:
|
|
|
161
161
|
async def execute_mutation(
|
|
162
162
|
self,
|
|
163
163
|
mutation_string: str,
|
|
164
|
-
variables:
|
|
164
|
+
variables: dict[str, Any] | None = None,
|
|
165
165
|
retries: int = 3,
|
|
166
|
-
) ->
|
|
166
|
+
) -> dict[str, Any]:
|
|
167
167
|
"""Execute a GraphQL mutation with error handling.
|
|
168
168
|
|
|
169
169
|
Args:
|
|
@@ -206,7 +206,7 @@ class LinearGraphQLClient:
|
|
|
206
206
|
except Exception:
|
|
207
207
|
return False
|
|
208
208
|
|
|
209
|
-
async def get_team_info(self, team_id: str) ->
|
|
209
|
+
async def get_team_info(self, team_id: str) -> dict[str, Any] | None:
|
|
210
210
|
"""Get team information by ID.
|
|
211
211
|
|
|
212
212
|
Args:
|
|
@@ -234,7 +234,7 @@ class LinearGraphQLClient:
|
|
|
234
234
|
except Exception:
|
|
235
235
|
return None
|
|
236
236
|
|
|
237
|
-
async def get_user_by_email(self, email: str) ->
|
|
237
|
+
async def get_user_by_email(self, email: str) -> dict[str, Any] | None:
|
|
238
238
|
"""Get user information by email.
|
|
239
239
|
|
|
240
240
|
Args:
|