mcp-ticketer 0.1.30__py3-none-any.whl → 1.2.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcp-ticketer might be problematic. Click here for more details.

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