mcp-ticketer 0.3.5__py3-none-any.whl → 0.12.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.

Potentially problematic release.


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

Files changed (84) hide show
  1. mcp_ticketer/__version__.py +3 -3
  2. mcp_ticketer/adapters/__init__.py +2 -0
  3. mcp_ticketer/adapters/aitrackdown.py +263 -14
  4. mcp_ticketer/adapters/asana/__init__.py +15 -0
  5. mcp_ticketer/adapters/asana/adapter.py +1308 -0
  6. mcp_ticketer/adapters/asana/client.py +292 -0
  7. mcp_ticketer/adapters/asana/mappers.py +334 -0
  8. mcp_ticketer/adapters/asana/types.py +146 -0
  9. mcp_ticketer/adapters/github.py +326 -109
  10. mcp_ticketer/adapters/hybrid.py +11 -11
  11. mcp_ticketer/adapters/jira.py +271 -25
  12. mcp_ticketer/adapters/linear/adapter.py +693 -39
  13. mcp_ticketer/adapters/linear/client.py +61 -9
  14. mcp_ticketer/adapters/linear/mappers.py +9 -3
  15. mcp_ticketer/adapters/linear/queries.py +9 -7
  16. mcp_ticketer/cache/memory.py +9 -8
  17. mcp_ticketer/cli/adapter_diagnostics.py +1 -1
  18. mcp_ticketer/cli/auggie_configure.py +104 -15
  19. mcp_ticketer/cli/codex_configure.py +188 -32
  20. mcp_ticketer/cli/configure.py +37 -48
  21. mcp_ticketer/cli/diagnostics.py +20 -18
  22. mcp_ticketer/cli/discover.py +292 -26
  23. mcp_ticketer/cli/gemini_configure.py +107 -26
  24. mcp_ticketer/cli/instruction_commands.py +429 -0
  25. mcp_ticketer/cli/linear_commands.py +105 -22
  26. mcp_ticketer/cli/main.py +1830 -435
  27. mcp_ticketer/cli/mcp_configure.py +296 -89
  28. mcp_ticketer/cli/migrate_config.py +12 -8
  29. mcp_ticketer/cli/platform_commands.py +123 -0
  30. mcp_ticketer/cli/platform_detection.py +412 -0
  31. mcp_ticketer/cli/python_detection.py +126 -0
  32. mcp_ticketer/cli/queue_commands.py +15 -15
  33. mcp_ticketer/cli/simple_health.py +1 -1
  34. mcp_ticketer/cli/ticket_commands.py +773 -0
  35. mcp_ticketer/cli/update_checker.py +313 -0
  36. mcp_ticketer/cli/utils.py +67 -62
  37. mcp_ticketer/core/__init__.py +14 -1
  38. mcp_ticketer/core/adapter.py +84 -15
  39. mcp_ticketer/core/config.py +44 -39
  40. mcp_ticketer/core/env_discovery.py +42 -12
  41. mcp_ticketer/core/env_loader.py +15 -14
  42. mcp_ticketer/core/exceptions.py +3 -3
  43. mcp_ticketer/core/http_client.py +26 -26
  44. mcp_ticketer/core/instructions.py +405 -0
  45. mcp_ticketer/core/mappers.py +11 -11
  46. mcp_ticketer/core/models.py +50 -20
  47. mcp_ticketer/core/onepassword_secrets.py +379 -0
  48. mcp_ticketer/core/project_config.py +57 -35
  49. mcp_ticketer/core/registry.py +3 -3
  50. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  51. mcp_ticketer/mcp/__init__.py +29 -1
  52. mcp_ticketer/mcp/__main__.py +60 -0
  53. mcp_ticketer/mcp/server/__init__.py +25 -0
  54. mcp_ticketer/mcp/server/__main__.py +60 -0
  55. mcp_ticketer/mcp/{dto.py → server/dto.py} +32 -32
  56. mcp_ticketer/mcp/{server.py → server/main.py} +127 -74
  57. mcp_ticketer/mcp/{response_builder.py → server/response_builder.py} +2 -2
  58. mcp_ticketer/mcp/server/server_sdk.py +93 -0
  59. mcp_ticketer/mcp/server/tools/__init__.py +47 -0
  60. mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
  61. mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
  62. mcp_ticketer/mcp/server/tools/comment_tools.py +90 -0
  63. mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
  64. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +532 -0
  65. mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
  66. mcp_ticketer/mcp/server/tools/pr_tools.py +154 -0
  67. mcp_ticketer/mcp/server/tools/search_tools.py +206 -0
  68. mcp_ticketer/mcp/server/tools/ticket_tools.py +430 -0
  69. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
  70. mcp_ticketer/queue/__init__.py +1 -0
  71. mcp_ticketer/queue/health_monitor.py +5 -4
  72. mcp_ticketer/queue/manager.py +15 -51
  73. mcp_ticketer/queue/queue.py +19 -19
  74. mcp_ticketer/queue/run_worker.py +1 -1
  75. mcp_ticketer/queue/ticket_registry.py +14 -14
  76. mcp_ticketer/queue/worker.py +16 -14
  77. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +168 -32
  78. mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
  79. mcp_ticketer-0.3.5.dist-info/RECORD +0 -62
  80. /mcp_ticketer/mcp/{constants.py → server/constants.py} +0 -0
  81. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
  82. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
  83. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
  84. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/top_level.txt +0 -0
