mcp-ticketer 0.3.1__py3-none-any.whl → 0.3.2__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 (37) hide show
  1. mcp_ticketer/__version__.py +1 -1
  2. mcp_ticketer/adapters/aitrackdown.py +12 -15
  3. mcp_ticketer/adapters/github.py +7 -4
  4. mcp_ticketer/adapters/jira.py +23 -22
  5. mcp_ticketer/adapters/linear/__init__.py +1 -1
  6. mcp_ticketer/adapters/linear/adapter.py +88 -89
  7. mcp_ticketer/adapters/linear/client.py +71 -52
  8. mcp_ticketer/adapters/linear/mappers.py +88 -68
  9. mcp_ticketer/adapters/linear/queries.py +28 -7
  10. mcp_ticketer/adapters/linear/types.py +57 -50
  11. mcp_ticketer/adapters/linear.py +2 -2
  12. mcp_ticketer/cli/adapter_diagnostics.py +86 -51
  13. mcp_ticketer/cli/diagnostics.py +165 -72
  14. mcp_ticketer/cli/linear_commands.py +156 -113
  15. mcp_ticketer/cli/main.py +153 -82
  16. mcp_ticketer/cli/simple_health.py +73 -45
  17. mcp_ticketer/cli/utils.py +15 -10
  18. mcp_ticketer/core/config.py +23 -19
  19. mcp_ticketer/core/env_discovery.py +5 -4
  20. mcp_ticketer/core/env_loader.py +109 -86
  21. mcp_ticketer/core/exceptions.py +20 -18
  22. mcp_ticketer/core/models.py +9 -0
  23. mcp_ticketer/core/project_config.py +1 -1
  24. mcp_ticketer/mcp/server.py +294 -139
  25. mcp_ticketer/queue/health_monitor.py +152 -121
  26. mcp_ticketer/queue/manager.py +11 -4
  27. mcp_ticketer/queue/queue.py +15 -3
  28. mcp_ticketer/queue/run_worker.py +1 -1
  29. mcp_ticketer/queue/ticket_registry.py +190 -132
  30. mcp_ticketer/queue/worker.py +54 -25
  31. {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.2.dist-info}/METADATA +1 -1
  32. mcp_ticketer-0.3.2.dist-info/RECORD +59 -0
  33. mcp_ticketer-0.3.1.dist-info/RECORD +0 -59
  34. {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.2.dist-info}/WHEEL +0 -0
  35. {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.2.dist-info}/entry_points.txt +0 -0
  36. {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.2.dist-info}/licenses/LICENSE +0 -0
  37. {mcp_ticketer-0.3.1.dist-info → mcp_ticketer-0.3.2.dist-info}/top_level.txt +0 -0
@@ -2,9 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import asyncio
6
5
  import os
7
- from datetime import datetime
8
6
  from typing import Any, Dict, List, Optional, Union
9
7
 
10
8
  try:
@@ -15,22 +13,12 @@ except ImportError:
15
13
  TransportQueryError = Exception
16
14
 
17
15
  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
- )
16
+ from ...core.models import Comment, Epic, SearchQuery, Task, TicketState
27
17
  from ...core.registry import AdapterRegistry
28
-
29
18
  from .client import LinearGraphQLClient
