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.
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +9 -3
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1308 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +334 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +313 -96
- mcp_ticketer/adapters/jira.py +251 -1
- mcp_ticketer/adapters/linear/adapter.py +524 -22
- mcp_ticketer/adapters/linear/client.py +61 -9
- mcp_ticketer/adapters/linear/mappers.py +9 -3
- mcp_ticketer/cache/memory.py +3 -3
- mcp_ticketer/cli/adapter_diagnostics.py +1 -1
- mcp_ticketer/cli/auggie_configure.py +1 -1
- mcp_ticketer/cli/codex_configure.py +80 -1
- mcp_ticketer/cli/configure.py +33 -43
- mcp_ticketer/cli/diagnostics.py +18 -16
- mcp_ticketer/cli/discover.py +288 -21
- mcp_ticketer/cli/gemini_configure.py +1 -1
- mcp_ticketer/cli/instruction_commands.py +429 -0
- mcp_ticketer/cli/linear_commands.py +99 -15
- mcp_ticketer/cli/main.py +1199 -227
- mcp_ticketer/cli/mcp_configure.py +1 -1
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +6 -6
- mcp_ticketer/cli/platform_detection.py +412 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/simple_health.py +1 -1
- mcp_ticketer/cli/ticket_commands.py +14 -13
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +45 -41
- mcp_ticketer/core/__init__.py +12 -0
- mcp_ticketer/core/adapter.py +4 -4
- mcp_ticketer/core/config.py +17 -10
- mcp_ticketer/core/env_discovery.py +33 -3
- mcp_ticketer/core/env_loader.py +7 -6
- mcp_ticketer/core/exceptions.py +3 -3
- mcp_ticketer/core/http_client.py +10 -10
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/mappers.py +1 -1
- mcp_ticketer/core/models.py +1 -1
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/project_config.py +17 -1
- mcp_ticketer/core/registry.py +1 -1
- mcp_ticketer/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +2 -2
- mcp_ticketer/mcp/server/__init__.py +2 -2
- mcp_ticketer/mcp/server/main.py +82 -69
- mcp_ticketer/mcp/server/tools/__init__.py +9 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +63 -16
- mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +154 -5
- mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +157 -4
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
- mcp_ticketer/queue/health_monitor.py +1 -0
- mcp_ticketer/queue/manager.py +4 -4
- mcp_ticketer/queue/queue.py +3 -3
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +2 -2
- mcp_ticketer/queue/worker.py +14 -12
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +106 -52
- mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
- mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
- {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(
|
|
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
|
|
526
|
+
"""Get Linear user ID from email, display name, or user ID.
|
|
470
527
|
|
|
471
528
|
Args:
|
|
472
|
-
user_identifier: Email
|
|
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
|
-
|
|
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
|
-
#
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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()
|