mcp-ticketer 0.12.0__py3-none-any.whl → 2.0.1__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 (87) hide show
  1. mcp_ticketer/__init__.py +10 -10
  2. mcp_ticketer/__version__.py +1 -1
  3. mcp_ticketer/adapters/aitrackdown.py +385 -6
  4. mcp_ticketer/adapters/asana/adapter.py +108 -0
  5. mcp_ticketer/adapters/asana/mappers.py +14 -0
  6. mcp_ticketer/adapters/github.py +525 -11
  7. mcp_ticketer/adapters/hybrid.py +47 -5
  8. mcp_ticketer/adapters/jira.py +521 -0
  9. mcp_ticketer/adapters/linear/adapter.py +1784 -101
  10. mcp_ticketer/adapters/linear/client.py +85 -3
  11. mcp_ticketer/adapters/linear/mappers.py +96 -8
  12. mcp_ticketer/adapters/linear/queries.py +168 -1
  13. mcp_ticketer/adapters/linear/types.py +80 -4
  14. mcp_ticketer/analysis/__init__.py +56 -0
  15. mcp_ticketer/analysis/dependency_graph.py +255 -0
  16. mcp_ticketer/analysis/health_assessment.py +304 -0
  17. mcp_ticketer/analysis/orphaned.py +218 -0
  18. mcp_ticketer/analysis/project_status.py +594 -0
  19. mcp_ticketer/analysis/similarity.py +224 -0
  20. mcp_ticketer/analysis/staleness.py +266 -0
  21. mcp_ticketer/automation/__init__.py +11 -0
  22. mcp_ticketer/automation/project_updates.py +378 -0
  23. mcp_ticketer/cli/adapter_diagnostics.py +3 -1
  24. mcp_ticketer/cli/auggie_configure.py +17 -5
  25. mcp_ticketer/cli/codex_configure.py +97 -61
  26. mcp_ticketer/cli/configure.py +851 -103
  27. mcp_ticketer/cli/cursor_configure.py +314 -0
  28. mcp_ticketer/cli/diagnostics.py +13 -12
  29. mcp_ticketer/cli/discover.py +5 -0
  30. mcp_ticketer/cli/gemini_configure.py +17 -5
  31. mcp_ticketer/cli/init_command.py +880 -0
  32. mcp_ticketer/cli/instruction_commands.py +6 -0
  33. mcp_ticketer/cli/main.py +233 -3151
  34. mcp_ticketer/cli/mcp_configure.py +672 -98
  35. mcp_ticketer/cli/mcp_server_commands.py +415 -0
  36. mcp_ticketer/cli/platform_detection.py +77 -12
  37. mcp_ticketer/cli/platform_installer.py +536 -0
  38. mcp_ticketer/cli/project_update_commands.py +350 -0
  39. mcp_ticketer/cli/setup_command.py +639 -0
  40. mcp_ticketer/cli/simple_health.py +12 -10
  41. mcp_ticketer/cli/ticket_commands.py +264 -24
  42. mcp_ticketer/core/__init__.py +28 -6
  43. mcp_ticketer/core/adapter.py +166 -1
  44. mcp_ticketer/core/config.py +21 -21
  45. mcp_ticketer/core/exceptions.py +7 -1
  46. mcp_ticketer/core/label_manager.py +732 -0
  47. mcp_ticketer/core/mappers.py +31 -19
  48. mcp_ticketer/core/models.py +135 -0
  49. mcp_ticketer/core/onepassword_secrets.py +1 -1
  50. mcp_ticketer/core/priority_matcher.py +463 -0
  51. mcp_ticketer/core/project_config.py +132 -14
  52. mcp_ticketer/core/session_state.py +171 -0
  53. mcp_ticketer/core/state_matcher.py +592 -0
  54. mcp_ticketer/core/url_parser.py +425 -0
  55. mcp_ticketer/core/validators.py +69 -0
  56. mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
  57. mcp_ticketer/mcp/server/main.py +106 -25
  58. mcp_ticketer/mcp/server/routing.py +655 -0
  59. mcp_ticketer/mcp/server/server_sdk.py +58 -0
  60. mcp_ticketer/mcp/server/tools/__init__.py +31 -12
  61. mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
  62. mcp_ticketer/mcp/server/tools/attachment_tools.py +6 -8
  63. mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
  64. mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
  65. mcp_ticketer/mcp/server/tools/config_tools.py +1184 -136
  66. mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
  67. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
  68. mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
  69. mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
  70. mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
  71. mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
  72. mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
  73. mcp_ticketer/mcp/server/tools/search_tools.py +180 -97
  74. mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
  75. mcp_ticketer/mcp/server/tools/ticket_tools.py +1070 -123
  76. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
  77. mcp_ticketer/queue/worker.py +1 -1
  78. mcp_ticketer/utils/__init__.py +5 -0
  79. mcp_ticketer/utils/token_utils.py +246 -0
  80. mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
  81. mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
  82. mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
  83. mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
  84. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
  85. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
  86. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
  87. {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,7 @@
1
1
  """GitHub adapter implementation using REST API v3 and GraphQL API v4."""
2
2
 
3
3
  import builtins
4
+ import logging
4
5
  import re
5
6
  from datetime import datetime
6
7
  from pathlib import Path
@@ -13,6 +14,8 @@ from ..core.env_loader import load_adapter_config, validate_adapter_config
13
14
  from ..core.models import Comment, Epic, Priority, SearchQuery, Task, TicketState
14
15
  from ..core.registry import AdapterRegistry
15
16
 
17
+ logger = logging.getLogger(__name__)
18
+
16
19
 
17
20
  class GitHubStateMapping:
18
21
  """GitHub issue states and label-based extended states."""
@@ -133,6 +136,27 @@ class GitHubGraphQLQueries:
133
136
  }
