mcp-ticketer 0.1.30__py3-none-any.whl → 1.2.11__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 +796 -46
- 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 +879 -129
- mcp_ticketer/adapters/hybrid.py +11 -11
- mcp_ticketer/adapters/jira.py +973 -73
- mcp_ticketer/adapters/linear/__init__.py +24 -0
- mcp_ticketer/adapters/linear/adapter.py +2732 -0
- mcp_ticketer/adapters/linear/client.py +344 -0
- mcp_ticketer/adapters/linear/mappers.py +420 -0
- mcp_ticketer/adapters/linear/queries.py +479 -0
- mcp_ticketer/adapters/linear/types.py +360 -0
- mcp_ticketer/adapters/linear.py +10 -2315
- mcp_ticketer/analysis/__init__.py +23 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +421 -0
- mcp_ticketer/cli/auggie_configure.py +116 -15
- mcp_ticketer/cli/codex_configure.py +274 -82
- mcp_ticketer/cli/configure.py +888 -151
- mcp_ticketer/cli/diagnostics.py +400 -157
- mcp_ticketer/cli/discover.py +297 -26
- mcp_ticketer/cli/gemini_configure.py +119 -26
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/instruction_commands.py +435 -0
- mcp_ticketer/cli/linear_commands.py +616 -0
- mcp_ticketer/cli/main.py +203 -1165
- mcp_ticketer/cli/mcp_configure.py +474 -90
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +418 -0
- mcp_ticketer/cli/platform_installer.py +513 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/setup_command.py +639 -0
- mcp_ticketer/cli/simple_health.py +90 -65
- mcp_ticketer/cli/ticket_commands.py +1013 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +114 -66
- mcp_ticketer/core/__init__.py +24 -1
- mcp_ticketer/core/adapter.py +250 -16
- mcp_ticketer/core/config.py +145 -37
- mcp_ticketer/core/env_discovery.py +101 -22
- mcp_ticketer/core/env_loader.py +349 -0
- mcp_ticketer/core/exceptions.py +160 -0
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +42 -30
- mcp_ticketer/core/models.py +280 -28
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/project_config.py +183 -49
- mcp_ticketer/core/registry.py +3 -3
- 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 +29 -1
- mcp_ticketer/mcp/__main__.py +60 -0
- mcp_ticketer/mcp/server/__init__.py +25 -0
- mcp_ticketer/mcp/server/__main__.py +60 -0
- mcp_ticketer/mcp/server/constants.py +58 -0
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/dto.py +195 -0
- mcp_ticketer/mcp/server/main.py +1343 -0
- mcp_ticketer/mcp/server/response_builder.py +206 -0
- mcp_ticketer/mcp/server/routing.py +655 -0
- mcp_ticketer/mcp/server/server_sdk.py +151 -0
- mcp_ticketer/mcp/server/tools/__init__.py +56 -0
- mcp_ticketer/mcp/server/tools/analysis_tools.py +495 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +1439 -0
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +921 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +300 -0
- mcp_ticketer/mcp/server/tools/label_tools.py +948 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +152 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +215 -0
- mcp_ticketer/mcp/server/tools/session_tools.py +170 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1268 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +547 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +168 -136
- mcp_ticketer/queue/manager.py +95 -25
- mcp_ticketer/queue/queue.py +40 -21
- mcp_ticketer/queue/run_worker.py +6 -1
- mcp_ticketer/queue/ticket_registry.py +213 -155
- mcp_ticketer/queue/worker.py +109 -49
- mcp_ticketer-1.2.11.dist-info/METADATA +792 -0
- mcp_ticketer-1.2.11.dist-info/RECORD +110 -0
- mcp_ticketer/mcp/server.py +0 -1895
- mcp_ticketer-0.1.30.dist-info/METADATA +0 -413
- mcp_ticketer-0.1.30.dist-info/RECORD +0 -49
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.1.30.dist-info → mcp_ticketer-1.2.11.dist-info}/top_level.txt +0 -0
mcp_ticketer/adapters/github.py
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
"""GitHub adapter implementation using REST API v3 and GraphQL API v4."""
|
|
2
2
|
|
|
3
3
|
import builtins
|
|
4
|
-
import
|
|
4
|
+
import logging
|
|
5
5
|
import re
|
|
6
6
|
from datetime import datetime
|
|
7
|
-
from
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
8
9
|
|
|
9
10
|
import httpx
|
|
10
11
|
|
|
11
12
|
from ..core.adapter import BaseAdapter
|
|
13
|
+
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,32 +165,39 @@ 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
|
"""
|
|
152
178
|
super().__init__(config)
|
|
153
179
|
|
|
154
|
-
#
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
180
|
+
# Load configuration with environment variable resolution
|
|
181
|
+
full_config = load_adapter_config("github", config)
|
|
182
|
+
|
|
183
|
+
# Validate required configuration
|
|
184
|
+
missing_keys = validate_adapter_config("github", full_config)
|
|
185
|
+
if missing_keys:
|
|
186
|
+
missing = ", ".join(missing_keys)
|
|
159
187
|
raise ValueError(
|
|
160
|
-
"GitHub
|
|
188
|
+
f"GitHub adapter missing required configuration: {missing}"
|
|
161
189
|
)
|
|
162
190
|
|
|
163
|
-
# Get
|
|
164
|
-
self.
|
|
165
|
-
|
|
191
|
+
# Get authentication token - support 'api_key' and 'token'
|
|
192
|
+
self.token = (
|
|
193
|
+
full_config.get("api_key")
|
|
194
|
+
or full_config.get("token")
|
|
195
|
+
or full_config.get("token")
|
|
196
|
+
)
|
|
166
197
|
|
|
167
|
-
|
|
168
|
-
|
|
198
|
+
# Get repository information
|
|
199
|
+
self.owner = full_config.get("owner")
|
|
200
|
+
self.repo = full_config.get("repo")
|
|
169
201
|
|
|
170
202
|
# API URLs
|
|
171
203
|
self.api_url = config.get("api_url", "https://api.github.com")
|
|
@@ -193,31 +225,36 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
193
225
|
)
|
|
194
226
|
|
|
195
227
|
# Cache for labels and milestones
|
|
196
|
-
self._labels_cache:
|
|
197
|
-
self._milestones_cache:
|
|
228
|
+
self._labels_cache: list[dict[str, Any]] | None = None
|
|
229
|
+
self._milestones_cache: list[dict[str, Any]] | None = None
|
|
198
230
|
self._rate_limit: dict[str, Any] = {}
|
|
199
231
|
|
|
200
232
|
def validate_credentials(self) -> tuple[bool, str]:
|
|
201
233
|
"""Validate that required credentials are present.
|
|
202
234
|
|
|
203
235
|
Returns:
|
|
236
|
+
-------
|
|
204
237
|
(is_valid, error_message) - Tuple of validation result and error message
|
|
205
238
|
|
|
206
239
|
"""
|
|
207
240
|
if not self.token:
|
|
208
241
|
return (
|
|
209
242
|
False,
|
|
210
|
-
"GITHUB_TOKEN is required
|
|
243
|
+
"GITHUB_TOKEN is required. Set it in .env.local or environment.",
|
|
211
244
|
)
|
|
212
245
|
if not self.owner:
|
|
213
246
|
return (
|
|
214
247
|
False,
|
|
215
|
-
"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>'",
|
|
216
251
|
)
|
|
217
252
|
if not self.repo:
|
|
218
253
|
return (
|
|
219
254
|
False,
|
|
220
|
-
"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>'",
|
|
221
258
|
)
|
|
222
259
|
return True, ""
|
|
223
260
|
|
|
@@ -234,7 +271,7 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
234
271
|
TicketState.CLOSED: GitHubStateMapping.CLOSED,
|
|
235
272
|
}
|
|
236
273
|
|
|
237
|
-
def _get_state_label(self, state: TicketState) ->
|
|
274
|
+
def _get_state_label(self, state: TicketState) -> str | None:
|
|
238
275
|
"""Get the label name for extended states."""
|
|
239
276
|
return GitHubStateMapping.STATE_LABELS.get(state)
|
|
240
277
|
|
|
@@ -273,6 +310,41 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
273
310
|
else f"P{['0', '1', '2', '3'][list(Priority).index(priority)]}"
|
|
274
311
|
)
|
|
275
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
|
+
|
|
276
348
|
def _extract_state_from_issue(self, issue: dict[str, Any]) -> TicketState:
|
|
277
349
|
"""Extract ticket state from GitHub issue data."""
|
|
278
350
|
# Check if closed
|
|
@@ -503,32 +575,74 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
503
575
|
|
|
504
576
|
return self._task_from_github_issue(created_issue)
|
|
505
577
|
|
|
506
|
-
async def read(self, ticket_id: str) ->
|
|
507
|
-
"""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
|
+
"""
|
|
508
606
|
# Validate credentials before attempting operation
|
|
509
607
|
is_valid, error_message = self.validate_credentials()
|
|
510
608
|
if not is_valid:
|
|
511
609
|
raise ValueError(error_message)
|
|
512
610
|
|
|
513
611
|
try:
|
|
514
|
-
|
|
612
|
+
entity_number = int(ticket_id)
|
|
515
613
|
except ValueError:
|
|
516
614
|
return None
|
|
517
615
|
|
|
616
|
+
# Try reading as Issue first (most common case)
|
|
518
617
|
try:
|
|
519
618
|
response = await self.client.get(
|
|
520
|
-
f"/repos/{self.owner}/{self.repo}/issues/{
|
|
619
|
+
f"/repos/{self.owner}/{self.repo}/issues/{entity_number}"
|
|
521
620
|
)
|
|
522
|
-
if response.status_code ==
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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}")
|
|
640
|
+
|
|
641
|
+
# Not found as either Issue or Milestone
|
|
642
|
+
logger.warning(f"GitHub entity not found: {ticket_id}")
|
|
643
|
+
return None
|
|
644
|
+
|
|
645
|
+
async def update(self, ticket_id: str, updates: dict[str, Any]) -> Task | None:
|
|
532
646
|
"""Update a GitHub issue."""
|
|
533
647
|
# Validate credentials before attempting operation
|
|
534
648
|
is_valid, error_message = self.validate_credentials()
|
|
@@ -598,7 +712,7 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
598
712
|
labels_to_update = update_data.get("labels", current_labels)
|
|
599
713
|
all_priority_labels = []
|
|
600
714
|
for labels in GitHubStateMapping.PRIORITY_LABELS.values():
|
|
601
|
-
all_priority_labels.extend([
|
|
715
|
+
all_priority_labels.extend([label.lower() for label in labels])
|
|
602
716
|
|
|
603
717
|
labels_to_update = [
|
|
604
718
|
label
|
|
@@ -680,11 +794,11 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
680
794
|
return False
|
|
681
795
|
|
|
682
796
|
async def list(
|
|
683
|
-
self, limit: int = 10, offset: int = 0, filters:
|
|
797
|
+
self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
|
|
684
798
|
) -> list[Task]:
|
|
685
799
|
"""List GitHub issues with filters."""
|
|
686
800
|
# Build query parameters
|
|
687
|
-
params = {
|
|
801
|
+
params: dict[str, Any] = {
|
|
688
802
|
"per_page": min(limit, 100), # GitHub max is 100
|
|
689
803
|
"page": (offset // limit) + 1 if limit > 0 else 1,
|
|
690
804
|
}
|
|
@@ -832,7 +946,7 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
832
946
|
|
|
833
947
|
async def transition_state(
|
|
834
948
|
self, ticket_id: str, target_state: TicketState
|
|
835
|
-
) ->
|
|
949
|
+
) -> Task | None:
|
|
836
950
|
"""Transition GitHub issue to a new state."""
|
|
837
951
|
# Validate transition
|
|
838
952
|
if not await self.validate_transition(ticket_id, target_state):
|
|
@@ -845,8 +959,8 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
845
959
|
"""Add a comment to a GitHub issue."""
|
|
846
960
|
try:
|
|
847
961
|
issue_number = int(comment.ticket_id)
|
|
848
|
-
except ValueError:
|
|
849
|
-
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
|
|
850
964
|
|
|
851
965
|
# Create comment
|
|
852
966
|
response = await self.client.post(
|
|
@@ -940,33 +1054,9 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
940
1054
|
response.raise_for_status()
|
|
941
1055
|
|
|
942
1056
|
created_milestone = response.json()
|
|
1057
|
+
return self._milestone_to_epic(created_milestone)
|
|
943
1058
|
|
|
944
|
-
|
|
945
|
-
id=str(created_milestone["number"]),
|
|
946
|
-
title=created_milestone["title"],
|
|
947
|
-
description=created_milestone["description"],
|
|
948
|
-
state=(
|
|
949
|
-
TicketState.OPEN
|
|
950
|
-
if created_milestone["state"] == "open"
|
|
951
|
-
else TicketState.CLOSED
|
|
952
|
-
),
|
|
953
|
-
created_at=datetime.fromisoformat(
|
|
954
|
-
created_milestone["created_at"].replace("Z", "+00:00")
|
|
955
|
-
),
|
|
956
|
-
updated_at=datetime.fromisoformat(
|
|
957
|
-
created_milestone["updated_at"].replace("Z", "+00:00")
|
|
958
|
-
),
|
|
959
|
-
metadata={
|
|
960
|
-
"github": {
|
|
961
|
-
"number": created_milestone["number"],
|
|
962
|
-
"url": created_milestone["html_url"],
|
|
963
|
-
"open_issues": created_milestone["open_issues"],
|
|
964
|
-
"closed_issues": created_milestone["closed_issues"],
|
|
965
|
-
}
|
|
966
|
-
},
|
|
967
|
-
)
|
|
968
|
-
|
|
969
|
-
async def get_milestone(self, milestone_number: int) -> Optional[Epic]:
|
|
1059
|
+
async def get_milestone(self, milestone_number: int) -> Epic | None:
|
|
970
1060
|
"""Get a GitHub milestone as an Epic."""
|
|
971
1061
|
try:
|
|
972
1062
|
response = await self.client.get(
|
|
@@ -977,31 +1067,7 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
977
1067
|
response.raise_for_status()
|
|
978
1068
|
|
|
979
1069
|
milestone = response.json()
|
|
980
|
-
|
|
981
|
-
return Epic(
|
|
982
|
-
id=str(milestone["number"]),
|
|
983
|
-
title=milestone["title"],
|
|
984
|
-
description=milestone["description"],
|
|
985
|
-
state=(
|
|
986
|
-
TicketState.OPEN
|
|
987
|
-
if milestone["state"] == "open"
|
|
988
|
-
else TicketState.CLOSED
|
|
989
|
-
),
|
|
990
|
-
created_at=datetime.fromisoformat(
|
|
991
|
-
milestone["created_at"].replace("Z", "+00:00")
|
|
992
|
-
),
|
|
993
|
-
updated_at=datetime.fromisoformat(
|
|
994
|
-
milestone["updated_at"].replace("Z", "+00:00")
|
|
995
|
-
),
|
|
996
|
-
metadata={
|
|
997
|
-
"github": {
|
|
998
|
-
"number": milestone["number"],
|
|
999
|
-
"url": milestone["html_url"],
|
|
1000
|
-
"open_issues": milestone["open_issues"],
|
|
1001
|
-
"closed_issues": milestone["closed_issues"],
|
|
1002
|
-
}
|
|
1003
|
-
},
|
|
1004
|
-
)
|
|
1070
|
+
return self._milestone_to_epic(milestone)
|
|
1005
1071
|
except httpx.HTTPError:
|
|
1006
1072
|
return None
|
|
1007
1073
|
|
|
@@ -1020,36 +1086,63 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
1020
1086
|
)
|
|
1021
1087
|
response.raise_for_status()
|
|
1022
1088
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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}"
|
|
1050
1124
|
)
|
|
1051
1125
|
|
|
1052
|
-
|
|
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
|
|
1053
1146
|
|
|
1054
1147
|
async def link_to_pull_request(self, issue_number: int, pr_number: int) -> bool:
|
|
1055
1148
|
"""Link an issue to a pull request using keywords."""
|
|
@@ -1068,14 +1161,15 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
1068
1161
|
self,
|
|
1069
1162
|
ticket_id: str,
|
|
1070
1163
|
base_branch: str = "main",
|
|
1071
|
-
head_branch:
|
|
1072
|
-
title:
|
|
1073
|
-
body:
|
|
1164
|
+
head_branch: str | None = None,
|
|
1165
|
+
title: str | None = None,
|
|
1166
|
+
body: str | None = None,
|
|
1074
1167
|
draft: bool = False,
|
|
1075
1168
|
) -> dict[str, Any]:
|
|
1076
1169
|
"""Create a pull request linked to an issue.
|
|
1077
1170
|
|
|
1078
1171
|
Args:
|
|
1172
|
+
----
|
|
1079
1173
|
ticket_id: Issue number to link the PR to
|
|
1080
1174
|
base_branch: Target branch for the PR (default: main)
|
|
1081
1175
|
head_branch: Source branch name (auto-generated if not provided)
|
|
@@ -1084,13 +1178,14 @@ class GitHubAdapter(BaseAdapter[Task]):
|
|
|
1084
1178
|
draft: Create as draft PR
|
|
1085
1179
|
|
|
1086
1180
|
Returns:
|
|
1181
|
+
-------
|
|
1087
1182
|
Dictionary with PR details including number, url, and branch
|
|
1088
1183
|
|
|
1089
1184
|
"""
|
|
1090
1185
|
try:
|
|
1091
1186
|
issue_number = int(ticket_id)
|
|
1092
|
-
except ValueError:
|
|
1093
|
-
raise ValueError(f"Invalid issue number: {ticket_id}")
|
|
1187
|
+
except ValueError as e:
|
|
1188
|
+
raise ValueError(f"Invalid issue number: {ticket_id}") from e
|
|
1094
1189
|
|
|
1095
1190
|
# Get the issue details
|
|
1096
1191
|
issue = await self.read(ticket_id)
|
|
@@ -1224,10 +1319,11 @@ Fixes #{issue_number}
|
|
|
1224
1319
|
pr = pr_response.json()
|
|
1225
1320
|
|
|
1226
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']}"
|
|
1227
1323
|
await self.add_comment(
|
|
1228
1324
|
Comment(
|
|
1229
1325
|
ticket_id=ticket_id,
|
|
1230
|
-
content=
|
|
1326
|
+
content=pr_msg,
|
|
1231
1327
|
author="system",
|
|
1232
1328
|
)
|
|
1233
1329
|
)
|
|
@@ -1251,17 +1347,19 @@ Fixes #{issue_number}
|
|
|
1251
1347
|
"""Link an existing pull request to a ticket.
|
|
1252
1348
|
|
|
1253
1349
|
Args:
|
|
1350
|
+
----
|
|
1254
1351
|
ticket_id: Issue number to link the PR to
|
|
1255
1352
|
pr_url: GitHub PR URL to link
|
|
1256
1353
|
|
|
1257
1354
|
Returns:
|
|
1355
|
+
-------
|
|
1258
1356
|
Dictionary with link status and PR details
|
|
1259
1357
|
|
|
1260
1358
|
"""
|
|
1261
1359
|
try:
|
|
1262
1360
|
issue_number = int(ticket_id)
|
|
1263
|
-
except ValueError:
|
|
1264
|
-
raise ValueError(f"Invalid issue number: {ticket_id}")
|
|
1361
|
+
except ValueError as e:
|
|
1362
|
+
raise ValueError(f"Invalid issue number: {ticket_id}") from e
|
|
1265
1363
|
|
|
1266
1364
|
# Parse PR URL to extract owner, repo, and PR number
|
|
1267
1365
|
# Expected format: https://github.com/owner/repo/pull/123
|
|
@@ -1329,6 +1427,658 @@ Fixes #{issue_number}
|
|
|
1329
1427
|
"message": f"Successfully linked PR #{pr_number} to issue #{issue_number}",
|
|
1330
1428
|
}
|
|
1331
1429
|
|
|
1430
|
+
async def get_collaborators(self) -> builtins.list[dict[str, Any]]:
|
|
1431
|
+
"""Get repository collaborators."""
|
|
1432
|
+
response = await self.client.get(
|
|
1433
|
+
f"/repos/{self.owner}/{self.repo}/collaborators"
|
|
1434
|
+
)
|
|
1435
|
+
response.raise_for_status()
|
|
1436
|
+
return response.json()
|
|
1437
|
+
|
|
1438
|
+
async def get_current_user(self) -> dict[str, Any] | None:
|
|
1439
|
+
"""Get current authenticated user information."""
|
|
1440
|
+
response = await self.client.get("/user")
|
|
1441
|
+
response.raise_for_status()
|
|
1442
|
+
return response.json()
|
|
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
|
+
|
|
1332
2082
|
async def close(self) -> None:
|
|
1333
2083
|
"""Close the HTTP client connection."""
|
|
1334
2084
|
await self.client.aclose()
|