@@ -2,15 +2,21 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
6
+ import logging
7
+ import mimetypes
5
8
  import os
9
+ from pathlib import Path
6
10
  from typing import Any
7
11
 
8
12
  try:
13
+ import httpx
9
14
  from gql import gql
10
15
  from gql.transport.exceptions import TransportQueryError
11
16
  except ImportError:
12
17
  gql = None
13
18
  TransportQueryError = Exception
19
+ httpx = None
14
20
 
15
21
  import builtins
16
22
 
@@ -92,10 +98,28 @@ class LinearAdapter(BaseAdapter[Task]):
92
98
  "Linear API key is required (api_key or LINEAR_API_KEY env var)"
93
99
  )
94
100
 
95
- # Clean API key - remove Bearer prefix if accidentally included in config
96
- # (The client will add it back when making requests)
97
- if self.api_key.startswith("Bearer "):
98
- self.api_key = self.api_key.replace("Bearer ", "")
101
+ # Clean API key - remove common prefixes if accidentally included in config
102
+ # (The client will add Bearer back when making requests)
103
+ if isinstance(self.api_key, str):
104
+ # Remove Bearer prefix
105
+ if self.api_key.startswith("Bearer "):
106
+ self.api_key = self.api_key.replace("Bearer ", "")
107
+ # Remove environment variable name prefix (e.g., "LINEAR_API_KEY=")
108
+ if "=" in self.api_key:
109
+ parts = self.api_key.split("=", 1)
110
+ if len(parts) == 2 and parts[0].upper() in (
111
+ "LINEAR_API_KEY",
112
+ "API_KEY",
113
+ ):
114
+ self.api_key = parts[1]
115
+
116
+ # Validate API key format (Linear keys start with "lin_api_")
117
+ if not self.api_key.startswith("lin_api_"):
118
+ raise ValueError(
119
+ f"Invalid Linear API key format. Expected key starting with 'lin_api_', "
120
+ f"got: {self.api_key[:15]}... "
121
+ f"Please check your configuration and ensure the API key is correct."
122
+ )
99
123
 
100
124
  self.workspace = config.get("workspace", "")
101
125
  self.team_key = config.get("team_key")
@@ -144,7 +168,7 @@ class LinearAdapter(BaseAdapter[Task]):
144
168
  self._initialized = True
145
169
 
146
170
  except Exception as e:
147
- raise ValueError(f"Failed to initialize Linear adapter: {e}")
171
+ raise ValueError(f"Failed to initialize Linear adapter: {e}") from e
148
172
 
149
173
  async def _ensure_team_id(self) -> str:
