mcp-ticketer 0.1.12__py3-none-any.whl → 0.1.14__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 +1 -1
- mcp_ticketer/adapters/linear.py +427 -3
- mcp_ticketer/cli/discover.py +402 -0
- mcp_ticketer/cli/main.py +311 -87
- mcp_ticketer/core/__init__.py +2 -1
- mcp_ticketer/core/adapter.py +155 -2
- mcp_ticketer/core/env_discovery.py +555 -0
- mcp_ticketer/core/models.py +58 -6
- mcp_ticketer/core/project_config.py +65 -12
- {mcp_ticketer-0.1.12.dist-info → mcp_ticketer-0.1.14.dist-info}/METADATA +1 -1
- {mcp_ticketer-0.1.12.dist-info → mcp_ticketer-0.1.14.dist-info}/RECORD +15 -13
- {mcp_ticketer-0.1.12.dist-info → mcp_ticketer-0.1.14.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.12.dist-info → mcp_ticketer-0.1.14.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.12.dist-info → mcp_ticketer-0.1.14.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.12.dist-info → mcp_ticketer-0.1.14.dist-info}/top_level.txt +0 -0
mcp_ticketer/__version__.py
CHANGED
mcp_ticketer/adapters/linear.py
CHANGED
|
@@ -12,7 +12,7 @@ from gql.transport.exceptions import TransportQueryError
|
|
|
12
12
|
import httpx
|
|
13
13
|
|
|
14
14
|
from ..core.adapter import BaseAdapter
|
|
15
|
-
from ..core.models import Epic, Task, Comment, SearchQuery, TicketState, Priority
|
|
15
|
+
from ..core.models import Epic, Task, Comment, SearchQuery, TicketState, Priority, TicketType
|
|
16
16
|
from ..core.registry import AdapterRegistry
|
|
17
17
|
|
|
18
18
|
|
|
@@ -629,6 +629,20 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
629
629
|
child_ids = [child["identifier"] for child in issue["children"]["nodes"]]
|
|
630
630
|
metadata["linear"]["child_issues"] = child_ids
|
|
631
631
|
|
|
632
|
+
# Determine ticket type based on parent relationships
|
|
633
|
+
ticket_type = TicketType.ISSUE
|
|
634
|
+
parent_issue_id = None
|
|
635
|
+
parent_epic_id = None
|
|
636
|
+
|
|
637
|
+
if issue.get("parent"):
|
|
638
|
+
# Has a parent issue, so this is a sub-task
|
|
639
|
+
ticket_type = TicketType.TASK
|
|
640
|
+
parent_issue_id = issue["parent"]["identifier"]
|
|
641
|
+
elif issue.get("project"):
|
|
642
|
+
# Has a project but no parent, so it's a standard issue under an epic
|
|
643
|
+
ticket_type = TicketType.ISSUE
|
|
644
|
+
parent_epic_id = issue["project"]["id"]
|
|
645
|
+
|
|
632
646
|
return Task(
|
|
633
647
|
id=issue["identifier"],
|
|
634
648
|
title=issue["title"],
|
|
@@ -636,9 +650,11 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
636
650
|
state=state,
|
|
637
651
|
priority=priority,
|
|
638
652
|
tags=tags,
|
|
639
|
-
|
|
640
|
-
|
|
653
|
+
ticket_type=ticket_type,
|
|
654
|
+
parent_issue=parent_issue_id,
|
|
655
|
+
parent_epic=parent_epic_id,
|
|
641
656
|
assignee=issue.get("assignee", {}).get("email") if issue.get("assignee") else None,
|
|
657
|
+
children=child_ids,
|
|
642
658
|
estimated_hours=issue.get("estimate"),
|
|
643
659
|
created_at=datetime.fromisoformat(issue["createdAt"].replace("Z", "+00:00"))
|
|
644
660
|
if issue.get("createdAt") else None,
|
|
@@ -647,6 +663,52 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
647
663
|
metadata=metadata,
|
|
648
664
|
)
|
|
649
665
|
|
|
666
|
+
def _epic_from_linear_project(self, project: Dict[str, Any]) -> Epic:
|
|
667
|
+
"""Convert Linear project to universal Epic."""
|
|
668
|
+
# Map project state to ticket state
|
|
669
|
+
project_state = project.get("state", "planned").lower()
|
|
670
|
+
state_mapping = {
|
|
671
|
+
"planned": TicketState.OPEN,
|
|
672
|
+
"started": TicketState.IN_PROGRESS,
|
|
673
|
+
"paused": TicketState.WAITING,
|
|
674
|
+
"completed": TicketState.DONE,
|
|
675
|
+
"canceled": TicketState.CLOSED,
|
|
676
|
+
}
|
|
677
|
+
state = state_mapping.get(project_state, TicketState.OPEN)
|
|
678
|
+
|
|
679
|
+
# Extract teams
|
|
680
|
+
teams = []
|
|
681
|
+
if project.get("teams") and project["teams"].get("nodes"):
|
|
682
|
+
teams = [team["name"] for team in project["teams"]["nodes"]]
|
|
683
|
+
|
|
684
|
+
metadata = {
|
|
685
|
+
"linear": {
|
|
686
|
+
"id": project["id"],
|
|
687
|
+
"state": project.get("state"),
|
|
688
|
+
"url": project.get("url"),
|
|
689
|
+
"icon": project.get("icon"),
|
|
690
|
+
"color": project.get("color"),
|
|
691
|
+
"target_date": project.get("targetDate"),
|
|
692
|
+
"started_at": project.get("startedAt"),
|
|
693
|
+
"completed_at": project.get("completedAt"),
|
|
694
|
+
"teams": teams,
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return Epic(
|
|
699
|
+
id=project["id"],
|
|
700
|
+
title=project["name"],
|
|
701
|
+
description=project.get("description"),
|
|
702
|
+
state=state,
|
|
703
|
+
ticket_type=TicketType.EPIC,
|
|
704
|
+
tags=[f"team:{team}" for team in teams],
|
|
705
|
+
created_at=datetime.fromisoformat(project["createdAt"].replace("Z", "+00:00"))
|
|
706
|
+
if project.get("createdAt") else None,
|
|
707
|
+
updated_at=datetime.fromisoformat(project["updatedAt"].replace("Z", "+00:00"))
|
|
708
|
+
if project.get("updatedAt") else None,
|
|
709
|
+
metadata=metadata,
|
|
710
|
+
)
|
|
711
|
+
|
|
650
712
|
async def create(self, ticket: Task) -> Task:
|
|
651
713
|
"""Create a new Linear issue with full field support."""
|
|
652
714
|
team_id = await self._ensure_team_id()
|
|
@@ -1563,6 +1625,368 @@ class LinearAdapter(BaseAdapter[Task]):
|
|
|
1563
1625
|
except:
|
|
1564
1626
|
return None
|
|
1565
1627
|
|
|
1628
|
+
# Epic/Issue/Task Hierarchy Methods (Linear: Project = Epic, Issue = Issue, Sub-issue = Task)
|
|
1629
|
+
|
|
1630
|
+
async def create_epic(
|
|
1631
|
+
self,
|
|
1632
|
+
title: str,
|
|
1633
|
+
description: Optional[str] = None,
|
|
1634
|
+
**kwargs
|
|
1635
|
+
) -> Optional[Epic]:
|
|
1636
|
+
"""Create epic (Linear Project).
|
|
1637
|
+
|
|
1638
|
+
Args:
|
|
1639
|
+
title: Epic/Project name
|
|
1640
|
+
description: Epic/Project description
|
|
1641
|
+
**kwargs: Additional fields (e.g., target_date, lead_id)
|
|
1642
|
+
|
|
1643
|
+
Returns:
|
|
1644
|
+
Created epic or None if failed
|
|
1645
|
+
"""
|
|
1646
|
+
team_id = await self._ensure_team_id()
|
|
1647
|
+
|
|
1648
|
+
create_query = gql(PROJECT_FRAGMENT + """
|
|
1649
|
+
mutation CreateProject($input: ProjectCreateInput!) {
|
|
1650
|
+
projectCreate(input: $input) {
|
|
1651
|
+
success
|
|
1652
|
+
project {
|
|
1653
|
+
...ProjectFields
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
""")
|
|
1658
|
+
|
|
1659
|
+
project_input = {
|
|
1660
|
+
"name": title,
|
|
1661
|
+
"teamIds": [team_id],
|
|
1662
|
+
}
|
|
1663
|
+
if description:
|
|
1664
|
+
project_input["description"] = description
|
|
1665
|
+
|
|
1666
|
+
# Handle additional Linear-specific fields
|
|
1667
|
+
if "target_date" in kwargs:
|
|
1668
|
+
project_input["targetDate"] = kwargs["target_date"]
|
|
1669
|
+
if "lead_id" in kwargs:
|
|
1670
|
+
project_input["leadId"] = kwargs["lead_id"]
|
|
1671
|
+
|
|
1672
|
+
async with self.client as session:
|
|
1673
|
+
result = await session.execute(
|
|
1674
|
+
create_query,
|
|
1675
|
+
variable_values={"input": project_input}
|
|
1676
|
+
)
|
|
1677
|
+
|
|
1678
|
+
if not result["projectCreate"]["success"]:
|
|
1679
|
+
return None
|
|
1680
|
+
|
|
1681
|
+
project = result["projectCreate"]["project"]
|
|
1682
|
+
return self._epic_from_linear_project(project)
|
|
1683
|
+
|
|
1684
|
+
async def get_epic(self, epic_id: str) -> Optional[Epic]:
|
|
1685
|
+
"""Get epic (Linear Project) by ID.
|
|
1686
|
+
|
|
1687
|
+
Args:
|
|
1688
|
+
epic_id: Linear project ID
|
|
1689
|
+
|
|
1690
|
+
Returns:
|
|
1691
|
+
Epic if found, None otherwise
|
|
1692
|
+
"""
|
|
1693
|
+
query = gql(PROJECT_FRAGMENT + """
|
|
1694
|
+
query GetProject($id: String!) {
|
|
1695
|
+
project(id: $id) {
|
|
1696
|
+
...ProjectFields
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
""")
|
|
1700
|
+
|
|
1701
|
+
try:
|
|
1702
|
+
async with self.client as session:
|
|
1703
|
+
result = await session.execute(
|
|
1704
|
+
query,
|
|
1705
|
+
variable_values={"id": epic_id}
|
|
1706
|
+
)
|
|
1707
|
+
|
|
1708
|
+
if result.get("project"):
|
|
1709
|
+
return self._epic_from_linear_project(result["project"])
|
|
1710
|
+
except TransportQueryError:
|
|
1711
|
+
pass
|
|
1712
|
+
|
|
1713
|
+
return None
|
|
1714
|
+
|
|
1715
|
+
async def list_epics(self, **kwargs) -> List[Epic]:
|
|
1716
|
+
"""List all Linear Projects (Epics).
|
|
1717
|
+
|
|
1718
|
+
Args:
|
|
1719
|
+
**kwargs: Optional filters (team_id, state)
|
|
1720
|
+
|
|
1721
|
+
Returns:
|
|
1722
|
+
List of epics
|
|
1723
|
+
"""
|
|
1724
|
+
team_id = await self._ensure_team_id()
|
|
1725
|
+
|
|
1726
|
+
# Build project filter
|
|
1727
|
+
project_filter = {"team": {"id": {"eq": team_id}}}
|
|
1728
|
+
|
|
1729
|
+
if "state" in kwargs:
|
|
1730
|
+
# Map TicketState to Linear project state
|
|
1731
|
+
state_mapping = {
|
|
1732
|
+
TicketState.OPEN: "planned",
|
|
1733
|
+
TicketState.IN_PROGRESS: "started",
|
|
1734
|
+
TicketState.WAITING: "paused",
|
|
1735
|
+
TicketState.DONE: "completed",
|
|
1736
|
+
TicketState.CLOSED: "canceled",
|
|
1737
|
+
}
|
|
1738
|
+
linear_state = state_mapping.get(kwargs["state"], "planned")
|
|
1739
|
+
project_filter["state"] = {"eq": linear_state}
|
|
1740
|
+
|
|
1741
|
+
query = gql(PROJECT_FRAGMENT + """
|
|
1742
|
+
query ListProjects($filter: ProjectFilter, $first: Int!) {
|
|
1743
|
+
projects(filter: $filter, first: $first, orderBy: updatedAt) {
|
|
1744
|
+
nodes {
|
|
1745
|
+
...ProjectFields
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
""")
|
|
1750
|
+
|
|
1751
|
+
async with self.client as session:
|
|
1752
|
+
result = await session.execute(
|
|
1753
|
+
query,
|
|
1754
|
+
variable_values={
|
|
1755
|
+
"filter": project_filter,
|
|
1756
|
+
"first": kwargs.get("limit", 50)
|
|
1757
|
+
}
|
|
1758
|
+
)
|
|
1759
|
+
|
|
1760
|
+
epics = []
|
|
1761
|
+
for project in result["projects"]["nodes"]:
|
|
1762
|
+
epics.append(self._epic_from_linear_project(project))
|
|
1763
|
+
|
|
1764
|
+
return epics
|
|
1765
|
+
|
|
1766
|
+
async def create_issue(
|
|
1767
|
+
self,
|
|
1768
|
+
title: str,
|
|
1769
|
+
description: Optional[str] = None,
|
|
1770
|
+
epic_id: Optional[str] = None,
|
|
1771
|
+
**kwargs
|
|
1772
|
+
) -> Optional[Task]:
|
|
1773
|
+
"""Create issue and optionally associate with project (epic).
|
|
1774
|
+
|
|
1775
|
+
Args:
|
|
1776
|
+
title: Issue title
|
|
1777
|
+
description: Issue description
|
|
1778
|
+
epic_id: Optional Linear project ID (epic)
|
|
1779
|
+
**kwargs: Additional fields
|
|
1780
|
+
|
|
1781
|
+
Returns:
|
|
1782
|
+
Created issue or None if failed
|
|
1783
|
+
"""
|
|
1784
|
+
# Use existing create method but ensure it's created as an ISSUE type
|
|
1785
|
+
task = Task(
|
|
1786
|
+
title=title,
|
|
1787
|
+
description=description,
|
|
1788
|
+
ticket_type=TicketType.ISSUE,
|
|
1789
|
+
parent_epic=epic_id,
|
|
1790
|
+
**{k: v for k, v in kwargs.items() if k in Task.__fields__}
|
|
1791
|
+
)
|
|
1792
|
+
|
|
1793
|
+
# The existing create method handles project association via parent_epic field
|
|
1794
|
+
return await self.create(task)
|
|
1795
|
+
|
|
1796
|
+
async def list_issues_by_epic(self, epic_id: str) -> List[Task]:
|
|
1797
|
+
"""List all issues in a Linear project (epic).
|
|
1798
|
+
|
|
1799
|
+
Args:
|
|
1800
|
+
epic_id: Linear project ID
|
|
1801
|
+
|
|
1802
|
+
Returns:
|
|
1803
|
+
List of issues belonging to project
|
|
1804
|
+
"""
|
|
1805
|
+
query = gql(ISSUE_LIST_FRAGMENTS + """
|
|
1806
|
+
query GetProjectIssues($projectId: String!, $first: Int!) {
|
|
1807
|
+
project(id: $projectId) {
|
|
1808
|
+
issues(first: $first) {
|
|
1809
|
+
nodes {
|
|
1810
|
+
...IssueCompactFields
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
""")
|
|
1816
|
+
|
|
1817
|
+
try:
|
|
1818
|
+
async with self.client as session:
|
|
1819
|
+
result = await session.execute(
|
|
1820
|
+
query,
|
|
1821
|
+
variable_values={"projectId": epic_id, "first": 100}
|
|
1822
|
+
)
|
|
1823
|
+
|
|
1824
|
+
if not result.get("project"):
|
|
1825
|
+
return []
|
|
1826
|
+
|
|
1827
|
+
issues = []
|
|
1828
|
+
for issue_data in result["project"]["issues"]["nodes"]:
|
|
1829
|
+
task = self._task_from_linear_issue(issue_data)
|
|
1830
|
+
# Only return issues (not sub-tasks)
|
|
1831
|
+
if task.is_issue():
|
|
1832
|
+
issues.append(task)
|
|
1833
|
+
|
|
1834
|
+
return issues
|
|
1835
|
+
except TransportQueryError:
|
|
1836
|
+
return []
|
|
1837
|
+
|
|
1838
|
+
async def create_task(
|
|
1839
|
+
self,
|
|
1840
|
+
title: str,
|
|
1841
|
+
parent_id: str,
|
|
1842
|
+
description: Optional[str] = None,
|
|
1843
|
+
**kwargs
|
|
1844
|
+
) -> Optional[Task]:
|
|
1845
|
+
"""Create task as sub-issue of parent.
|
|
1846
|
+
|
|
1847
|
+
Args:
|
|
1848
|
+
title: Task title
|
|
1849
|
+
parent_id: Required parent issue identifier (e.g., 'BTA-123')
|
|
1850
|
+
description: Task description
|
|
1851
|
+
**kwargs: Additional fields
|
|
1852
|
+
|
|
1853
|
+
Returns:
|
|
1854
|
+
Created task or None if failed
|
|
1855
|
+
|
|
1856
|
+
Raises:
|
|
1857
|
+
ValueError: If parent_id is not provided
|
|
1858
|
+
"""
|
|
1859
|
+
if not parent_id:
|
|
1860
|
+
raise ValueError("Tasks must have a parent_id (issue identifier)")
|
|
1861
|
+
|
|
1862
|
+
# Get parent issue's Linear ID
|
|
1863
|
+
parent_query = gql("""
|
|
1864
|
+
query GetIssueId($identifier: String!) {
|
|
1865
|
+
issue(id: $identifier) {
|
|
1866
|
+
id
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
""")
|
|
1870
|
+
|
|
1871
|
+
async with self.client as session:
|
|
1872
|
+
parent_result = await session.execute(
|
|
1873
|
+
parent_query,
|
|
1874
|
+
variable_values={"identifier": parent_id}
|
|
1875
|
+
)
|
|
1876
|
+
|
|
1877
|
+
if not parent_result.get("issue"):
|
|
1878
|
+
raise ValueError(f"Parent issue {parent_id} not found")
|
|
1879
|
+
|
|
1880
|
+
parent_linear_id = parent_result["issue"]["id"]
|
|
1881
|
+
|
|
1882
|
+
# Create task using existing create method
|
|
1883
|
+
task = Task(
|
|
1884
|
+
title=title,
|
|
1885
|
+
description=description,
|
|
1886
|
+
ticket_type=TicketType.TASK,
|
|
1887
|
+
parent_issue=parent_id,
|
|
1888
|
+
**{k: v for k, v in kwargs.items() if k in Task.__fields__}
|
|
1889
|
+
)
|
|
1890
|
+
|
|
1891
|
+
# Validate hierarchy
|
|
1892
|
+
errors = task.validate_hierarchy()
|
|
1893
|
+
if errors:
|
|
1894
|
+
raise ValueError(f"Invalid task hierarchy: {'; '.join(errors)}")
|
|
1895
|
+
|
|
1896
|
+
# Create with parent relationship
|
|
1897
|
+
team_id = await self._ensure_team_id()
|
|
1898
|
+
states = await self._get_workflow_states()
|
|
1899
|
+
|
|
1900
|
+
# Map state to Linear state ID
|
|
1901
|
+
linear_state_type = self._map_state_to_linear(task.state)
|
|
1902
|
+
state_data = states.get(linear_state_type)
|
|
1903
|
+
if not state_data:
|
|
1904
|
+
state_data = states.get("backlog")
|
|
1905
|
+
state_id = state_data["id"] if state_data else None
|
|
1906
|
+
|
|
1907
|
+
# Build issue input (sub-issue)
|
|
1908
|
+
issue_input = {
|
|
1909
|
+
"title": task.title,
|
|
1910
|
+
"teamId": team_id,
|
|
1911
|
+
"parentId": parent_linear_id, # This makes it a sub-issue
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
if task.description:
|
|
1915
|
+
issue_input["description"] = task.description
|
|
1916
|
+
|
|
1917
|
+
if state_id:
|
|
1918
|
+
issue_input["stateId"] = state_id
|
|
1919
|
+
|
|
1920
|
+
# Set priority
|
|
1921
|
+
if task.priority:
|
|
1922
|
+
issue_input["priority"] = LinearPriorityMapping.TO_LINEAR.get(task.priority, 3)
|
|
1923
|
+
|
|
1924
|
+
# Create sub-issue mutation
|
|
1925
|
+
create_query = gql(ALL_FRAGMENTS + """
|
|
1926
|
+
mutation CreateSubIssue($input: IssueCreateInput!) {
|
|
1927
|
+
issueCreate(input: $input) {
|
|
1928
|
+
success
|
|
1929
|
+
issue {
|
|
1930
|
+
...IssueFullFields
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
""")
|
|
1935
|
+
|
|
1936
|
+
async with self.client as session:
|
|
1937
|
+
result = await session.execute(
|
|
1938
|
+
create_query,
|
|
1939
|
+
variable_values={"input": issue_input}
|
|
1940
|
+
)
|
|
1941
|
+
|
|
1942
|
+
if not result["issueCreate"]["success"]:
|
|
1943
|
+
return None
|
|
1944
|
+
|
|
1945
|
+
created_issue = result["issueCreate"]["issue"]
|
|
1946
|
+
return self._task_from_linear_issue(created_issue)
|
|
1947
|
+
|
|
1948
|
+
async def list_tasks_by_issue(self, issue_id: str) -> List[Task]:
|
|
1949
|
+
"""List all tasks (sub-issues) under an issue.
|
|
1950
|
+
|
|
1951
|
+
Args:
|
|
1952
|
+
issue_id: Issue identifier (e.g., 'BTA-123')
|
|
1953
|
+
|
|
1954
|
+
Returns:
|
|
1955
|
+
List of tasks belonging to issue
|
|
1956
|
+
"""
|
|
1957
|
+
query = gql(ISSUE_LIST_FRAGMENTS + """
|
|
1958
|
+
query GetIssueSubtasks($identifier: String!) {
|
|
1959
|
+
issue(id: $identifier) {
|
|
1960
|
+
children {
|
|
1961
|
+
nodes {
|
|
1962
|
+
...IssueCompactFields
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
""")
|
|
1968
|
+
|
|
1969
|
+
try:
|
|
1970
|
+
async with self.client as session:
|
|
1971
|
+
result = await session.execute(
|
|
1972
|
+
query,
|
|
1973
|
+
variable_values={"identifier": issue_id}
|
|
1974
|
+
)
|
|
1975
|
+
|
|
1976
|
+
if not result.get("issue"):
|
|
1977
|
+
return []
|
|
1978
|
+
|
|
1979
|
+
tasks = []
|
|
1980
|
+
for child_data in result["issue"]["children"]["nodes"]:
|
|
1981
|
+
task = self._task_from_linear_issue(child_data)
|
|
1982
|
+
# Only return tasks (sub-issues)
|
|
1983
|
+
if task.is_task():
|
|
1984
|
+
tasks.append(task)
|
|
1985
|
+
|
|
1986
|
+
return tasks
|
|
1987
|
+
except TransportQueryError:
|
|
1988
|
+
return []
|
|
1989
|
+
|
|
1566
1990
|
async def close(self) -> None:
|
|
1567
1991
|
"""Close the GraphQL client connection."""
|
|
1568
1992
|
if hasattr(self.client, 'close_async'):
|