mcp-ticketer 0.4.11__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 (70) hide show
  1. mcp_ticketer/__version__.py +3 -3
  2. mcp_ticketer/adapters/__init__.py +2 -0
  3. mcp_ticketer/adapters/aitrackdown.py +9 -3
  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 +313 -96
  10. mcp_ticketer/adapters/jira.py +251 -1
  11. mcp_ticketer/adapters/linear/adapter.py +524 -22
  12. mcp_ticketer/adapters/linear/client.py +61 -9
  13. mcp_ticketer/adapters/linear/mappers.py +9 -3
  14. mcp_ticketer/cache/memory.py +3 -3
  15. mcp_ticketer/cli/adapter_diagnostics.py +1 -1
  16. mcp_ticketer/cli/auggie_configure.py +1 -1
  17. mcp_ticketer/cli/codex_configure.py +80 -1
  18. mcp_ticketer/cli/configure.py +33 -43
  19. mcp_ticketer/cli/diagnostics.py +18 -16
  20. mcp_ticketer/cli/discover.py +288 -21
  21. mcp_ticketer/cli/gemini_configure.py +1 -1
  22. mcp_ticketer/cli/instruction_commands.py +429 -0
  23. mcp_ticketer/cli/linear_commands.py +99 -15
  24. mcp_ticketer/cli/main.py +1199 -227
  25. mcp_ticketer/cli/mcp_configure.py +1 -1
  26. mcp_ticketer/cli/migrate_config.py +12 -8
  27. mcp_ticketer/cli/platform_commands.py +6 -6
  28. mcp_ticketer/cli/platform_detection.py +412 -0
  29. mcp_ticketer/cli/queue_commands.py +15 -15
  30. mcp_ticketer/cli/simple_health.py +1 -1
  31. mcp_ticketer/cli/ticket_commands.py +14 -13
  32. mcp_ticketer/cli/update_checker.py +313 -0
  33. mcp_ticketer/cli/utils.py +45 -41
  34. mcp_ticketer/core/__init__.py +12 -0
  35. mcp_ticketer/core/adapter.py +4 -4
  36. mcp_ticketer/core/config.py +17 -10
  37. mcp_ticketer/core/env_discovery.py +33 -3
  38. mcp_ticketer/core/env_loader.py +7 -6
  39. mcp_ticketer/core/exceptions.py +3 -3
  40. mcp_ticketer/core/http_client.py +10 -10
  41. mcp_ticketer/core/instructions.py +405 -0
  42. mcp_ticketer/core/mappers.py +1 -1
  43. mcp_ticketer/core/models.py +1 -1
  44. mcp_ticketer/core/onepassword_secrets.py +379 -0
  45. mcp_ticketer/core/project_config.py +17 -1
  46. mcp_ticketer/core/registry.py +1 -1
  47. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  48. mcp_ticketer/mcp/__init__.py +2 -2
  49. mcp_ticketer/mcp/server/__init__.py +2 -2
  50. mcp_ticketer/mcp/server/main.py +82 -69
  51. mcp_ticketer/mcp/server/tools/__init__.py +9 -0
  52. mcp_ticketer/mcp/server/tools/attachment_tools.py +63 -16
  53. mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
  54. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +154 -5
  55. mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
  56. mcp_ticketer/mcp/server/tools/ticket_tools.py +157 -4
  57. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
  58. mcp_ticketer/queue/health_monitor.py +1 -0
  59. mcp_ticketer/queue/manager.py +4 -4
  60. mcp_ticketer/queue/queue.py +3 -3
  61. mcp_ticketer/queue/run_worker.py +1 -1
  62. mcp_ticketer/queue/ticket_registry.py +2 -2
  63. mcp_ticketer/queue/worker.py +14 -12
  64. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +106 -52
  65. mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
  66. mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
  67. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
  68. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
  69. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
  70. {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/top_level.txt +0 -0
@@ -4,15 +4,19 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import logging
7
+ import mimetypes
7
8
  import os
9
+ from pathlib import Path
8
10
  from typing import Any
9
11
 
10
12
  try:
13
+ import httpx
11
14
  from gql import gql
12
15
  from gql.transport.exceptions import TransportQueryError
13
16
  except ImportError:
14
17
  gql = None
15
18
  TransportQueryError = Exception
19
+ httpx = None
16
20
 
17
21
  import builtins
18
22
 
@@ -164,7 +168,7 @@ class LinearAdapter(BaseAdapter[Task]):
164
168
  self._initialized = True
165
169
 
166
170
  except Exception as e:
167
- raise ValueError(f"Failed to initialize Linear adapter: {e}")
171
+ raise ValueError(f"Failed to initialize Linear adapter: {e}") from e
168
172
 
169
173
  async def _ensure_team_id(self) -> str:
170
174
  """Ensure we have a team ID, resolving from team_key if needed.
@@ -210,7 +214,7 @@ class LinearAdapter(BaseAdapter[Task]):
210
214
  return self.team_id
211
215
 
212
216
  except Exception as e:
213
- 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
214
218
 
215
219
  async def _resolve_project_id(self, project_identifier: str) -> str | None:
216
220
  """Resolve project identifier (slug, name, short ID, or URL) to full UUID.
@@ -300,7 +304,60 @@ class LinearAdapter(BaseAdapter[Task]):
300
304
  return None
301
305
 
302
306
  except Exception as e:
303
- raise ValueError(f"Failed to resolve project '{project_identifier}': {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
304
361
 
305
362
  async def _load_workflow_states(self, team_id: str) -> None:
306
363
  """Load and cache workflow states for the team.
@@ -325,7 +382,7 @@ class LinearAdapter(BaseAdapter[Task]):
325
382
  self._workflow_states = workflow_states
326
383
 
327
384
  except Exception as e:
328
- raise ValueError(f"Failed to load workflow states: {e}")
385
+ raise ValueError(f"Failed to load workflow states: {e}") from e
329
386
 
330
387
  async def _load_team_labels(self, team_id: str) -> None:
331
388
  """Load and cache labels for the team with retry logic.
@@ -466,23 +523,48 @@ class LinearAdapter(BaseAdapter[Task]):
466
523
  return mapping
467
524
 
468
525
  async def _get_user_id(self, user_identifier: str) -> str | None:
469
- """Get Linear user ID from email or display name.
526
+ """Get Linear user ID from email, display name, or user ID.
470
527
 
471
528
  Args:
472
- user_identifier: Email address or display name
529
+ user_identifier: Email, display name, or user ID
473
530
 
474
531
  Returns:
475
532
  Linear user ID or None if not found
476
533
 
477
534
  """
478
- # Try to get user by email first
535
+ if not user_identifier:
536
+ return None
537
+
538
+ # Try email lookup first (most specific)
479
539
  user = await self.client.get_user_by_email(user_identifier)
480
540
  if user:
481
541
  return user["id"]
482
542
 
483
- # If not found by email, could implement search by display name
484
- # For now, assume the identifier is already a user ID
485
- 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
486
568
 
487
569
  # CRUD Operations
488
570
 
@@ -515,7 +597,13 @@ class LinearAdapter(BaseAdapter[Task]):
515
597
  return await self._create_task(ticket)
516
598
 
517
599
  async def _create_task(self, task: Task) -> Task:
518
- """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)
519
607
 
520
608
  Args:
521
609
  task: Task to create
@@ -565,19 +653,36 @@ class LinearAdapter(BaseAdapter[Task]):
565
653
  # Remove projectId if we couldn't resolve it
566
654
  issue_input.pop("projectId", None)
567
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
+
568
671
  try:
569
672
  result = await self.client.execute_mutation(
570
673
  CREATE_ISSUE_MUTATION, {"input": issue_input}
571
674
  )
572
675
 
573
676
  if not result["issueCreate"]["success"]:
574
- 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}")
575
679
 