150
174
  """Ensure we have a team ID, resolving from team_key if needed.
@@ -190,7 +214,150 @@ class LinearAdapter(BaseAdapter[Task]):
190
214
  return self.team_id
191
215
 
192
216
  except Exception as e:
193
- raise ValueError(f"Failed to resolve team '{self.team_key}': {e}")
217
+ raise ValueError(f"Failed to resolve team '{self.team_key}': {e}") from e
218
+
219
+ async def _resolve_project_id(self, project_identifier: str) -> str | None:
220
+ """Resolve project identifier (slug, name, short ID, or URL) to full UUID.
221
+
222
+ Args:
223
+ project_identifier: Project slug, name, short ID, or URL
224
+
225
+ Returns:
226
+ Full Linear project UUID, or None if not found
227
+
228
+ Raises:
229
+ ValueError: If project lookup fails
230
+
231
+ Examples:
232
+ - "crm-smart-monitoring-system" (slug)
233
+ - "CRM Smart Monitoring System" (name)
234
+ - "f59a41a96c52" (short ID from URL)
235
+ - "https://linear.app/travel-bta/project/crm-smart-monitoring-system-f59a41a96c52/overview" (full URL)
236
+
237
+ """
238
+ if not project_identifier:
239
+ return None
240
+
241
+ # Extract slug/ID from URL if full URL provided
242
+ if project_identifier.startswith("http"):
243
+ # Extract slug-shortid from URL like:
244
+ # https://linear.app/travel-bta/project/crm-smart-monitoring-system-f59a41a96c52/overview
245
+ parts = project_identifier.split("/project/")
246
+ if len(parts) > 1:
247
+ slug_with_id = parts[1].split("/")[
248
+ 0
249
+ ] # Get "crm-smart-monitoring-system-f59a41a96c52"
250
+ project_identifier = slug_with_id
251
+ else:
252
+ raise ValueError(f"Invalid Linear project URL: {project_identifier}")
253
+
254
+ # If it looks like a full UUID already (exactly 36 chars with exactly 4 dashes), return it
255
+ # UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
256
+ if len(project_identifier) == 36 and project_identifier.count("-") == 4:
257
+ return project_identifier
258
+
259
+ # Query all projects and search for matching slug, name, or slugId
260
+ query = """
261
+ query GetProjects {
262
+ projects(first: 100) {
263
+ nodes {
264
+ id
265
+ name
266
+ slugId
267
+ }
268
+ }
269
+ }
270
+ """
271
+
272
+ try:
273
+ result = await self.client.execute_query(query, {})
274
+ projects = result.get("projects", {}).get("nodes", [])
275
+
276
+ # Search for match by slug, slugId, name (case-insensitive)
277
+ project_lower = project_identifier.lower()
278
+ for project in projects:
279
+ # Check if identifier matches slug pattern (extracted from slugId)
280
+ slug_id = project.get("slugId", "")
281
+ if slug_id:
282
+ # slugId format: "crm-smart-monitoring-system-f59a41a96c52"
283
+ # Extract both the slug part and short ID
284
+ if "-" in slug_id:
285
+ parts = slug_id.rsplit(
286
+ "-", 1
287
+ ) # Split from right to get last part
288
+ slug_part = parts[0] # "crm-smart-monitoring-system"
289
+ short_id = parts[1] if len(parts) > 1 else "" # "f59a41a96c52"
290
+
291
+ # Match full slugId, slug part, or short ID
292
+ if (
293
+ slug_id.lower() == project_lower
294
+ or slug_part.lower() == project_lower
295
+ or short_id.lower() == project_lower
296
+ ):
297
+ return project["id"]
298
+
299
+ # Also check exact name match (case-insensitive)
300
+ if project["name"].lower() == project_lower:
301
+ return project["id"]
302
+
303
+ # No match found
304
+ return None
305
+
306
+ except Exception as e:
307
+ raise ValueError(
308
+ f"Failed to resolve project '{project_identifier}': {e}"
309
+ ) from e
310
+
311
+ async def _resolve_issue_id(self, issue_identifier: str) -> str | None:
312
+ """Resolve issue identifier (like "ENG-842") to full UUID.
313
+
314
+ Args:
315
+ issue_identifier: Issue identifier (e.g., "ENG-842") or UUID
316
+
317
+ Returns:
318
+ Full Linear issue UUID, or None if not found
319
+
320
+ Raises:
321
+ ValueError: If issue lookup fails
322
+
323
+ Examples:
324
+ - "ENG-842" (issue identifier)
325
+ - "BTA-123" (issue identifier)
326
+ - "a1b2c3d4-e5f6-7890-abcd-ef1234567890" (already a UUID)
327
+
328
+ """
329
+ if not issue_identifier:
330
+ return None
331
+
332
+ # If it looks like a full UUID already (exactly 36 chars with exactly 4 dashes), return it
333
+ # UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
334
+ if len(issue_identifier) == 36 and issue_identifier.count("-") == 4:
335
+ return issue_identifier
336
+
337
+ # Query issue by identifier to get its UUID
338
+ query = """
339
+ query GetIssueId($identifier: String!) {
340
+ issue(id: $identifier) {
341
+ id
342
+ }
343
+ }
344
+ """
345
+
346
+ try:
347
+ result = await self.client.execute_query(
348
+ query, {"identifier": issue_identifier}
349
+ )
350
+
351
+ if result.get("issue"):
352
+ return result["issue"]["id"]
353
+
354
+ # No match found
355
+ return None
356
+
357
+ except Exception as e:
358
+ raise ValueError(
359
+ f"Failed to resolve issue '{issue_identifier}': {e}"
360
+ ) from e
194
361
 
195
362
  async def _load_workflow_states(self, team_id: str) -> None:
196
363
  """Load and cache workflow states for the team.
@@ -205,7 +372,7 @@ class LinearAdapter(BaseAdapter[Task]):
205
372
  )
206
373
 
207
374
  workflow_states = {}
208
- for state in result["workflowStates"]["nodes"]:
375
+ for state in result["team"]["states"]["nodes"]:
209
376
  state_type = state["type"].lower()
210
377
  if state_type not in workflow_states:
211
378
  workflow_states[state_type] = state
@@ -215,15 +382,17 @@ class LinearAdapter(BaseAdapter[Task]):
215
382
  self._workflow_states = workflow_states
216
383
 
217
384
  except Exception as e:
218
- raise ValueError(f"Failed to load workflow states: {e}")
385
+ raise ValueError(f"Failed to load workflow states: {e}") from e
219
386
 
220
387
  async def _load_team_labels(self, team_id: str) -> None:
221
- """Load and cache labels for the team.
388
+ """Load and cache labels for the team with retry logic.
222
389
 
223
390
  Args:
224
391
  team_id: Linear team ID
225
392
 
226
393
  """
394
+ logger = logging.getLogger(__name__)
395
+
227
396
  query = """