30
19
  from .mappers import (
31
20
  build_linear_issue_input,
32
21
  build_linear_issue_update_input,
33
- extract_child_issue_ids,
34
22
  map_linear_comment_to_comment,
35
23
  map_linear_issue_to_task,
36
24
  map_linear_project_to_epic,
@@ -38,7 +26,6 @@ from .mappers import (
38
26
  from .queries import (
39
27
  ALL_FRAGMENTS,
40
28
  CREATE_ISSUE_MUTATION,
41
- GET_CURRENT_USER_QUERY,
42
29
  LIST_ISSUES_QUERY,
43
30
  SEARCH_ISSUES_QUERY,
44
31
  UPDATE_ISSUE_MUTATION,
@@ -49,22 +36,21 @@ from .types import (
49
36
  build_issue_filter,
50
37
  get_linear_priority,
51
38
  get_linear_state_type,
52
- get_universal_state,
53
39
  )
54
40
 
55
41
 
56
42
  class LinearAdapter(BaseAdapter[Task]):
57
43
  """Adapter for Linear issue tracking system using native GraphQL API.
58
-
44
+
59
45
  This adapter provides comprehensive integration with Linear's GraphQL API,
60
46
  supporting all major ticket management operations including:
61
-
47
+
62
48
  - CRUD operations for issues and projects
63
49
  - State transitions and workflow management
64
50
  - User assignment and search functionality
65
51
  - Comment management
66
52
  - Epic/Issue/Task hierarchy support
67
-
53
+
68
54
  The adapter is organized into multiple modules for better maintainability:
69
55
  - client.py: GraphQL client management
70
56
  - queries.py: GraphQL queries and fragments
@@ -85,6 +71,7 @@ class LinearAdapter(BaseAdapter[Task]):
85
71
 
86
72
  Raises:
87
73
  ValueError: If required configuration is missing
74
+
88
75
  """
89
76
  # Initialize instance variables before calling super().__init__
90
77
  # because parent constructor calls _get_state_mapping()
@@ -95,79 +82,83 @@ class LinearAdapter(BaseAdapter[Task]):
95
82
  self._initialized = False
96
83
 
97
84
  super().__init__(config)
98
-
85
+
99
86
  # Extract configuration
100
87
  self.api_key = config.get("api_key") or os.getenv("LINEAR_API_KEY")
101
88
  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
-
89
+ raise ValueError(
90
+ "Linear API key is required (api_key or LINEAR_API_KEY env var)"
91
+ )
92
+
93
+ # Clean API key - remove Bearer prefix if accidentally included in config
94
+ # (The client will add it back when making requests)
95
+ if self.api_key.startswith("Bearer "):
96
+ self.api_key = self.api_key.replace("Bearer ", "")
97
+
108
98
  self.workspace = config.get("workspace", "")
109
99
  self.team_key = config.get("team_key")
110
100
  self.team_id = config.get("team_id")
111
101
  self.api_url = config.get("api_url", "https://api.linear.app/graphql")
112
-
102
+
113
103
  # Validate team configuration
114
104
  if not self.team_key and not self.team_id:
115
105
  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)
106
+
107
+ # Initialize client with clean API key
108
+ self.client = LinearGraphQLClient(self.api_key)
120
109
 
121
110
  def validate_credentials(self) -> tuple[bool, str]:
122
111
  """Validate Linear API credentials.
123
-
112
+
124
113
  Returns:
125
114
  Tuple of (is_valid, error_message)
115
+
126
116
  """
127
117
  if not self.api_key:
128
118
  return False, "Linear API key is required"
129
-
119
+
130
120
  if not self.team_key and not self.team_id:
131
121
  return False, "Either team_key or team_id must be provided"
132
-
122
+
133
123
  return True, ""
134
124
 
135
125
  async def initialize(self) -> None:
136
126
  """Initialize adapter by preloading team, states, and labels data concurrently."""
137
127
  if self._initialized:
138
128
  return
139
-
129
+
140
130
  try:
141
131
  # Test connection first
142
132
  if not await self.client.test_connection():
143
133
  raise ValueError("Failed to connect to Linear API - check credentials")
144
-
134
+
145
135
  # Load team data and workflow states concurrently
146
136
  team_id = await self._ensure_team_id()
147
-
137
+
148
138
  # Load workflow states for the team
149
139
  await self._load_workflow_states(team_id)
150
-
140
+
151
141
  self._initialized = True
152
-
142
+
153
143
  except Exception as e:
154
144
  raise ValueError(f"Failed to initialize Linear adapter: {e}")
155
145
 
156
146
  async def _ensure_team_id(self) -> str:
157
147
  """Ensure we have a team ID, resolving from team_key if needed.
158
-
148
+
159
149
  Returns:
160
150
  Linear team UUID
161
-
151
+
162
152
  Raises:
163
153
  ValueError: If team cannot be found or resolved
154
+
164
155
  """
165
156
  if self.team_id:
166
157
  return self.team_id
167
-
158
+
168
159
  if not self.team_key:
169
160
  raise ValueError("Either team_id or team_key must be provided")
170
-
161
+
171
162
  # Query team by key
172
163
  query = """
173
164
  query GetTeamByKey($key: String!) {
@@ -181,35 +172,35 @@ class LinearAdapter(BaseAdapter[Task]):
181
172
  }
182
173
  }
183
174
  """
184
-
175
+
185
176
  try:
186
177
  result = await self.client.execute_query(query, {"key": self.team_key})
187
178
  teams = result.get("teams", {}).get("nodes", [])
188
-
179
+
189
180
  if not teams:
190
181
  raise ValueError(f"Team with key '{self.team_key}' not found")
191
-
182
+
192
183
  team = teams[0]
193
184
  self.team_id = team["id"]
194
185
  self._team_data = team
195
-
186
+
196
187
  return self.team_id
197
-
188
+
198
189
  except Exception as e:
199
190
  raise ValueError(f"Failed to resolve team '{self.team_key}': {e}")
200
191
 
201
192
  async def _load_workflow_states(self, team_id: str) -> None:
202
193
  """Load and cache workflow states for the team.
203
-
194
+
204
195
  Args:
205
196
  team_id: Linear team ID
197
+
206
198
  """
207
199
  try:
208
200
  result = await self.client.execute_query(
209
- WORKFLOW_STATES_QUERY,
210
- {"teamId": team_id}
201
+ WORKFLOW_STATES_QUERY, {"teamId": team_id}
211
202
  )
212
-
203
+
213
204
  workflow_states = {}
214
205
  for state in result["workflowStates"]["nodes"]:
215
206
  state_type = state["type"].lower()
@@ -217,23 +208,24 @@ class LinearAdapter(BaseAdapter[Task]):
217
208
  workflow_states[state_type] = state
218
209
  elif state["position"] < workflow_states[state_type]["position"]:
219
210
  workflow_states[state_type] = state
220
-
211
+
221
212
  self._workflow_states = workflow_states
222
-
213
+
223
214
  except Exception as e:
224
215
  raise ValueError(f"Failed to load workflow states: {e}")
225
216
 
226
217
  def _get_state_mapping(self) -> Dict[TicketState, str]:
227
218
  """Get mapping from universal states to Linear workflow state IDs.
228
-
219
+
229
220
  Returns:
230
221
  Dictionary mapping TicketState to Linear state ID
222
+
231
223
  """
232
224
  if not self._workflow_states:
233
225
  # Return type-based mapping if states not loaded
234
226
  return {
235
227
  TicketState.OPEN: "unstarted",
236
- TicketState.IN_PROGRESS: "started",
228
+ TicketState.IN_PROGRESS: "started",
237
229
  TicketState.READY: "unstarted",
238
230
  TicketState.TESTED: "started",
239
231
  TicketState.DONE: "completed",
@@ -241,7 +233,7 @@ class LinearAdapter(BaseAdapter[Task]):
241
233
  TicketState.WAITING: "unstarted",
242
234
  TicketState.BLOCKED: "unstarted",
243
235
  }
244
-
236
+
245
237
  # Return ID-based mapping using cached workflow states
246
238
  mapping = {}
247
239
  for universal_state, linear_type in LinearStateMapping.TO_LINEAR.items():
@@ -250,23 +242,24 @@ class LinearAdapter(BaseAdapter[Task]):
250
242
  else:
251
243
  # Fallback to type name
252
244
  mapping[universal_state] = linear_type
253
-
245
+
254
246
  return mapping
255
247
 
256
248
  async def _get_user_id(self, user_identifier: str) -> Optional[str]:
257
249
  """Get Linear user ID from email or display name.
258
-
250
+
259
251
  Args:
260
252
  user_identifier: Email address or display name
261
-
253
+
262
254
  Returns:
263
255
  Linear user ID or None if not found
256
+
264
257
  """
265
258
  # Try to get user by email first
266
259
  user = await self.client.get_user_by_email(user_identifier)
267
260
  if user:
268
261
  return user["id"]
269
-
262
+
270
263
  # If not found by email, could implement search by display name
271
264
  # For now, assume the identifier is already a user ID
272
265
  return user_identifier if user_identifier else None
@@ -284,6 +277,7 @@ class LinearAdapter(BaseAdapter[Task]):
284
277
 
285
278
  Raises:
286
279
  ValueError: If credentials are invalid or creation fails
280
+
287
281
  """
288
282
  # Validate credentials before attempting operation
289
283
  is_valid, error_message = self.validate_credentials()
@@ -308,6 +302,7 @@ class LinearAdapter(BaseAdapter[Task]):
308
302
 
309
303
  Returns:
310
304
  Created task with Linear metadata
305
+
311
306
  """
312
307
  team_id = await self._ensure_team_id()
313
308
 
@@ -322,8 +317,7 @@ class LinearAdapter(BaseAdapter[Task]):
322
317
 
323
318
  try:
324
319
  result = await self.client.execute_mutation(
325
- CREATE_ISSUE_MUTATION,
326
- {"input": issue_input}
320
+ CREATE_ISSUE_MUTATION, {"input": issue_input}
327
321
  )
328
322
 
329
323
  if not result["issueCreate"]["success"]:
@@ -343,6 +337,7 @@ class LinearAdapter(BaseAdapter[Task]):
343
337
 
344
338
  Returns:
345
339
  Created epic with Linear metadata
340
+
346
341
  """
347
342
  team_id = await self._ensure_team_id()
348
343
 
@@ -387,8 +382,7 @@ class LinearAdapter(BaseAdapter[Task]):
387
382
 
388
383
  try:
389
384
  result = await self.client.execute_mutation(
390
- create_query,
391
- {"input": project_input}
385
+ create_query, {"input": project_input}
392
386
  )
393
387
 
394
388
  if not result["projectCreate"]["success"]:
@@ -408,25 +402,26 @@ class LinearAdapter(BaseAdapter[Task]):
408
402
 
409
403
  Returns:
410
404
  Task with full details or None if not found
405
+
411
406
  """
412
407
  # Validate credentials before attempting operation
413
408
  is_valid, error_message = self.validate_credentials()
414
409
  if not is_valid:
415
410
  raise ValueError(error_message)
416
411
 
417
- query = ALL_FRAGMENTS + """
412
+ query = (
413
+ ALL_FRAGMENTS
414
+ + """
418
415
  query GetIssue($identifier: String!) {
419
416
  issue(id: $identifier) {
420
417
  ...IssueFullFields
421
418
  }
422
419
  }
423
420
  """
421
+ )
424
422
 
425
423
  try:
426
- result = await self.client.execute_query(
427
- query,
428
- {"identifier": ticket_id}
429
- )
424
+ result = await self.client.execute_query(query, {"identifier": ticket_id})
430
425
 
431
426
  if result.get("issue"):
432
427
  return map_linear_issue_to_task(result["issue"])
@@ -446,6 +441,7 @@ class LinearAdapter(BaseAdapter[Task]):
446
441
 
447
442
  Returns:
448
443
  Updated task or None if not found
444
+
449
445
  """
450
446
  # Validate credentials before attempting operation
451
447
  is_valid, error_message = self.validate_credentials()
@@ -463,8 +459,7 @@ class LinearAdapter(BaseAdapter[Task]):
463
459
 
464
460
  try:
465
461
  result = await self.client.execute_query(
466
- id_query,
467
- {"identifier": ticket_id}
462
+ id_query, {"identifier": ticket_id}
468
463
  )
469
464
 
470
465
  if not result.get("issue"):
@@ -477,7 +472,11 @@ class LinearAdapter(BaseAdapter[Task]):
477
472
 
478
473
  # Handle state transitions
479
474
  if "state" in updates:
480
- target_state = TicketState(updates["state"]) if isinstance(updates["state"], str) else updates["state"]
475
+ target_state = (
476
+ TicketState(updates["state"])
477
+ if isinstance(updates["state"], str)
478
+ else updates["state"]
479
+ )
481
480
  state_mapping = self._get_state_mapping()
482
481
  if target_state in state_mapping:
483
482
  update_input["stateId"] = state_mapping[target_state]
@@ -490,8 +489,7 @@ class LinearAdapter(BaseAdapter[Task]):
490
489
 
491
490
  # Execute update
492
491
  result = await self.client.execute_mutation(
493
- UPDATE_ISSUE_MUTATION,
494
- {"id": linear_id, "input": update_input}
492
+ UPDATE_ISSUE_MUTATION, {"id": linear_id, "input": update_input}
495
493
  )
496
494
 
497
495
  if not result["issueUpdate"]["success"]:
@@ -511,6 +509,7 @@ class LinearAdapter(BaseAdapter[Task]):
511
509
 
512
510
  Returns:
513
511
  True if successfully deleted/archived
512
+
514
513
  """
515
514
  # Linear doesn't support true deletion, so we archive the issue
516
515
  try:
@@ -520,10 +519,7 @@ class LinearAdapter(BaseAdapter[Task]):
520
519
  return False
521
520
 
522
521
  async def list(
523
- self,
524
- limit: int = 10,
525
- offset: int = 0,
526
- filters: Optional[Dict[str, Any]] = None
522
+ self, limit: int = 10, offset: int = 0, filters: Optional[Dict[str, Any]] = None
527
523
  ) -> List[Task]:
528
524
  """List Linear issues with optional filtering.
529
525
 
@@ -534,6 +530,7 @@ class LinearAdapter(BaseAdapter[Task]):
534
530
 
535
531
  Returns:
536
532
  List of tasks matching the criteria
533
+
537
534
  """
538
535
  # Validate credentials
539
536
  is_valid, error_message = self.validate_credentials()
@@ -548,7 +545,9 @@ class LinearAdapter(BaseAdapter[Task]):
548
545
  team_id=team_id,
549
546
  state=filters.get("state") if filters else None,
550
547
  priority=filters.get("priority") if filters else None,
551
- include_archived=filters.get("includeArchived", False) if filters else False,
548
+ include_archived=(
549
+ filters.get("includeArchived", False) if filters else False
550
+ ),
552
551
  )
553
552
 
554
553
  # Add additional filters
@@ -567,8 +566,7 @@ class LinearAdapter(BaseAdapter[Task]):
567
566
 
568
567
  try:
569
568
  result = await self.client.execute_query(
570
- LIST_ISSUES_QUERY,
571
- {"filter": issue_filter, "first": limit}
569
+ LIST_ISSUES_QUERY, {"filter": issue_filter, "first": limit}
572
570
  )
573
571
 
574
572
  tasks = []
@@ -588,6 +586,7 @@ class LinearAdapter(BaseAdapter[Task]):
588
586
 
589
587
  Returns:
590
588
  List of tasks matching the search criteria
589
+
591
590
  """
592
591
  # Validate credentials
593
592
  is_valid, error_message = self.validate_credentials()
@@ -631,8 +630,7 @@ class LinearAdapter(BaseAdapter[Task]):
631
630
 
632
631
  try:
633
632
  result = await self.client.execute_query(
634
- SEARCH_ISSUES_QUERY,
635
- {"filter": issue_filter, "first": query.limit}
633
+ SEARCH_ISSUES_QUERY, {"filter": issue_filter, "first": query.limit}
636
634
  )
637
635
 
638
636
  tasks = []
@@ -655,6 +653,7 @@ class LinearAdapter(BaseAdapter[Task]):
655
653
 
656
654
  Returns:
657
655
  Updated task or None if transition failed
656
+
658
657
  """
659
658
  # Validate transition
660
659
  if not await self.validate_transition(ticket_id, target_state):
@@ -674,6 +673,7 @@ class LinearAdapter(BaseAdapter[Task]):
674
673
 
675
674
  Returns:
676
675
  True if transition is valid
676
+
677
677
  """
678
678
  # For now, allow all transitions
679
679
  # In practice, you might want to implement Linear's workflow rules
@@ -687,6 +687,7 @@ class LinearAdapter(BaseAdapter[Task]):
687
687
 
688
688
  Returns:
689
689
  Created comment with ID
690
+
690
691
  """
691
692
  # First get the Linear internal ID
692
693
  id_query = """
@@ -699,8 +700,7 @@ class LinearAdapter(BaseAdapter[Task]):
699
700
 
700
701
  try:
701
702
  result = await self.client.execute_query(
702
- id_query,
703
- {"identifier": comment.ticket_id}
703
+ id_query, {"identifier": comment.ticket_id}
704
704
  )
705
705
 
706
706
  if not result.get("issue"):
@@ -735,8 +735,7 @@ class LinearAdapter(BaseAdapter[Task]):
735
735
  }
736
736
 
737
737
  result = await self.client.execute_mutation(
738
- create_comment_query,
739
- {"input": comment_input}
738
+ create_comment_query, {"input": comment_input}
740
739
  )
741
740
 
742
741
  if not result["commentCreate"]["success"]:
@@ -760,6 +759,7 @@ class LinearAdapter(BaseAdapter[Task]):
760
759
 
761
760
  Returns:
762
761
  List of comments for the issue
762
+
763
763
  """
764
764
  query = """
765
765
  query GetIssueComments($identifier: String!, $first: Int!) {
@@ -788,8 +788,7 @@ class LinearAdapter(BaseAdapter[Task]):
788
788
 
789
789
  try:
790
790
  result = await self.client.execute_query(
791
- query,
792
- {"identifier": ticket_id, "first": limit}
791
+ query, {"identifier": ticket_id, "first": limit}
793
792
  )
794
793
 
795
794
  if not result.get("issue"):