576
680
  created_issue = result["issueCreate"]["issue"]
577
681
  return map_linear_issue_to_task(created_issue)
578
682
 
579
683
  except Exception as e:
580
- 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
581
686
 
582
687
  async def _create_epic(self, epic: Epic) -> Epic:
583
688
  """Create a Linear project from an Epic.
@@ -642,7 +747,98 @@ class LinearAdapter(BaseAdapter[Task]):
642
747
  return map_linear_project_to_epic(created_project)
643
748
 
644
749
  except Exception as e:
645
- 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
646
842
 
647
843
  async def read(self, ticket_id: str) -> Task | None:
648
844
  """Read a Linear issue by identifier with full details.
@@ -738,10 +934,23 @@ class LinearAdapter(BaseAdapter[Task]):
738
934
  update_input["assigneeId"] = user_id
739
935
 
740
936
  # Resolve label names to IDs if provided
741
- if "tags" in updates and updates["tags"]:
742
- label_ids = await self._resolve_label_ids(updates["tags"])
743
- if label_ids:
744
- 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
+ )
745
954
 
746
955
  # Execute update
747
956
  result = await self.client.execute_mutation(
@@ -755,7 +964,7 @@ class LinearAdapter(BaseAdapter[Task]):
755
964
  return map_linear_issue_to_task(updated_issue)
756
965
 
757
966
  except Exception as e:
758
- raise ValueError(f"Failed to update Linear issue: {e}")
967
+ raise ValueError(f"Failed to update Linear issue: {e}") from e
759
968
 
760
969
  async def delete(self, ticket_id: str) -> bool:
761
970
  """Delete a Linear issue (archive it).
@@ -832,7 +1041,7 @@ class LinearAdapter(BaseAdapter[Task]):
832
1041
  return tasks
833
1042
 
834
1043
  except Exception as e:
835
- raise ValueError(f"Failed to list Linear issues: {e}")
1044
+ raise ValueError(f"Failed to list Linear issues: {e}") from e
836
1045
 
837
1046
  async def search(self, query: SearchQuery) -> builtins.list[Task]:
838
1047
  """Search Linear issues using comprehensive filters.
@@ -896,7 +1105,7 @@ class LinearAdapter(BaseAdapter[Task]):
896
1105
  return tasks
897
1106
 
898
1107
  except Exception as e:
899
- raise ValueError(f"Failed to search Linear issues: {e}")
1108
+ raise ValueError(f"Failed to search Linear issues: {e}") from e
900
1109
 
901
1110
  async def transition_state(
902
1111
  self, ticket_id: str, target_state: TicketState
@@ -1001,7 +1210,7 @@ class LinearAdapter(BaseAdapter[Task]):
1001
1210
  return map_linear_comment_to_comment(created_comment, comment.ticket_id)
1002
1211
 
1003
1212
  except Exception as e:
1004
- raise ValueError(f"Failed to add comment: {e}")
1213
+ raise ValueError(f"Failed to add comment: {e}") from e
1005
1214
 
1006
1215
  async def get_comments(
1007
1216
  self, ticket_id: str, limit: int = 10, offset: int = 0
@@ -1059,6 +1268,299 @@ class LinearAdapter(BaseAdapter[Task]):
1059
1268
  except Exception:
1060
1269
  return []
1061
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
+
1062
1564
  async def close(self) -> None:
1063
1565
  """Close the adapter and clean up resources."""
1064
1566
  await self.client.close()