mcp-ticketer 0.1.39__py3-none-any.whl → 0.2.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/core/exceptions.py +152 -0
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.2.0.dist-info}/METADATA +1 -1
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.2.0.dist-info}/RECORD +15 -8
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.2.0.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.2.0.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.39.dist-info → mcp_ticketer-0.2.0.dist-info}/top_level.txt +0 -0
mcp_ticketer/adapters/linear.py
CHANGED
|
@@ -1,2389 +1,15 @@
|
|
|
1
|
-
"""Linear adapter implementation using native GraphQL API with full feature support.
|
|
1
|
+
"""Linear adapter implementation using native GraphQL API with full feature support.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
from datetime import date, datetime
|
|
7
|
-
from enum import Enum
|
|
8
|
-
from typing import Any, Dict, List, Optional, Union
|
|
3
|
+
This module provides backward compatibility by importing the refactored LinearAdapter
|
|
4
|
+
from the new modular structure. The adapter has been split into multiple modules
|
|
5
|
+
for better organization and maintainability.
|
|
9
6
|
|
|
10
|
-
|
|
11
|
-
from
|
|
12
|
-
from gql.transport.httpx import HTTPXAsyncTransport
|
|
13
|
-
|
|
14
|
-
from ..core.adapter import BaseAdapter
|
|
15
|
-
from ..core.models import (
|
|
16
|
-
Comment,
|
|
17
|
-
Epic,
|
|
18
|
-
Priority,
|
|
19
|
-
SearchQuery,
|
|
20
|
-
Task,
|
|
21
|
-
TicketState,
|
|
22
|
-
TicketType,
|
|
23
|
-
)
|
|
24
|
-
from ..core.registry import AdapterRegistry
|
|
25
|
-
from ..core.env_loader import load_adapter_config, validate_adapter_config
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class LinearStateType(str, Enum):
|
|
29
|
-
"""Linear workflow state types."""
|
|
30
|
-
|
|
31
|
-
BACKLOG = "backlog"
|
|
32
|
-
UNSTARTED = "unstarted"
|
|
33
|
-
STARTED = "started"
|
|
34
|
-
COMPLETED = "completed"
|
|
35
|
-
CANCELED = "canceled"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
class LinearPriorityMapping:
|
|
39
|
-
"""Maps between Linear priority numbers and our Priority enum."""
|
|
40
|
-
|
|
41
|
-
TO_LINEAR = {
|
|
42
|
-
Priority.LOW: 4,
|
|
43
|
-
Priority.MEDIUM: 3,
|
|
44
|
-
Priority.HIGH: 2,
|
|
45
|
-
Priority.CRITICAL: 1,
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
FROM_LINEAR = {
|
|
49
|
-
0: Priority.CRITICAL, # Urgent
|
|
50
|
-
1: Priority.CRITICAL, # High
|
|
51
|
-
2: Priority.HIGH, # Medium
|
|
52
|
-
3: Priority.MEDIUM, # Low
|
|
53
|
-
4: Priority.LOW, # No priority
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
# GraphQL Fragments for reusable field definitions
|
|
58
|
-
USER_FRAGMENT = """
|
|
59
|
-
fragment UserFields on User {
|
|
60
|
-
id
|
|
61
|
-
name
|
|
62
|
-
email
|
|
63
|
-
displayName
|
|
64
|
-
avatarUrl
|
|
65
|
-
isMe
|
|
66
|
-
}
|
|
67
|
-
"""
|
|
68
|
-
|
|
69
|
-
WORKFLOW_STATE_FRAGMENT = """
|
|
70
|
-
fragment WorkflowStateFields on WorkflowState {
|
|
71
|
-
id
|
|
72
|
-
name
|
|
73
|
-
type
|
|
74
|
-
position
|
|
75
|
-
color
|
|
76
|
-
}
|
|
77
|
-
"""
|
|
78
|
-
|
|
79
|
-
TEAM_FRAGMENT = """
|
|
80
|
-
fragment TeamFields on Team {
|
|
81
|
-
id
|
|
82
|
-
name
|
|
83
|
-
key
|
|
84
|
-
description
|
|
85
|
-
}
|
|
86
|
-
"""
|
|
87
|
-
|
|
88
|
-
CYCLE_FRAGMENT = """
|
|
89
|
-
fragment CycleFields on Cycle {
|
|
90
|
-
id
|
|
91
|
-
number
|
|
92
|
-
name
|
|
93
|
-
description
|
|
94
|
-
startsAt
|
|
95
|
-
endsAt
|
|
96
|
-
completedAt
|
|
97
|
-
}
|
|
98
|
-
"""
|
|
99
|
-
|
|
100
|
-
PROJECT_FRAGMENT = """
|
|
101
|
-
fragment ProjectFields on Project {
|
|
102
|
-
id
|
|
103
|
-
name
|
|
104
|
-
description
|
|
105
|
-
state
|
|
106
|
-
createdAt
|
|
107
|
-
updatedAt
|
|
108
|
-
url
|
|
109
|
-
icon
|
|
110
|
-
color
|
|
111
|
-
targetDate
|
|
112
|
-
startedAt
|
|
113
|
-
completedAt
|
|
114
|
-
teams {
|
|
115
|
-
nodes {
|
|
116
|
-
...TeamFields
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
"""
|
|
121
|
-
|
|
122
|
-
LABEL_FRAGMENT = """
|
|
123
|
-
fragment LabelFields on IssueLabel {
|
|
124
|
-
id
|
|
125
|
-
name
|
|
126
|
-
color
|
|
127
|
-
description
|
|
128
|
-
}
|
|
129
|
-
"""
|
|
130
|
-
|
|
131
|
-
ATTACHMENT_FRAGMENT = """
|
|
132
|
-
fragment AttachmentFields on Attachment {
|
|
133
|
-
id
|
|
134
|
-
url
|
|
135
|
-
title
|
|
136
|
-
subtitle
|
|
137
|
-
metadata
|
|
138
|
-
source
|
|
139
|
-
sourceType
|
|
140
|
-
createdAt
|
|
141
|
-
}
|
|
7
|
+
For new code, import directly from the linear package:
|
|
8
|
+
from mcp_ticketer.adapters.linear import LinearAdapter
|
|
142
9
|
"""
|
|
143
10
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
id
|
|
147
|
-
body
|
|
148
|
-
createdAt
|
|
149
|
-
updatedAt
|
|
150
|
-
user {
|
|
151
|
-
...UserFields
|
|
152
|
-
}
|
|
153
|
-
parent {
|
|
154
|
-
id
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
"""
|
|
158
|
-
|
|
159
|
-
ISSUE_COMPACT_FRAGMENT = """
|
|
160
|
-
fragment IssueCompactFields on Issue {
|
|
161
|
-
id
|
|
162
|
-
identifier
|
|
163
|
-
title
|
|
164
|
-
description
|
|
165
|
-
priority
|
|
166
|
-
priorityLabel
|
|
167
|
-
estimate
|
|
168
|
-
dueDate
|
|
169
|
-
slaBreachesAt
|
|
170
|
-
slaStartedAt
|
|
171
|
-
createdAt
|
|
172
|
-
updatedAt
|
|
173
|
-
archivedAt
|
|
174
|
-
canceledAt
|
|
175
|
-
completedAt
|
|
176
|
-
startedAt
|
|
177
|
-
startedTriageAt
|
|
178
|
-
triagedAt
|
|
179
|
-
url
|
|
180
|
-
branchName
|
|
181
|
-
customerTicketCount
|
|
182
|
-
|
|
183
|
-
state {
|
|
184
|
-
...WorkflowStateFields
|
|
185
|
-
}
|
|
186
|
-
assignee {
|
|
187
|
-
...UserFields
|
|
188
|
-
}
|
|
189
|
-
creator {
|
|
190
|
-
...UserFields
|
|
191
|
-
}
|
|
192
|
-
labels {
|
|
193
|
-
nodes {
|
|
194
|
-
...LabelFields
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
team {
|
|
198
|
-
...TeamFields
|
|
199
|
-
}
|
|
200
|
-
cycle {
|
|
201
|
-
...CycleFields
|
|
202
|
-
}
|
|
203
|
-
project {
|
|
204
|
-
...ProjectFields
|
|
205
|
-
}
|
|
206
|
-
parent {
|
|
207
|
-
id
|
|
208
|
-
identifier
|
|
209
|
-
title
|
|
210
|
-
}
|
|
211
|
-
children {
|
|
212
|
-
nodes {
|
|
213
|
-
id
|
|
214
|
-
identifier
|
|
215
|
-
title
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
attachments {
|
|
219
|
-
nodes {
|
|
220
|
-
...AttachmentFields
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
"""
|
|
225
|
-
|
|
226
|
-
ISSUE_FULL_FRAGMENT = """
|
|
227
|
-
fragment IssueFullFields on Issue {
|
|
228
|
-
...IssueCompactFields
|
|
229
|
-
comments {
|
|
230
|
-
nodes {
|
|
231
|
-
...CommentFields
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
subscribers {
|
|
235
|
-
nodes {
|
|
236
|
-
...UserFields
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
relations {
|
|
240
|
-
nodes {
|
|
241
|
-
id
|
|
242
|
-
type
|
|
243
|
-
relatedIssue {
|
|
244
|
-
id
|
|
245
|
-
identifier
|
|
246
|
-
title
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
"""
|
|
252
|
-
|
|
253
|
-
# Combine all fragments
|
|
254
|
-
ALL_FRAGMENTS = (
|
|
255
|
-
USER_FRAGMENT
|
|
256
|
-
+ WORKFLOW_STATE_FRAGMENT
|
|
257
|
-
+ TEAM_FRAGMENT
|
|
258
|
-
+ CYCLE_FRAGMENT
|
|
259
|
-
+ PROJECT_FRAGMENT
|
|
260
|
-
+ LABEL_FRAGMENT
|
|
261
|
-
+ ATTACHMENT_FRAGMENT
|
|
262
|
-
+ COMMENT_FRAGMENT
|
|
263
|
-
+ ISSUE_COMPACT_FRAGMENT
|
|
264
|
-
+ ISSUE_FULL_FRAGMENT
|
|
265
|
-
)
|
|
266
|
-
|
|
267
|
-
# Fragments needed for issue list/search (without comments)
|
|
268
|
-
ISSUE_LIST_FRAGMENTS = (
|
|
269
|
-
USER_FRAGMENT
|
|
270
|
-
+ WORKFLOW_STATE_FRAGMENT
|
|
271
|
-
+ TEAM_FRAGMENT
|
|
272
|
-
+ CYCLE_FRAGMENT
|
|
273
|
-
+ PROJECT_FRAGMENT
|
|
274
|
-
+ LABEL_FRAGMENT
|
|
275
|
-
+ ATTACHMENT_FRAGMENT
|
|
276
|
-
+ ISSUE_COMPACT_FRAGMENT
|
|
277
|
-
)
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
class LinearAdapter(BaseAdapter[Task]):
|
|
281
|
-
"""Adapter for Linear issue tracking system using native GraphQL API."""
|
|
282
|
-
|
|
283
|
-
def __init__(self, config: dict[str, Any]):
|
|
284
|
-
"""Initialize Linear adapter.
|
|
285
|
-
|
|
286
|
-
Args:
|
|
287
|
-
config: Configuration with:
|
|
288
|
-
- api_key: Linear API key (or LINEAR_API_KEY env var)
|
|
289
|
-
- workspace: Linear workspace name (optional, for documentation)
|
|
290
|
-
- team_key: Linear team key (e.g., 'BTA') OR
|
|
291
|
-
- team_id: Linear team UUID (e.g., '02d15669-7351-4451-9719-807576c16049')
|
|
292
|
-
- api_url: Optional Linear API URL
|
|
293
|
-
|
|
294
|
-
Note: Either team_key or team_id is required. If both are provided, team_id takes precedence.
|
|
295
|
-
|
|
296
|
-
"""
|
|
297
|
-
super().__init__(config)
|
|
298
|
-
|
|
299
|
-
# Load configuration with environment variable resolution
|
|
300
|
-
full_config = load_adapter_config("linear", config)
|
|
301
|
-
|
|
302
|
-
# Get API key from config or environment
|
|
303
|
-
self.api_key = full_config.get("api_key")
|
|
304
|
-
if not self.api_key:
|
|
305
|
-
raise ValueError(
|
|
306
|
-
"Linear API key required (config.api_key or LINEAR_API_KEY env var)"
|
|
307
|
-
)
|
|
308
|
-
|
|
309
|
-
self.workspace = full_config.get("workspace") # Optional, for documentation
|
|
310
|
-
|
|
311
|
-
# Support both team_key (short key) and team_id (UUID)
|
|
312
|
-
self.team_key = full_config.get("team_key") # Short key like "BTA"
|
|
313
|
-
self.team_id_config = full_config.get("team_id") # UUID like "02d15669-..."
|
|
314
|
-
|
|
315
|
-
# Require at least one team identifier
|
|
316
|
-
if not self.team_key and not self.team_id_config:
|
|
317
|
-
raise ValueError("Either team_key or team_id is required in configuration")
|
|
318
|
-
|
|
319
|
-
self.api_url = full_config.get("api_url", "https://api.linear.app/graphql")
|
|
320
|
-
|
|
321
|
-
# DEBUG: Log API key details for debugging
|
|
322
|
-
import logging
|
|
323
|
-
logger = logging.getLogger(__name__)
|
|
324
|
-
logger.info(f"LinearAdapter initialized with API key: {self.api_key[:20]}...")
|
|
325
|
-
logger.info(f"LinearAdapter config api_key: {config.get('api_key', 'Not set')[:20] if config.get('api_key') else 'Not set'}...")
|
|
326
|
-
logger.info(f"LinearAdapter env LINEAR_API_KEY: {os.getenv('LINEAR_API_KEY', 'Not set')[:20] if os.getenv('LINEAR_API_KEY') else 'Not set'}...")
|
|
327
|
-
logger.info(f"LinearAdapter team_id_config: {self.team_id_config}")
|
|
328
|
-
logger.info(f"LinearAdapter team_key: {self.team_key}")
|
|
329
|
-
|
|
330
|
-
# Caches for frequently used data
|
|
331
|
-
self._team_id: Optional[str] = None
|
|
332
|
-
self._workflow_states: Optional[dict[str, dict[str, Any]]] = None
|
|
333
|
-
self._labels: Optional[dict[str, str]] = None # name -> id
|
|
334
|
-
self._users: Optional[dict[str, str]] = None # email -> id
|
|
335
|
-
|
|
336
|
-
# Initialize state mapping
|
|
337
|
-
self._state_mapping = self._get_state_mapping()
|
|
338
|
-
|
|
339
|
-
# Initialization lock to prevent concurrent initialization
|
|
340
|
-
self._init_lock = asyncio.Lock()
|
|
341
|
-
self._initialized = False
|
|
342
|
-
|
|
343
|
-
def _create_client(self) -> Client:
|
|
344
|
-
"""Create a fresh GraphQL client for each operation.
|
|
345
|
-
|
|
346
|
-
This prevents 'Transport is already connected' errors by ensuring
|
|
347
|
-
each operation gets its own client and transport instance.
|
|
348
|
-
|
|
349
|
-
Returns:
|
|
350
|
-
Client: Fresh GraphQL client instance
|
|
351
|
-
|
|
352
|
-
"""
|
|
353
|
-
transport = HTTPXAsyncTransport(
|
|
354
|
-
url=self.api_url,
|
|
355
|
-
headers={"Authorization": self.api_key},
|
|
356
|
-
timeout=30.0,
|
|
357
|
-
)
|
|
358
|
-
return Client(transport=transport, fetch_schema_from_transport=False)
|
|
359
|
-
|
|
360
|
-
async def initialize(self) -> None:
|
|
361
|
-
"""Initialize adapter by preloading team, states, and labels data concurrently."""
|
|
362
|
-
if self._initialized:
|
|
363
|
-
return
|
|
364
|
-
|
|
365
|
-
async with self._init_lock:
|
|
366
|
-
if self._initialized:
|
|
367
|
-
return
|
|
368
|
-
|
|
369
|
-
try:
|
|
370
|
-
# First get team ID as it's required for other queries
|
|
371
|
-
team_id = await self._fetch_team_data()
|
|
372
|
-
|
|
373
|
-
# Then fetch states and labels concurrently
|
|
374
|
-
states_task = self._fetch_workflow_states_data(team_id)
|
|
375
|
-
labels_task = self._fetch_labels_data(team_id)
|
|
376
|
-
|
|
377
|
-
workflow_states, labels = await asyncio.gather(states_task, labels_task)
|
|
378
|
-
|
|
379
|
-
# Cache the results
|
|
380
|
-
self._team_id = team_id
|
|
381
|
-
self._workflow_states = workflow_states
|
|
382
|
-
self._labels = labels
|
|
383
|
-
self._initialized = True
|
|
384
|
-
|
|
385
|
-
except Exception as e:
|
|
386
|
-
# Reset on error
|
|
387
|
-
self._team_id = None
|
|
388
|
-
self._workflow_states = None
|
|
389
|
-
self._labels = None
|
|
390
|
-
raise e
|
|
391
|
-
|
|
392
|
-
async def _fetch_team_data(self) -> str:
|
|
393
|
-
"""Fetch team ID.
|
|
394
|
-
|
|
395
|
-
If team_id is configured, validate it exists and return it.
|
|
396
|
-
If team_key is configured, fetch the team_id by key.
|
|
397
|
-
"""
|
|
398
|
-
# If team_id (UUID) is provided, use it directly (preferred)
|
|
399
|
-
if self.team_id_config:
|
|
400
|
-
# Validate that this team ID exists
|
|
401
|
-
query = gql(
|
|
402
|
-
"""
|
|
403
|
-
query GetTeamById($id: String!) {
|
|
404
|
-
team(id: $id) {
|
|
405
|
-
id
|
|
406
|
-
name
|
|
407
|
-
key
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
"""
|
|
411
|
-
)
|
|
412
|
-
|
|
413
|
-
client = self._create_client()
|
|
414
|
-
async with client as session:
|
|
415
|
-
result = await session.execute(
|
|
416
|
-
query, variable_values={"id": self.team_id_config}
|
|
417
|
-
)
|
|
418
|
-
|
|
419
|
-
if not result.get("team"):
|
|
420
|
-
raise ValueError(f"Team with ID '{self.team_id_config}' not found")
|
|
421
|
-
|
|
422
|
-
return result["team"]["id"]
|
|
423
|
-
|
|
424
|
-
# Otherwise, fetch team ID by key
|
|
425
|
-
query = gql(
|
|
426
|
-
"""
|
|
427
|
-
query GetTeamByKey($key: String!) {
|
|
428
|
-
teams(filter: { key: { eq: $key } }) {
|
|
429
|
-
nodes {
|
|
430
|
-
id
|
|
431
|
-
name
|
|
432
|
-
key
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
"""
|
|
437
|
-
)
|
|
438
|
-
|
|
439
|
-
client = self._create_client()
|
|
440
|
-
async with client as session:
|
|
441
|
-
result = await session.execute(
|
|
442
|
-
query, variable_values={"key": self.team_key}
|
|
443
|
-
)
|
|
444
|
-
|
|
445
|
-
if not result["teams"]["nodes"]:
|
|
446
|
-
raise ValueError(f"Team with key '{self.team_key}' not found")
|
|
447
|
-
|
|
448
|
-
return result["teams"]["nodes"][0]["id"]
|
|
449
|
-
|
|
450
|
-
async def _fetch_workflow_states_data(
|
|
451
|
-
self, team_id: str
|
|
452
|
-
) -> dict[str, dict[str, Any]]:
|
|
453
|
-
"""Fetch workflow states data."""
|
|
454
|
-
query = gql(
|
|
455
|
-
"""
|
|
456
|
-
query WorkflowStates($teamId: ID!) {
|
|
457
|
-
workflowStates(filter: { team: { id: { eq: $teamId } } }) {
|
|
458
|
-
nodes {
|
|
459
|
-
id
|
|
460
|
-
name
|
|
461
|
-
type
|
|
462
|
-
position
|
|
463
|
-
color
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
"""
|
|
468
|
-
)
|
|
469
|
-
|
|
470
|
-
client = self._create_client()
|
|
471
|
-
async with client as session:
|
|
472
|
-
result = await session.execute(query, variable_values={"teamId": team_id})
|
|
473
|
-
|
|
474
|
-
workflow_states = {}
|
|
475
|
-
for state in result["workflowStates"]["nodes"]:
|
|
476
|
-
state_type = state["type"].lower()
|
|
477
|
-
if state_type not in workflow_states:
|
|
478
|
-
workflow_states[state_type] = state
|
|
479
|
-
elif state["position"] < workflow_states[state_type]["position"]:
|
|
480
|
-
workflow_states[state_type] = state
|
|
481
|
-
|
|
482
|
-
return workflow_states
|
|
483
|
-
|
|
484
|
-
async def _fetch_labels_data(self, team_id: str) -> dict[str, str]:
|
|
485
|
-
"""Fetch labels data."""
|
|
486
|
-
query = gql(
|
|
487
|
-
"""
|
|
488
|
-
query GetLabels($teamId: ID!) {
|
|
489
|
-
issueLabels(filter: { team: { id: { eq: $teamId } } }) {
|
|
490
|
-
nodes {
|
|
491
|
-
id
|
|
492
|
-
name
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
"""
|
|
497
|
-
)
|
|
498
|
-
|
|
499
|
-
client = self._create_client()
|
|
500
|
-
async with client as session:
|
|
501
|
-
result = await session.execute(query, variable_values={"teamId": team_id})
|
|
502
|
-
|
|
503
|
-
return {label["name"]: label["id"] for label in result["issueLabels"]["nodes"]}
|
|
504
|
-
|
|
505
|
-
async def _ensure_initialized(self) -> None:
|
|
506
|
-
"""Ensure adapter is initialized before operations."""
|
|
507
|
-
if not self._initialized:
|
|
508
|
-
await self.initialize()
|
|
509
|
-
|
|
510
|
-
async def _ensure_team_id(self) -> str:
|
|
511
|
-
"""Get and cache the team ID."""
|
|
512
|
-
await self._ensure_initialized()
|
|
513
|
-
return self._team_id
|
|
514
|
-
|
|
515
|
-
async def _get_workflow_states(self) -> dict[str, dict[str, Any]]:
|
|
516
|
-
"""Get cached workflow states from Linear."""
|
|
517
|
-
await self._ensure_initialized()
|
|
518
|
-
return self._workflow_states
|
|
519
|
-
|
|
520
|
-
async def _get_or_create_label(self, name: str, color: Optional[str] = None) -> str:
|
|
521
|
-
"""Get existing label ID or create new label."""
|
|
522
|
-
await self._ensure_initialized()
|
|
523
|
-
|
|
524
|
-
# Check cache
|
|
525
|
-
if name in self._labels:
|
|
526
|
-
return self._labels[name]
|
|
527
|
-
|
|
528
|
-
# Try to find existing label (may have been added since initialization)
|
|
529
|
-
team_id = self._team_id
|
|
530
|
-
search_query = gql(
|
|
531
|
-
"""
|
|
532
|
-
query GetLabel($name: String!, $teamId: ID!) {
|
|
533
|
-
issueLabels(filter: { name: { eq: $name }, team: { id: { eq: $teamId } } }) {
|
|
534
|
-
nodes {
|
|
535
|
-
id
|
|
536
|
-
name
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
"""
|
|
541
|
-
)
|
|
542
|
-
|
|
543
|
-
client = self._create_client()
|
|
544
|
-
async with client as session:
|
|
545
|
-
result = await session.execute(
|
|
546
|
-
search_query, variable_values={"name": name, "teamId": team_id}
|
|
547
|
-
)
|
|
548
|
-
|
|
549
|
-
if result["issueLabels"]["nodes"]:
|
|
550
|
-
label_id = result["issueLabels"]["nodes"][0]["id"]
|
|
551
|
-
self._labels[name] = label_id
|
|
552
|
-
return label_id
|
|
553
|
-
|
|
554
|
-
# Create new label
|
|
555
|
-
create_query = gql(
|
|
556
|
-
"""
|
|
557
|
-
mutation CreateLabel($input: IssueLabelCreateInput!) {
|
|
558
|
-
issueLabelCreate(input: $input) {
|
|
559
|
-
issueLabel {
|
|
560
|
-
id
|
|
561
|
-
name
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
"""
|
|
566
|
-
)
|
|
567
|
-
|
|
568
|
-
label_input = {
|
|
569
|
-
"name": name,
|
|
570
|
-
"teamId": team_id,
|
|
571
|
-
}
|
|
572
|
-
if color:
|
|
573
|
-
label_input["color"] = color
|
|
574
|
-
|
|
575
|
-
client = self._create_client()
|
|
576
|
-
async with client as session:
|
|
577
|
-
result = await session.execute(
|
|
578
|
-
create_query, variable_values={"input": label_input}
|
|
579
|
-
)
|
|
580
|
-
|
|
581
|
-
label_id = result["issueLabelCreate"]["issueLabel"]["id"]
|
|
582
|
-
self._labels[name] = label_id
|
|
583
|
-
return label_id
|
|
584
|
-
|
|
585
|
-
async def _get_user_id(self, email: str) -> Optional[str]:
|
|
586
|
-
"""Get user ID by email."""
|
|
587
|
-
if not self._users:
|
|
588
|
-
self._users = {}
|
|
589
|
-
|
|
590
|
-
if email in self._users:
|
|
591
|
-
return self._users[email]
|
|
592
|
-
|
|
593
|
-
query = gql(
|
|
594
|
-
"""
|
|
595
|
-
query GetUser($email: String!) {
|
|
596
|
-
users(filter: { email: { eq: $email } }) {
|
|
597
|
-
nodes {
|
|
598
|
-
id
|
|
599
|
-
email
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
"""
|
|
604
|
-
)
|
|
605
|
-
|
|
606
|
-
client = self._create_client()
|
|
607
|
-
async with client as session:
|
|
608
|
-
result = await session.execute(query, variable_values={"email": email})
|
|
609
|
-
|
|
610
|
-
if result["users"]["nodes"]:
|
|
611
|
-
user_id = result["users"]["nodes"][0]["id"]
|
|
612
|
-
self._users[email] = user_id
|
|
613
|
-
return user_id
|
|
614
|
-
|
|
615
|
-
return None
|
|
616
|
-
|
|
617
|
-
def validate_credentials(self) -> tuple[bool, str]:
|
|
618
|
-
"""Validate that required credentials are present.
|
|
619
|
-
|
|
620
|
-
Returns:
|
|
621
|
-
(is_valid, error_message) - Tuple of validation result and error message
|
|
622
|
-
|
|
623
|
-
"""
|
|
624
|
-
if not self.api_key:
|
|
625
|
-
return (
|
|
626
|
-
False,
|
|
627
|
-
"LINEAR_API_KEY is required but not found. Set it in .env.local or environment.",
|
|
628
|
-
)
|
|
629
|
-
if not self.team_key and not self.team_id_config:
|
|
630
|
-
return (
|
|
631
|
-
False,
|
|
632
|
-
"Either Linear team_key or team_id is required in configuration. Set it in .mcp-ticketer/config.json",
|
|
633
|
-
)
|
|
634
|
-
return True, ""
|
|
635
|
-
|
|
636
|
-
def _get_state_mapping(self) -> dict[TicketState, str]:
|
|
637
|
-
"""Get mapping from universal states to Linear state types.
|
|
638
|
-
|
|
639
|
-
Required by BaseAdapter abstract method.
|
|
640
|
-
"""
|
|
641
|
-
return {
|
|
642
|
-
TicketState.OPEN: LinearStateType.BACKLOG,
|
|
643
|
-
TicketState.IN_PROGRESS: LinearStateType.STARTED,
|
|
644
|
-
TicketState.READY: LinearStateType.STARTED, # Will use label for distinction
|
|
645
|
-
TicketState.TESTED: LinearStateType.STARTED, # Will use label
|
|
646
|
-
TicketState.DONE: LinearStateType.COMPLETED,
|
|
647
|
-
TicketState.WAITING: LinearStateType.UNSTARTED,
|
|
648
|
-
TicketState.BLOCKED: LinearStateType.UNSTARTED, # Will use label
|
|
649
|
-
TicketState.CLOSED: LinearStateType.CANCELED,
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
def _map_state_to_linear(self, state: TicketState) -> str:
|
|
653
|
-
"""Map universal state to Linear state type."""
|
|
654
|
-
# Handle both enum and string values
|
|
655
|
-
if isinstance(state, str):
|
|
656
|
-
state = TicketState(state)
|
|
657
|
-
return self._state_mapping.get(state, LinearStateType.BACKLOG)
|
|
658
|
-
|
|
659
|
-
def _map_linear_state(
|
|
660
|
-
self, state_data: dict[str, Any], labels: list[str]
|
|
661
|
-
) -> TicketState:
|
|
662
|
-
"""Map Linear state and labels to universal state."""
|
|
663
|
-
state_type = state_data.get("type", "").lower()
|
|
664
|
-
|
|
665
|
-
# Check for special states via labels
|
|
666
|
-
labels_lower = [l.lower() for l in labels]
|
|
667
|
-
if "blocked" in labels_lower:
|
|
668
|
-
return TicketState.BLOCKED
|
|
669
|
-
if "waiting" in labels_lower:
|
|
670
|
-
return TicketState.WAITING
|
|
671
|
-
if "ready" in labels_lower or "review" in labels_lower:
|
|
672
|
-
return TicketState.READY
|
|
673
|
-
if "tested" in labels_lower or "qa" in labels_lower:
|
|
674
|
-
return TicketState.TESTED
|
|
675
|
-
|
|
676
|
-
# Map by state type
|
|
677
|
-
state_mapping = {
|
|
678
|
-
"backlog": TicketState.OPEN,
|
|
679
|
-
"unstarted": TicketState.OPEN,
|
|
680
|
-
"started": TicketState.IN_PROGRESS,
|
|
681
|
-
"completed": TicketState.DONE,
|
|
682
|
-
"canceled": TicketState.CLOSED,
|
|
683
|
-
}
|
|
684
|
-
return state_mapping.get(state_type, TicketState.OPEN)
|
|
685
|
-
|
|
686
|
-
def _task_from_linear_issue(self, issue: dict[str, Any]) -> Task:
|
|
687
|
-
"""Convert Linear issue to universal Task."""
|
|
688
|
-
# Extract labels
|
|
689
|
-
tags = []
|
|
690
|
-
if issue.get("labels") and issue["labels"].get("nodes"):
|
|
691
|
-
tags = [label["name"] for label in issue["labels"]["nodes"]]
|
|
692
|
-
|
|
693
|
-
# Map priority
|
|
694
|
-
linear_priority = issue.get("priority", 4)
|
|
695
|
-
priority = LinearPriorityMapping.FROM_LINEAR.get(
|
|
696
|
-
linear_priority, Priority.MEDIUM
|
|
697
|
-
)
|
|
698
|
-
|
|
699
|
-
# Map state
|
|
700
|
-
state = self._map_linear_state(issue.get("state", {}), tags)
|
|
701
|
-
|
|
702
|
-
# Build metadata with all Linear-specific fields
|
|
703
|
-
metadata = {
|
|
704
|
-
"linear": {
|
|
705
|
-
"id": issue["id"],
|
|
706
|
-
"identifier": issue["identifier"],
|
|
707
|
-
"url": issue.get("url"),
|
|
708
|
-
"state_id": issue.get("state", {}).get("id"),
|
|
709
|
-
"state_name": issue.get("state", {}).get("name"),
|
|
710
|
-
"team_id": issue.get("team", {}).get("id"),
|
|
711
|
-
"team_name": issue.get("team", {}).get("name"),
|
|
712
|
-
"cycle_id": (
|
|
713
|
-
issue.get("cycle", {}).get("id") if issue.get("cycle") else None
|
|
714
|
-
),
|
|
715
|
-
"cycle_name": (
|
|
716
|
-
issue.get("cycle", {}).get("name") if issue.get("cycle") else None
|
|
717
|
-
),
|
|
718
|
-
"project_id": (
|
|
719
|
-
issue.get("project", {}).get("id") if issue.get("project") else None
|
|
720
|
-
),
|
|
721
|
-
"project_name": (
|
|
722
|
-
issue.get("project", {}).get("name")
|
|
723
|
-
if issue.get("project")
|
|
724
|
-
else None
|
|
725
|
-
),
|
|
726
|
-
"priority_label": issue.get("priorityLabel"),
|
|
727
|
-
"estimate": issue.get("estimate"),
|
|
728
|
-
"due_date": issue.get("dueDate"),
|
|
729
|
-
"branch_name": issue.get("branchName"),
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
# Add timestamps if available
|
|
734
|
-
if issue.get("startedAt"):
|
|
735
|
-
metadata["linear"]["started_at"] = issue["startedAt"]
|
|
736
|
-
if issue.get("completedAt"):
|
|
737
|
-
metadata["linear"]["completed_at"] = issue["completedAt"]
|
|
738
|
-
if issue.get("canceledAt"):
|
|
739
|
-
metadata["linear"]["canceled_at"] = issue["canceledAt"]
|
|
740
|
-
|
|
741
|
-
# Add attachments metadata
|
|
742
|
-
if issue.get("attachments") and issue["attachments"].get("nodes"):
|
|
743
|
-
metadata["linear"]["attachments"] = [
|
|
744
|
-
{
|
|
745
|
-
"id": att["id"],
|
|
746
|
-
"url": att["url"],
|
|
747
|
-
"title": att.get("title"),
|
|
748
|
-
"source": att.get("source"),
|
|
749
|
-
}
|
|
750
|
-
for att in issue["attachments"]["nodes"]
|
|
751
|
-
]
|
|
752
|
-
|
|
753
|
-
# Extract child issue IDs
|
|
754
|
-
child_ids = []
|
|
755
|
-
if issue.get("children") and issue["children"].get("nodes"):
|
|
756
|
-
child_ids = [child["identifier"] for child in issue["children"]["nodes"]]
|
|
757
|
-
metadata["linear"]["child_issues"] = child_ids
|
|
758
|
-
|
|
759
|
-
# Determine ticket type based on parent relationships
|
|
760
|
-
ticket_type = TicketType.ISSUE
|
|
761
|
-
parent_issue_id = None
|
|
762
|
-
parent_epic_id = None
|
|
763
|
-
|
|
764
|
-
if issue.get("parent"):
|
|
765
|
-
# Has a parent issue, so this is a sub-task
|
|
766
|
-
ticket_type = TicketType.TASK
|
|
767
|
-
parent_issue_id = issue["parent"]["identifier"]
|
|
768
|
-
elif issue.get("project"):
|
|
769
|
-
# Has a project but no parent, so it's a standard issue under an epic
|
|
770
|
-
ticket_type = TicketType.ISSUE
|
|
771
|
-
parent_epic_id = issue["project"]["id"]
|
|
772
|
-
|
|
773
|
-
return Task(
|
|
774
|
-
id=issue["identifier"],
|
|
775
|
-
title=issue["title"],
|
|
776
|
-
description=issue.get("description"),
|
|
777
|
-
state=state,
|
|
778
|
-
priority=priority,
|
|
779
|
-
tags=tags,
|
|
780
|
-
ticket_type=ticket_type,
|
|
781
|
-
parent_issue=parent_issue_id,
|
|
782
|
-
parent_epic=parent_epic_id,
|
|
783
|
-
assignee=(
|
|
784
|
-
issue.get("assignee", {}).get("email")
|
|
785
|
-
if issue.get("assignee")
|
|
786
|
-
else None
|
|
787
|
-
),
|
|
788
|
-
children=child_ids,
|
|
789
|
-
estimated_hours=issue.get("estimate"),
|
|
790
|
-
created_at=(
|
|
791
|
-
datetime.fromisoformat(issue["createdAt"].replace("Z", "+00:00"))
|
|
792
|
-
if issue.get("createdAt")
|
|
793
|
-
else None
|
|
794
|
-
),
|
|
795
|
-
updated_at=(
|
|
796
|
-
datetime.fromisoformat(issue["updatedAt"].replace("Z", "+00:00"))
|
|
797
|
-
if issue.get("updatedAt")
|
|
798
|
-
else None
|
|
799
|
-
),
|
|
800
|
-
metadata=metadata,
|
|
801
|
-
)
|
|
802
|
-
|
|
803
|
-
def _epic_from_linear_project(self, project: dict[str, Any]) -> Epic:
|
|
804
|
-
"""Convert Linear project to universal Epic."""
|
|
805
|
-
# Map project state to ticket state
|
|
806
|
-
project_state = project.get("state", "planned").lower()
|
|
807
|
-
state_mapping = {
|
|
808
|
-
"planned": TicketState.OPEN,
|
|
809
|
-
"started": TicketState.IN_PROGRESS,
|
|
810
|
-
"paused": TicketState.WAITING,
|
|
811
|
-
"completed": TicketState.DONE,
|
|
812
|
-
"canceled": TicketState.CLOSED,
|
|
813
|
-
}
|
|
814
|
-
state = state_mapping.get(project_state, TicketState.OPEN)
|
|
815
|
-
|
|
816
|
-
# Extract teams
|
|
817
|
-
teams = []
|
|
818
|
-
if project.get("teams") and project["teams"].get("nodes"):
|
|
819
|
-
teams = [team["name"] for team in project["teams"]["nodes"]]
|
|
820
|
-
|
|
821
|
-
metadata = {
|
|
822
|
-
"linear": {
|
|
823
|
-
"id": project["id"],
|
|
824
|
-
"state": project.get("state"),
|
|
825
|
-
"url": project.get("url"),
|
|
826
|
-
"icon": project.get("icon"),
|
|
827
|
-
"color": project.get("color"),
|
|
828
|
-
"target_date": project.get("targetDate"),
|
|
829
|
-
"started_at": project.get("startedAt"),
|
|
830
|
-
"completed_at": project.get("completedAt"),
|
|
831
|
-
"teams": teams,
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
return Epic(
|
|
836
|
-
id=project["id"],
|
|
837
|
-
title=project["name"],
|
|
838
|
-
description=project.get("description"),
|
|
839
|
-
state=state,
|
|
840
|
-
ticket_type=TicketType.EPIC,
|
|
841
|
-
tags=[f"team:{team}" for team in teams],
|
|
842
|
-
created_at=(
|
|
843
|
-
datetime.fromisoformat(project["createdAt"].replace("Z", "+00:00"))
|
|
844
|
-
if project.get("createdAt")
|
|
845
|
-
else None
|
|
846
|
-
),
|
|
847
|
-
updated_at=(
|
|
848
|
-
datetime.fromisoformat(project["updatedAt"].replace("Z", "+00:00"))
|
|
849
|
-
if project.get("updatedAt")
|
|
850
|
-
else None
|
|
851
|
-
),
|
|
852
|
-
metadata=metadata,
|
|
853
|
-
)
|
|
854
|
-
|
|
855
|
-
async def create(self, ticket: Union[Epic, Task]) -> Union[Epic, Task]:
|
|
856
|
-
"""Create a new Linear issue or project with full field support."""
|
|
857
|
-
# Validate credentials before attempting operation
|
|
858
|
-
is_valid, error_message = self.validate_credentials()
|
|
859
|
-
if not is_valid:
|
|
860
|
-
raise ValueError(error_message)
|
|
861
|
-
|
|
862
|
-
# Handle Epic creation (Linear Projects)
|
|
863
|
-
if isinstance(ticket, Epic):
|
|
864
|
-
return await self.create_epic(
|
|
865
|
-
title=ticket.title,
|
|
866
|
-
description=ticket.description,
|
|
867
|
-
tags=ticket.tags,
|
|
868
|
-
priority=ticket.priority
|
|
869
|
-
)
|
|
870
|
-
|
|
871
|
-
team_id = await self._ensure_team_id()
|
|
872
|
-
states = await self._get_workflow_states()
|
|
873
|
-
|
|
874
|
-
# Map state to Linear state ID
|
|
875
|
-
linear_state_type = self._map_state_to_linear(ticket.state)
|
|
876
|
-
state_data = states.get(linear_state_type)
|
|
877
|
-
if not state_data:
|
|
878
|
-
# Fallback to backlog state
|
|
879
|
-
state_data = states.get("backlog")
|
|
880
|
-
state_id = state_data["id"] if state_data else None
|
|
881
|
-
|
|
882
|
-
# Build issue input
|
|
883
|
-
issue_input = {
|
|
884
|
-
"title": ticket.title,
|
|
885
|
-
"teamId": team_id,
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
if ticket.description:
|
|
889
|
-
issue_input["description"] = ticket.description
|
|
890
|
-
|
|
891
|
-
if state_id:
|
|
892
|
-
issue_input["stateId"] = state_id
|
|
893
|
-
|
|
894
|
-
# Set priority
|
|
895
|
-
if ticket.priority:
|
|
896
|
-
issue_input["priority"] = LinearPriorityMapping.TO_LINEAR.get(
|
|
897
|
-
ticket.priority, 3
|
|
898
|
-
)
|
|
899
|
-
|
|
900
|
-
# Handle labels/tags
|
|
901
|
-
if ticket.tags:
|
|
902
|
-
label_ids = []
|
|
903
|
-
for tag in ticket.tags:
|
|
904
|
-
# Add special state labels if needed
|
|
905
|
-
if ticket.state == TicketState.BLOCKED and "blocked" not in [
|
|
906
|
-
t.lower() for t in ticket.tags
|
|
907
|
-
]:
|
|
908
|
-
label_ids.append(
|
|
909
|
-
await self._get_or_create_label("blocked", "#FF0000")
|
|
910
|
-
)
|
|
911
|
-
elif ticket.state == TicketState.WAITING and "waiting" not in [
|
|
912
|
-
t.lower() for t in ticket.tags
|
|
913
|
-
]:
|
|
914
|
-
label_ids.append(
|
|
915
|
-
await self._get_or_create_label("waiting", "#FFA500")
|
|
916
|
-
)
|
|
917
|
-
elif ticket.state == TicketState.READY and "ready" not in [
|
|
918
|
-
t.lower() for t in ticket.tags
|
|
919
|
-
]:
|
|
920
|
-
label_ids.append(
|
|
921
|
-
await self._get_or_create_label("ready", "#00FF00")
|
|
922
|
-
)
|
|
923
|
-
|
|
924
|
-
label_id = await self._get_or_create_label(tag)
|
|
925
|
-
label_ids.append(label_id)
|
|
926
|
-
if label_ids:
|
|
927
|
-
issue_input["labelIds"] = label_ids
|
|
928
|
-
|
|
929
|
-
# Handle assignee
|
|
930
|
-
if ticket.assignee:
|
|
931
|
-
user_id = await self._get_user_id(ticket.assignee)
|
|
932
|
-
if user_id:
|
|
933
|
-
issue_input["assigneeId"] = user_id
|
|
934
|
-
|
|
935
|
-
# Handle estimate (Linear uses integer points, so we round hours)
|
|
936
|
-
if ticket.estimated_hours:
|
|
937
|
-
issue_input["estimate"] = int(round(ticket.estimated_hours))
|
|
938
|
-
|
|
939
|
-
# Handle parent issue
|
|
940
|
-
if ticket.parent_issue:
|
|
941
|
-
# Get parent issue's Linear ID
|
|
942
|
-
parent_query = gql(
|
|
943
|
-
"""
|
|
944
|
-
query GetIssue($identifier: String!) {
|
|
945
|
-
issue(id: $identifier) {
|
|
946
|
-
id
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
"""
|
|
950
|
-
)
|
|
951
|
-
client = self._create_client()
|
|
952
|
-
async with client as session:
|
|
953
|
-
parent_result = await session.execute(
|
|
954
|
-
parent_query, variable_values={"identifier": ticket.parent_issue}
|
|
955
|
-
)
|
|
956
|
-
if parent_result.get("issue"):
|
|
957
|
-
issue_input["parentId"] = parent_result["issue"]["id"]
|
|
958
|
-
|
|
959
|
-
# Handle project (epic)
|
|
960
|
-
if ticket.parent_epic:
|
|
961
|
-
issue_input["projectId"] = ticket.parent_epic
|
|
962
|
-
|
|
963
|
-
# Handle metadata fields
|
|
964
|
-
if ticket.metadata and "linear" in ticket.metadata:
|
|
965
|
-
linear_meta = ticket.metadata["linear"]
|
|
966
|
-
if "due_date" in linear_meta:
|
|
967
|
-
issue_input["dueDate"] = linear_meta["due_date"]
|
|
968
|
-
if "cycle_id" in linear_meta:
|
|
969
|
-
issue_input["cycleId"] = linear_meta["cycle_id"]
|
|
970
|
-
|
|
971
|
-
# Create issue mutation with full fields
|
|
972
|
-
create_query = gql(
|
|
973
|
-
ALL_FRAGMENTS
|
|
974
|
-
+ """
|
|
975
|
-
mutation CreateIssue($input: IssueCreateInput!) {
|
|
976
|
-
issueCreate(input: $input) {
|
|
977
|
-
success
|
|
978
|
-
issue {
|
|
979
|
-
...IssueFullFields
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
"""
|
|
984
|
-
)
|
|
985
|
-
|
|
986
|
-
client = self._create_client()
|
|
987
|
-
async with client as session:
|
|
988
|
-
result = await session.execute(
|
|
989
|
-
create_query, variable_values={"input": issue_input}
|
|
990
|
-
)
|
|
991
|
-
|
|
992
|
-
if not result["issueCreate"]["success"]:
|
|
993
|
-
raise Exception("Failed to create Linear issue")
|
|
994
|
-
|
|
995
|
-
created_issue = result["issueCreate"]["issue"]
|
|
996
|
-
return self._task_from_linear_issue(created_issue)
|
|
997
|
-
|
|
998
|
-
async def create_epic(self, title: str, description: str = None, **kwargs) -> Task:
|
|
999
|
-
"""Create a new epic (Linear project).
|
|
1000
|
-
|
|
1001
|
-
Args:
|
|
1002
|
-
title: Epic title
|
|
1003
|
-
description: Epic description
|
|
1004
|
-
**kwargs: Additional epic properties
|
|
1005
|
-
|
|
1006
|
-
Returns:
|
|
1007
|
-
Created Task instance representing the epic
|
|
1008
|
-
"""
|
|
1009
|
-
# In Linear, epics are represented as issues with special labels/properties
|
|
1010
|
-
task = Task(
|
|
1011
|
-
title=title,
|
|
1012
|
-
description=description,
|
|
1013
|
-
tags=kwargs.get('tags', []) + ['epic'], # Add epic tag
|
|
1014
|
-
**{k: v for k, v in kwargs.items() if k != 'tags'}
|
|
1015
|
-
)
|
|
1016
|
-
return await self.create(task)
|
|
1017
|
-
|
|
1018
|
-
async def create_issue(self, title: str, parent_epic: str = None, description: str = None, **kwargs) -> Task:
|
|
1019
|
-
"""Create a new issue.
|
|
1020
|
-
|
|
1021
|
-
Args:
|
|
1022
|
-
title: Issue title
|
|
1023
|
-
parent_epic: Parent epic ID
|
|
1024
|
-
description: Issue description
|
|
1025
|
-
**kwargs: Additional issue properties
|
|
1026
|
-
|
|
1027
|
-
Returns:
|
|
1028
|
-
Created Task instance representing the issue
|
|
1029
|
-
"""
|
|
1030
|
-
task = Task(
|
|
1031
|
-
title=title,
|
|
1032
|
-
description=description,
|
|
1033
|
-
parent_epic=parent_epic,
|
|
1034
|
-
**kwargs
|
|
1035
|
-
)
|
|
1036
|
-
return await self.create(task)
|
|
1037
|
-
|
|
1038
|
-
async def create_task(self, title: str, parent_id: str, description: str = None, **kwargs) -> Task:
|
|
1039
|
-
"""Create a new task under an issue.
|
|
1040
|
-
|
|
1041
|
-
Args:
|
|
1042
|
-
title: Task title
|
|
1043
|
-
parent_id: Parent issue ID
|
|
1044
|
-
description: Task description
|
|
1045
|
-
**kwargs: Additional task properties
|
|
1046
|
-
|
|
1047
|
-
Returns:
|
|
1048
|
-
Created Task instance
|
|
1049
|
-
"""
|
|
1050
|
-
task = Task(
|
|
1051
|
-
title=title,
|
|
1052
|
-
description=description,
|
|
1053
|
-
parent_issue=parent_id,
|
|
1054
|
-
**kwargs
|
|
1055
|
-
)
|
|
1056
|
-
return await self.create(task)
|
|
1057
|
-
|
|
1058
|
-
async def read(self, ticket_id: str) -> Optional[Task]:
|
|
1059
|
-
"""Read a Linear issue by identifier with full details."""
|
|
1060
|
-
# Validate credentials before attempting operation
|
|
1061
|
-
is_valid, error_message = self.validate_credentials()
|
|
1062
|
-
if not is_valid:
|
|
1063
|
-
raise ValueError(error_message)
|
|
1064
|
-
|
|
1065
|
-
query = gql(
|
|
1066
|
-
ALL_FRAGMENTS
|
|
1067
|
-
+ """
|
|
1068
|
-
query GetIssue($identifier: String!) {
|
|
1069
|
-
issue(id: $identifier) {
|
|
1070
|
-
...IssueFullFields
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
"""
|
|
1074
|
-
)
|
|
1075
|
-
|
|
1076
|
-
try:
|
|
1077
|
-
client = self._create_client()
|
|
1078
|
-
async with client as session:
|
|
1079
|
-
result = await session.execute(
|
|
1080
|
-
query, variable_values={"identifier": ticket_id}
|
|
1081
|
-
)
|
|
1082
|
-
|
|
1083
|
-
if result.get("issue"):
|
|
1084
|
-
return self._task_from_linear_issue(result["issue"])
|
|
1085
|
-
except TransportQueryError:
|
|
1086
|
-
# Issue not found
|
|
1087
|
-
pass
|
|
1088
|
-
|
|
1089
|
-
return None
|
|
1090
|
-
|
|
1091
|
-
async def update(self, ticket_id: str, updates: dict[str, Any]) -> Optional[Task]:
|
|
1092
|
-
"""Update a Linear issue with comprehensive field support."""
|
|
1093
|
-
# Validate credentials before attempting operation
|
|
1094
|
-
is_valid, error_message = self.validate_credentials()
|
|
1095
|
-
if not is_valid:
|
|
1096
|
-
raise ValueError(error_message)
|
|
1097
|
-
|
|
1098
|
-
# First get the Linear internal ID
|
|
1099
|
-
query = gql(
|
|
1100
|
-
"""
|
|
1101
|
-
query GetIssueId($identifier: String!) {
|
|
1102
|
-
issue(id: $identifier) {
|
|
1103
|
-
id
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
"""
|
|
1107
|
-
)
|
|
1108
|
-
|
|
1109
|
-
client = self._create_client()
|
|
1110
|
-
async with client as session:
|
|
1111
|
-
result = await session.execute(
|
|
1112
|
-
query, variable_values={"identifier": ticket_id}
|
|
1113
|
-
)
|
|
1114
|
-
|
|
1115
|
-
if not result.get("issue"):
|
|
1116
|
-
return None
|
|
1117
|
-
|
|
1118
|
-
linear_id = result["issue"]["id"]
|
|
1119
|
-
|
|
1120
|
-
# Build update input
|
|
1121
|
-
update_input = {}
|
|
1122
|
-
|
|
1123
|
-
if "title" in updates:
|
|
1124
|
-
update_input["title"] = updates["title"]
|
|
1125
|
-
|
|
1126
|
-
if "description" in updates:
|
|
1127
|
-
update_input["description"] = updates["description"]
|
|
1128
|
-
|
|
1129
|
-
if "priority" in updates:
|
|
1130
|
-
priority = updates["priority"]
|
|
1131
|
-
if isinstance(priority, str):
|
|
1132
|
-
priority = Priority(priority)
|
|
1133
|
-
update_input["priority"] = LinearPriorityMapping.TO_LINEAR.get(priority, 3)
|
|
1134
|
-
|
|
1135
|
-
if "state" in updates:
|
|
1136
|
-
states = await self._get_workflow_states()
|
|
1137
|
-
state = updates["state"]
|
|
1138
|
-
if isinstance(state, str):
|
|
1139
|
-
state = TicketState(state)
|
|
1140
|
-
linear_state_type = self._map_state_to_linear(state)
|
|
1141
|
-
state_data = states.get(linear_state_type)
|
|
1142
|
-
if state_data:
|
|
1143
|
-
update_input["stateId"] = state_data["id"]
|
|
1144
|
-
|
|
1145
|
-
if "assignee" in updates:
|
|
1146
|
-
if updates["assignee"]:
|
|
1147
|
-
user_id = await self._get_user_id(updates["assignee"])
|
|
1148
|
-
if user_id:
|
|
1149
|
-
update_input["assigneeId"] = user_id
|
|
1150
|
-
else:
|
|
1151
|
-
update_input["assigneeId"] = None
|
|
1152
|
-
|
|
1153
|
-
if "tags" in updates:
|
|
1154
|
-
label_ids = []
|
|
1155
|
-
for tag in updates["tags"]:
|
|
1156
|
-
label_id = await self._get_or_create_label(tag)
|
|
1157
|
-
label_ids.append(label_id)
|
|
1158
|
-
update_input["labelIds"] = label_ids
|
|
1159
|
-
|
|
1160
|
-
if "estimated_hours" in updates:
|
|
1161
|
-
update_input["estimate"] = int(round(updates["estimated_hours"]))
|
|
1162
|
-
|
|
1163
|
-
# Handle metadata updates
|
|
1164
|
-
if "metadata" in updates and "linear" in updates["metadata"]:
|
|
1165
|
-
linear_meta = updates["metadata"]["linear"]
|
|
1166
|
-
if "due_date" in linear_meta:
|
|
1167
|
-
update_input["dueDate"] = linear_meta["due_date"]
|
|
1168
|
-
if "cycle_id" in linear_meta:
|
|
1169
|
-
update_input["cycleId"] = linear_meta["cycle_id"]
|
|
1170
|
-
if "project_id" in linear_meta:
|
|
1171
|
-
update_input["projectId"] = linear_meta["project_id"]
|
|
1172
|
-
|
|
1173
|
-
# Update mutation
|
|
1174
|
-
update_query = gql(
|
|
1175
|
-
ALL_FRAGMENTS
|
|
1176
|
-
+ """
|
|
1177
|
-
mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
|
|
1178
|
-
issueUpdate(id: $id, input: $input) {
|
|
1179
|
-
success
|
|
1180
|
-
issue {
|
|
1181
|
-
...IssueFullFields
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
"""
|
|
1186
|
-
)
|
|
1187
|
-
|
|
1188
|
-
client = self._create_client()
|
|
1189
|
-
async with client as session:
|
|
1190
|
-
result = await session.execute(
|
|
1191
|
-
update_query, variable_values={"id": linear_id, "input": update_input}
|
|
1192
|
-
)
|
|
1193
|
-
|
|
1194
|
-
if result["issueUpdate"]["success"]:
|
|
1195
|
-
return self._task_from_linear_issue(result["issueUpdate"]["issue"])
|
|
1196
|
-
|
|
1197
|
-
return None
|
|
1198
|
-
|
|
1199
|
-
async def delete(self, ticket_id: str) -> bool:
|
|
1200
|
-
"""Archive (soft delete) a Linear issue."""
|
|
1201
|
-
# Validate credentials before attempting operation
|
|
1202
|
-
is_valid, error_message = self.validate_credentials()
|
|
1203
|
-
if not is_valid:
|
|
1204
|
-
raise ValueError(error_message)
|
|
1205
|
-
|
|
1206
|
-
# Get Linear ID
|
|
1207
|
-
query = gql(
|
|
1208
|
-
"""
|
|
1209
|
-
query GetIssueId($identifier: String!) {
|
|
1210
|
-
issue(id: $identifier) {
|
|
1211
|
-
id
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
"""
|
|
1215
|
-
)
|
|
1216
|
-
|
|
1217
|
-
client = self._create_client()
|
|
1218
|
-
async with client as session:
|
|
1219
|
-
result = await session.execute(
|
|
1220
|
-
query, variable_values={"identifier": ticket_id}
|
|
1221
|
-
)
|
|
1222
|
-
|
|
1223
|
-
if not result.get("issue"):
|
|
1224
|
-
return False
|
|
1225
|
-
|
|
1226
|
-
linear_id = result["issue"]["id"]
|
|
1227
|
-
|
|
1228
|
-
# Archive mutation
|
|
1229
|
-
archive_query = gql(
|
|
1230
|
-
"""
|
|
1231
|
-
mutation ArchiveIssue($id: String!) {
|
|
1232
|
-
issueArchive(id: $id) {
|
|
1233
|
-
success
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1236
|
-
"""
|
|
1237
|
-
)
|
|
1238
|
-
|
|
1239
|
-
client = self._create_client()
|
|
1240
|
-
async with client as session:
|
|
1241
|
-
result = await session.execute(
|
|
1242
|
-
archive_query, variable_values={"id": linear_id}
|
|
1243
|
-
)
|
|
1244
|
-
|
|
1245
|
-
return result.get("issueArchive", {}).get("success", False)
|
|
1246
|
-
|
|
1247
|
-
async def list(
|
|
1248
|
-
self, limit: int = 10, offset: int = 0, filters: Optional[dict[str, Any]] = None
|
|
1249
|
-
) -> list[Task]:
|
|
1250
|
-
"""List Linear issues with comprehensive filtering."""
|
|
1251
|
-
team_id = await self._ensure_team_id()
|
|
1252
|
-
|
|
1253
|
-
# Build filter
|
|
1254
|
-
issue_filter = {"team": {"id": {"eq": team_id}}}
|
|
1255
|
-
|
|
1256
|
-
if filters:
|
|
1257
|
-
# State filter
|
|
1258
|
-
if "state" in filters:
|
|
1259
|
-
state = filters["state"]
|
|
1260
|
-
if isinstance(state, str):
|
|
1261
|
-
state = TicketState(state)
|
|
1262
|
-
# Map to Linear state types
|
|
1263
|
-
state_mapping = {
|
|
1264
|
-
TicketState.OPEN: ["backlog", "unstarted"],
|
|
1265
|
-
TicketState.IN_PROGRESS: ["started"],
|
|
1266
|
-
TicketState.DONE: ["completed"],
|
|
1267
|
-
TicketState.CLOSED: ["canceled"],
|
|
1268
|
-
}
|
|
1269
|
-
if state in state_mapping:
|
|
1270
|
-
issue_filter["state"] = {"type": {"in": state_mapping[state]}}
|
|
1271
|
-
|
|
1272
|
-
# Priority filter
|
|
1273
|
-
if "priority" in filters:
|
|
1274
|
-
priority = filters["priority"]
|
|
1275
|
-
if isinstance(priority, str):
|
|
1276
|
-
priority = Priority(priority)
|
|
1277
|
-
linear_priority = LinearPriorityMapping.TO_LINEAR.get(priority, 3)
|
|
1278
|
-
issue_filter["priority"] = {"eq": linear_priority}
|
|
1279
|
-
|
|
1280
|
-
# Assignee filter
|
|
1281
|
-
if "assignee" in filters and filters["assignee"]:
|
|
1282
|
-
user_id = await self._get_user_id(filters["assignee"])
|
|
1283
|
-
if user_id:
|
|
1284
|
-
issue_filter["assignee"] = {"id": {"eq": user_id}}
|
|
1285
|
-
|
|
1286
|
-
# Project filter
|
|
1287
|
-
if "project_id" in filters:
|
|
1288
|
-
issue_filter["project"] = {"id": {"eq": filters["project_id"]}}
|
|
1289
|
-
|
|
1290
|
-
# Cycle filter
|
|
1291
|
-
if "cycle_id" in filters:
|
|
1292
|
-
issue_filter["cycle"] = {"id": {"eq": filters["cycle_id"]}}
|
|
1293
|
-
|
|
1294
|
-
# Label filter
|
|
1295
|
-
if "labels" in filters:
|
|
1296
|
-
issue_filter["labels"] = {"some": {"name": {"in": filters["labels"]}}}
|
|
1297
|
-
|
|
1298
|
-
# Parent filter
|
|
1299
|
-
if "parent_id" in filters:
|
|
1300
|
-
issue_filter["parent"] = {"identifier": {"eq": filters["parent_id"]}}
|
|
1301
|
-
|
|
1302
|
-
# Date filters
|
|
1303
|
-
if "created_after" in filters:
|
|
1304
|
-
issue_filter["createdAt"] = {"gte": filters["created_after"]}
|
|
1305
|
-
if "updated_after" in filters:
|
|
1306
|
-
issue_filter["updatedAt"] = {"gte": filters["updated_after"]}
|
|
1307
|
-
if "due_before" in filters:
|
|
1308
|
-
issue_filter["dueDate"] = {"lte": filters["due_before"]}
|
|
1309
|
-
|
|
1310
|
-
# Exclude archived issues by default
|
|
1311
|
-
if (
|
|
1312
|
-
not filters
|
|
1313
|
-
or "includeArchived" not in filters
|
|
1314
|
-
or not filters["includeArchived"]
|
|
1315
|
-
):
|
|
1316
|
-
issue_filter["archivedAt"] = {"null": True}
|
|
1317
|
-
|
|
1318
|
-
query = gql(
|
|
1319
|
-
ISSUE_LIST_FRAGMENTS
|
|
1320
|
-
+ """
|
|
1321
|
-
query ListIssues($filter: IssueFilter, $first: Int!) {
|
|
1322
|
-
issues(
|
|
1323
|
-
filter: $filter
|
|
1324
|
-
first: $first
|
|
1325
|
-
orderBy: updatedAt
|
|
1326
|
-
) {
|
|
1327
|
-
nodes {
|
|
1328
|
-
...IssueCompactFields
|
|
1329
|
-
}
|
|
1330
|
-
pageInfo {
|
|
1331
|
-
hasNextPage
|
|
1332
|
-
hasPreviousPage
|
|
1333
|
-
}
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
"""
|
|
1337
|
-
)
|
|
1338
|
-
|
|
1339
|
-
client = self._create_client()
|
|
1340
|
-
async with client as session:
|
|
1341
|
-
result = await session.execute(
|
|
1342
|
-
query,
|
|
1343
|
-
variable_values={
|
|
1344
|
-
"filter": issue_filter,
|
|
1345
|
-
"first": limit,
|
|
1346
|
-
# Note: Linear uses cursor-based pagination, not offset
|
|
1347
|
-
# For simplicity, we ignore offset here
|
|
1348
|
-
},
|
|
1349
|
-
)
|
|
1350
|
-
|
|
1351
|
-
tasks = []
|
|
1352
|
-
for issue in result["issues"]["nodes"]:
|
|
1353
|
-
tasks.append(self._task_from_linear_issue(issue))
|
|
1354
|
-
|
|
1355
|
-
return tasks
|
|
1356
|
-
|
|
1357
|
-
async def search(self, query: SearchQuery) -> builtins.list[Task]:
|
|
1358
|
-
"""Search Linear issues with advanced filtering and text search."""
|
|
1359
|
-
team_id = await self._ensure_team_id()
|
|
1360
|
-
|
|
1361
|
-
# Build filter
|
|
1362
|
-
issue_filter = {"team": {"id": {"eq": team_id}}}
|
|
1363
|
-
|
|
1364
|
-
# Text search in title and description
|
|
1365
|
-
if query.query:
|
|
1366
|
-
issue_filter["or"] = [
|
|
1367
|
-
{"title": {"containsIgnoreCase": query.query}},
|
|
1368
|
-
{"description": {"containsIgnoreCase": query.query}},
|
|
1369
|
-
]
|
|
1370
|
-
|
|
1371
|
-
# State filter
|
|
1372
|
-
if query.state:
|
|
1373
|
-
state_mapping = {
|
|
1374
|
-
TicketState.OPEN: ["backlog", "unstarted"],
|
|
1375
|
-
TicketState.IN_PROGRESS: ["started"],
|
|
1376
|
-
TicketState.DONE: ["completed"],
|
|
1377
|
-
TicketState.CLOSED: ["canceled"],
|
|
1378
|
-
}
|
|
1379
|
-
if query.state in state_mapping:
|
|
1380
|
-
issue_filter["state"] = {"type": {"in": state_mapping[query.state]}}
|
|
1381
|
-
|
|
1382
|
-
# Priority filter
|
|
1383
|
-
if query.priority:
|
|
1384
|
-
linear_priority = LinearPriorityMapping.TO_LINEAR.get(query.priority, 3)
|
|
1385
|
-
issue_filter["priority"] = {"eq": linear_priority}
|
|
1386
|
-
|
|
1387
|
-
# Assignee filter
|
|
1388
|
-
if query.assignee:
|
|
1389
|
-
user_id = await self._get_user_id(query.assignee)
|
|
1390
|
-
if user_id:
|
|
1391
|
-
issue_filter["assignee"] = {"id": {"eq": user_id}}
|
|
1392
|
-
|
|
1393
|
-
# Tags filter (labels in Linear)
|
|
1394
|
-
if query.tags:
|
|
1395
|
-
issue_filter["labels"] = {"some": {"name": {"in": query.tags}}}
|
|
1396
|
-
|
|
1397
|
-
# Exclude archived
|
|
1398
|
-
issue_filter["archivedAt"] = {"null": True}
|
|
1399
|
-
|
|
1400
|
-
search_query = gql(
|
|
1401
|
-
ISSUE_LIST_FRAGMENTS
|
|
1402
|
-
+ """
|
|
1403
|
-
query SearchIssues($filter: IssueFilter, $first: Int!) {
|
|
1404
|
-
issues(
|
|
1405
|
-
filter: $filter
|
|
1406
|
-
first: $first
|
|
1407
|
-
orderBy: updatedAt
|
|
1408
|
-
) {
|
|
1409
|
-
nodes {
|
|
1410
|
-
...IssueCompactFields
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
"""
|
|
1415
|
-
)
|
|
1416
|
-
|
|
1417
|
-
client = self._create_client()
|
|
1418
|
-
async with client as session:
|
|
1419
|
-
result = await session.execute(
|
|
1420
|
-
search_query,
|
|
1421
|
-
variable_values={
|
|
1422
|
-
"filter": issue_filter,
|
|
1423
|
-
"first": query.limit,
|
|
1424
|
-
# Note: Linear uses cursor-based pagination, not offset
|
|
1425
|
-
},
|
|
1426
|
-
)
|
|
1427
|
-
|
|
1428
|
-
tasks = []
|
|
1429
|
-
for issue in result["issues"]["nodes"]:
|
|
1430
|
-
tasks.append(self._task_from_linear_issue(issue))
|
|
1431
|
-
|
|
1432
|
-
return tasks
|
|
1433
|
-
|
|
1434
|
-
async def transition_state(
|
|
1435
|
-
self, ticket_id: str, target_state: TicketState
|
|
1436
|
-
) -> Optional[Task]:
|
|
1437
|
-
"""Transition Linear issue to new state with workflow validation."""
|
|
1438
|
-
# Validate transition
|
|
1439
|
-
if not await self.validate_transition(ticket_id, target_state):
|
|
1440
|
-
return None
|
|
1441
|
-
|
|
1442
|
-
# Update state
|
|
1443
|
-
return await self.update(ticket_id, {"state": target_state})
|
|
1444
|
-
|
|
1445
|
-
async def add_comment(self, comment: Comment) -> Comment:
|
|
1446
|
-
"""Add comment to a Linear issue."""
|
|
1447
|
-
# Get Linear issue ID
|
|
1448
|
-
query = gql(
|
|
1449
|
-
"""
|
|
1450
|
-
query GetIssueId($identifier: String!) {
|
|
1451
|
-
issue(id: $identifier) {
|
|
1452
|
-
id
|
|
1453
|
-
}
|
|
1454
|
-
}
|
|
1455
|
-
"""
|
|
1456
|
-
)
|
|
1457
|
-
|
|
1458
|
-
client = self._create_client()
|
|
1459
|
-
async with client as session:
|
|
1460
|
-
result = await session.execute(
|
|
1461
|
-
query, variable_values={"identifier": comment.ticket_id}
|
|
1462
|
-
)
|
|
1463
|
-
|
|
1464
|
-
if not result.get("issue"):
|
|
1465
|
-
raise ValueError(f"Issue {comment.ticket_id} not found")
|
|
1466
|
-
|
|
1467
|
-
linear_id = result["issue"]["id"]
|
|
1468
|
-
|
|
1469
|
-
# Create comment mutation (only include needed fragments)
|
|
1470
|
-
create_comment_query = gql(
|
|
1471
|
-
USER_FRAGMENT
|
|
1472
|
-
+ COMMENT_FRAGMENT
|
|
1473
|
-
+ """
|
|
1474
|
-
mutation CreateComment($input: CommentCreateInput!) {
|
|
1475
|
-
commentCreate(input: $input) {
|
|
1476
|
-
success
|
|
1477
|
-
comment {
|
|
1478
|
-
...CommentFields
|
|
1479
|
-
}
|
|
1480
|
-
}
|
|
1481
|
-
}
|
|
1482
|
-
"""
|
|
1483
|
-
)
|
|
1484
|
-
|
|
1485
|
-
comment_input = {
|
|
1486
|
-
"issueId": linear_id,
|
|
1487
|
-
"body": comment.content,
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
# Handle parent comment for threading
|
|
1491
|
-
if comment.metadata and "parent_comment_id" in comment.metadata:
|
|
1492
|
-
comment_input["parentId"] = comment.metadata["parent_comment_id"]
|
|
1493
|
-
|
|
1494
|
-
client = self._create_client()
|
|
1495
|
-
async with client as session:
|
|
1496
|
-
result = await session.execute(
|
|
1497
|
-
create_comment_query, variable_values={"input": comment_input}
|
|
1498
|
-
)
|
|
1499
|
-
|
|
1500
|
-
if not result["commentCreate"]["success"]:
|
|
1501
|
-
raise Exception("Failed to create comment")
|
|
1502
|
-
|
|
1503
|
-
created_comment = result["commentCreate"]["comment"]
|
|
1504
|
-
|
|
1505
|
-
return Comment(
|
|
1506
|
-
id=created_comment["id"],
|
|
1507
|
-
ticket_id=comment.ticket_id,
|
|
1508
|
-
author=(
|
|
1509
|
-
created_comment["user"]["email"]
|
|
1510
|
-
if created_comment.get("user")
|
|
1511
|
-
else None
|
|
1512
|
-
),
|
|
1513
|
-
content=created_comment["body"],
|
|
1514
|
-
created_at=datetime.fromisoformat(
|
|
1515
|
-
created_comment["createdAt"].replace("Z", "+00:00")
|
|
1516
|
-
),
|
|
1517
|
-
metadata={
|
|
1518
|
-
"linear": {
|
|
1519
|
-
"id": created_comment["id"],
|
|
1520
|
-
"parent_id": (
|
|
1521
|
-
created_comment.get("parent", {}).get("id")
|
|
1522
|
-
if created_comment.get("parent")
|
|
1523
|
-
else None
|
|
1524
|
-
),
|
|
1525
|
-
}
|
|
1526
|
-
},
|
|
1527
|
-
)
|
|
1528
|
-
|
|
1529
|
-
async def get_comments(
|
|
1530
|
-
self, ticket_id: str, limit: int = 10, offset: int = 0
|
|
1531
|
-
) -> builtins.list[Comment]:
|
|
1532
|
-
"""Get comments for a Linear issue with pagination."""
|
|
1533
|
-
query = gql(
|
|
1534
|
-
USER_FRAGMENT
|
|
1535
|
-
+ COMMENT_FRAGMENT
|
|
1536
|
-
+ """
|
|
1537
|
-
query GetIssueComments($identifier: String!, $first: Int!) {
|
|
1538
|
-
issue(id: $identifier) {
|
|
1539
|
-
comments(first: $first, orderBy: createdAt) {
|
|
1540
|
-
nodes {
|
|
1541
|
-
...CommentFields
|
|
1542
|
-
}
|
|
1543
|
-
}
|
|
1544
|
-
}
|
|
1545
|
-
}
|
|
1546
|
-
"""
|
|
1547
|
-
)
|
|
1548
|
-
|
|
1549
|
-
try:
|
|
1550
|
-
client = self._create_client()
|
|
1551
|
-
async with client as session:
|
|
1552
|
-
result = await session.execute(
|
|
1553
|
-
query,
|
|
1554
|
-
variable_values={
|
|
1555
|
-
"identifier": ticket_id,
|
|
1556
|
-
"first": limit,
|
|
1557
|
-
# Note: Linear uses cursor-based pagination
|
|
1558
|
-
},
|
|
1559
|
-
)
|
|
1560
|
-
|
|
1561
|
-
if not result.get("issue"):
|
|
1562
|
-
return []
|
|
1563
|
-
|
|
1564
|
-
comments = []
|
|
1565
|
-
for comment_data in result["issue"]["comments"]["nodes"]:
|
|
1566
|
-
comments.append(
|
|
1567
|
-
Comment(
|
|
1568
|
-
id=comment_data["id"],
|
|
1569
|
-
ticket_id=ticket_id,
|
|
1570
|
-
author=(
|
|
1571
|
-
comment_data["user"]["email"]
|
|
1572
|
-
if comment_data.get("user")
|
|
1573
|
-
else None
|
|
1574
|
-
),
|
|
1575
|
-
content=comment_data["body"],
|
|
1576
|
-
created_at=datetime.fromisoformat(
|
|
1577
|
-
comment_data["createdAt"].replace("Z", "+00:00")
|
|
1578
|
-
),
|
|
1579
|
-
metadata={
|
|
1580
|
-
"linear": {
|
|
1581
|
-
"id": comment_data["id"],
|
|
1582
|
-
"parent_id": (
|
|
1583
|
-
comment_data.get("parent", {}).get("id")
|
|
1584
|
-
if comment_data.get("parent")
|
|
1585
|
-
else None
|
|
1586
|
-
),
|
|
1587
|
-
}
|
|
1588
|
-
},
|
|
1589
|
-
)
|
|
1590
|
-
)
|
|
1591
|
-
|
|
1592
|
-
return comments
|
|
1593
|
-
except TransportQueryError:
|
|
1594
|
-
return []
|
|
1595
|
-
|
|
1596
|
-
async def create_project(self, name: str, description: Optional[str] = None) -> str:
|
|
1597
|
-
"""Create a Linear project."""
|
|
1598
|
-
team_id = await self._ensure_team_id()
|
|
1599
|
-
|
|
1600
|
-
create_query = gql(
|
|
1601
|
-
"""
|
|
1602
|
-
mutation CreateProject($input: ProjectCreateInput!) {
|
|
1603
|
-
projectCreate(input: $input) {
|
|
1604
|
-
success
|
|
1605
|
-
project {
|
|
1606
|
-
id
|
|
1607
|
-
name
|
|
1608
|
-
}
|
|
1609
|
-
}
|
|
1610
|
-
}
|
|
1611
|
-
"""
|
|
1612
|
-
)
|
|
1613
|
-
|
|
1614
|
-
project_input = {
|
|
1615
|
-
"name": name,
|
|
1616
|
-
"teamIds": [team_id],
|
|
1617
|
-
}
|
|
1618
|
-
if description:
|
|
1619
|
-
project_input["description"] = description
|
|
1620
|
-
|
|
1621
|
-
client = self._create_client()
|
|
1622
|
-
async with client as session:
|
|
1623
|
-
result = await session.execute(
|
|
1624
|
-
create_query, variable_values={"input": project_input}
|
|
1625
|
-
)
|
|
1626
|
-
|
|
1627
|
-
if not result["projectCreate"]["success"]:
|
|
1628
|
-
raise Exception("Failed to create project")
|
|
1629
|
-
|
|
1630
|
-
return result["projectCreate"]["project"]["id"]
|
|
1631
|
-
|
|
1632
|
-
async def get_cycles(
|
|
1633
|
-
self, active_only: bool = True
|
|
1634
|
-
) -> builtins.list[dict[str, Any]]:
|
|
1635
|
-
"""Get Linear cycles (sprints) for the team."""
|
|
1636
|
-
team_id = await self._ensure_team_id()
|
|
1637
|
-
|
|
1638
|
-
cycle_filter = {"team": {"id": {"eq": team_id}}}
|
|
1639
|
-
if active_only:
|
|
1640
|
-
cycle_filter["isActive"] = {"eq": True}
|
|
1641
|
-
|
|
1642
|
-
query = gql(
|
|
1643
|
-
"""
|
|
1644
|
-
query GetCycles($filter: CycleFilter) {
|
|
1645
|
-
cycles(filter: $filter, orderBy: createdAt) {
|
|
1646
|
-
nodes {
|
|
1647
|
-
id
|
|
1648
|
-
number
|
|
1649
|
-
name
|
|
1650
|
-
description
|
|
1651
|
-
startsAt
|
|
1652
|
-
endsAt
|
|
1653
|
-
completedAt
|
|
1654
|
-
issues {
|
|
1655
|
-
nodes {
|
|
1656
|
-
id
|
|
1657
|
-
identifier
|
|
1658
|
-
}
|
|
1659
|
-
}
|
|
1660
|
-
}
|
|
1661
|
-
}
|
|
1662
|
-
}
|
|
1663
|
-
"""
|
|
1664
|
-
)
|
|
1665
|
-
|
|
1666
|
-
client = self._create_client()
|
|
1667
|
-
async with client as session:
|
|
1668
|
-
result = await session.execute(
|
|
1669
|
-
query, variable_values={"filter": cycle_filter}
|
|
1670
|
-
)
|
|
1671
|
-
|
|
1672
|
-
return result["cycles"]["nodes"]
|
|
1673
|
-
|
|
1674
|
-
async def add_to_cycle(self, ticket_id: str, cycle_id: str) -> bool:
|
|
1675
|
-
"""Add an issue to a cycle."""
|
|
1676
|
-
return (
|
|
1677
|
-
await self.update(
|
|
1678
|
-
ticket_id, {"metadata": {"linear": {"cycle_id": cycle_id}}}
|
|
1679
|
-
)
|
|
1680
|
-
is not None
|
|
1681
|
-
)
|
|
1682
|
-
|
|
1683
|
-
async def set_due_date(self, ticket_id: str, due_date: Union[str, date]) -> bool:
|
|
1684
|
-
"""Set due date for an issue."""
|
|
1685
|
-
if isinstance(due_date, date):
|
|
1686
|
-
due_date = due_date.isoformat()
|
|
1687
|
-
|
|
1688
|
-
return (
|
|
1689
|
-
await self.update(
|
|
1690
|
-
ticket_id, {"metadata": {"linear": {"due_date": due_date}}}
|
|
1691
|
-
)
|
|
1692
|
-
is not None
|
|
1693
|
-
)
|
|
1694
|
-
|
|
1695
|
-
async def add_reaction(self, comment_id: str, emoji: str) -> bool:
|
|
1696
|
-
"""Add reaction to a comment."""
|
|
1697
|
-
create_query = gql(
|
|
1698
|
-
"""
|
|
1699
|
-
mutation CreateReaction($input: ReactionCreateInput!) {
|
|
1700
|
-
reactionCreate(input: $input) {
|
|
1701
|
-
success
|
|
1702
|
-
}
|
|
1703
|
-
}
|
|
1704
|
-
"""
|
|
1705
|
-
)
|
|
1706
|
-
|
|
1707
|
-
client = self._create_client()
|
|
1708
|
-
async with client as session:
|
|
1709
|
-
result = await session.execute(
|
|
1710
|
-
create_query,
|
|
1711
|
-
variable_values={
|
|
1712
|
-
"input": {
|
|
1713
|
-
"commentId": comment_id,
|
|
1714
|
-
"emoji": emoji,
|
|
1715
|
-
}
|
|
1716
|
-
},
|
|
1717
|
-
)
|
|
1718
|
-
|
|
1719
|
-
return result.get("reactionCreate", {}).get("success", False)
|
|
1720
|
-
|
|
1721
|
-
async def link_to_pull_request(
|
|
1722
|
-
self,
|
|
1723
|
-
ticket_id: str,
|
|
1724
|
-
pr_url: str,
|
|
1725
|
-
pr_number: Optional[int] = None,
|
|
1726
|
-
) -> dict[str, Any]:
|
|
1727
|
-
"""Link a Linear issue to a GitHub pull request.
|
|
1728
|
-
|
|
1729
|
-
Args:
|
|
1730
|
-
ticket_id: Linear issue identifier (e.g., 'BTA-123')
|
|
1731
|
-
pr_url: GitHub PR URL
|
|
1732
|
-
pr_number: Optional PR number (extracted from URL if not provided)
|
|
1733
|
-
|
|
1734
|
-
Returns:
|
|
1735
|
-
Dictionary with link status and details
|
|
1736
|
-
|
|
1737
|
-
"""
|
|
1738
|
-
# Parse PR URL to extract details
|
|
1739
|
-
import re
|
|
1740
|
-
|
|
1741
|
-
pr_pattern = r"github\.com/([^/]+)/([^/]+)/pull/(\d+)"
|
|
1742
|
-
match = re.search(pr_pattern, pr_url)
|
|
1743
|
-
|
|
1744
|
-
if not match:
|
|
1745
|
-
raise ValueError(f"Invalid GitHub PR URL format: {pr_url}")
|
|
1746
|
-
|
|
1747
|
-
owner, repo, extracted_pr_number = match.groups()
|
|
1748
|
-
if not pr_number:
|
|
1749
|
-
pr_number = int(extracted_pr_number)
|
|
1750
|
-
|
|
1751
|
-
# Create an attachment to link the PR
|
|
1752
|
-
create_query = gql(
|
|
1753
|
-
"""
|
|
1754
|
-
mutation CreateAttachment($input: AttachmentCreateInput!) {
|
|
1755
|
-
attachmentCreate(input: $input) {
|
|
1756
|
-
attachment {
|
|
1757
|
-
id
|
|
1758
|
-
url
|
|
1759
|
-
title
|
|
1760
|
-
subtitle
|
|
1761
|
-
source
|
|
1762
|
-
}
|
|
1763
|
-
success
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
|
-
"""
|
|
1767
|
-
)
|
|
1768
|
-
|
|
1769
|
-
# Get the issue ID from the identifier
|
|
1770
|
-
issue = await self.read(ticket_id)
|
|
1771
|
-
if not issue:
|
|
1772
|
-
raise ValueError(f"Issue {ticket_id} not found")
|
|
1773
|
-
|
|
1774
|
-
# Create attachment input
|
|
1775
|
-
attachment_input = {
|
|
1776
|
-
"issueId": issue.metadata.get("linear", {}).get("id"),
|
|
1777
|
-
"url": pr_url,
|
|
1778
|
-
"title": f"Pull Request #{pr_number}",
|
|
1779
|
-
"subtitle": f"{owner}/{repo}",
|
|
1780
|
-
"source": {
|
|
1781
|
-
"type": "githubPr",
|
|
1782
|
-
"data": {
|
|
1783
|
-
"number": pr_number,
|
|
1784
|
-
"owner": owner,
|
|
1785
|
-
"repo": repo,
|
|
1786
|
-
},
|
|
1787
|
-
},
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
|
-
client = self._create_client()
|
|
1791
|
-
async with client as session:
|
|
1792
|
-
result = await session.execute(
|
|
1793
|
-
create_query, variable_values={"input": attachment_input}
|
|
1794
|
-
)
|
|
1795
|
-
|
|
1796
|
-
if result.get("attachmentCreate", {}).get("success"):
|
|
1797
|
-
attachment = result["attachmentCreate"]["attachment"]
|
|
1798
|
-
|
|
1799
|
-
# Also add a comment about the PR link
|
|
1800
|
-
comment_text = f"Linked to GitHub PR: {pr_url}"
|
|
1801
|
-
await self.add_comment(
|
|
1802
|
-
Comment(
|
|
1803
|
-
ticket_id=ticket_id,
|
|
1804
|
-
content=comment_text,
|
|
1805
|
-
author="system",
|
|
1806
|
-
)
|
|
1807
|
-
)
|
|
1808
|
-
|
|
1809
|
-
return {
|
|
1810
|
-
"success": True,
|
|
1811
|
-
"attachment_id": attachment["id"],
|
|
1812
|
-
"pr_url": pr_url,
|
|
1813
|
-
"pr_number": pr_number,
|
|
1814
|
-
"linked_issue": ticket_id,
|
|
1815
|
-
"message": f"Successfully linked PR #{pr_number} to issue {ticket_id}",
|
|
1816
|
-
}
|
|
1817
|
-
else:
|
|
1818
|
-
return {
|
|
1819
|
-
"success": False,
|
|
1820
|
-
"pr_url": pr_url,
|
|
1821
|
-
"pr_number": pr_number,
|
|
1822
|
-
"linked_issue": ticket_id,
|
|
1823
|
-
"message": "Failed to create attachment link",
|
|
1824
|
-
}
|
|
1825
|
-
|
|
1826
|
-
async def create_pull_request_for_issue(
|
|
1827
|
-
self,
|
|
1828
|
-
ticket_id: str,
|
|
1829
|
-
github_config: dict[str, Any],
|
|
1830
|
-
) -> dict[str, Any]:
|
|
1831
|
-
"""Create a GitHub PR for a Linear issue using GitHub integration.
|
|
1832
|
-
|
|
1833
|
-
This requires GitHub integration to be configured in Linear.
|
|
1834
|
-
|
|
1835
|
-
Args:
|
|
1836
|
-
ticket_id: Linear issue identifier
|
|
1837
|
-
github_config: GitHub configuration including:
|
|
1838
|
-
- owner: GitHub repository owner
|
|
1839
|
-
- repo: GitHub repository name
|
|
1840
|
-
- base_branch: Target branch (default: main)
|
|
1841
|
-
- head_branch: Source branch (auto-generated if not provided)
|
|
1842
|
-
|
|
1843
|
-
Returns:
|
|
1844
|
-
Dictionary with PR creation status
|
|
1845
|
-
|
|
1846
|
-
"""
|
|
1847
|
-
# Get the issue details
|
|
1848
|
-
issue = await self.read(ticket_id)
|
|
1849
|
-
if not issue:
|
|
1850
|
-
raise ValueError(f"Issue {ticket_id} not found")
|
|
1851
|
-
|
|
1852
|
-
# Generate branch name if not provided
|
|
1853
|
-
head_branch = github_config.get("head_branch")
|
|
1854
|
-
if not head_branch:
|
|
1855
|
-
# Use Linear's branch naming convention
|
|
1856
|
-
# e.g., "bta-123-fix-authentication-bug"
|
|
1857
|
-
safe_title = "-".join(
|
|
1858
|
-
issue.title.lower()
|
|
1859
|
-
.replace("[", "")
|
|
1860
|
-
.replace("]", "")
|
|
1861
|
-
.replace("#", "")
|
|
1862
|
-
.replace("/", "-")
|
|
1863
|
-
.replace("\\", "-")
|
|
1864
|
-
.split()[:5] # Limit to 5 words
|
|
1865
|
-
)
|
|
1866
|
-
head_branch = f"{ticket_id.lower()}-{safe_title}"
|
|
1867
|
-
|
|
1868
|
-
# Update the issue with the branch name
|
|
1869
|
-
update_query = gql(
|
|
1870
|
-
"""
|
|
1871
|
-
mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
|
|
1872
|
-
issueUpdate(id: $id, input: $input) {
|
|
1873
|
-
issue {
|
|
1874
|
-
id
|
|
1875
|
-
identifier
|
|
1876
|
-
branchName
|
|
1877
|
-
}
|
|
1878
|
-
success
|
|
1879
|
-
}
|
|
1880
|
-
}
|
|
1881
|
-
"""
|
|
1882
|
-
)
|
|
1883
|
-
|
|
1884
|
-
linear_id = issue.metadata.get("linear", {}).get("id")
|
|
1885
|
-
if not linear_id:
|
|
1886
|
-
# Need to get the full issue ID
|
|
1887
|
-
search_result = await self._search_by_identifier(ticket_id)
|
|
1888
|
-
if not search_result:
|
|
1889
|
-
raise ValueError(f"Could not find Linear ID for issue {ticket_id}")
|
|
1890
|
-
linear_id = search_result["id"]
|
|
1891
|
-
|
|
1892
|
-
client = self._create_client()
|
|
1893
|
-
async with client as session:
|
|
1894
|
-
result = await session.execute(
|
|
1895
|
-
update_query,
|
|
1896
|
-
variable_values={"id": linear_id, "input": {"branchName": head_branch}},
|
|
1897
|
-
)
|
|
1898
|
-
|
|
1899
|
-
if result.get("issueUpdate", {}).get("success"):
|
|
1900
|
-
# Prepare PR metadata to return
|
|
1901
|
-
pr_metadata = {
|
|
1902
|
-
"branch_name": head_branch,
|
|
1903
|
-
"issue_id": ticket_id,
|
|
1904
|
-
"issue_title": issue.title,
|
|
1905
|
-
"issue_description": issue.description,
|
|
1906
|
-
"github_owner": github_config.get("owner"),
|
|
1907
|
-
"github_repo": github_config.get("repo"),
|
|
1908
|
-
"base_branch": github_config.get("base_branch", "main"),
|
|
1909
|
-
"message": f"Branch name '{head_branch}' set for issue {ticket_id}. Use GitHub integration or API to create the actual PR.",
|
|
1910
|
-
}
|
|
1911
|
-
|
|
1912
|
-
# Add a comment about the branch
|
|
1913
|
-
await self.add_comment(
|
|
1914
|
-
Comment(
|
|
1915
|
-
ticket_id=ticket_id,
|
|
1916
|
-
content=f"Branch created: `{head_branch}`\nReady for pull request to `{pr_metadata['base_branch']}`",
|
|
1917
|
-
author="system",
|
|
1918
|
-
)
|
|
1919
|
-
)
|
|
1920
|
-
|
|
1921
|
-
return pr_metadata
|
|
1922
|
-
else:
|
|
1923
|
-
raise ValueError(f"Failed to update issue {ticket_id} with branch name")
|
|
1924
|
-
|
|
1925
|
-
async def _search_by_identifier(self, identifier: str) -> Optional[dict[str, Any]]:
|
|
1926
|
-
"""Search for an issue by its identifier."""
|
|
1927
|
-
search_query = gql(
|
|
1928
|
-
"""
|
|
1929
|
-
query SearchIssue($identifier: String!) {
|
|
1930
|
-
issue(id: $identifier) {
|
|
1931
|
-
id
|
|
1932
|
-
identifier
|
|
1933
|
-
}
|
|
1934
|
-
}
|
|
1935
|
-
"""
|
|
1936
|
-
)
|
|
1937
|
-
|
|
1938
|
-
try:
|
|
1939
|
-
client = self._create_client()
|
|
1940
|
-
async with client as session:
|
|
1941
|
-
result = await session.execute(
|
|
1942
|
-
search_query, variable_values={"identifier": identifier}
|
|
1943
|
-
)
|
|
1944
|
-
return result.get("issue")
|
|
1945
|
-
except Exception:
|
|
1946
|
-
return None
|
|
1947
|
-
|
|
1948
|
-
# Epic/Issue/Task Hierarchy Methods (Linear: Project = Epic, Issue = Issue, Sub-issue = Task)
|
|
1949
|
-
|
|
1950
|
-
async def create_epic(
|
|
1951
|
-
self, title: str, description: Optional[str] = None, **kwargs
|
|
1952
|
-
) -> Optional[Epic]:
|
|
1953
|
-
"""Create epic (Linear Project).
|
|
1954
|
-
|
|
1955
|
-
Args:
|
|
1956
|
-
title: Epic/Project name
|
|
1957
|
-
description: Epic/Project description
|
|
1958
|
-
**kwargs: Additional fields (e.g., target_date, lead_id)
|
|
1959
|
-
|
|
1960
|
-
Returns:
|
|
1961
|
-
Created epic or None if failed
|
|
1962
|
-
|
|
1963
|
-
"""
|
|
1964
|
-
team_id = await self._ensure_team_id()
|
|
1965
|
-
|
|
1966
|
-
create_query = gql(
|
|
1967
|
-
TEAM_FRAGMENT
|
|
1968
|
-
+ PROJECT_FRAGMENT
|
|
1969
|
-
+ """
|
|
1970
|
-
mutation CreateProject($input: ProjectCreateInput!) {
|
|
1971
|
-
projectCreate(input: $input) {
|
|
1972
|
-
success
|
|
1973
|
-
project {
|
|
1974
|
-
...ProjectFields
|
|
1975
|
-
}
|
|
1976
|
-
}
|
|
1977
|
-
}
|
|
1978
|
-
"""
|
|
1979
|
-
)
|
|
1980
|
-
|
|
1981
|
-
project_input = {
|
|
1982
|
-
"name": title,
|
|
1983
|
-
"teamIds": [team_id],
|
|
1984
|
-
}
|
|
1985
|
-
if description:
|
|
1986
|
-
project_input["description"] = description
|
|
1987
|
-
|
|
1988
|
-
# Handle additional Linear-specific fields
|
|
1989
|
-
if "target_date" in kwargs:
|
|
1990
|
-
project_input["targetDate"] = kwargs["target_date"]
|
|
1991
|
-
if "lead_id" in kwargs:
|
|
1992
|
-
project_input["leadId"] = kwargs["lead_id"]
|
|
1993
|
-
|
|
1994
|
-
client = self._create_client()
|
|
1995
|
-
async with client as session:
|
|
1996
|
-
result = await session.execute(
|
|
1997
|
-
create_query, variable_values={"input": project_input}
|
|
1998
|
-
)
|
|
1999
|
-
|
|
2000
|
-
if not result["projectCreate"]["success"]:
|
|
2001
|
-
return None
|
|
2002
|
-
|
|
2003
|
-
project = result["projectCreate"]["project"]
|
|
2004
|
-
return self._epic_from_linear_project(project)
|
|
2005
|
-
|
|
2006
|
-
async def get_epic(self, epic_id: str) -> Optional[Epic]:
|
|
2007
|
-
"""Get epic (Linear Project) by ID.
|
|
2008
|
-
|
|
2009
|
-
Args:
|
|
2010
|
-
epic_id: Linear project ID
|
|
2011
|
-
|
|
2012
|
-
Returns:
|
|
2013
|
-
Epic if found, None otherwise
|
|
2014
|
-
|
|
2015
|
-
"""
|
|
2016
|
-
query = gql(
|
|
2017
|
-
PROJECT_FRAGMENT
|
|
2018
|
-
+ """
|
|
2019
|
-
query GetProject($id: String!) {
|
|
2020
|
-
project(id: $id) {
|
|
2021
|
-
...ProjectFields
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
"""
|
|
2025
|
-
)
|
|
2026
|
-
|
|
2027
|
-
try:
|
|
2028
|
-
client = self._create_client()
|
|
2029
|
-
async with client as session:
|
|
2030
|
-
result = await session.execute(query, variable_values={"id": epic_id})
|
|
2031
|
-
|
|
2032
|
-
if result.get("project"):
|
|
2033
|
-
return self._epic_from_linear_project(result["project"])
|
|
2034
|
-
except TransportQueryError:
|
|
2035
|
-
pass
|
|
2036
|
-
|
|
2037
|
-
return None
|
|
2038
|
-
|
|
2039
|
-
async def list_epics(self, **kwargs) -> builtins.list[Epic]:
|
|
2040
|
-
"""List all Linear Projects (Epics).
|
|
2041
|
-
|
|
2042
|
-
Args:
|
|
2043
|
-
**kwargs: Optional filters (team_id, state)
|
|
2044
|
-
|
|
2045
|
-
Returns:
|
|
2046
|
-
List of epics
|
|
2047
|
-
|
|
2048
|
-
"""
|
|
2049
|
-
team_id = await self._ensure_team_id()
|
|
2050
|
-
|
|
2051
|
-
# Build project filter
|
|
2052
|
-
project_filter = {"team": {"id": {"eq": team_id}}}
|
|
2053
|
-
|
|
2054
|
-
if "state" in kwargs:
|
|
2055
|
-
# Map TicketState to Linear project state
|
|
2056
|
-
state_mapping = {
|
|
2057
|
-
TicketState.OPEN: "planned",
|
|
2058
|
-
TicketState.IN_PROGRESS: "started",
|
|
2059
|
-
TicketState.WAITING: "paused",
|
|
2060
|
-
TicketState.DONE: "completed",
|
|
2061
|
-
TicketState.CLOSED: "canceled",
|
|
2062
|
-
}
|
|
2063
|
-
linear_state = state_mapping.get(kwargs["state"], "planned")
|
|
2064
|
-
project_filter["state"] = {"eq": linear_state}
|
|
2065
|
-
|
|
2066
|
-
query = gql(
|
|
2067
|
-
PROJECT_FRAGMENT
|
|
2068
|
-
+ """
|
|
2069
|
-
query ListProjects($filter: ProjectFilter, $first: Int!) {
|
|
2070
|
-
projects(filter: $filter, first: $first, orderBy: updatedAt) {
|
|
2071
|
-
nodes {
|
|
2072
|
-
...ProjectFields
|
|
2073
|
-
}
|
|
2074
|
-
}
|
|
2075
|
-
}
|
|
2076
|
-
"""
|
|
2077
|
-
)
|
|
2078
|
-
|
|
2079
|
-
client = self._create_client()
|
|
2080
|
-
async with client as session:
|
|
2081
|
-
result = await session.execute(
|
|
2082
|
-
query,
|
|
2083
|
-
variable_values={
|
|
2084
|
-
"filter": project_filter,
|
|
2085
|
-
"first": kwargs.get("limit", 50),
|
|
2086
|
-
},
|
|
2087
|
-
)
|
|
2088
|
-
|
|
2089
|
-
epics = []
|
|
2090
|
-
for project in result["projects"]["nodes"]:
|
|
2091
|
-
epics.append(self._epic_from_linear_project(project))
|
|
2092
|
-
|
|
2093
|
-
return epics
|
|
2094
|
-
|
|
2095
|
-
async def create_issue(
|
|
2096
|
-
self,
|
|
2097
|
-
title: str,
|
|
2098
|
-
description: Optional[str] = None,
|
|
2099
|
-
epic_id: Optional[str] = None,
|
|
2100
|
-
**kwargs,
|
|
2101
|
-
) -> Optional[Task]:
|
|
2102
|
-
"""Create issue and optionally associate with project (epic).
|
|
2103
|
-
|
|
2104
|
-
Args:
|
|
2105
|
-
title: Issue title
|
|
2106
|
-
description: Issue description
|
|
2107
|
-
epic_id: Optional Linear project ID (epic)
|
|
2108
|
-
**kwargs: Additional fields
|
|
2109
|
-
|
|
2110
|
-
Returns:
|
|
2111
|
-
Created issue or None if failed
|
|
2112
|
-
|
|
2113
|
-
"""
|
|
2114
|
-
# Use existing create method but ensure it's created as an ISSUE type
|
|
2115
|
-
task = Task(
|
|
2116
|
-
title=title,
|
|
2117
|
-
description=description,
|
|
2118
|
-
ticket_type=TicketType.ISSUE,
|
|
2119
|
-
parent_epic=epic_id,
|
|
2120
|
-
**{k: v for k, v in kwargs.items() if k in Task.__fields__},
|
|
2121
|
-
)
|
|
2122
|
-
|
|
2123
|
-
# The existing create method handles project association via parent_epic field
|
|
2124
|
-
return await self.create(task)
|
|
2125
|
-
|
|
2126
|
-
async def list_issues_by_epic(self, epic_id: str) -> builtins.list[Task]:
|
|
2127
|
-
"""List all issues in a Linear project (epic).
|
|
2128
|
-
|
|
2129
|
-
Args:
|
|
2130
|
-
epic_id: Linear project ID
|
|
2131
|
-
|
|
2132
|
-
Returns:
|
|
2133
|
-
List of issues belonging to project
|
|
2134
|
-
|
|
2135
|
-
"""
|
|
2136
|
-
query = gql(
|
|
2137
|
-
ISSUE_LIST_FRAGMENTS
|
|
2138
|
-
+ """
|
|
2139
|
-
query GetProjectIssues($projectId: String!, $first: Int!) {
|
|
2140
|
-
project(id: $projectId) {
|
|
2141
|
-
issues(first: $first) {
|
|
2142
|
-
nodes {
|
|
2143
|
-
...IssueCompactFields
|
|
2144
|
-
}
|
|
2145
|
-
}
|
|
2146
|
-
}
|
|
2147
|
-
}
|
|
2148
|
-
"""
|
|
2149
|
-
)
|
|
2150
|
-
|
|
2151
|
-
try:
|
|
2152
|
-
client = self._create_client()
|
|
2153
|
-
async with client as session:
|
|
2154
|
-
result = await session.execute(
|
|
2155
|
-
query, variable_values={"projectId": epic_id, "first": 100}
|
|
2156
|
-
)
|
|
2157
|
-
|
|
2158
|
-
if not result.get("project"):
|
|
2159
|
-
return []
|
|
2160
|
-
|
|
2161
|
-
issues = []
|
|
2162
|
-
for issue_data in result["project"]["issues"]["nodes"]:
|
|
2163
|
-
task = self._task_from_linear_issue(issue_data)
|
|
2164
|
-
# Only return issues (not sub-tasks)
|
|
2165
|
-
if task.is_issue():
|
|
2166
|
-
issues.append(task)
|
|
2167
|
-
|
|
2168
|
-
return issues
|
|
2169
|
-
except TransportQueryError:
|
|
2170
|
-
return []
|
|
2171
|
-
|
|
2172
|
-
async def create_task(
|
|
2173
|
-
self, title: str, parent_id: str, description: Optional[str] = None, **kwargs
|
|
2174
|
-
) -> Optional[Task]:
|
|
2175
|
-
"""Create task as sub-issue of parent.
|
|
2176
|
-
|
|
2177
|
-
Args:
|
|
2178
|
-
title: Task title
|
|
2179
|
-
parent_id: Required parent issue identifier (e.g., 'BTA-123')
|
|
2180
|
-
description: Task description
|
|
2181
|
-
**kwargs: Additional fields
|
|
2182
|
-
|
|
2183
|
-
Returns:
|
|
2184
|
-
Created task or None if failed
|
|
2185
|
-
|
|
2186
|
-
Raises:
|
|
2187
|
-
ValueError: If parent_id is not provided
|
|
2188
|
-
|
|
2189
|
-
"""
|
|
2190
|
-
if not parent_id:
|
|
2191
|
-
raise ValueError("Tasks must have a parent_id (issue identifier)")
|
|
2192
|
-
|
|
2193
|
-
# Get parent issue's Linear ID
|
|
2194
|
-
parent_query = gql(
|
|
2195
|
-
"""
|
|
2196
|
-
query GetIssueId($identifier: String!) {
|
|
2197
|
-
issue(id: $identifier) {
|
|
2198
|
-
id
|
|
2199
|
-
}
|
|
2200
|
-
}
|
|
2201
|
-
"""
|
|
2202
|
-
)
|
|
2203
|
-
|
|
2204
|
-
client = self._create_client()
|
|
2205
|
-
async with client as session:
|
|
2206
|
-
parent_result = await session.execute(
|
|
2207
|
-
parent_query, variable_values={"identifier": parent_id}
|
|
2208
|
-
)
|
|
2209
|
-
|
|
2210
|
-
if not parent_result.get("issue"):
|
|
2211
|
-
raise ValueError(f"Parent issue {parent_id} not found")
|
|
2212
|
-
|
|
2213
|
-
parent_linear_id = parent_result["issue"]["id"]
|
|
2214
|
-
|
|
2215
|
-
# Create task using existing create method
|
|
2216
|
-
task = Task(
|
|
2217
|
-
title=title,
|
|
2218
|
-
description=description,
|
|
2219
|
-
ticket_type=TicketType.TASK,
|
|
2220
|
-
parent_issue=parent_id,
|
|
2221
|
-
**{k: v for k, v in kwargs.items() if k in Task.__fields__},
|
|
2222
|
-
)
|
|
2223
|
-
|
|
2224
|
-
# Validate hierarchy
|
|
2225
|
-
errors = task.validate_hierarchy()
|
|
2226
|
-
if errors:
|
|
2227
|
-
raise ValueError(f"Invalid task hierarchy: {'; '.join(errors)}")
|
|
2228
|
-
|
|
2229
|
-
# Create with parent relationship
|
|
2230
|
-
team_id = await self._ensure_team_id()
|
|
2231
|
-
states = await self._get_workflow_states()
|
|
2232
|
-
|
|
2233
|
-
# Map state to Linear state ID
|
|
2234
|
-
linear_state_type = self._map_state_to_linear(task.state)
|
|
2235
|
-
state_data = states.get(linear_state_type)
|
|
2236
|
-
if not state_data:
|
|
2237
|
-
state_data = states.get("backlog")
|
|
2238
|
-
state_id = state_data["id"] if state_data else None
|
|
2239
|
-
|
|
2240
|
-
# Build issue input (sub-issue)
|
|
2241
|
-
issue_input = {
|
|
2242
|
-
"title": task.title,
|
|
2243
|
-
"teamId": team_id,
|
|
2244
|
-
"parentId": parent_linear_id, # This makes it a sub-issue
|
|
2245
|
-
}
|
|
2246
|
-
|
|
2247
|
-
if task.description:
|
|
2248
|
-
issue_input["description"] = task.description
|
|
2249
|
-
|
|
2250
|
-
if state_id:
|
|
2251
|
-
issue_input["stateId"] = state_id
|
|
2252
|
-
|
|
2253
|
-
# Set priority
|
|
2254
|
-
if task.priority:
|
|
2255
|
-
issue_input["priority"] = LinearPriorityMapping.TO_LINEAR.get(
|
|
2256
|
-
task.priority, 3
|
|
2257
|
-
)
|
|
2258
|
-
|
|
2259
|
-
# Create sub-issue mutation
|
|
2260
|
-
create_query = gql(
|
|
2261
|
-
ALL_FRAGMENTS
|
|
2262
|
-
+ """
|
|
2263
|
-
mutation CreateSubIssue($input: IssueCreateInput!) {
|
|
2264
|
-
issueCreate(input: $input) {
|
|
2265
|
-
success
|
|
2266
|
-
issue {
|
|
2267
|
-
...IssueFullFields
|
|
2268
|
-
}
|
|
2269
|
-
}
|
|
2270
|
-
}
|
|
2271
|
-
"""
|
|
2272
|
-
)
|
|
2273
|
-
|
|
2274
|
-
client = self._create_client()
|
|
2275
|
-
async with client as session:
|
|
2276
|
-
result = await session.execute(
|
|
2277
|
-
create_query, variable_values={"input": issue_input}
|
|
2278
|
-
)
|
|
2279
|
-
|
|
2280
|
-
if not result["issueCreate"]["success"]:
|
|
2281
|
-
return None
|
|
2282
|
-
|
|
2283
|
-
created_issue = result["issueCreate"]["issue"]
|
|
2284
|
-
return self._task_from_linear_issue(created_issue)
|
|
2285
|
-
|
|
2286
|
-
async def list_tasks_by_issue(self, issue_id: str) -> builtins.list[Task]:
|
|
2287
|
-
"""List all tasks (sub-issues) under an issue.
|
|
2288
|
-
|
|
2289
|
-
Args:
|
|
2290
|
-
issue_id: Issue identifier (e.g., 'BTA-123')
|
|
2291
|
-
|
|
2292
|
-
Returns:
|
|
2293
|
-
List of tasks belonging to issue
|
|
2294
|
-
|
|
2295
|
-
"""
|
|
2296
|
-
query = gql(
|
|
2297
|
-
ISSUE_LIST_FRAGMENTS
|
|
2298
|
-
+ """
|
|
2299
|
-
query GetIssueSubtasks($identifier: String!) {
|
|
2300
|
-
issue(id: $identifier) {
|
|
2301
|
-
children {
|
|
2302
|
-
nodes {
|
|
2303
|
-
...IssueCompactFields
|
|
2304
|
-
}
|
|
2305
|
-
}
|
|
2306
|
-
}
|
|
2307
|
-
}
|
|
2308
|
-
"""
|
|
2309
|
-
)
|
|
2310
|
-
|
|
2311
|
-
try:
|
|
2312
|
-
client = self._create_client()
|
|
2313
|
-
async with client as session:
|
|
2314
|
-
result = await session.execute(
|
|
2315
|
-
query, variable_values={"identifier": issue_id}
|
|
2316
|
-
)
|
|
2317
|
-
|
|
2318
|
-
if not result.get("issue"):
|
|
2319
|
-
return []
|
|
2320
|
-
|
|
2321
|
-
tasks = []
|
|
2322
|
-
for child_data in result["issue"]["children"]["nodes"]:
|
|
2323
|
-
task = self._task_from_linear_issue(child_data)
|
|
2324
|
-
# Only return tasks (sub-issues)
|
|
2325
|
-
if task.is_task():
|
|
2326
|
-
tasks.append(task)
|
|
2327
|
-
|
|
2328
|
-
return tasks
|
|
2329
|
-
except TransportQueryError:
|
|
2330
|
-
return []
|
|
2331
|
-
|
|
2332
|
-
async def get_team_members(self) -> List[Dict[str, Any]]:
|
|
2333
|
-
"""Get team members for the current team."""
|
|
2334
|
-
team_id = await self._ensure_team_id()
|
|
2335
|
-
|
|
2336
|
-
query = gql(
|
|
2337
|
-
USER_FRAGMENT + """
|
|
2338
|
-
query GetTeamMembers($teamId: String!) {
|
|
2339
|
-
team(id: $teamId) {
|
|
2340
|
-
members {
|
|
2341
|
-
nodes {
|
|
2342
|
-
...UserFields
|
|
2343
|
-
}
|
|
2344
|
-
}
|
|
2345
|
-
}
|
|
2346
|
-
}
|
|
2347
|
-
"""
|
|
2348
|
-
)
|
|
2349
|
-
|
|
2350
|
-
client = self._create_client()
|
|
2351
|
-
async with client as session:
|
|
2352
|
-
result = await session.execute(
|
|
2353
|
-
query, variable_values={"teamId": team_id}
|
|
2354
|
-
)
|
|
2355
|
-
|
|
2356
|
-
if result.get("team", {}).get("members", {}).get("nodes"):
|
|
2357
|
-
return result["team"]["members"]["nodes"]
|
|
2358
|
-
return []
|
|
2359
|
-
|
|
2360
|
-
async def get_current_user(self) -> Optional[Dict[str, Any]]:
|
|
2361
|
-
"""Get current user information."""
|
|
2362
|
-
query = gql(
|
|
2363
|
-
USER_FRAGMENT + """
|
|
2364
|
-
query GetCurrentUser {
|
|
2365
|
-
viewer {
|
|
2366
|
-
...UserFields
|
|
2367
|
-
}
|
|
2368
|
-
}
|
|
2369
|
-
"""
|
|
2370
|
-
)
|
|
2371
|
-
|
|
2372
|
-
client = self._create_client()
|
|
2373
|
-
async with client as session:
|
|
2374
|
-
result = await session.execute(query)
|
|
2375
|
-
|
|
2376
|
-
return result.get("viewer")
|
|
2377
|
-
|
|
2378
|
-
async def close(self) -> None:
|
|
2379
|
-
"""Close the GraphQL client connection.
|
|
2380
|
-
|
|
2381
|
-
Since we create fresh clients for each operation, there's no persistent
|
|
2382
|
-
connection to close. Each client's transport is automatically closed when
|
|
2383
|
-
the async context manager exits.
|
|
2384
|
-
"""
|
|
2385
|
-
pass
|
|
2386
|
-
|
|
11
|
+
# Import the refactored adapter
|
|
12
|
+
from .linear import LinearAdapter
|
|
2387
13
|
|
|
2388
|
-
#
|
|
2389
|
-
|
|
14
|
+
# Re-export for backward compatibility
|
|
15
|
+
__all__ = ["LinearAdapter"]
|