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.
@@ -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
- import asyncio
4
- import builtins
5
- import os
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
- from gql import Client, gql
11
- from gql.transport.exceptions import TransportQueryError
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
- COMMENT_FRAGMENT = """
145
- fragment CommentFields on Comment {
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
- # Register the adapter
2389
- AdapterRegistry.register("linear", LinearAdapter)
14
+ # Re-export for backward compatibility
15
+ __all__ = ["LinearAdapter"]