mcp-ticketer 0.1.39__py3-none-any.whl → 0.3.0__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.
- mcp_ticketer/__version__.py +1 -1
- mcp_ticketer/adapters/linear/__init__.py +24 -0
- mcp_ticketer/adapters/linear/adapter.py +813 -0
- mcp_ticketer/adapters/linear/client.py +257 -0
- mcp_ticketer/adapters/linear/mappers.py +307 -0
- mcp_ticketer/adapters/linear/queries.py +366 -0
- mcp_ticketer/adapters/linear/types.py +277 -0
- mcp_ticketer/adapters/linear.py +10 -2384
- mcp_ticketer/cli/adapter_diagnostics.py +384 -0
- mcp_ticketer/cli/main.py +327 -67
- mcp_ticketer/core/exceptions.py +152 -0
- mcp_ticketer/mcp/server.py +172 -37
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.3.0.dist-info}/METADATA +1 -1
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.3.0.dist-info}/RECORD +18 -10
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.3.0.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.3.0.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
"""Main LinearAdapter class for Linear API integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, Dict, List, Optional, Union
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from gql import gql
|
|
12
|
+
from gql.transport.exceptions import TransportQueryError
|
|
13
|
+
except ImportError:
|
|
14
|
+
gql = None
|
|
15
|
+
TransportQueryError = Exception
|
|
16
|
+
|
|
17
|
+
from ...core.adapter import BaseAdapter
|
|
18
|
+
from ...core.models import (
|
|
19
|
+
Comment,
|
|
20
|
+
Epic,
|
|
21
|
+
Priority,
|
|
22
|
+
SearchQuery,
|
|
23
|
+
Task,
|
|
24
|
+
TicketState,
|
|
25
|
+
TicketType,
|
|
26
|
+
)
|
|
27
|
+
from ...core.registry import AdapterRegistry
|
|
28
|
+
|
|
29
|
+
from .client import LinearGraphQLClient
|
|
30
|
+
from .mappers import (
|
|
31
|
+
build_linear_issue_input,
|
|
32
|
+
build_linear_issue_update_input,
|
|
33
|
+
extract_child_issue_ids,
|
|
34
|
+
map_linear_comment_to_comment,
|
|
35
|
+
map_linear_issue_to_task,
|
|
36
|
+
map_linear_project_to_epic,
|
|
37
|
+
)
|
|
38
|
+
from .queries import (
|
|
39
|
+
ALL_FRAGMENTS,
|
|
40
|
+
CREATE_ISSUE_MUTATION,
|
|
41
|
+
GET_CURRENT_USER_QUERY,
|
|
42
|
+
LIST_ISSUES_QUERY,
|
|
43
|
+
SEARCH_ISSUES_QUERY,
|
|
44
|
+
UPDATE_ISSUE_MUTATION,
|
|
45
|
+
WORKFLOW_STATES_QUERY,
|
|
46
|
+
)
|
|
47
|
+
from .types import (
|
|
48
|
+
LinearStateMapping,
|
|
49
|
+
build_issue_filter,
|
|
50
|
+
get_linear_priority,
|
|
51
|
+
get_linear_state_type,
|
|
52
|
+
get_universal_state,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class LinearAdapter(BaseAdapter[Task]):
|
|
57
|
+
"""Adapter for Linear issue tracking system using native GraphQL API.
|
|
58
|
+
|
|
59
|
+
This adapter provides comprehensive integration with Linear's GraphQL API,
|
|
60
|
+
supporting all major ticket management operations including:
|
|
61
|
+
|
|
62
|
+
- CRUD operations for issues and projects
|
|
63
|
+
- State transitions and workflow management
|
|
64
|
+
- User assignment and search functionality
|
|
65
|
+
- Comment management
|
|
66
|
+
- Epic/Issue/Task hierarchy support
|
|
67
|
+
|
|
68
|
+
The adapter is organized into multiple modules for better maintainability:
|
|
69
|
+
- client.py: GraphQL client management
|
|
70
|
+
- queries.py: GraphQL queries and fragments
|
|
71
|
+
- types.py: Linear-specific types and mappings
|
|
72
|
+
- mappers.py: Data transformation logic
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, config: Dict[str, Any]):
|
|
76
|
+
"""Initialize Linear adapter.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
config: Configuration with:
|
|
80
|
+
- api_key: Linear API key (or LINEAR_API_KEY env var)
|
|
81
|
+
- workspace: Linear workspace name (optional, for documentation)
|
|
82
|
+
- team_key: Linear team key (e.g., 'BTA') OR
|
|
83
|
+
- team_id: Linear team UUID (e.g., '02d15669-7351-4451-9719-807576c16049')
|
|
84
|
+
- api_url: Optional Linear API URL (defaults to https://api.linear.app/graphql)
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If required configuration is missing
|
|
88
|
+
"""
|
|
89
|
+
# Initialize instance variables before calling super().__init__
|
|
90
|
+
# because parent constructor calls _get_state_mapping()
|
|
91
|
+
self._team_data: Optional[Dict[str, Any]] = None
|
|
92
|
+
self._workflow_states: Optional[Dict[str, Dict[str, Any]]] = None
|
|
93
|
+
self._labels_cache: Optional[List[Dict[str, Any]]] = None
|
|
94
|
+
self._users_cache: Optional[Dict[str, Dict[str, Any]]] = None
|
|
95
|
+
self._initialized = False
|
|
96
|
+
|
|
97
|
+
super().__init__(config)
|
|
98
|
+
|
|
99
|
+
# Extract configuration
|
|
100
|
+
self.api_key = config.get("api_key") or os.getenv("LINEAR_API_KEY")
|
|
101
|
+
if not self.api_key:
|
|
102
|
+
raise ValueError("Linear API key is required (api_key or LINEAR_API_KEY env var)")
|
|
103
|
+
|
|
104
|
+
# Ensure API key has Bearer prefix
|
|
105
|
+
if not self.api_key.startswith("Bearer "):
|
|
106
|
+
self.api_key = f"Bearer {self.api_key}"
|
|
107
|
+
|
|
108
|
+
self.workspace = config.get("workspace", "")
|
|
109
|
+
self.team_key = config.get("team_key")
|
|
110
|
+
self.team_id = config.get("team_id")
|
|
111
|
+
self.api_url = config.get("api_url", "https://api.linear.app/graphql")
|
|
112
|
+
|
|
113
|
+
# Validate team configuration
|
|
114
|
+
if not self.team_key and not self.team_id:
|
|
115
|
+
raise ValueError("Either team_key or team_id must be provided")
|
|
116
|
+
|
|
117
|
+
# Initialize client
|
|
118
|
+
api_key_clean = self.api_key.replace("Bearer ", "")
|
|
119
|
+
self.client = LinearGraphQLClient(api_key_clean)
|
|
120
|
+
|
|
121
|
+
def validate_credentials(self) -> tuple[bool, str]:
|
|
122
|
+
"""Validate Linear API credentials.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Tuple of (is_valid, error_message)
|
|
126
|
+
"""
|
|
127
|
+
if not self.api_key:
|
|
128
|
+
return False, "Linear API key is required"
|
|
129
|
+
|
|
130
|
+
if not self.team_key and not self.team_id:
|
|
131
|
+
return False, "Either team_key or team_id must be provided"
|
|
132
|
+
|
|
133
|
+
return True, ""
|
|
134
|
+
|
|
135
|
+
async def initialize(self) -> None:
|
|
136
|
+
"""Initialize adapter by preloading team, states, and labels data concurrently."""
|
|
137
|
+
if self._initialized:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
# Test connection first
|
|
142
|
+
if not await self.client.test_connection():
|
|
143
|
+
raise ValueError("Failed to connect to Linear API - check credentials")
|
|
144
|
+
|
|
145
|
+
# Load team data and workflow states concurrently
|
|
146
|
+
team_id = await self._ensure_team_id()
|
|
147
|
+
|
|
148
|
+
# Load workflow states for the team
|
|
149
|
+
await self._load_workflow_states(team_id)
|
|
150
|
+
|
|
151
|
+
self._initialized = True
|
|
152
|
+
|
|
153
|
+
except Exception as e:
|
|
154
|
+
raise ValueError(f"Failed to initialize Linear adapter: {e}")
|
|
155
|
+
|
|
156
|
+
async def _ensure_team_id(self) -> str:
|
|
157
|
+
"""Ensure we have a team ID, resolving from team_key if needed.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Linear team UUID
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
ValueError: If team cannot be found or resolved
|
|
164
|
+
"""
|
|
165
|
+
if self.team_id:
|
|
166
|
+
return self.team_id
|
|
167
|
+
|
|
168
|
+
if not self.team_key:
|
|
169
|
+
raise ValueError("Either team_id or team_key must be provided")
|
|
170
|
+
|
|
171
|
+
# Query team by key
|
|
172
|
+
query = """
|
|
173
|
+
query GetTeamByKey($key: String!) {
|
|
174
|
+
teams(filter: { key: { eq: $key } }) {
|
|
175
|
+
nodes {
|
|
176
|
+
id
|
|
177
|
+
name
|
|
178
|
+
key
|
|
179
|
+
description
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
result = await self.client.execute_query(query, {"key": self.team_key})
|
|
187
|
+
teams = result.get("teams", {}).get("nodes", [])
|
|
188
|
+
|
|
189
|
+
if not teams:
|
|
190
|
+
raise ValueError(f"Team with key '{self.team_key}' not found")
|
|
191
|
+
|
|
192
|
+
team = teams[0]
|
|
193
|
+
self.team_id = team["id"]
|
|
194
|
+
self._team_data = team
|
|
195
|
+
|
|
196
|
+
return self.team_id
|
|
197
|
+
|
|
198
|
+
except Exception as e:
|
|
199
|
+
raise ValueError(f"Failed to resolve team '{self.team_key}': {e}")
|
|
200
|
+
|
|
201
|
+
async def _load_workflow_states(self, team_id: str) -> None:
|
|
202
|
+
"""Load and cache workflow states for the team.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
team_id: Linear team ID
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
result = await self.client.execute_query(
|
|
209
|
+
WORKFLOW_STATES_QUERY,
|
|
210
|
+
{"teamId": team_id}
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
workflow_states = {}
|
|
214
|
+
for state in result["workflowStates"]["nodes"]:
|
|
215
|
+
state_type = state["type"].lower()
|
|
216
|
+
if state_type not in workflow_states:
|
|
217
|
+
workflow_states[state_type] = state
|
|
218
|
+
elif state["position"] < workflow_states[state_type]["position"]:
|
|
219
|
+
workflow_states[state_type] = state
|
|
220
|
+
|
|
221
|
+
self._workflow_states = workflow_states
|
|
222
|
+
|
|
223
|
+
except Exception as e:
|
|
224
|
+
raise ValueError(f"Failed to load workflow states: {e}")
|
|
225
|
+
|
|
226
|
+
def _get_state_mapping(self) -> Dict[TicketState, str]:
|
|
227
|
+
"""Get mapping from universal states to Linear workflow state IDs.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Dictionary mapping TicketState to Linear state ID
|
|
231
|
+
"""
|
|
232
|
+
if not self._workflow_states:
|
|
233
|
+
# Return type-based mapping if states not loaded
|
|
234
|
+
return {
|
|
235
|
+
TicketState.OPEN: "unstarted",
|
|
236
|
+
TicketState.IN_PROGRESS: "started",
|
|
237
|
+
TicketState.READY: "unstarted",
|
|
238
|
+
TicketState.TESTED: "started",
|
|
239
|
+
TicketState.DONE: "completed",
|
|
240
|
+
TicketState.CLOSED: "canceled",
|
|
241
|
+
TicketState.WAITING: "unstarted",
|
|
242
|
+
TicketState.BLOCKED: "unstarted",
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
# Return ID-based mapping using cached workflow states
|
|
246
|
+
mapping = {}
|
|
247
|
+
for universal_state, linear_type in LinearStateMapping.TO_LINEAR.items():
|
|
248
|
+
if linear_type in self._workflow_states:
|
|
249
|
+
mapping[universal_state] = self._workflow_states[linear_type]["id"]
|
|
250
|
+
else:
|
|
251
|
+
# Fallback to type name
|
|
252
|
+
mapping[universal_state] = linear_type
|
|
253
|
+
|
|
254
|
+
return mapping
|
|
255
|
+
|
|
256
|
+
async def _get_user_id(self, user_identifier: str) -> Optional[str]:
|
|
257
|
+
"""Get Linear user ID from email or display name.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
user_identifier: Email address or display name
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
Linear user ID or None if not found
|
|
264
|
+
"""
|
|
265
|
+
# Try to get user by email first
|
|
266
|
+
user = await self.client.get_user_by_email(user_identifier)
|
|
267
|
+
if user:
|
|
268
|
+
return user["id"]
|
|
269
|
+
|
|
270
|
+
# If not found by email, could implement search by display name
|
|
271
|
+
# For now, assume the identifier is already a user ID
|
|
272
|
+
return user_identifier if user_identifier else None
|
|
273
|
+
|
|
274
|
+
# CRUD Operations
|
|
275
|
+
|
|
276
|
+
async def create(self, ticket: Union[Epic, Task]) -> Union[Epic, Task]:
|
|
277
|
+
"""Create a new Linear issue or project with full field support.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
ticket: Epic or Task to create
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Created ticket with populated ID and metadata
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
ValueError: If credentials are invalid or creation fails
|
|
287
|
+
"""
|
|
288
|
+
# Validate credentials before attempting operation
|
|
289
|
+
is_valid, error_message = self.validate_credentials()
|
|
290
|
+
if not is_valid:
|
|
291
|
+
raise ValueError(error_message)
|
|
292
|
+
|
|
293
|
+
# Ensure adapter is initialized
|
|
294
|
+
await self.initialize()
|
|
295
|
+
|
|
296
|
+
# Handle Epic creation (Linear Projects)
|
|
297
|
+
if isinstance(ticket, Epic):
|
|
298
|
+
return await self._create_epic(ticket)
|
|
299
|
+
|
|
300
|
+
# Handle Task creation (Linear Issues)
|
|
301
|
+
return await self._create_task(ticket)
|
|
302
|
+
|
|
303
|
+
async def _create_task(self, task: Task) -> Task:
|
|
304
|
+
"""Create a Linear issue from a Task.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
task: Task to create
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Created task with Linear metadata
|
|
311
|
+
"""
|
|
312
|
+
team_id = await self._ensure_team_id()
|
|
313
|
+
|
|
314
|
+
# Build issue input using mapper
|
|
315
|
+
issue_input = build_linear_issue_input(task, team_id)
|
|
316
|
+
|
|
317
|
+
# Resolve assignee to user ID if provided
|
|
318
|
+
if task.assignee:
|
|
319
|
+
user_id = await self._get_user_id(task.assignee)
|
|
320
|
+
if user_id:
|
|
321
|
+
issue_input["assigneeId"] = user_id
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
result = await self.client.execute_mutation(
|
|
325
|
+
CREATE_ISSUE_MUTATION,
|
|
326
|
+
{"input": issue_input}
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
if not result["issueCreate"]["success"]:
|
|
330
|
+
raise ValueError("Failed to create Linear issue")
|
|
331
|
+
|
|
332
|
+
created_issue = result["issueCreate"]["issue"]
|
|
333
|
+
return map_linear_issue_to_task(created_issue)
|
|
334
|
+
|
|
335
|
+
except Exception as e:
|
|
336
|
+
raise ValueError(f"Failed to create Linear issue: {e}")
|
|
337
|
+
|
|
338
|
+
async def _create_epic(self, epic: Epic) -> Epic:
|
|
339
|
+
"""Create a Linear project from an Epic.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
epic: Epic to create
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Created epic with Linear metadata
|
|
346
|
+
"""
|
|
347
|
+
team_id = await self._ensure_team_id()
|
|
348
|
+
|
|
349
|
+
project_input = {
|
|
350
|
+
"name": epic.title,
|
|
351
|
+
"teamIds": [team_id],
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if epic.description:
|
|
355
|
+
project_input["description"] = epic.description
|
|
356
|
+
|
|
357
|
+
# Create project mutation
|
|
358
|
+
create_query = """
|
|
359
|
+
mutation CreateProject($input: ProjectCreateInput!) {
|
|
360
|
+
projectCreate(input: $input) {
|
|
361
|
+
success
|
|
362
|
+
project {
|
|
363
|
+
id
|
|
364
|
+
name
|
|
365
|
+
description
|
|
366
|
+
state
|
|
367
|
+
createdAt
|
|
368
|
+
updatedAt
|
|
369
|
+
url
|
|
370
|
+
icon
|
|
371
|
+
color
|
|
372
|
+
targetDate
|
|
373
|
+
startedAt
|
|
374
|
+
completedAt
|
|
375
|
+
teams {
|
|
376
|
+
nodes {
|
|
377
|
+
id
|
|
378
|
+
name
|
|
379
|
+
key
|
|
380
|
+
description
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
result = await self.client.execute_mutation(
|
|
390
|
+
create_query,
|
|
391
|
+
{"input": project_input}
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
if not result["projectCreate"]["success"]:
|
|
395
|
+
raise ValueError("Failed to create Linear project")
|
|
396
|
+
|
|
397
|
+
created_project = result["projectCreate"]["project"]
|
|
398
|
+
return map_linear_project_to_epic(created_project)
|
|
399
|
+
|
|
400
|
+
except Exception as e:
|
|
401
|
+
raise ValueError(f"Failed to create Linear project: {e}")
|
|
402
|
+
|
|
403
|
+
async def read(self, ticket_id: str) -> Optional[Task]:
|
|
404
|
+
"""Read a Linear issue by identifier with full details.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
ticket_id: Linear issue identifier (e.g., 'BTA-123')
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
Task with full details or None if not found
|
|
411
|
+
"""
|
|
412
|
+
# Validate credentials before attempting operation
|
|
413
|
+
is_valid, error_message = self.validate_credentials()
|
|
414
|
+
if not is_valid:
|
|
415
|
+
raise ValueError(error_message)
|
|
416
|
+
|
|
417
|
+
query = ALL_FRAGMENTS + """
|
|
418
|
+
query GetIssue($identifier: String!) {
|
|
419
|
+
issue(id: $identifier) {
|
|
420
|
+
...IssueFullFields
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
"""
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
result = await self.client.execute_query(
|
|
427
|
+
query,
|
|
428
|
+
{"identifier": ticket_id}
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
if result.get("issue"):
|
|
432
|
+
return map_linear_issue_to_task(result["issue"])
|
|
433
|
+
|
|
434
|
+
except TransportQueryError:
|
|
435
|
+
# Issue not found
|
|
436
|
+
pass
|
|
437
|
+
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
async def update(self, ticket_id: str, updates: Dict[str, Any]) -> Optional[Task]:
|
|
441
|
+
"""Update a Linear issue with comprehensive field support.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
ticket_id: Linear issue identifier
|
|
445
|
+
updates: Dictionary of fields to update
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
Updated task or None if not found
|
|
449
|
+
"""
|
|
450
|
+
# Validate credentials before attempting operation
|
|
451
|
+
is_valid, error_message = self.validate_credentials()
|
|
452
|
+
if not is_valid:
|
|
453
|
+
raise ValueError(error_message)
|
|
454
|
+
|
|
455
|
+
# First get the Linear internal ID
|
|
456
|
+
id_query = """
|
|
457
|
+
query GetIssueId($identifier: String!) {
|
|
458
|
+
issue(id: $identifier) {
|
|
459
|
+
id
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
"""
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
result = await self.client.execute_query(
|
|
466
|
+
id_query,
|
|
467
|
+
{"identifier": ticket_id}
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
if not result.get("issue"):
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
linear_id = result["issue"]["id"]
|
|
474
|
+
|
|
475
|
+
# Build update input using mapper
|
|
476
|
+
update_input = build_linear_issue_update_input(updates)
|
|
477
|
+
|
|
478
|
+
# Handle state transitions
|
|
479
|
+
if "state" in updates:
|
|
480
|
+
target_state = TicketState(updates["state"]) if isinstance(updates["state"], str) else updates["state"]
|
|
481
|
+
state_mapping = self._get_state_mapping()
|
|
482
|
+
if target_state in state_mapping:
|
|
483
|
+
update_input["stateId"] = state_mapping[target_state]
|
|
484
|
+
|
|
485
|
+
# Resolve assignee to user ID if provided
|
|
486
|
+
if "assignee" in updates and updates["assignee"]:
|
|
487
|
+
user_id = await self._get_user_id(updates["assignee"])
|
|
488
|
+
if user_id:
|
|
489
|
+
update_input["assigneeId"] = user_id
|
|
490
|
+
|
|
491
|
+
# Execute update
|
|
492
|
+
result = await self.client.execute_mutation(
|
|
493
|
+
UPDATE_ISSUE_MUTATION,
|
|
494
|
+
{"id": linear_id, "input": update_input}
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
if not result["issueUpdate"]["success"]:
|
|
498
|
+
raise ValueError("Failed to update Linear issue")
|
|
499
|
+
|
|
500
|
+
updated_issue = result["issueUpdate"]["issue"]
|
|
501
|
+
return map_linear_issue_to_task(updated_issue)
|
|
502
|
+
|
|
503
|
+
except Exception as e:
|
|
504
|
+
raise ValueError(f"Failed to update Linear issue: {e}")
|
|
505
|
+
|
|
506
|
+
async def delete(self, ticket_id: str) -> bool:
|
|
507
|
+
"""Delete a Linear issue (archive it).
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
ticket_id: Linear issue identifier
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
True if successfully deleted/archived
|
|
514
|
+
"""
|
|
515
|
+
# Linear doesn't support true deletion, so we archive the issue
|
|
516
|
+
try:
|
|
517
|
+
result = await self.update(ticket_id, {"archived": True})
|
|
518
|
+
return result is not None
|
|
519
|
+
except Exception:
|
|
520
|
+
return False
|
|
521
|
+
|
|
522
|
+
async def list(
|
|
523
|
+
self,
|
|
524
|
+
limit: int = 10,
|
|
525
|
+
offset: int = 0,
|
|
526
|
+
filters: Optional[Dict[str, Any]] = None
|
|
527
|
+
) -> List[Task]:
|
|
528
|
+
"""List Linear issues with optional filtering.
|
|
529
|
+
|
|
530
|
+
Args:
|
|
531
|
+
limit: Maximum number of issues to return
|
|
532
|
+
offset: Number of issues to skip (Note: Linear uses cursor-based pagination)
|
|
533
|
+
filters: Optional filters (state, assignee, priority, etc.)
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
List of tasks matching the criteria
|
|
537
|
+
"""
|
|
538
|
+
# Validate credentials
|
|
539
|
+
is_valid, error_message = self.validate_credentials()
|
|
540
|
+
if not is_valid:
|
|
541
|
+
raise ValueError(error_message)
|
|
542
|
+
|
|
543
|
+
await self.initialize()
|
|
544
|
+
team_id = await self._ensure_team_id()
|
|
545
|
+
|
|
546
|
+
# Build issue filter
|
|
547
|
+
issue_filter = build_issue_filter(
|
|
548
|
+
team_id=team_id,
|
|
549
|
+
state=filters.get("state") if filters else None,
|
|
550
|
+
priority=filters.get("priority") if filters else None,
|
|
551
|
+
include_archived=filters.get("includeArchived", False) if filters else False,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
# Add additional filters
|
|
555
|
+
if filters:
|
|
556
|
+
if "assignee" in filters:
|
|
557
|
+
user_id = await self._get_user_id(filters["assignee"])
|
|
558
|
+
if user_id:
|
|
559
|
+
issue_filter["assignee"] = {"id": {"eq": user_id}}
|
|
560
|
+
|
|
561
|
+
if "created_after" in filters:
|
|
562
|
+
issue_filter["createdAt"] = {"gte": filters["created_after"]}
|
|
563
|
+
if "updated_after" in filters:
|
|
564
|
+
issue_filter["updatedAt"] = {"gte": filters["updated_after"]}
|
|
565
|
+
if "due_before" in filters:
|
|
566
|
+
issue_filter["dueDate"] = {"lte": filters["due_before"]}
|
|
567
|
+
|
|
568
|
+
try:
|
|
569
|
+
result = await self.client.execute_query(
|
|
570
|
+
LIST_ISSUES_QUERY,
|
|
571
|
+
{"filter": issue_filter, "first": limit}
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
tasks = []
|
|
575
|
+
for issue in result["issues"]["nodes"]:
|
|
576
|
+
tasks.append(map_linear_issue_to_task(issue))
|
|
577
|
+
|
|
578
|
+
return tasks
|
|
579
|
+
|
|
580
|
+
except Exception as e:
|
|
581
|
+
raise ValueError(f"Failed to list Linear issues: {e}")
|
|
582
|
+
|
|
583
|
+
async def search(self, query: SearchQuery) -> List[Task]:
|
|
584
|
+
"""Search Linear issues using comprehensive filters.
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
query: Search query with filters and criteria
|
|
588
|
+
|
|
589
|
+
Returns:
|
|
590
|
+
List of tasks matching the search criteria
|
|
591
|
+
"""
|
|
592
|
+
# Validate credentials
|
|
593
|
+
is_valid, error_message = self.validate_credentials()
|
|
594
|
+
if not is_valid:
|
|
595
|
+
raise ValueError(error_message)
|
|
596
|
+
|
|
597
|
+
await self.initialize()
|
|
598
|
+
team_id = await self._ensure_team_id()
|
|
599
|
+
|
|
600
|
+
# Build comprehensive issue filter
|
|
601
|
+
issue_filter = {"team": {"id": {"eq": team_id}}}
|
|
602
|
+
|
|
603
|
+
# Text search (Linear supports full-text search)
|
|
604
|
+
if query.query:
|
|
605
|
+
# Linear's search is quite sophisticated, but we'll use a simple approach
|
|
606
|
+
# In practice, you might want to use Linear's search API endpoint
|
|
607
|
+
issue_filter["title"] = {"containsIgnoreCase": query.query}
|
|
608
|
+
|
|
609
|
+
# State filter
|
|
610
|
+
if query.state:
|
|
611
|
+
state_type = get_linear_state_type(query.state)
|
|
612
|
+
issue_filter["state"] = {"type": {"eq": state_type}}
|
|
613
|
+
|
|
614
|
+
# Priority filter
|
|
615
|
+
if query.priority:
|
|
616
|
+
linear_priority = get_linear_priority(query.priority)
|
|
617
|
+
issue_filter["priority"] = {"eq": linear_priority}
|
|
618
|
+
|
|
619
|
+
# Assignee filter
|
|
620
|
+
if query.assignee:
|
|
621
|
+
user_id = await self._get_user_id(query.assignee)
|
|
622
|
+
if user_id:
|
|
623
|
+
issue_filter["assignee"] = {"id": {"eq": user_id}}
|
|
624
|
+
|
|
625
|
+
# Tags filter (labels in Linear)
|
|
626
|
+
if query.tags:
|
|
627
|
+
issue_filter["labels"] = {"some": {"name": {"in": query.tags}}}
|
|
628
|
+
|
|
629
|
+
# Exclude archived by default
|
|
630
|
+
issue_filter["archivedAt"] = {"null": True}
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
result = await self.client.execute_query(
|
|
634
|
+
SEARCH_ISSUES_QUERY,
|
|
635
|
+
{"filter": issue_filter, "first": query.limit}
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
tasks = []
|
|
639
|
+
for issue in result["issues"]["nodes"]:
|
|
640
|
+
tasks.append(map_linear_issue_to_task(issue))
|
|
641
|
+
|
|
642
|
+
return tasks
|
|
643
|
+
|
|
644
|
+
except Exception as e:
|
|
645
|
+
raise ValueError(f"Failed to search Linear issues: {e}")
|
|
646
|
+
|
|
647
|
+
async def transition_state(
|
|
648
|
+
self, ticket_id: str, target_state: TicketState
|
|
649
|
+
) -> Optional[Task]:
|
|
650
|
+
"""Transition Linear issue to new state with workflow validation.
|
|
651
|
+
|
|
652
|
+
Args:
|
|
653
|
+
ticket_id: Linear issue identifier
|
|
654
|
+
target_state: Target state to transition to
|
|
655
|
+
|
|
656
|
+
Returns:
|
|
657
|
+
Updated task or None if transition failed
|
|
658
|
+
"""
|
|
659
|
+
# Validate transition
|
|
660
|
+
if not await self.validate_transition(ticket_id, target_state):
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
# Update state
|
|
664
|
+
return await self.update(ticket_id, {"state": target_state})
|
|
665
|
+
|
|
666
|
+
async def validate_transition(
|
|
667
|
+
self, ticket_id: str, target_state: TicketState
|
|
668
|
+
) -> bool:
|
|
669
|
+
"""Validate if state transition is allowed.
|
|
670
|
+
|
|
671
|
+
Args:
|
|
672
|
+
ticket_id: Linear issue identifier
|
|
673
|
+
target_state: Target state to validate
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
True if transition is valid
|
|
677
|
+
"""
|
|
678
|
+
# For now, allow all transitions
|
|
679
|
+
# In practice, you might want to implement Linear's workflow rules
|
|
680
|
+
return True
|
|
681
|
+
|
|
682
|
+
async def add_comment(self, comment: Comment) -> Comment:
|
|
683
|
+
"""Add a comment to a Linear issue.
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
comment: Comment to add
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
Created comment with ID
|
|
690
|
+
"""
|
|
691
|
+
# First get the Linear internal ID
|
|
692
|
+
id_query = """
|
|
693
|
+
query GetIssueId($identifier: String!) {
|
|
694
|
+
issue(id: $identifier) {
|
|
695
|
+
id
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
"""
|
|
699
|
+
|
|
700
|
+
try:
|
|
701
|
+
result = await self.client.execute_query(
|
|
702
|
+
id_query,
|
|
703
|
+
{"identifier": comment.ticket_id}
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
if not result.get("issue"):
|
|
707
|
+
raise ValueError(f"Issue {comment.ticket_id} not found")
|
|
708
|
+
|
|
709
|
+
linear_id = result["issue"]["id"]
|
|
710
|
+
|
|
711
|
+
# Create comment mutation
|
|
712
|
+
create_comment_query = """
|
|
713
|
+
mutation CreateComment($input: CommentCreateInput!) {
|
|
714
|
+
commentCreate(input: $input) {
|
|
715
|
+
success
|
|
716
|
+
comment {
|
|
717
|
+
id
|
|
718
|
+
body
|
|
719
|
+
createdAt
|
|
720
|
+
updatedAt
|
|
721
|
+
user {
|
|
722
|
+
id
|
|
723
|
+
name
|
|
724
|
+
email
|
|
725
|
+
displayName
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
"""
|
|
731
|
+
|
|
732
|
+
comment_input = {
|
|
733
|
+
"issueId": linear_id,
|
|
734
|
+
"body": comment.body,
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
result = await self.client.execute_mutation(
|
|
738
|
+
create_comment_query,
|
|
739
|
+
{"input": comment_input}
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
if not result["commentCreate"]["success"]:
|
|
743
|
+
raise ValueError("Failed to create comment")
|
|
744
|
+
|
|
745
|
+
created_comment = result["commentCreate"]["comment"]
|
|
746
|
+
return map_linear_comment_to_comment(created_comment, comment.ticket_id)
|
|
747
|
+
|
|
748
|
+
except Exception as e:
|
|
749
|
+
raise ValueError(f"Failed to add comment: {e}")
|
|
750
|
+
|
|
751
|
+
async def get_comments(
|
|
752
|
+
self, ticket_id: str, limit: int = 10, offset: int = 0
|
|
753
|
+
) -> List[Comment]:
|
|
754
|
+
"""Get comments for a Linear issue.
|
|
755
|
+
|
|
756
|
+
Args:
|
|
757
|
+
ticket_id: Linear issue identifier
|
|
758
|
+
limit: Maximum number of comments to return
|
|
759
|
+
offset: Number of comments to skip
|
|
760
|
+
|
|
761
|
+
Returns:
|
|
762
|
+
List of comments for the issue
|
|
763
|
+
"""
|
|
764
|
+
query = """
|
|
765
|
+
query GetIssueComments($identifier: String!, $first: Int!) {
|
|
766
|
+
issue(id: $identifier) {
|
|
767
|
+
comments(first: $first) {
|
|
768
|
+
nodes {
|
|
769
|
+
id
|
|
770
|
+
body
|
|
771
|
+
createdAt
|
|
772
|
+
updatedAt
|
|
773
|
+
user {
|
|
774
|
+
id
|
|
775
|
+
name
|
|
776
|
+
email
|
|
777
|
+
displayName
|
|
778
|
+
avatarUrl
|
|
779
|
+
}
|
|
780
|
+
parent {
|
|
781
|
+
id
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
"""
|
|
788
|
+
|
|
789
|
+
try:
|
|
790
|
+
result = await self.client.execute_query(
|
|
791
|
+
query,
|
|
792
|
+
{"identifier": ticket_id, "first": limit}
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
if not result.get("issue"):
|
|
796
|
+
return []
|
|
797
|
+
|
|
798
|
+
comments = []
|
|
799
|
+
for comment_data in result["issue"]["comments"]["nodes"]:
|
|
800
|
+
comments.append(map_linear_comment_to_comment(comment_data, ticket_id))
|
|
801
|
+
|
|
802
|
+
return comments
|
|
803
|
+
|
|
804
|
+
except Exception:
|
|
805
|
+
return []
|
|
806
|
+
|
|
807
|
+
async def close(self) -> None:
|
|
808
|
+
"""Close the adapter and clean up resources."""
|
|
809
|
+
await self.client.close()
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
# Register the adapter
|
|
813
|
+
AdapterRegistry.register("linear", LinearAdapter)
|