mcp-ticketer 0.4.11__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 +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +394 -9
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1416 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +348 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +836 -105
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira.py +772 -1
- mcp_ticketer/adapters/linear/adapter.py +2293 -108
- mcp_ticketer/adapters/linear/client.py +146 -12
- mcp_ticketer/adapters/linear/mappers.py +105 -11
- 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/cache/memory.py +3 -3
- mcp_ticketer/cli/adapter_diagnostics.py +4 -2
- mcp_ticketer/cli/auggie_configure.py +18 -6
- mcp_ticketer/cli/codex_configure.py +175 -60
- mcp_ticketer/cli/configure.py +884 -146
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +31 -28
- mcp_ticketer/cli/discover.py +293 -21
- mcp_ticketer/cli/gemini_configure.py +18 -6
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +99 -15
- mcp_ticketer/cli/main.py +109 -2055
- mcp_ticketer/cli/mcp_configure.py +673 -99
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +6 -6
- mcp_ticketer/cli/platform_detection.py +477 -0
- mcp_ticketer/cli/platform_installer.py +536 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +13 -11
- mcp_ticketer/cli/ticket_commands.py +277 -36
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +45 -41
- mcp_ticketer/core/__init__.py +35 -1
- mcp_ticketer/core/adapter.py +170 -5
- mcp_ticketer/core/config.py +38 -31
- mcp_ticketer/core/env_discovery.py +33 -3
- mcp_ticketer/core/env_loader.py +7 -6
- mcp_ticketer/core/exceptions.py +10 -4
- mcp_ticketer/core/http_client.py +10 -10
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +32 -20
- mcp_ticketer/core/models.py +136 -1
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +148 -14
- mcp_ticketer/core/registry.py +1 -1
- 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/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +2 -2
- mcp_ticketer/mcp/server/__init__.py +2 -2
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +187 -93
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +37 -9
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +65 -20
- 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 +1429 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +878 -319
- mcp_ticketer/mcp/server/tools/instruction_tools.py +295 -0
- 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 +1182 -82
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +364 -0
- mcp_ticketer/queue/health_monitor.py +1 -0
- mcp_ticketer/queue/manager.py +4 -4
- mcp_ticketer/queue/queue.py +3 -3
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +2 -2
- mcp_ticketer/queue/worker.py +15 -13
- 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.4.11.dist-info/METADATA +0 -496
- mcp_ticketer-0.4.11.dist-info/RECORD +0 -77
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.4.11.dist-info → mcp_ticketer-2.0.1.dist-info}/top_level.txt +0 -0
mcp_ticketer/adapters/github.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
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
|
|
7
|
+
from pathlib import Path
|
|
6
8
|
from typing import Any
|
|
7
9
|
|
|
8
10
|
import httpx
|
|
@@ -12,6 +14,8 @@ from ..core.env_loader import load_adapter_config, validate_adapter_config
|
|
|
12
14
|
from ..core.models import Comment, Epic, Priority, SearchQuery, Task, TicketState
|
|
13
15
|
from ..core.registry import AdapterRegistry
|
|
14
16
|
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
15
19
|
|
|
16
20
|
class GitHubStateMapping:
|
|
17
21
|
"""GitHub issue states and label-based extended states."""
|
|
@@ -132,6 +136,27 @@ class GitHubGraphQLQueries:
|
|
|
132
136
|
}
|
|
133
137
|
"""
|
|
134
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
|
+
|
|
135
160
|
|
|
136
161
|
class GitHubAdapter(BaseAdapter[Task]):
|
|
137
162
|
"""Adapter for GitHub Issues tracking system."""
|
|
@@ -140,12 +165,13 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
140
165
|
"""Initialize GitHub adapter.
|
|
141
166
|
|
|
142
167
|
Args:
|
|
168
|
+
----
|
|
143
169
|
config: Configuration with:
|
|
144
|
-
- token: GitHub
|
|
170
|
+
- token: GitHub PAT (or GITHUB_TOKEN env var)
|
|
145
171
|
- owner: Repository owner (or GITHUB_OWNER env var)
|
|
146
172
|
- repo: Repository name (or GITHUB_REPO env var)
|
|
147
|
-
- api_url: Optional API URL for GitHub Enterprise
|
|
148
|
-
- use_projects_v2: Enable
|
|
173
|
+
- api_url: Optional API URL for GitHub Enterprise
|
|
174
|
+
- use_projects_v2: Enable Projects v2 (default: False)
|
|
149
175
|
- custom_priority_scheme: Custom priority label mapping
|
|
150
176
|
|
|
151
177
|
"""
|
|
@@ -157,11 +183,12 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
157
183
|
# Validate required configuration
|
|
158
184
|
missing_keys = validate_adapter_config("github", full_config)
|
|
159
185
|
if missing_keys:
|
|
186
|
+
missing = ", ".join(missing_keys)
|
|
160
187
|
raise ValueError(
|
|
161
|
-
f"GitHub adapter missing required configuration: {
|
|
188
|
+
f"GitHub adapter missing required configuration: {missing}"
|
|
162
189
|
)
|
|
163
190
|
|
|
164
|
-
# Get authentication token - support
|
|
191
|
+
# Get authentication token - support 'api_key' and 'token'
|
|
165
192
|
self.token = (
|
|
166
193
|
full_config.get("api_key")
|
|
167
194
|
or full_config.get("token")
|
|
@@ -206,23 +233,28 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
206
233
|
"""Validate that required credentials are present.
|
|
207
234
|
|
|
208
235
|
Returns:
|
|
236
|
+
-------
|
|
209
237
|
(is_valid, error_message) - Tuple of validation result and error message
|
|
210
238
|
|
|
211
239
|
"""
|
|
212
240
|
if not self.token:
|
|
213
241
|
return (
|
|
214
242
|
False,
|
|
215
|
-
"GITHUB_TOKEN is required
|
|
243
|
+
"GITHUB_TOKEN is required. Set it in .env.local or environment.",
|
|
216
244
|
)
|
|
217
245
|
if not self.owner:
|
|
218
246
|
return (
|
|
219
247
|
False,
|
|
220
|
-
"GitHub owner is required
|
|
248
|
+
"GitHub owner is required. Set GITHUB_OWNER in .env.local "
|
|
249
|
+
"or configure with 'mcp-ticketer init --adapter github "
|
|
250
|
+
"--github-owner <owner>'",
|
|
221
251
|
)
|
|
222
252
|
if not self.repo:
|
|
223
253
|
return (
|
|
224
254
|
False,
|
|
225
|
-
"GitHub repo is required
|
|
255
|
+
"GitHub repo is required. Set GITHUB_REPO in .env.local "
|
|
256
|
+
"or configure with 'mcp-ticketer init --adapter github "
|
|
257
|
+
"--github-repo <repo>'",
|
|
226
258
|
)
|
|
227
259
|
return True, ""
|
|
228
260
|
|
|
@@ -278,6 +310,41 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
278
310
|
else f"P{['0', '1', '2', '3'][list(Priority).index(priority)]}"
|
|
279
311
|
)
|
|
280
312
|
|
|
313
|
+
def _milestone_to_epic(self, milestone: dict[str, Any]) -> Epic:
|
|
314
|
+
"""Convert GitHub milestone to Epic model.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
----
|
|
318
|
+
milestone: GitHub milestone data
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
-------
|
|
322
|
+
Epic instance
|
|
323
|
+
|
|
324
|
+
"""
|
|
325
|
+
return Epic(
|
|
326
|
+
id=str(milestone["number"]),
|
|
327
|
+
title=milestone["title"],
|
|
328
|
+
description=milestone.get("description", ""),
|
|
329
|
+
state=(
|
|
330
|
+
TicketState.OPEN if milestone["state"] == "open" else TicketState.CLOSED
|
|
331
|
+
),
|
|
332
|
+
created_at=datetime.fromisoformat(
|
|
333
|
+
milestone["created_at"].replace("Z", "+00:00")
|
|
334
|
+
),
|
|
335
|
+
updated_at=datetime.fromisoformat(
|
|
336
|
+
milestone["updated_at"].replace("Z", "+00:00")
|
|
337
|
+
),
|
|
338
|
+
metadata={
|
|
339
|
+
"github": {
|
|
340
|
+
"number": milestone["number"],
|
|
341
|
+
"url": milestone.get("html_url"),
|
|
342
|
+
"open_issues": milestone.get("open_issues", 0),
|
|
343
|
+
"closed_issues": milestone.get("closed_issues", 0),
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
)
|
|
347
|
+
|
|
281
348
|
def _extract_state_from_issue(self, issue: dict[str, Any]) -> TicketState:
|
|
282
349
|
"""Extract ticket state from GitHub issue data."""
|
|
283
350
|
# Check if closed
|
|
@@ -508,30 +575,72 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
508
575
|
|
|
509
576
|
return self._task_from_github_issue(created_issue)
|
|
510
577
|
|
|
511
|
-
async def read(self, ticket_id: str) -> Task | None:
|
|
512
|
-
"""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
|
+
"""
|
|
513
606
|
# Validate credentials before attempting operation
|
|
514
607
|
is_valid, error_message = self.validate_credentials()
|
|
515
608
|
if not is_valid:
|
|
516
609
|
raise ValueError(error_message)
|
|
517
610
|
|
|
518
611
|
try:
|
|
519
|
-
|
|
612
|
+
entity_number = int(ticket_id)
|
|
520
613
|
except ValueError:
|
|
521
614
|
return None
|
|
522
615
|
|
|
616
|
+
# Try reading as Issue first (most common case)
|
|
523
617
|
try:
|
|
524
618
|
response = await self.client.get(
|
|
525
|
-
f"/repos/{self.owner}/{self.repo}/issues/{
|
|
619
|
+
f"/repos/{self.owner}/{self.repo}/issues/{entity_number}"
|
|
526
620
|
)
|
|
527
|
-
if response.status_code ==
|
|
528
|
-
|
|
529
|
-
|
|
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}")
|
|
530
640
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
return None
|
|
641
|
+
# Not found as either Issue or Milestone
|
|
642
|
+
logger.warning(f"GitHub entity not found: {ticket_id}")
|
|
643
|
+
return None
|
|
535
644
|
|
|
536
645
|
async def update(self, ticket_id: str, updates: dict[str, Any]) -> Task | None:
|
|
537
646
|
"""Update a GitHub issue."""
|
|
@@ -689,7 +798,7 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
689
798
|
) -> list[Task]:
|
|
690
799
|
"""List GitHub issues with filters."""
|
|
691
800
|
# Build query parameters
|
|
692
|
-
params = {
|
|
801
|
+
params: dict[str, Any] = {
|
|
693
802
|
"per_page": min(limit, 100), # GitHub max is 100
|
|
694
803
|
"page": (offset // limit) + 1 if limit > 0 else 1,
|
|
695
804
|
}
|
|
@@ -850,8 +959,8 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
850
959
|
"""Add a comment to a GitHub issue."""
|
|
851
960
|
try:
|
|
852
961
|
issue_number = int(comment.ticket_id)
|
|
853
|
-
except ValueError:
|
|
854
|
-
raise ValueError(f"Invalid issue number: {comment.ticket_id}")
|
|
962
|
+
except ValueError as e:
|
|
963
|
+
raise ValueError(f"Invalid issue number: {comment.ticket_id}") from e
|
|
855
964
|
|
|
856
965
|
# Create comment
|
|
857
966
|
response = await self.client.post(
|
|
@@ -945,31 +1054,7 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
945
1054
|
response.raise_for_status()
|
|
946
1055
|
|
|
947
1056
|
created_milestone = response.json()
|
|
948
|
-
|
|
949
|
-
return Epic(
|
|
950
|
-
id=str(created_milestone["number"]),
|
|
951
|
-
title=created_milestone["title"],
|
|
952
|
-
description=created_milestone["description"],
|
|
953
|
-
state=(
|
|
954
|
-
TicketState.OPEN
|
|
955
|
-
if created_milestone["state"] == "open"
|
|
956
|
-
else TicketState.CLOSED
|
|
957
|
-
),
|
|
958
|
-
created_at=datetime.fromisoformat(
|
|
959
|
-
created_milestone["created_at"].replace("Z", "+00:00")
|
|
960
|
-
),
|
|
961
|
-
updated_at=datetime.fromisoformat(
|
|
962
|
-
created_milestone["updated_at"].replace("Z", "+00:00")
|
|
963
|
-
),
|
|
964
|
-
metadata={
|
|
965
|
-
"github": {
|
|
966
|
-
"number": created_milestone["number"],
|
|
967
|
-
"url": created_milestone["html_url"],
|
|
968
|
-
"open_issues": created_milestone["open_issues"],
|
|
969
|
-
"closed_issues": created_milestone["closed_issues"],
|
|
970
|
-
}
|
|
971
|
-
},
|
|
972
|
-
)
|
|
1057
|
+
return self._milestone_to_epic(created_milestone)
|
|
973
1058
|
|
|
974
1059
|
async def get_milestone(self, milestone_number: int) -> Epic | None:
|
|
975
1060
|
"""Get a GitHub milestone as an Epic."""
|
|
@@ -982,31 +1067,7 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
982
1067
|
response.raise_for_status()
|
|
983
1068
|
|
|
984
1069
|
milestone = response.json()
|
|
985
|
-
|
|
986
|
-
return Epic(
|
|
987
|
-
id=str(milestone["number"]),
|
|
988
|
-
title=milestone["title"],
|
|
989
|
-
description=milestone["description"],
|
|
990
|
-
state=(
|
|
991
|
-
TicketState.OPEN
|
|
992
|
-
if milestone["state"] == "open"
|
|
993
|
-
else TicketState.CLOSED
|
|
994
|
-
),
|
|
995
|
-
created_at=datetime.fromisoformat(
|
|
996
|
-
milestone["created_at"].replace("Z", "+00:00")
|
|
997
|
-
),
|
|
998
|
-
updated_at=datetime.fromisoformat(
|
|
999
|
-
milestone["updated_at"].replace("Z", "+00:00")
|
|
1000
|
-
),
|
|
1001
|
-
metadata={
|
|
1002
|
-
"github": {
|
|
1003
|
-
"number": milestone["number"],
|
|
1004
|
-
"url": milestone["html_url"],
|
|
1005
|
-
"open_issues": milestone["open_issues"],
|
|
1006
|
-
"closed_issues": milestone["closed_issues"],
|
|
1007
|
-
}
|
|
1008
|
-
},
|
|
1009
|
-
)
|
|
1070
|
+
return self._milestone_to_epic(milestone)
|
|
1010
1071
|
except httpx.HTTPError:
|
|
1011
1072
|
return None
|
|
1012
1073
|
|
|
@@ -1025,36 +1086,63 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
1025
1086
|
)
|
|
1026
1087
|
response.raise_for_status()
|
|
1027
1088
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1089
|
+
return [self._milestone_to_epic(milestone) for milestone in response.json()]
|
|
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}"
|
|
1055
1124
|
)
|
|
1056
1125
|
|
|
1057
|
-
|
|
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
|
|
1058
1146
|
|
|
1059
1147
|
async def link_to_pull_request(self, issue_number: int, pr_number: int) -> bool:
|
|
1060
1148
|
"""Link an issue to a pull request using keywords."""
|
|
@@ -1081,6 +1169,7 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
1081
1169
|
"""Create a pull request linked to an issue.
|
|
1082
1170
|
|
|
1083
1171
|
Args:
|
|
1172
|
+
----
|
|
1084
1173
|
ticket_id: Issue number to link the PR to
|
|
1085
1174
|
base_branch: Target branch for the PR (default: main)
|
|
1086
1175
|
head_branch: Source branch name (auto-generated if not provided)
|
|
@@ -1089,13 +1178,14 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
1089
1178
|
draft: Create as draft PR
|
|
1090
1179
|
|
|
1091
1180
|
Returns:
|
|
1181
|
+
-------
|
|
1092
1182
|
Dictionary with PR details including number, url, and branch
|
|
1093
1183
|
|
|
1094
1184
|
"""
|
|
1095
1185
|
try:
|
|
1096
1186
|
issue_number = int(ticket_id)
|
|
1097
|
-
except ValueError:
|
|
1098
|
-
raise ValueError(f"Invalid issue number: {ticket_id}")
|
|
1187
|
+
except ValueError as e:
|
|
1188
|
+
raise ValueError(f"Invalid issue number: {ticket_id}") from e
|
|
1099
1189
|
|
|
1100
1190
|
# Get the issue details
|
|
1101
1191
|
issue = await self.read(ticket_id)
|
|
@@ -1229,10 +1319,11 @@ Fixes #{issue_number}
|
|
|
1229
1319
|
pr = pr_response.json()
|
|
1230
1320
|
|
|
1231
1321
|
# Add a comment to the issue about the PR
|
|
1322
|
+
pr_msg = f"Pull request #{pr['number']} has been created: " f"{pr['html_url']}"
|
|
1232
1323
|
await self.add_comment(
|
|
1233
1324
|
Comment(
|
|
1234
1325
|
ticket_id=ticket_id,
|
|
1235
|
-
content=
|
|
1326
|
+
content=pr_msg,
|
|
1236
1327
|
author="system",
|
|
1237
1328
|
)
|
|
1238
1329
|
)
|
|
@@ -1256,17 +1347,19 @@ Fixes #{issue_number}
|
|
|
1256
1347
|
"""Link an existing pull request to a ticket.
|
|
1257
1348
|
|
|
1258
1349
|
Args:
|
|
1350
|
+
----
|
|
1259
1351
|
ticket_id: Issue number to link the PR to
|
|
1260
1352
|
pr_url: GitHub PR URL to link
|
|
1261
1353
|
|
|
1262
1354
|
Returns:
|
|
1355
|
+
-------
|
|
1263
1356
|
Dictionary with link status and PR details
|
|
1264
1357
|
|
|
1265
1358
|
"""
|
|
1266
1359
|
try:
|
|
1267
1360
|
issue_number = int(ticket_id)
|
|
1268
|
-
except ValueError:
|
|
1269
|
-
raise ValueError(f"Invalid issue number: {ticket_id}")
|
|
1361
|
+
except ValueError as e:
|
|
1362
|
+
raise ValueError(f"Invalid issue number: {ticket_id}") from e
|
|
1270
1363
|
|
|
1271
1364
|
# Parse PR URL to extract owner, repo, and PR number
|
|
1272
1365
|
# Expected format: https://github.com/owner/repo/pull/123
|
|
@@ -1348,6 +1441,644 @@ Fixes #{issue_number}
|
|
|
1348
1441
|
response.raise_for_status()
|
|
1349
1442
|
return response.json()
|
|
1350
1443
|
|
|
1444
|
+
async def list_labels(self) -> builtins.list[dict[str, Any]]:
|
|
1445
|
+
"""List all labels available in the repository.
|
|
1446
|
+
|
|
1447
|
+
Returns:
|
|
1448
|
+
-------
|
|
1449
|
+
List of label dictionaries with 'id', 'name', and 'color' fields
|
|
1450
|
+
|
|
1451
|
+
"""
|
|
1452
|
+
if self._labels_cache:
|
|
1453
|
+
return self._labels_cache
|
|
1454
|
+
|
|
1455
|
+
response = await self.client.get(f"/repos/{self.owner}/{self.repo}/labels")
|
|
1456
|
+
response.raise_for_status()
|
|
1457
|
+
labels = response.json()
|
|
1458
|
+
|
|
1459
|
+
# Transform to standardized format
|
|
1460
|
+
standardized_labels = [
|
|
1461
|
+
{"id": label["name"], "name": label["name"], "color": label["color"]}
|
|
1462
|
+
for label in labels
|
|
1463
|
+
]
|
|
1464
|
+
|
|
1465
|
+
self._labels_cache = standardized_labels
|
|
1466
|
+
return standardized_labels
|
|
1467
|
+
|
|
1468
|
+
async def update_milestone(
|
|
1469
|
+
self, milestone_number: int, updates: dict[str, Any]
|
|
1470
|
+
) -> Epic | None:
|
|
1471
|
+
"""Update a GitHub milestone (Epic).
|
|
1472
|
+
|
|
1473
|
+
Args:
|
|
1474
|
+
----
|
|
1475
|
+
milestone_number: Milestone number (not ID)
|
|
1476
|
+
updates: Dictionary with fields to update:
|
|
1477
|
+
- title: Milestone title
|
|
1478
|
+
- description: Milestone description (supports markdown)
|
|
1479
|
+
- state: TicketState value (maps to open/closed)
|
|
1480
|
+
- target_date: Due date in ISO format
|
|
1481
|
+
|
|
1482
|
+
Returns:
|
|
1483
|
+
-------
|
|
1484
|
+
Updated Epic object or None if not found
|
|
1485
|
+
|
|
1486
|
+
Raises:
|
|
1487
|
+
------
|
|
1488
|
+
ValueError: If no fields to update
|
|
1489
|
+
httpx.HTTPError: If API request fails
|
|
1490
|
+
|
|
1491
|
+
"""
|
|
1492
|
+
update_data = {}
|
|
1493
|
+
|
|
1494
|
+
# Map title directly
|
|
1495
|
+
if "title" in updates:
|
|
1496
|
+
update_data["title"] = updates["title"]
|
|
1497
|
+
|
|
1498
|
+
# Map description (supports markdown)
|
|
1499
|
+
if "description" in updates:
|
|
1500
|
+
update_data["description"] = updates["description"]
|
|
1501
|
+
|
|
1502
|
+
# Map state to GitHub milestone state
|
|
1503
|
+
if "state" in updates:
|
|
1504
|
+
state = updates["state"]
|
|
1505
|
+
if isinstance(state, TicketState):
|
|
1506
|
+
# GitHub only has open/closed
|
|
1507
|
+
update_data["state"] = (
|
|
1508
|
+
"closed"
|
|
1509
|
+
if state in [TicketState.DONE, TicketState.CLOSED]
|
|
1510
|
+
else "open"
|
|
1511
|
+
)
|
|
1512
|
+
else:
|
|
1513
|
+
update_data["state"] = state
|
|
1514
|
+
|
|
1515
|
+
# Map target_date to due_on
|
|
1516
|
+
if "target_date" in updates:
|
|
1517
|
+
# GitHub expects ISO 8601 format
|
|
1518
|
+
target_date = updates["target_date"]
|
|
1519
|
+
if isinstance(target_date, str):
|
|
1520
|
+
update_data["due_on"] = target_date
|
|
1521
|
+
elif hasattr(target_date, "isoformat"):
|
|
1522
|
+
update_data["due_on"] = target_date.isoformat()
|
|
1523
|
+
|
|
1524
|
+
if not update_data:
|
|
1525
|
+
raise ValueError("At least one field must be updated")
|
|
1526
|
+
|
|
1527
|
+
# Make API request
|
|
1528
|
+
response = await self.client.patch(
|
|
1529
|
+
f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}",
|
|
1530
|
+
json=update_data,
|
|
1531
|
+
)
|
|
1532
|
+
response.raise_for_status()
|
|
1533
|
+
|
|
1534
|
+
# Convert response to Epic
|
|
1535
|
+
milestone_data = response.json()
|
|
1536
|
+
return self._milestone_to_epic(milestone_data)
|
|
1537
|
+
|
|
1538
|
+
async def update_epic(self, epic_id: str, updates: dict[str, Any]) -> Epic | None:
|
|
1539
|
+
"""Update a GitHub epic (milestone) by ID or number.
|
|
1540
|
+
|
|
1541
|
+
This is a convenience wrapper around update_milestone() that accepts
|
|
1542
|
+
either a milestone number or the epic ID from the Epic object.
|
|
1543
|
+
|
|
1544
|
+
Args:
|
|
1545
|
+
----
|
|
1546
|
+
epic_id: Epic ID (e.g., "milestone-5") or milestone number as string
|
|
1547
|
+
updates: Dictionary with fields to update
|
|
1548
|
+
|
|
1549
|
+
Returns:
|
|
1550
|
+
-------
|
|
1551
|
+
Updated Epic object or None if not found
|
|
1552
|
+
|
|
1553
|
+
"""
|
|
1554
|
+
# Extract milestone number from ID
|
|
1555
|
+
if epic_id.startswith("milestone-"):
|
|
1556
|
+
milestone_number = int(epic_id.replace("milestone-", ""))
|
|
1557
|
+
else:
|
|
1558
|
+
milestone_number = int(epic_id)
|
|
1559
|
+
|
|
1560
|
+
return await self.update_milestone(milestone_number, updates)
|
|
1561
|
+
|
|
1562
|
+
async def add_attachment_to_issue(
|
|
1563
|
+
self, issue_number: int, file_path: str, comment: str | None = None
|
|
1564
|
+
) -> dict[str, Any]:
|
|
1565
|
+
"""Attach file to GitHub issue via comment.
|
|
1566
|
+
|
|
1567
|
+
GitHub doesn't have direct file attachment API. This method:
|
|
1568
|
+
1. Creates a comment with the file reference
|
|
1569
|
+
2. Returns metadata about the attachment
|
|
1570
|
+
|
|
1571
|
+
Note: GitHub's actual file upload in comments requires browser-based
|
|
1572
|
+
drag-and-drop or git-lfs. This method creates a placeholder comment
|
|
1573
|
+
that users can edit to add actual file attachments through the UI.
|
|
1574
|
+
|
|
1575
|
+
Args:
|
|
1576
|
+
----
|
|
1577
|
+
issue_number: Issue number
|
|
1578
|
+
file_path: Path to file to attach
|
|
1579
|
+
comment: Optional comment text (defaults to "Attached: {filename}")
|
|
1580
|
+
|
|
1581
|
+
Returns:
|
|
1582
|
+
-------
|
|
1583
|
+
Dictionary with comment data and file info
|
|
1584
|
+
|
|
1585
|
+
Raises:
|
|
1586
|
+
------
|
|
1587
|
+
FileNotFoundError: If file doesn't exist
|
|
1588
|
+
ValueError: If file too large (>25 MB)
|
|
1589
|
+
|
|
1590
|
+
Note:
|
|
1591
|
+
----
|
|
1592
|
+
GitHub file size limit: 25 MB
|
|
1593
|
+
Supported: Images, videos, documents
|
|
1594
|
+
|
|
1595
|
+
"""
|
|
1596
|
+
file_path_obj = Path(file_path)
|
|
1597
|
+
if not file_path_obj.exists():
|
|
1598
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
1599
|
+
|
|
1600
|
+
# Check file size (25 MB limit)
|
|
1601
|
+
file_size = file_path_obj.stat().st_size
|
|
1602
|
+
if file_size > 25 * 1024 * 1024: # 25 MB
|
|
1603
|
+
raise ValueError(
|
|
1604
|
+
f"File too large: {file_size} bytes (max 25 MB). "
|
|
1605
|
+
"Upload file externally and reference URL instead."
|
|
1606
|
+
)
|
|
1607
|
+
|
|
1608
|
+
# Prepare comment body
|
|
1609
|
+
comment_body = comment or f"📎 Attached: `{file_path_obj.name}`"
|
|
1610
|
+
comment_body += (
|
|
1611
|
+
f"\n\n*Note: File `{file_path_obj.name}` ({file_size} bytes) "
|
|
1612
|
+
"needs to be manually uploaded through GitHub UI or referenced via URL.*"
|
|
1613
|
+
)
|
|
1614
|
+
|
|
1615
|
+
# Create comment with file reference
|
|
1616
|
+
response = await self.client.post(
|
|
1617
|
+
f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
|
|
1618
|
+
json={"body": comment_body},
|
|
1619
|
+
)
|
|
1620
|
+
response.raise_for_status()
|
|
1621
|
+
|
|
1622
|
+
comment_data = response.json()
|
|
1623
|
+
|
|
1624
|
+
return {
|
|
1625
|
+
"comment_id": comment_data["id"],
|
|
1626
|
+
"comment_url": comment_data["html_url"],
|
|
1627
|
+
"filename": file_path_obj.name,
|
|
1628
|
+
"file_size": file_size,
|
|
1629
|
+
"note": "File reference created. Upload file manually through GitHub UI.",
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
async def add_attachment_reference_to_milestone(
|
|
1633
|
+
self, milestone_number: int, file_url: str, description: str
|
|
1634
|
+
) -> Epic | None:
|
|
1635
|
+
"""Add file reference to milestone description.
|
|
1636
|
+
|
|
1637
|
+
Since GitHub milestones don't support direct file attachments,
|
|
1638
|
+
this method appends a markdown link to the milestone description.
|
|
1639
|
+
|
|
1640
|
+
Args:
|
|
1641
|
+
----
|
|
1642
|
+
milestone_number: Milestone number
|
|
1643
|
+
file_url: URL to the file (external or GitHub-hosted)
|
|
1644
|
+
description: Description/title for the file
|
|
1645
|
+
|
|
1646
|
+
Returns:
|
|
1647
|
+
-------
|
|
1648
|
+
Updated Epic object
|
|
1649
|
+
|
|
1650
|
+
Example:
|
|
1651
|
+
-------
|
|
1652
|
+
await adapter.add_attachment_reference_to_milestone(
|
|
1653
|
+
5,
|
|
1654
|
+
"https://example.com/spec.pdf",
|
|
1655
|
+
"Technical Specification"
|
|
1656
|
+
)
|
|
1657
|
+
# Appends to description: "[Technical Specification](https://example.com/spec.pdf)"
|
|
1658
|
+
|
|
1659
|
+
"""
|
|
1660
|
+
# Get current milestone
|
|
1661
|
+
response = await self.client.get(
|
|
1662
|
+
f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}"
|
|
1663
|
+
)
|
|
1664
|
+
response.raise_for_status()
|
|
1665
|
+
milestone = response.json()
|
|
1666
|
+
|
|
1667
|
+
# Append file reference to description
|
|
1668
|
+
current_desc = milestone.get("description", "")
|
|
1669
|
+
attachment_markdown = f"\n\n📎 [{description}]({file_url})"
|
|
1670
|
+
new_description = current_desc + attachment_markdown
|
|
1671
|
+
|
|
1672
|
+
# Update milestone with new description
|
|
1673
|
+
return await self.update_milestone(
|
|
1674
|
+
milestone_number, {"description": new_description}
|
|
1675
|
+
)
|
|
1676
|
+
|
|
1677
|
+
async def add_attachment(
|
|
1678
|
+
self, ticket_id: str, file_path: str, description: str | None = None
|
|
1679
|
+
) -> dict[str, Any]:
|
|
1680
|
+
"""Add attachment to GitHub ticket (issue or milestone).
|
|
1681
|
+
|
|
1682
|
+
This method routes to appropriate attachment method based on ticket type:
|
|
1683
|
+
- Issues: Creates comment with file reference
|
|
1684
|
+
- Milestones: Not supported, raises NotImplementedError with guidance
|
|
1685
|
+
|
|
1686
|
+
Args:
|
|
1687
|
+
----
|
|
1688
|
+
ticket_id: Ticket identifier (issue number or milestone ID)
|
|
1689
|
+
file_path: Path to file to attach
|
|
1690
|
+
description: Optional description
|
|
1691
|
+
|
|
1692
|
+
Returns:
|
|
1693
|
+
-------
|
|
1694
|
+
Attachment metadata
|
|
1695
|
+
|
|
1696
|
+
Raises:
|
|
1697
|
+
------
|
|
1698
|
+
NotImplementedError: For milestones (no native support)
|
|
1699
|
+
FileNotFoundError: If file doesn't exist
|
|
1700
|
+
|
|
1701
|
+
"""
|
|
1702
|
+
# Determine ticket type from ID format
|
|
1703
|
+
if ticket_id.startswith("milestone-"):
|
|
1704
|
+
raise NotImplementedError(
|
|
1705
|
+
"GitHub milestones do not support direct file attachments. "
|
|
1706
|
+
"Workaround: Upload file externally and use "
|
|
1707
|
+
"add_attachment_reference_to_milestone() to add URL to description."
|
|
1708
|
+
)
|
|
1709
|
+
|
|
1710
|
+
# Assume it's an issue number
|
|
1711
|
+
issue_number = int(ticket_id.replace("issue-", ""))
|
|
1712
|
+
return await self.add_attachment_to_issue(issue_number, file_path, description)
|
|
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
|
+
|
|
1351
2082
|
async def close(self) -> None:
|
|
1352
2083
|
"""Close the HTTP client connection."""
|
|
1353
2084
|
await self.client.aclose()
|