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.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +1 -1
- mcp_ticketer/adapters/aitrackdown.py +385 -6
- mcp_ticketer/adapters/asana/adapter.py +108 -0
- mcp_ticketer/adapters/asana/mappers.py +14 -0
- mcp_ticketer/adapters/github.py +525 -11
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira.py +521 -0
- mcp_ticketer/adapters/linear/adapter.py +1784 -101
- mcp_ticketer/adapters/linear/client.py +85 -3
- mcp_ticketer/adapters/linear/mappers.py +96 -8
- mcp_ticketer/adapters/linear/queries.py +168 -1
- mcp_ticketer/adapters/linear/types.py +80 -4
- mcp_ticketer/analysis/__init__.py +56 -0
- mcp_ticketer/analysis/dependency_graph.py +255 -0
- mcp_ticketer/analysis/health_assessment.py +304 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/project_status.py +594 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/automation/__init__.py +11 -0
- mcp_ticketer/automation/project_updates.py +378 -0
- mcp_ticketer/cli/adapter_diagnostics.py +3 -1
- mcp_ticketer/cli/auggie_configure.py +17 -5
- mcp_ticketer/cli/codex_configure.py +97 -61
- mcp_ticketer/cli/configure.py +851 -103
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +13 -12
- mcp_ticketer/cli/discover.py +5 -0
- mcp_ticketer/cli/gemini_configure.py +17 -5
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/instruction_commands.py +6 -0
- mcp_ticketer/cli/main.py +233 -3151
- mcp_ticketer/cli/mcp_configure.py +672 -98
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/platform_detection.py +77 -12
- mcp_ticketer/cli/platform_installer.py +536 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +12 -10
- mcp_ticketer/cli/ticket_commands.py +264 -24
- mcp_ticketer/core/__init__.py +28 -6
- mcp_ticketer/core/adapter.py +166 -1
- mcp_ticketer/core/config.py +21 -21
- mcp_ticketer/core/exceptions.py +7 -1
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +31 -19
- mcp_ticketer/core/models.py +135 -0
- mcp_ticketer/core/onepassword_secrets.py +1 -1
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +132 -14
- mcp_ticketer/core/session_state.py +171 -0
- mcp_ticketer/core/state_matcher.py +592 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +106 -25
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +31 -12
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +6 -8
- mcp_ticketer/mcp/server/tools/bulk_tools.py +259 -202
- mcp_ticketer/mcp/server/tools/comment_tools.py +74 -12
- mcp_ticketer/mcp/server/tools/config_tools.py +1184 -136
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
- mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +3 -7
- mcp_ticketer/mcp/server/tools/project_status_tools.py +158 -0
- mcp_ticketer/mcp/server/tools/project_update_tools.py +473 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +180 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1070 -123
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
- mcp_ticketer/queue/worker.py +1 -1
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.0.1.dist-info/METADATA +1366 -0
- mcp_ticketer-2.0.1.dist-info/RECORD +122 -0
- mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
- mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.12.0.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
mcp_ticketer/adapters/github.py
CHANGED
|
@@ -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
|
-
|
|
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/{
|
|
619
|
+
f"/repos/{self.owner}/{self.repo}/issues/{entity_number}"
|
|
565
620
|
)
|
|
566
|
-
if response.status_code ==
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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()
|