mcp-ticketer 0.1.11__py3-none-any.whl → 0.1.13__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.

@@ -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
- parent_issue=issue.get("parent", {}).get("identifier") if issue.get("parent") else None,
640
- parent_epic=issue.get("project", {}).get("id") if issue.get("project") else None,
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'):