228
397
  query GetTeamLabels($teamId: String!) {
229
398
  team(id: $teamId) {
@@ -239,15 +408,32 @@ class LinearAdapter(BaseAdapter[Task]):
239
408
  }
240
409
  """
241
410
 
242
- try:
243
- result = await self.client.execute_query(query, {"teamId": team_id})
244
- self._labels_cache = result["team"]["labels"]["nodes"]
245
- except Exception:
246
- # Log error but don't fail - labels are optional
247
- self._labels_cache = []
411
+ max_retries = 3
412
+ for attempt in range(max_retries):
413
+ try:
414
+ result = await self.client.execute_query(query, {"teamId": team_id})
415
+ labels = result.get("team", {}).get("labels", {}).get("nodes", [])
416
+ self._labels_cache = labels
417
+ logger.info(f"Loaded {len(labels)} labels for team {team_id}")
418
+ return # Success
419
+
420
+ except Exception as e:
421
+ if attempt < max_retries - 1:
422
+ wait_time = 2**attempt
423
+ logger.warning(
424
+ f"Failed to load labels (attempt {attempt + 1}/{max_retries}): {e}. "
425
+ f"Retrying in {wait_time}s..."
426
+ )
427
+ await asyncio.sleep(wait_time)
428
+ else:
429
+ logger.error(
430
+ f"Failed to load team labels after {max_retries} attempts: {e}",
431
+ exc_info=True,
432
+ )
433
+ self._labels_cache = [] # Explicitly empty on failure
248
434
 
249
435
  async def _resolve_label_ids(self, label_names: list[str]) -> list[str]:
250
- """Resolve label names to Linear label IDs.
436
+ """Resolve label names to Linear label IDs with proper None vs empty list handling.
251
437
 
252
438
  Args:
253
439
  label_names: List of label names
@@ -256,16 +442,25 @@ class LinearAdapter(BaseAdapter[Task]):
256
442
  List of Linear label IDs that exist
257
443
 
258
444
  """
259
- import logging
260
-
261
445
  logger = logging.getLogger(__name__)
262
446
 
263
- if not self._labels_cache:
447
+ # None = not loaded yet, [] = loaded but empty or failed
448
+ if self._labels_cache is None:
264
449
  team_id = await self._ensure_team_id()
265
450
  await self._load_team_labels(team_id)
266
451
 
452
+ if self._labels_cache is None:
453
+ # Still None after load attempt - should not happen
454
+ logger.error(
455
+ "Label cache is None after load attempt. Tags will be skipped."
456
+ )
457
+ return []
458
+
267
459
  if not self._labels_cache:
268
- logger.warning("No labels found in team cache")
460
+ # Empty list - either no labels in team or load failed
461
+ logger.warning(
462
+ f"Team has no labels available. Cannot resolve tags: {label_names}"
463
+ )
269
464
  return []
270
465
 
271
466
  # Create name -> ID mapping (case-insensitive)
@@ -328,23 +523,48 @@ class LinearAdapter(BaseAdapter[Task]):
328
523
  return mapping
329
524
 
330
525
  async def _get_user_id(self, user_identifier: str) -> str | None:
331
- """Get Linear user ID from email or display name.
526
+ """Get Linear user ID from email, display name, or user ID.
332
527
 
333
528
  Args:
334
- user_identifier: Email address or display name
529
+ user_identifier: Email, display name, or user ID
335
530
 
336
531
  Returns:
337
532
  Linear user ID or None if not found
338
533
 
339
534
  """
340
- # Try to get user by email first
535
+ if not user_identifier:
536
+ return None
537
+
538
+ # Try email lookup first (most specific)
341
539
  user = await self.client.get_user_by_email(user_identifier)
342
540
  if user:
343
541
  return user["id"]
344
542
 
345
- # If not found by email, could implement search by display name
346
- # For now, assume the identifier is already a user ID
347
- return user_identifier if user_identifier else None
543
+ # Try name search (displayName or full name)
544
+ users = await self.client.get_users_by_name(user_identifier)
545
+ if users:
546
+ if len(users) == 1:
547
+ # Exact match found
548
+ return users[0]["id"]
549
+ else:
550
+ # Multiple matches - try exact match
551
+ for u in users:
552
+ if (
553
+ u.get("displayName", "").lower() == user_identifier.lower()
554
+ or u.get("name", "").lower() == user_identifier.lower()
555
+ ):
556
+ return u["id"]
557
+
558
+ # No exact match - log ambiguity and return first
559
+ logging.getLogger(__name__).warning(
560
+ f"Multiple users match '{user_identifier}': "
561
+ f"{[u.get('displayName', u.get('name')) for u in users]}. "
562
+ f"Using first match: {users[0].get('displayName')}"
563
+ )
564
+ return users[0]["id"]
565
+
566
+ # Assume it's already a user ID
567
+ return user_identifier
348
568
 
349
569
  # CRUD Operations
350
570
 
@@ -377,7 +597,13 @@ class LinearAdapter(BaseAdapter[Task]):
377
597
  return await self._create_task(ticket)
378
598
 
379
599
  async def _create_task(self, task: Task) -> Task:
380
- """Create a Linear issue from a Task.
600
+ """Create a Linear issue or sub-issue from a Task.
601
+
602
+ Creates a top-level issue when task.parent_issue is not set, or a
603
+ sub-issue (child of another issue) when task.parent_issue is provided.
604
+ In Linear terminology:
605
+ - Issue: Top-level work item (no parent)
606
+ - Sub-issue: Child work item (has parent issue)
381
607
 
382
608
  Args:
383
609
  task: Task to create
@@ -413,19 +639,50 @@ class LinearAdapter(BaseAdapter[Task]):
413
639
  # Remove labelIds if no labels resolved
414
640
  issue_input.pop("labelIds", None)
415
641
 
642
+ # Resolve project ID if parent_epic is provided (supports slug, name, short ID, or URL)
643
+ if task.parent_epic:
644
+ project_id = await self._resolve_project_id(task.parent_epic)
645
+ if project_id:
646
+ issue_input["projectId"] = project_id
647
+ else:
648
+ # Log warning but don't fail - user may have provided invalid project
649
+ logging.getLogger(__name__).warning(
650
+ f"Could not resolve project identifier '{task.parent_epic}' to UUID. "
651
+ "Issue will be created without project assignment."
652
+ )
653
+ # Remove projectId if we couldn't resolve it
654
+ issue_input.pop("projectId", None)
655
+
656
+ # Resolve parent issue ID if provided (creates a sub-issue when parent is set)
657
+ # Supports identifiers like "ENG-842" or UUIDs
658
+ if task.parent_issue:
659
+ issue_id = await self._resolve_issue_id(task.parent_issue)
660
+ if issue_id:
661
+ issue_input["parentId"] = issue_id
662
+ else:
663
+ # Log warning but don't fail - user may have provided invalid issue
664
+ logging.getLogger(__name__).warning(
665
+ f"Could not resolve issue identifier '{task.parent_issue}' to UUID. "
666
+ "Sub-issue will be created without parent assignment."
667
+ )
668
+ # Remove parentId if we couldn't resolve it
669
+ issue_input.pop("parentId", None)
670
+
416
671
  try:
417
672
  result = await self.client.execute_mutation(
418
673
  CREATE_ISSUE_MUTATION, {"input": issue_input}
419
674
  )
420
675
 
421
676
  if not result["issueCreate"]["success"]:
422
- raise ValueError("Failed to create Linear issue")
677
+ item_type = "sub-issue" if task.parent_issue else "issue"
678
+ raise ValueError(f"Failed to create Linear {item_type}")
423
679
 
424
680
  created_issue = result["issueCreate"]["issue"]
425
681
  return map_linear_issue_to_task(created_issue)
426
682
 
427
683
  except Exception as e:
428
- raise ValueError(f"Failed to create Linear issue: {e}")
684
+ item_type = "sub-issue" if task.parent_issue else "issue"
685
+ raise ValueError(f"Failed to create Linear {item_type}: {e}") from e
429
686
 
430
687
  async def _create_epic(self, epic: Epic) -> Epic:
431
688
  """Create a Linear project from an Epic.
@@ -490,7 +747,98 @@ class LinearAdapter(BaseAdapter[Task]):
490
747
  return map_linear_project_to_epic(created_project)
491
748
 
492
749
  except Exception as e:
493
- raise ValueError(f"Failed to create Linear project: {e}")
750
+ raise ValueError(f"Failed to create Linear project: {e}") from e
751
+
752
+ async def update_epic(self, epic_id: str, updates: dict[str, Any]) -> Epic | None:
753
+ """Update a Linear project (Epic) with specified fields.
754
+
755
+ Args:
756
+ epic_id: Linear project UUID or slug-shortid
757
+ updates: Dictionary of fields to update. Supported fields:
758
+ - title: Project name
759
+ - description: Project description
760
+ - state: Project state (e.g., "planned", "started", "completed", "canceled")
761
+ - target_date: Target completion date (ISO format YYYY-MM-DD)
762
+ - color: Project color
763
+ - icon: Project icon
764
+
765
+ Returns:
766
+ Updated Epic object or None if not found
767
+
768
+ Raises:
769
+ ValueError: If update fails or project not found
770
+
771
+ """
772
+ # Validate credentials before attempting operation
773
+ is_valid, error_message = self.validate_credentials()
774
+ if not is_valid:
775
+ raise ValueError(error_message)
776
+
777
+ # Resolve project identifier to UUID if needed
778
+ project_uuid = await self._resolve_project_id(epic_id)
779
+ if not project_uuid:
780
+ raise ValueError(f"Project '{epic_id}' not found")
781
+
782
+ # Build update input from updates dict
783
+ update_input = {}
784
+
785
+ if "title" in updates:
786
+ update_input["name"] = updates["title"]
787
+ if "description" in updates:
788
+ update_input["description"] = updates["description"]
789
+ if "state" in updates:
790
+ update_input["state"] = updates["state"]
791
+ if "target_date" in updates:
792
+ update_input["targetDate"] = updates["target_date"]
793
+ if "color" in updates:
794
+ update_input["color"] = updates["color"]
795
+ if "icon" in updates:
796
+ update_input["icon"] = updates["icon"]
797
+
798
+ # ProjectUpdate mutation
799
+ update_query = """
800
+ mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) {
801
+ projectUpdate(id: $id, input: $input) {
802
+ success
803
+ project {
804
+ id
805
+ name
806
+ description
807
+ state
808
+ createdAt
809
+ updatedAt
810
+ url
811
+ icon
812
+ color
813
+ targetDate
814
+ startedAt
815
+ completedAt
816
+ teams {
817
+ nodes {
818
+ id
819
+ name
820
+ key
821
+ description
822
+ }
823
+ }
824
+ }
825
+ }
826
+ }
827
+ """
828
+
829
+ try:
830
+ result = await self.client.execute_mutation(
831
+ update_query, {"id": project_uuid, "input": update_input}
832
+ )
833
+
834
+ if not result["projectUpdate"]["success"]:
835
+ raise ValueError(f"Failed to update Linear project '{epic_id}'")
836
+
837
+ updated_project = result["projectUpdate"]["project"]
838
+ return map_linear_project_to_epic(updated_project)
839
+
840
+ except Exception as e:
841
+ raise ValueError(f"Failed to update Linear project: {e}") from e
494
842
 
495
843
  async def read(self, ticket_id: str) -> Task | None:
496
844
  """Read a Linear issue by identifier with full details.
@@ -586,10 +934,23 @@ class LinearAdapter(BaseAdapter[Task]):
586
934
  update_input["assigneeId"] = user_id
587
935
 
588
936
  # Resolve label names to IDs if provided
589
- if "tags" in updates and updates["tags"]:
590
- label_ids = await self._resolve_label_ids(updates["tags"])
591
- if label_ids:
592
- update_input["labelIds"] = label_ids
937
+ if "tags" in updates:
938
+ if updates["tags"]: # Non-empty list
939
+ label_ids = await self._resolve_label_ids(updates["tags"])
940
+ if label_ids:
941
+ update_input["labelIds"] = label_ids
942
+ else: # Empty list = remove all labels
943
+ update_input["labelIds"] = []
944
+
945
+ # Resolve project ID if parent_epic is provided (supports slug, name, short ID, or URL)
946
+ if "parent_epic" in updates and updates["parent_epic"]:
947
+ project_id = await self._resolve_project_id(updates["parent_epic"])
948
+ if project_id:
949
+ update_input["projectId"] = project_id
950
+ else:
951
+ logging.getLogger(__name__).warning(
952
+ f"Could not resolve project identifier '{updates['parent_epic']}'"
953
+ )
593
954
 
594
955
  # Execute update
595
956
  result = await self.client.execute_mutation(
@@ -603,7 +964,7 @@ class LinearAdapter(BaseAdapter[Task]):
603
964
  return map_linear_issue_to_task(updated_issue)
604
965
 
605
966
  except Exception as e:
606
- raise ValueError(f"Failed to update Linear issue: {e}")
967
+ raise ValueError(f"Failed to update Linear issue: {e}") from e
607
968
 
608
969
  async def delete(self, ticket_id: str) -> bool:
609
970
  """Delete a Linear issue (archive it).
@@ -680,7 +1041,7 @@ class LinearAdapter(BaseAdapter[Task]):
680
1041
  return tasks
681
1042
 
682
1043
  except Exception as e:
683
- raise ValueError(f"Failed to list Linear issues: {e}")
1044
+ raise ValueError(f"Failed to list Linear issues: {e}") from e
684
1045
 
685
1046
  async def search(self, query: SearchQuery) -> builtins.list[Task]:
686
1047
  """Search Linear issues using comprehensive filters.
@@ -744,7 +1105,7 @@ class LinearAdapter(BaseAdapter[Task]):
744
1105
  return tasks
745
1106
 
746
1107
  except Exception as e:
747
- raise ValueError(f"Failed to search Linear issues: {e}")
1108
+ raise ValueError(f"Failed to search Linear issues: {e}") from e
748
1109
 
749
1110
  async def transition_state(
750
1111
  self, ticket_id: str, target_state: TicketState
@@ -835,7 +1196,7 @@ class LinearAdapter(BaseAdapter[Task]):
835
1196
 
836
1197
  comment_input = {
837
1198
  "issueId": linear_id,
838
- "body": comment.body,
1199
+ "body": comment.content,
839
1200
  }
840
1201
 
841
1202
  result = await self.client.execute_mutation(
@@ -849,7 +1210,7 @@ class LinearAdapter(BaseAdapter[Task]):
849
1210
  return map_linear_comment_to_comment(created_comment, comment.ticket_id)
850
1211
 
851
1212
  except Exception as e:
852
- raise ValueError(f"Failed to add comment: {e}")
1213
+ raise ValueError(f"Failed to add comment: {e}") from e
853
1214
 
854
1215
  async def get_comments(
855
1216
  self, ticket_id: str, limit: int = 10, offset: int = 0
@@ -907,6 +1268,299 @@ class LinearAdapter(BaseAdapter[Task]):
907
1268
  except Exception:
908
1269
  return []
909
1270
 
1271
+ async def list_labels(self) -> builtins.list[dict[str, Any]]:
1272
+ """List all labels available in the Linear team.
1273
+
1274
+ Returns:
1275
+ List of label dictionaries with 'id', 'name', and 'color' fields
1276
+
1277
+ """
1278
+ # Ensure labels are loaded
1279
+ if self._labels_cache is None:
1280
+ team_id = await self._ensure_team_id()
1281
+ await self._load_team_labels(team_id)
1282
+
1283
+ # Return cached labels or empty list if not available
1284
+ if not self._labels_cache:
1285
+ return []
1286
+
1287
+ # Transform to standardized format
1288
+ return [
1289
+ {
1290
+ "id": label["id"],
1291
+ "name": label["name"],
1292
+ "color": label.get("color", ""),
1293
+ }
1294
+ for label in self._labels_cache
1295
+ ]
1296
+
1297
+ async def upload_file(self, file_path: str, mime_type: str | None = None) -> str:
1298
+ """Upload a file to Linear's storage and return the asset URL.
1299
+
1300
+ This method implements Linear's three-step file upload process:
1301
+ 1. Request a pre-signed upload URL via fileUpload mutation
1302
+ 2. Upload the file to S3 using the pre-signed URL
1303
+ 3. Return the asset URL for use in attachments
1304
+
1305
+ Args:
1306
+ file_path: Path to the file to upload
1307
+ mime_type: MIME type of the file. If None, will be auto-detected.
1308
+
1309
+ Returns:
1310
+ Asset URL that can be used with attachmentCreate mutation
1311
+
1312
+ Raises:
1313
+ ValueError: If file doesn't exist, upload fails, or httpx not available
1314
+ FileNotFoundError: If the specified file doesn't exist
1315
+
1316
+ """
1317
+ if httpx is None:
1318
+ raise ValueError(
1319
+ "httpx library not installed. Install with: pip install httpx"
1320
+ )
1321
+
1322
+ # Validate file exists
1323
+ file_path_obj = Path(file_path)
1324
+ if not file_path_obj.exists():
1325
+ raise FileNotFoundError(f"File not found: {file_path}")
1326
+ if not file_path_obj.is_file():
1327
+ raise ValueError(f"Path is not a file: {file_path}")
1328
+
1329
+ # Get file info
1330
+ file_size = file_path_obj.stat().st_size
1331
+ filename = file_path_obj.name
1332
+
1333
+ # Auto-detect MIME type if not provided
1334
+ if mime_type is None:
1335
+ mime_type, _ = mimetypes.guess_type(file_path)
1336
+ if mime_type is None:
1337
+ # Default to binary if can't detect
1338
+ mime_type = "application/octet-stream"
1339
+
1340
+ # Step 1: Request pre-signed upload URL
1341
+ upload_mutation = """
1342
+ mutation FileUpload($contentType: String!, $filename: String!, $size: Int!) {
1343
+ fileUpload(contentType: $contentType, filename: $filename, size: $size) {
1344
+ success
1345
+ uploadFile {
1346
+ uploadUrl
1347
+ assetUrl
1348
+ headers {
1349
+ key
1350
+ value
1351
+ }
1352
+ }
1353
+ }
1354
+ }
1355
+ """
1356
+
1357
+ try:
1358
+ result = await self.client.execute_mutation(
1359
+ upload_mutation,
1360
+ {
1361
+ "contentType": mime_type,
1362
+ "filename": filename,
1363
+ "size": file_size,
1364
+ },
1365
+ )
1366
+
1367
+ if not result["fileUpload"]["success"]:
1368
+ raise ValueError("Failed to get upload URL from Linear API")
1369
+
1370
+ upload_file_data = result["fileUpload"]["uploadFile"]
1371
+ upload_url = upload_file_data["uploadUrl"]
1372
+ asset_url = upload_file_data["assetUrl"]
1373
+ headers_list = upload_file_data.get("headers", [])
1374
+
1375
+ # Convert headers list to dict
1376
+ upload_headers = {h["key"]: h["value"] for h in headers_list}
1377
+ # Add Content-Type header
1378
+ upload_headers["Content-Type"] = mime_type
1379
+
1380
+ # Step 2: Upload file to S3 using pre-signed URL
1381
+ async with httpx.AsyncClient() as http_client:
1382
+ with open(file_path, "rb") as f:
1383
+ file_content = f.read()
1384
+
1385
+ response = await http_client.put(
1386
+ upload_url,
1387
+ content=file_content,
1388
+ headers=upload_headers,
1389
+ timeout=60.0, # 60 second timeout for large files
1390
+ )
1391
+
1392
+ if response.status_code not in (200, 201, 204):
1393
+ raise ValueError(
1394
+ f"Failed to upload file to S3. Status: {response.status_code}, "
1395
+ f"Response: {response.text}"
1396
+ )
1397
+
1398
+ # Step 3: Return asset URL
1399
+ logging.getLogger(__name__).info(
1400
+ f"Successfully uploaded file '{filename}' ({file_size} bytes) to Linear"
1401
+ )
1402
+ return asset_url
1403
+
1404
+ except Exception as e:
1405
+ raise ValueError(f"Failed to upload file '{filename}': {e}") from e
1406
+
1407
+ async def attach_file_to_issue(
1408
+ self,
1409
+ issue_id: str,
1410
+ file_url: str,
1411
+ title: str,
1412
+ subtitle: str | None = None,
1413
+ comment_body: str | None = None,
1414
+ ) -> dict[str, Any]:
1415
+ """Attach a file to a Linear issue.
1416
+
1417
+ The file must already be uploaded using upload_file() or be a publicly
1418
+ accessible URL.
1419
+
1420
+ Args:
1421
+ issue_id: Linear issue identifier (e.g., "ENG-842") or UUID
1422
+ file_url: URL of the file (from upload_file() or external URL)
1423
+ title: Title for the attachment
1424
+ subtitle: Optional subtitle for the attachment
1425
+ comment_body: Optional comment text to include with the attachment
1426
+
1427
+ Returns:
1428
+ Dictionary with attachment details including id, title, url, etc.
1429
+
1430
+ Raises:
1431
+ ValueError: If attachment creation fails or issue not found
1432
+
1433
+ """
1434
+ # Resolve issue identifier to UUID
1435
+ issue_uuid = await self._resolve_issue_id(issue_id)
1436
+ if not issue_uuid:
1437
+ raise ValueError(f"Issue '{issue_id}' not found")
1438
+
1439
+ # Build attachment input
1440
+ attachment_input: dict[str, Any] = {
1441
+ "issueId": issue_uuid,
1442
+ "title": title,
1443
+ "url": file_url,
1444
+ }
1445
+
1446
+ if subtitle:
1447
+ attachment_input["subtitle"] = subtitle
1448
+
1449
+ if comment_body:
1450
+ attachment_input["commentBody"] = comment_body
1451
+
1452
+ # Create attachment mutation
1453
+ attachment_mutation = """
1454
+ mutation AttachmentCreate($input: AttachmentCreateInput!) {
1455
+ attachmentCreate(input: $input) {
1456
+ success
1457
+ attachment {
1458
+ id
1459
+ title
1460
+ url
1461
+ subtitle
1462
+ metadata
1463
+ createdAt
1464
+ updatedAt
1465
+ }
1466
+ }
1467
+ }
1468
+ """
1469
+
1470
+ try:
1471
+ result = await self.client.execute_mutation(
1472
+ attachment_mutation, {"input": attachment_input}
1473
+ )
1474
+
1475
+ if not result["attachmentCreate"]["success"]:
1476
+ raise ValueError(f"Failed to attach file to issue '{issue_id}'")
1477
+
1478
+ attachment = result["attachmentCreate"]["attachment"]
1479
+ logging.getLogger(__name__).info(
1480
+ f"Successfully attached file '{title}' to issue '{issue_id}'"
1481
+ )
1482
+ return attachment
1483
+
1484
+ except Exception as e:
1485
+ raise ValueError(f"Failed to attach file to issue '{issue_id}': {e}") from e
1486
+
1487
+ async def attach_file_to_epic(
1488
+ self,
1489
+ epic_id: str,
1490
+ file_url: str,
1491
+ title: str,
1492
+ subtitle: str | None = None,
1493
+ ) -> dict[str, Any]:
1494
+ """Attach a file to a Linear project (Epic).
1495
+
1496
+ The file must already be uploaded using upload_file() or be a publicly
1497
+ accessible URL.
1498
+
1499
+ Args:
1500
+ epic_id: Linear project UUID or slug-shortid
1501
+ file_url: URL of the file (from upload_file() or external URL)
1502
+ title: Title for the attachment
1503
+ subtitle: Optional subtitle for the attachment
1504
+
1505
+ Returns:
1506
+ Dictionary with attachment details including id, title, url, etc.
1507
+
1508
+ Raises:
1509
+ ValueError: If attachment creation fails or project not found
1510
+
1511
+ """
1512
+ # Resolve project identifier to UUID
1513
+ project_uuid = await self._resolve_project_id(epic_id)
1514
+ if not project_uuid:
1515
+ raise ValueError(f"Project '{epic_id}' not found")
1516
+
1517
+ # Build attachment input (use projectId instead of issueId)
1518
+ attachment_input: dict[str, Any] = {
1519
+ "projectId": project_uuid,
1520
+ "title": title,
1521
+ "url": file_url,
1522
+ }
1523
+
1524
+ if subtitle:
1525
+ attachment_input["subtitle"] = subtitle
1526
+
1527
+ # Create attachment mutation (same as for issues)
1528
+ attachment_mutation = """
1529
+ mutation AttachmentCreate($input: AttachmentCreateInput!) {
1530
+ attachmentCreate(input: $input) {
1531
+ success
1532
+ attachment {
1533
+ id
1534
+ title
1535
+ url
1536
+ subtitle
1537
+ metadata
1538
+ createdAt
1539
+ updatedAt
1540
+ }
1541
+ }
1542
+ }
1543
+ """
1544
+
1545
+ try:
1546
+ result = await self.client.execute_mutation(
1547
+ attachment_mutation, {"input": attachment_input}
1548
+ )
1549
+
1550
+ if not result["attachmentCreate"]["success"]:
1551
+ raise ValueError(f"Failed to attach file to project '{epic_id}'")
1552
+
1553
+ attachment = result["attachmentCreate"]["attachment"]
1554
+ logging.getLogger(__name__).info(
1555
+ f"Successfully attached file '{title}' to project '{epic_id}'"
1556
+ )
1557
+ return attachment
1558
+
1559
+ except Exception as e:
1560
+ raise ValueError(
1561
+ f"Failed to attach file to project '{epic_id}': {e}"
1562
+ ) from e
1563
+
910
1564
  async def close(self) -> None:
911
1565
  """Close the adapter and clean up resources."""
912
1566
  await self.client.close()