mcp-ticketer 0.1.39__py3-none-any.whl → 0.3.0__py3-none-any.whl

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