134
137
  """
135
138
 
139
+ GET_PROJECT_ITERATIONS = """
140
+ query GetProjectIterations($projectId: ID!, $first: Int!, $after: String) {
141
+ node(id: $projectId) {
142
+ ... on ProjectV2 {
143
+ iterations(first: $first, after: $after) {
144
+ nodes {
145
+ id
146
+ title
147
+ startDate
148
+ duration
149
+ }
150
+ pageInfo {
151
+ hasNextPage
152
+ endCursor
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+ """
159
+
136
160
 
137
161
  class GitHubAdapter(BaseAdapter[Task]):
138
162
  """Adapter for GitHub Issues tracking system."""
@@ -141,6 +165,7 @@ class GitHubAdapter(BaseAdapter[Task]):
141
165
  """Initialize GitHub adapter.
142
166
 
143
167
  Args:
168
+ ----
144
169
  config: Configuration with:
145
170
  - token: GitHub PAT (or GITHUB_TOKEN env var)
146
171
  - owner: Repository owner (or GITHUB_OWNER env var)
@@ -208,6 +233,7 @@ class GitHubAdapter(BaseAdapter[Task]):
208
233
  """Validate that required credentials are present.
209
234
 
210
235
  Returns:
236
+ -------
211
237
  (is_valid, error_message) - Tuple of validation result and error message
212
238
 
213
239
  """
@@ -288,9 +314,11 @@ class GitHubAdapter(BaseAdapter[Task]):
288
314
  """Convert GitHub milestone to Epic model.
289
315
 
290
316
  Args:
317
+ ----
291
318
  milestone: GitHub milestone data
292
319
 
293
320
  Returns:
321
+ -------
294
322
  Epic instance
295
323
 
296
324
  """
@@ -547,30 +575,72 @@ class GitHubAdapter(BaseAdapter[Task]):
547
575
 
548
576
  return self._task_from_github_issue(created_issue)
549
577
 
550
- async def read(self, ticket_id: str) -> Task | None:
551
- """Read a GitHub issue by number."""
578
+ async def read(self, ticket_id: str) -> Task | Epic | None:
579
+ """Read a GitHub issue OR milestone by number with unified find.
580
+
581
+ Tries to find the entity in the following order:
582
+ 1. Issue (most common case) - returns Task
583
+ 2. Milestone (project/epic) - returns Epic
584
+
585
+ Args:
586
+ ----
587
+ ticket_id: GitHub issue number or milestone number (as string)
588
+
589
+ Returns:
590
+ -------
591
+ Task if issue found,
592
+ Epic if milestone found,
593
+ None if not found as either type
594
+
595
+ Examples:
596
+ --------
597
+ >>> # Read issue #123
598
+ >>> task = await adapter.read("123")
599
+ >>> isinstance(task, Task) # True
600
+ >>>
601
+ >>> # Read milestone #5
602
+ >>> epic = await adapter.read("5")
603
+ >>> isinstance(epic, Epic) # True (if 5 is milestone, not issue)
604
+
605
+ """
552
606
  # Validate credentials before attempting operation
553
607
  is_valid, error_message = self.validate_credentials()
554
608
  if not is_valid:
555
609
  raise ValueError(error_message)
556
610
 
557
611
  try:
558
- issue_number = int(ticket_id)
612
+ entity_number = int(ticket_id)
559
613
  except ValueError:
560
614
  return None
561
615
 
616
+ # Try reading as Issue first (most common case)
562
617
  try:
563
618
  response = await self.client.get(
564
- f"/repos/{self.owner}/{self.repo}/issues/{issue_number}"
619
+ f"/repos/{self.owner}/{self.repo}/issues/{entity_number}"
565
620
  )
566
- if response.status_code == 404:
567
- return None
568
- response.raise_for_status()
621
+ if response.status_code == 200:
622
+ response.raise_for_status()
623
+ issue = response.json()
624
+ logger.debug(f"Found GitHub entity as Issue: {ticket_id}")
625
+ return self._task_from_github_issue(issue)
626
+ elif response.status_code == 404:
627
+ # Not found as issue, will try milestone next
628
+ logger.debug(f"Not found as Issue ({ticket_id}), trying Milestone")
629
+ except httpx.HTTPError as e:
630
+ logger.debug(f"Error reading as Issue ({ticket_id}): {e}")
631
+
632
+ # Try reading as Milestone (Epic)
633
+ try:
634
+ milestone = await self.get_milestone(entity_number)
635
+ if milestone:
636
+ logger.debug(f"Found GitHub entity as Milestone: {ticket_id}")
637
+ return milestone
638
+ except Exception as e:
639
+ logger.debug(f"Error reading as Milestone ({ticket_id}): {e}")
569
640
 
570
- issue = response.json()
571
- return self._task_from_github_issue(issue)
572
- except httpx.HTTPError:
573
- return None
641
+ # Not found as either Issue or Milestone
642
+ logger.warning(f"GitHub entity not found: {ticket_id}")
643
+ return None
574
644
 
575
645
  async def update(self, ticket_id: str, updates: dict[str, Any]) -> Task | None:
576
646
  """Update a GitHub issue."""
@@ -1018,6 +1088,62 @@ class GitHubAdapter(BaseAdapter[Task]):
1018
1088
 
1019
1089
  return [self._milestone_to_epic(milestone) for milestone in response.json()]
1020
1090
 
1091
+ async def delete_epic(self, epic_id: str) -> bool:
1092
+ """Delete a GitHub milestone (Epic).
1093
+
1094
+ Args:
1095
+ ----
1096
+ epic_id: Milestone number (not ID) as a string
1097
+
1098
+ Returns:
1099
+ -------
1100
+ True if successfully deleted, False otherwise
1101
+
1102
+ Raises:
1103
+ ------
1104
+ ValueError: If credentials are invalid or epic_id is not a valid number
1105
+
1106
+ """
1107
+ # Validate credentials
1108
+ is_valid, error_message = self.validate_credentials()
1109
+ if not is_valid:
1110
+ raise ValueError(error_message)
1111
+
1112
+ try:
1113
+ # Extract milestone number from epic_id
1114
+ milestone_number = int(epic_id)
1115
+ except ValueError as e:
1116
+ raise ValueError(
1117
+ f"Invalid milestone number '{epic_id}'. GitHub milestones use numeric IDs."
1118
+ ) from e
1119
+
1120
+ try:
1121
+ # Delete milestone using REST API
1122
+ response = await self.client.delete(
1123
+ f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}"
1124
+ )
1125
+
1126
+ # GitHub returns 204 No Content on successful deletion
1127
+ if response.status_code == 204:
1128
+ return True
1129
+
1130
+ # Handle 404 errors gracefully
1131
+ if response.status_code == 404:
1132
+ return False
1133
+
1134
+ # Other errors - raise for visibility
1135
+ response.raise_for_status()
1136
+ return True
1137
+
1138
+ except httpx.HTTPStatusError as e:
1139
+ if e.response.status_code == 404:
1140
+ # Milestone not found
1141
+ return False
1142
+ # Re-raise other HTTP errors
1143
+ raise ValueError(f"Failed to delete milestone: {e}") from e
1144
+ except Exception as e:
1145
+ raise ValueError(f"Failed to delete milestone: {e}") from e
1146
+
1021
1147
  async def link_to_pull_request(self, issue_number: int, pr_number: int) -> bool:
1022
1148
  """Link an issue to a pull request using keywords."""
1023
1149
  # This is typically done through PR description keywords like "fixes #123"
@@ -1043,6 +1169,7 @@ class GitHubAdapter(BaseAdapter[Task]):
1043
1169
  """Create a pull request linked to an issue.
1044
1170
 
1045
1171
  Args:
1172
+ ----
1046
1173
  ticket_id: Issue number to link the PR to
1047
1174
  base_branch: Target branch for the PR (default: main)
1048
1175
  head_branch: Source branch name (auto-generated if not provided)
@@ -1051,6 +1178,7 @@ class GitHubAdapter(BaseAdapter[Task]):
1051
1178
  draft: Create as draft PR
1052
1179
 
1053
1180
  Returns:
1181
+ -------
1054
1182
  Dictionary with PR details including number, url, and branch
1055
1183
 
1056
1184
  """
@@ -1219,10 +1347,12 @@ Fixes #{issue_number}
1219
1347
  """Link an existing pull request to a ticket.
1220
1348
 
1221
1349
  Args:
1350
+ ----
1222
1351
  ticket_id: Issue number to link the PR to
1223
1352
  pr_url: GitHub PR URL to link
1224
1353
 
1225
1354
  Returns:
1355
+ -------
1226
1356
  Dictionary with link status and PR details
1227
1357
 
1228
1358
  """
@@ -1315,6 +1445,7 @@ Fixes #{issue_number}
1315
1445
  """List all labels available in the repository.
1316
1446
 
1317
1447
  Returns:
1448
+ -------
1318
1449
  List of label dictionaries with 'id', 'name', and 'color' fields
1319
1450
 
1320
1451
  """
@@ -1340,6 +1471,7 @@ Fixes #{issue_number}
1340
1471
  """Update a GitHub milestone (Epic).
1341
1472
 
1342
1473
  Args:
1474
+ ----
1343
1475
  milestone_number: Milestone number (not ID)
1344
1476
  updates: Dictionary with fields to update:
1345
1477
  - title: Milestone title
@@ -1348,9 +1480,11 @@ Fixes #{issue_number}
1348
1480
  - target_date: Due date in ISO format
1349
1481
 
1350
1482
  Returns:
1483
+ -------
1351
1484
  Updated Epic object or None if not found
1352
1485
 
1353
1486
  Raises:
1487
+ ------
1354
1488
  ValueError: If no fields to update
1355
1489
  httpx.HTTPError: If API request fails
1356
1490
 
@@ -1408,10 +1542,12 @@ Fixes #{issue_number}
1408
1542
  either a milestone number or the epic ID from the Epic object.
1409
1543
 
1410
1544
  Args:
1545
+ ----
1411
1546
  epic_id: Epic ID (e.g., "milestone-5") or milestone number as string
1412
1547
  updates: Dictionary with fields to update
1413
1548
 
1414
1549
  Returns:
1550
+ -------
1415
1551
  Updated Epic object or None if not found
1416
1552
 
1417
1553
  """
@@ -1437,18 +1573,22 @@ Fixes #{issue_number}
1437
1573
  that users can edit to add actual file attachments through the UI.
1438
1574
 
1439
1575
  Args:
1576
+ ----
1440
1577
  issue_number: Issue number
1441
1578
  file_path: Path to file to attach
1442
1579
  comment: Optional comment text (defaults to "Attached: {filename}")
1443
1580
 
1444
1581
  Returns:
1582
+ -------
1445
1583
  Dictionary with comment data and file info
1446
1584
 
1447
1585
  Raises:
1586
+ ------
1448
1587
  FileNotFoundError: If file doesn't exist
1449
1588
  ValueError: If file too large (>25 MB)
1450
1589
 
1451
1590
  Note:
1591
+ ----
1452
1592
  GitHub file size limit: 25 MB
1453
1593
  Supported: Images, videos, documents
1454
1594
 
@@ -1498,14 +1638,17 @@ Fixes #{issue_number}
1498
1638
  this method appends a markdown link to the milestone description.
1499
1639
 
1500
1640
  Args:
1641
+ ----
1501
1642
  milestone_number: Milestone number
1502
1643
  file_url: URL to the file (external or GitHub-hosted)
1503
1644
  description: Description/title for the file
1504
1645
 
1505
1646
  Returns:
1647
+ -------
1506
1648
  Updated Epic object
1507
1649
 
1508
1650
  Example:
1651
+ -------
1509
1652
  await adapter.add_attachment_reference_to_milestone(
1510
1653
  5,
1511
1654
  "https://example.com/spec.pdf",
@@ -1541,14 +1684,17 @@ Fixes #{issue_number}
1541
1684
  - Milestones: Not supported, raises NotImplementedError with guidance
1542
1685
 
1543
1686
  Args:
1687
+ ----
1544
1688
  ticket_id: Ticket identifier (issue number or milestone ID)
1545
1689
  file_path: Path to file to attach
1546
1690
  description: Optional description
1547
1691
 
1548
1692
  Returns:
1693
+ -------
1549
1694
  Attachment metadata
1550
1695
 
1551
1696
  Raises:
1697
+ ------
1552
1698
  NotImplementedError: For milestones (no native support)
1553
1699
  FileNotFoundError: If file doesn't exist
1554
1700
 
@@ -1565,6 +1711,374 @@ Fixes #{issue_number}
1565
1711
  issue_number = int(ticket_id.replace("issue-", ""))
1566
1712
  return await self.add_attachment_to_issue(issue_number, file_path, description)
1567
1713
 
1714
+ async def list_cycles(
1715
+ self, project_id: str | None = None, limit: int = 50
1716
+ ) -> builtins.list[dict[str, Any]]:
1717
+ """List GitHub Project iterations (cycles/sprints).
1718
+
1719
+ GitHub Projects V2 uses "iterations" for sprint/cycle functionality.
1720
+ Requires a project node ID (not numeric ID).
1721
+
1722
+ Args:
1723
+ ----
1724
+ project_id: GitHub Project V2 node ID (e.g., 'PVT_kwDOABcdefgh').
1725
+ This is required for Projects V2. Can be found in the
1726
+ project's GraphQL ID.
1727
+ limit: Maximum number of iterations to return (default: 50)
1728
+
1729
+ Returns:
1730
+ -------
1731
+ List of iteration dictionaries with fields:
1732
+ - id: Iteration node ID
1733
+ - title: Iteration title/name
1734
+ - startDate: Start date (ISO format)
1735
+ - duration: Duration in days
1736
+ - endDate: Calculated end date (startDate + duration)
1737
+
1738
+ Raises:
1739
+ ------
1740
+ ValueError: If project_id not provided or credentials invalid
1741
+ httpx.HTTPError: If GraphQL query fails
1742
+
1743
+ Example:
1744
+ -------
1745
+ >>> iterations = await adapter.list_cycles(
1746
+ ... project_id="PVT_kwDOABCD1234",
1747
+ ... limit=10
1748
+ ... )
1749
+ >>> for iteration in iterations:
1750
+ ... print(f"{iteration['title']}: {iteration['startDate']} ({iteration['duration']} days)")
1751
+
1752
+ Note:
1753
+ ----
1754
+ GitHub Projects V2 node IDs can be obtained via the GitHub GraphQL API.
1755
+ This is different from project numbers shown in the UI.
1756
+
1757
+ """
1758
+ # Validate credentials
1759
+ is_valid, error_message = self.validate_credentials()
1760
+ if not is_valid:
1761
+ raise ValueError(error_message)
1762
+
1763
+ if not project_id:
1764
+ raise ValueError(
1765
+ "project_id is required for GitHub Projects V2. "
1766
+ "Provide a project node ID (e.g., 'PVT_kwDOABcdefgh'). "
1767
+ "Find this via GraphQL API: query { organization(login: 'org') { "
1768
+ "projectV2(number: 1) { id } } }"
1769
+ )
1770
+
1771
+ # Execute GraphQL query to fetch iterations
1772
+ query = GitHubGraphQLQueries.GET_PROJECT_ITERATIONS
1773
+ variables = {"projectId": project_id, "first": min(limit, 100), "after": None}
1774
+
1775
+ try:
1776
+ result = await self._graphql_request(query, variables)
1777
+
1778
+ # Extract iterations from response
1779
+ project_node = result.get("node")
1780
+ if not project_node:
1781
+ raise ValueError(
1782
+ f"Project not found with ID: {project_id}. "
1783
+ "Verify the project ID is correct and you have access."
1784
+ )
1785
+
1786
+ iterations_data = project_node.get("iterations", {})
1787
+ iteration_nodes = iterations_data.get("nodes", [])
1788
+
1789
+ # Transform to standard format and calculate end dates
1790
+ iterations = []
1791
+ for iteration in iteration_nodes:
1792
+ # Calculate end date from start date + duration
1793
+ start_date = iteration.get("startDate")
1794
+ duration = iteration.get("duration", 0)
1795
+
1796
+ end_date = None
1797
+ if start_date and duration:
1798
+ from datetime import datetime, timedelta
1799
+
1800
+ start_dt = datetime.fromisoformat(start_date.replace("Z", "+00:00"))
1801
+ end_dt = start_dt + timedelta(days=duration)
1802
+ end_date = end_dt.isoformat()
1803
+
1804
+ iterations.append(
1805
+ {
1806
+ "id": iteration["id"],
1807
+ "title": iteration.get("title", ""),
1808
+ "startDate": start_date,
1809
+ "duration": duration,
1810
+ "endDate": end_date,
1811
+ }
1812
+ )
1813
+
1814
+ return iterations
1815
+
1816
+ except ValueError:
1817
+ # Re-raise validation errors
1818
+ raise
1819
+ except Exception as e:
1820
+ raise ValueError(f"Failed to list project iterations: {e}") from e
1821
+
1822
+ async def get_issue_status(self, issue_number: int) -> dict[str, Any]:
1823
+ """Get rich status information for a GitHub issue.
1824
+
1825
+ GitHub issues have binary states (open/closed) natively. Extended status
1826
+ tracking uses labels following the status:* convention (e.g., status:in_progress).
1827
+
1828
+ Args:
1829
+ ----
1830
+ issue_number: GitHub issue number
1831
+
1832
+ Returns:
1833
+ -------
1834
+ Dictionary with comprehensive status information:
1835
+ - state: Native GitHub state ('open' or 'closed')
1836
+ - status_label: Extended status from labels (in_progress, blocked, etc.)
1837
+ - extended_state: Universal TicketState value
1838
+ - labels: All issue labels
1839
+ - state_reason: For closed issues (completed or not_planned)
1840
+ - metadata: Additional issue metadata (assignees, milestone, etc.)
1841
+
1842
+ Raises:
1843
+ ------
1844
+ ValueError: If credentials invalid or issue not found
1845
+ httpx.HTTPError: If API request fails
1846
+
1847
+ Example:
1848
+ -------
1849
+ >>> status = await adapter.get_issue_status(123)
1850
+ >>> print(f"Issue #{status['number']}: {status['extended_state']}")
1851
+ >>> print(f"Native state: {status['state']}")
1852
+ >>> if status['status_label']:
1853
+ ... print(f"Label-based status: {status['status_label']}")
1854
+
1855
+ Note:
1856
+ ----
1857
+ GitHub's binary state model is extended via labels:
1858
+ - open + no label = OPEN
1859
+ - open + status:in-progress = IN_PROGRESS
1860
+ - open + status:blocked = BLOCKED
1861
+ - closed = CLOSED (check state_reason for details)
1862
+
1863
+ """
1864
+ # Validate credentials
1865
+ is_valid, error_message = self.validate_credentials()
1866
+ if not is_valid:
1867
+ raise ValueError(error_message)
1868
+
1869
+ try:
1870
+ # Fetch issue via REST API
1871
+ response = await self.client.get(
1872
+ f"/repos/{self.owner}/{self.repo}/issues/{issue_number}"
1873
+ )
1874
+
1875
+ if response.status_code == 404:
1876
+ raise ValueError(f"Issue #{issue_number} not found")
1877
+
1878
+ response.raise_for_status()
1879
+ issue = response.json()
1880
+
1881
+ # Extract labels
1882
+ labels = [label["name"] for label in issue.get("labels", [])]
1883
+
1884
+ # Derive extended state from issue data
1885
+ extended_state = self._extract_state_from_issue(issue)
1886
+
1887
+ # Find status label if present
1888
+ status_label = None
1889
+ for _state, label_name in GitHubStateMapping.STATE_LABELS.items():
1890
+ if label_name.lower() in [label.lower() for label in labels]:
1891
+ status_label = label_name
1892
+ break
1893
+
1894
+ # Build comprehensive status response
1895
+ status_info = {
1896
+ "number": issue["number"],
1897
+ "state": issue["state"], # 'open' or 'closed'
1898
+ "status_label": status_label, # Label-based extended status
1899
+ "extended_state": extended_state.value, # Universal TicketState
1900
+ "labels": labels,
1901
+ "state_reason": issue.get(
1902
+ "state_reason"
1903
+ ), # 'completed' or 'not_planned'
1904
+ "metadata": {
1905
+ "title": issue["title"],
1906
+ "url": issue["html_url"],
1907
+ "assignees": [
1908
+ assignee["login"] for assignee in issue.get("assignees", [])
1909
+ ],
1910
+ "milestone": (
1911
+ issue.get("milestone", {}).get("title")
1912
+ if issue.get("milestone")
1913
+ else None
1914
+ ),
1915
+ "created_at": issue["created_at"],
1916
+ "updated_at": issue["updated_at"],
1917
+ "closed_at": issue.get("closed_at"),
1918
+ },
1919
+ }
1920
+
1921
+ return status_info
1922
+
1923
+ except ValueError:
1924
+ # Re-raise validation errors
1925
+ raise
1926
+ except httpx.HTTPError as e:
1927
+ raise ValueError(f"Failed to get issue status: {e}") from e
1928
+
1929
+ async def list_issue_statuses(self) -> builtins.list[dict[str, Any]]:
1930
+ """List available issue statuses in GitHub.
1931
+
1932
+ Returns all possible issue statuses including native GitHub states
1933
+ and extended label-based states.
1934
+
1935
+ Returns:
1936
+ -------
1937
+ List of status dictionaries with fields:
1938
+ - name: Status name (e.g., 'open', 'in_progress', 'closed')
1939
+ - type: Status type ('native' or 'extended')
1940
+ - label: Associated label name (for extended statuses)
1941
+ - description: Human-readable description
1942
+ - category: Status category (open, in_progress, done, etc.)
1943
+
1944
+ Example:
1945
+ -------
1946
+ >>> statuses = await adapter.list_issue_statuses()
1947
+ >>> for status in statuses:
1948
+ ... print(f"{status['name']}: {status['description']}")
1949
+ ... if status['type'] == 'extended':
1950
+ ... print(f" Label: {status['label']}")
1951
+
1952
+ Note:
1953
+ ----
1954
+ GitHub natively supports only 'open' and 'closed' states.
1955
+ Extended statuses are implemented via labels following the
1956
+ status:* naming convention (e.g., status:in-progress).
1957
+
1958
+ """
1959
+ # Define native GitHub states
1960
+ statuses = [
1961
+ {
1962
+ "name": "open",
1963
+ "type": "native",
1964
+ "label": None,
1965
+ "description": "Issue is open and not yet completed",
1966
+ "category": "open",
1967
+ },
1968
+ {
1969
+ "name": "closed",
1970
+ "type": "native",
1971
+ "label": None,
1972
+ "description": "Issue is closed (completed or not planned)",
1973
+ "category": "done",
1974
+ },
1975
+ ]
1976
+
1977
+ # Add extended label-based states
1978
+ for state, label_name in GitHubStateMapping.STATE_LABELS.items():
1979
+ statuses.append(
1980
+ {
1981
+ "name": state.value,
1982
+ "type": "extended",
1983
+ "label": label_name,
1984
+ "description": f"Issue is {state.value.replace('_', ' ')} (tracked via label)",
1985
+ "category": state.value,
1986
+ }
1987
+ )
1988
+
1989
+ return statuses
1990
+
1991
+ async def list_project_labels(
1992
+ self, milestone_number: int | None = None
1993
+ ) -> builtins.list[dict[str, Any]]:
1994
+ """List labels used in a GitHub milestone (project/epic).
1995
+
1996
+ If milestone_number is provided, returns only labels used by issues
1997
+ in that milestone. Otherwise, returns all repository labels.
1998
+
1999
+ Args:
2000
+ ----
2001
+ milestone_number: Optional milestone number to filter labels.
2002
+ If None, returns all repository labels.
2003
+
2004
+ Returns:
2005
+ -------
2006
+ List of label dictionaries with fields:
2007
+ - id: Label identifier (name)
2008
+ - name: Label name
2009
+ - color: Label color (hex without #)
2010
+ - description: Label description (if available)
2011
+ - usage_count: Number of issues using this label (if milestone filtered)
2012
+
2013
+ Example:
2014
+ -------
2015
+ >>> # Get all repository labels
2016
+ >>> all_labels = await adapter.list_project_labels()
2017
+ >>> print(f"Repository has {len(all_labels)} labels")
2018
+ >>>
2019
+ >>> # Get labels used in milestone 5
2020
+ >>> milestone_labels = await adapter.list_project_labels(milestone_number=5)
2021
+ >>> for label in milestone_labels:
2022
+ ... print(f"{label['name']}: used by {label['usage_count']} issues")
2023
+
2024
+ Note:
2025
+ ----
2026
+ Labels are repository-scoped in GitHub, not milestone-scoped.
2027
+ When filtering by milestone, this method queries issues in that
2028
+ milestone and extracts their unique labels.
2029
+
2030
+ """
2031
+ # Validate credentials
2032
+ is_valid, error_message = self.validate_credentials()
2033
+ if not is_valid:
2034
+ raise ValueError(error_message)
2035
+
2036
+ try:
2037
+ if milestone_number is None:
2038
+ # Return all repository labels (delegate to existing method)
2039
+ return await self.list_labels()
2040
+
2041
+ # Query issues in the milestone
2042
+ params = {
2043
+ "milestone": str(milestone_number),
2044
+ "state": "all",
2045
+ "per_page": 100,
2046
+ }
2047
+
2048
+ response = await self.client.get(
2049
+ f"/repos/{self.owner}/{self.repo}/issues", params=params
2050
+ )
2051
+ response.raise_for_status()
2052
+ issues = response.json()
2053
+
2054
+ # Extract unique labels from issues
2055
+ label_usage = {} # {label_name: {data, count}}
2056
+ for issue in issues:
2057
+ # Skip pull requests
2058
+ if "pull_request" in issue:
2059
+ continue
2060
+
2061
+ for label in issue.get("labels", []):
2062
+ label_name = label["name"]
2063
+ if label_name not in label_usage:
2064
+ label_usage[label_name] = {
2065
+ "id": label_name,
2066
+ "name": label_name,
2067
+ "color": label["color"],
2068
+ "description": label.get("description", ""),
2069
+ "usage_count": 0,
2070
+ }
2071
+ label_usage[label_name]["usage_count"] += 1
2072
+
2073
+ # Convert to list and sort by usage count
2074
+ labels = list(label_usage.values())
2075
+ labels.sort(key=lambda x: x["usage_count"], reverse=True)
2076
+
2077
+ return labels
2078
+
2079
+ except httpx.HTTPError as e:
2080
+ raise ValueError(f"Failed to list project labels: {e}") from e
2081
+
1568
2082
  async def close(self) -> None:
1569
2083
  """Close the HTTP client connection."""
1570
2084
  await self.client.aclose()