mcp-ticketer 0.12.0__py3-none-any.whl ā 2.2.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mcp-ticketer might be problematic. Click here for more details.
- mcp_ticketer/__init__.py +10 -10
- mcp_ticketer/__version__.py +1 -1
- mcp_ticketer/_version_scm.py +1 -0
- mcp_ticketer/adapters/aitrackdown.py +507 -6
- mcp_ticketer/adapters/asana/adapter.py +229 -0
- mcp_ticketer/adapters/asana/mappers.py +14 -0
- mcp_ticketer/adapters/github/__init__.py +26 -0
- mcp_ticketer/adapters/github/adapter.py +3229 -0
- mcp_ticketer/adapters/github/client.py +335 -0
- mcp_ticketer/adapters/github/mappers.py +797 -0
- mcp_ticketer/adapters/github/queries.py +692 -0
- mcp_ticketer/adapters/github/types.py +460 -0
- mcp_ticketer/adapters/hybrid.py +47 -5
- mcp_ticketer/adapters/jira/__init__.py +35 -0
- mcp_ticketer/adapters/jira/adapter.py +1351 -0
- mcp_ticketer/adapters/jira/client.py +271 -0
- mcp_ticketer/adapters/jira/mappers.py +246 -0
- mcp_ticketer/adapters/jira/queries.py +216 -0
- mcp_ticketer/adapters/jira/types.py +304 -0
- mcp_ticketer/adapters/linear/adapter.py +2730 -139
- mcp_ticketer/adapters/linear/client.py +175 -3
- mcp_ticketer/adapters/linear/mappers.py +203 -8
- mcp_ticketer/adapters/linear/queries.py +280 -3
- mcp_ticketer/adapters/linear/types.py +120 -4
- mcp_ticketer/analysis/__init__.py +56 -0
- mcp_ticketer/analysis/dependency_graph.py +255 -0
- mcp_ticketer/analysis/health_assessment.py +304 -0
- mcp_ticketer/analysis/orphaned.py +218 -0
- mcp_ticketer/analysis/project_status.py +594 -0
- mcp_ticketer/analysis/similarity.py +224 -0
- mcp_ticketer/analysis/staleness.py +266 -0
- mcp_ticketer/automation/__init__.py +11 -0
- mcp_ticketer/automation/project_updates.py +378 -0
- mcp_ticketer/cli/adapter_diagnostics.py +3 -1
- mcp_ticketer/cli/auggie_configure.py +17 -5
- mcp_ticketer/cli/codex_configure.py +97 -61
- mcp_ticketer/cli/configure.py +1288 -105
- mcp_ticketer/cli/cursor_configure.py +314 -0
- mcp_ticketer/cli/diagnostics.py +13 -12
- mcp_ticketer/cli/discover.py +5 -0
- mcp_ticketer/cli/gemini_configure.py +17 -5
- mcp_ticketer/cli/init_command.py +880 -0
- mcp_ticketer/cli/install_mcp_server.py +418 -0
- mcp_ticketer/cli/instruction_commands.py +6 -0
- mcp_ticketer/cli/main.py +267 -3175
- mcp_ticketer/cli/mcp_configure.py +821 -119
- mcp_ticketer/cli/mcp_server_commands.py +415 -0
- mcp_ticketer/cli/platform_detection.py +77 -12
- mcp_ticketer/cli/platform_installer.py +545 -0
- mcp_ticketer/cli/project_update_commands.py +350 -0
- mcp_ticketer/cli/setup_command.py +795 -0
- mcp_ticketer/cli/simple_health.py +12 -10
- mcp_ticketer/cli/ticket_commands.py +705 -103
- mcp_ticketer/cli/utils.py +113 -0
- mcp_ticketer/core/__init__.py +56 -6
- mcp_ticketer/core/adapter.py +533 -2
- mcp_ticketer/core/config.py +21 -21
- mcp_ticketer/core/exceptions.py +7 -1
- mcp_ticketer/core/label_manager.py +732 -0
- mcp_ticketer/core/mappers.py +31 -19
- mcp_ticketer/core/milestone_manager.py +252 -0
- mcp_ticketer/core/models.py +480 -0
- mcp_ticketer/core/onepassword_secrets.py +1 -1
- mcp_ticketer/core/priority_matcher.py +463 -0
- mcp_ticketer/core/project_config.py +132 -14
- mcp_ticketer/core/project_utils.py +281 -0
- mcp_ticketer/core/project_validator.py +376 -0
- mcp_ticketer/core/session_state.py +176 -0
- mcp_ticketer/core/state_matcher.py +625 -0
- mcp_ticketer/core/url_parser.py +425 -0
- mcp_ticketer/core/validators.py +69 -0
- mcp_ticketer/mcp/server/__main__.py +2 -1
- mcp_ticketer/mcp/server/diagnostic_helper.py +175 -0
- mcp_ticketer/mcp/server/main.py +106 -25
- mcp_ticketer/mcp/server/routing.py +723 -0
- mcp_ticketer/mcp/server/server_sdk.py +58 -0
- mcp_ticketer/mcp/server/tools/__init__.py +33 -11
- mcp_ticketer/mcp/server/tools/analysis_tools.py +854 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +5 -5
- 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 +1391 -145
- mcp_ticketer/mcp/server/tools/diagnostic_tools.py +211 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +870 -460
- mcp_ticketer/mcp/server/tools/instruction_tools.py +7 -5
- mcp_ticketer/mcp/server/tools/label_tools.py +942 -0
- mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -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 +209 -97
- mcp_ticketer/mcp/server/tools/session_tools.py +308 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +1107 -124
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +218 -236
- mcp_ticketer/queue/queue.py +68 -0
- mcp_ticketer/queue/worker.py +1 -1
- mcp_ticketer/utils/__init__.py +5 -0
- mcp_ticketer/utils/token_utils.py +246 -0
- mcp_ticketer-2.2.13.dist-info/METADATA +1396 -0
- mcp_ticketer-2.2.13.dist-info/RECORD +158 -0
- mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
- py_mcp_installer/examples/phase3_demo.py +178 -0
- py_mcp_installer/scripts/manage_version.py +54 -0
- py_mcp_installer/setup.py +6 -0
- py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
- py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
- py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
- py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
- py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
- py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
- py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
- py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
- py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
- py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
- py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
- py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
- py_mcp_installer/src/py_mcp_installer/types.py +222 -0
- py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
- py_mcp_installer/tests/__init__.py +0 -0
- py_mcp_installer/tests/platforms/__init__.py +0 -0
- py_mcp_installer/tests/test_platform_detector.py +17 -0
- mcp_ticketer/adapters/github.py +0 -1574
- mcp_ticketer/adapters/jira.py +0 -1258
- mcp_ticketer-0.12.0.dist-info/METADATA +0 -550
- mcp_ticketer-0.12.0.dist-info/RECORD +0 -91
- mcp_ticketer-0.12.0.dist-info/top_level.txt +0 -1
- {mcp_ticketer-0.12.0.dist-info ā mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.12.0.dist-info ā mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.12.0.dist-info ā mcp_ticketer-2.2.13.dist-info}/licenses/LICENSE +0 -0
mcp_ticketer/adapters/github.py
DELETED
|
@@ -1,1574 +0,0 @@
|
|
|
1
|
-
"""GitHub adapter implementation using REST API v3 and GraphQL API v4."""
|
|
2
|
-
|
|
3
|
-
import builtins
|
|
4
|
-
import re
|
|
5
|
-
from datetime import datetime
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
import httpx
|
|
10
|
-
|
|
11
|
-
from ..core.adapter import BaseAdapter
|
|
12
|
-
from ..core.env_loader import load_adapter_config, validate_adapter_config
|
|
13
|
-
from ..core.models import Comment, Epic, Priority, SearchQuery, Task, TicketState
|
|
14
|
-
from ..core.registry import AdapterRegistry
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class GitHubStateMapping:
|
|
18
|
-
"""GitHub issue states and label-based extended states."""
|
|
19
|
-
|
|
20
|
-
# GitHub native states
|
|
21
|
-
OPEN = "open"
|
|
22
|
-
CLOSED = "closed"
|
|
23
|
-
|
|
24
|
-
# Extended states via labels
|
|
25
|
-
STATE_LABELS = {
|
|
26
|
-
TicketState.IN_PROGRESS: "in-progress",
|
|
27
|
-
TicketState.READY: "ready",
|
|
28
|
-
TicketState.TESTED: "tested",
|
|
29
|
-
TicketState.WAITING: "waiting",
|
|
30
|
-
TicketState.BLOCKED: "blocked",
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
# Priority labels
|
|
34
|
-
PRIORITY_LABELS = {
|
|
35
|
-
Priority.CRITICAL: ["P0", "critical", "urgent"],
|
|
36
|
-
Priority.HIGH: ["P1", "high"],
|
|
37
|
-
Priority.MEDIUM: ["P2", "medium"],
|
|
38
|
-
Priority.LOW: ["P3", "low"],
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
class GitHubGraphQLQueries:
|
|
43
|
-
"""GraphQL queries for GitHub API v4."""
|
|
44
|
-
|
|
45
|
-
ISSUE_FRAGMENT = """
|
|
46
|
-
fragment IssueFields on Issue {
|
|
47
|
-
id
|
|
48
|
-
number
|
|
49
|
-
title
|
|
50
|
-
body
|
|
51
|
-
state
|
|
52
|
-
createdAt
|
|
53
|
-
updatedAt
|
|
54
|
-
url
|
|
55
|
-
author {
|
|
56
|
-
login
|
|
57
|
-
}
|
|
58
|
-
assignees(first: 10) {
|
|
59
|
-
nodes {
|
|
60
|
-
login
|
|
61
|
-
email
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
labels(first: 20) {
|
|
65
|
-
nodes {
|
|
66
|
-
name
|
|
67
|
-
color
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
milestone {
|
|
71
|
-
id
|
|
72
|
-
number
|
|
73
|
-
title
|
|
74
|
-
state
|
|
75
|
-
description
|
|
76
|
-
}
|
|
77
|
-
projectCards(first: 10) {
|
|
78
|
-
nodes {
|
|
79
|
-
project {
|
|
80
|
-
name
|
|
81
|
-
url
|
|
82
|
-
}
|
|
83
|
-
column {
|
|
84
|
-
name
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
comments(first: 100) {
|
|
89
|
-
nodes {
|
|
90
|
-
id
|
|
91
|
-
body
|
|
92
|
-
author {
|
|
93
|
-
login
|
|
94
|
-
}
|
|
95
|
-
createdAt
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
reactions(first: 10) {
|
|
99
|
-
nodes {
|
|
100
|
-
content
|
|
101
|
-
user {
|
|
102
|
-
login
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
"""
|
|
108
|
-
|
|
109
|
-
GET_ISSUE = """
|
|
110
|
-
query GetIssue($owner: String!, $repo: String!, $number: Int!) {
|
|
111
|
-
repository(owner: $owner, name: $repo) {
|
|
112
|
-
issue(number: $number) {
|
|
113
|
-
...IssueFields
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
"""
|
|
118
|
-
|
|
119
|
-
SEARCH_ISSUES = """
|
|
120
|
-
query SearchIssues($query: String!, $first: Int!, $after: String) {
|
|
121
|
-
search(query: $query, type: ISSUE, first: $first, after: $after) {
|
|
122
|
-
issueCount
|
|
123
|
-
pageInfo {
|
|
124
|
-
hasNextPage
|
|
125
|
-
endCursor
|
|
126
|
-
}
|
|
127
|
-
nodes {
|
|
128
|
-
... on Issue {
|
|
129
|
-
...IssueFields
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
"""
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
class GitHubAdapter(BaseAdapter[Task]):
|
|
138
|
-
"""Adapter for GitHub Issues tracking system."""
|
|
139
|
-
|
|
140
|
-
def __init__(self, config: dict[str, Any]):
|
|
141
|
-
"""Initialize GitHub adapter.
|
|
142
|
-
|
|
143
|
-
Args:
|
|
144
|
-
config: Configuration with:
|
|
145
|
-
- token: GitHub PAT (or GITHUB_TOKEN env var)
|
|
146
|
-
- owner: Repository owner (or GITHUB_OWNER env var)
|
|
147
|
-
- repo: Repository name (or GITHUB_REPO env var)
|
|
148
|
-
- api_url: Optional API URL for GitHub Enterprise
|
|
149
|
-
- use_projects_v2: Enable Projects v2 (default: False)
|
|
150
|
-
- custom_priority_scheme: Custom priority label mapping
|
|
151
|
-
|
|
152
|
-
"""
|
|
153
|
-
super().__init__(config)
|
|
154
|
-
|
|
155
|
-
# Load configuration with environment variable resolution
|
|
156
|
-
full_config = load_adapter_config("github", config)
|
|
157
|
-
|
|
158
|
-
# Validate required configuration
|
|
159
|
-
missing_keys = validate_adapter_config("github", full_config)
|
|
160
|
-
if missing_keys:
|
|
161
|
-
missing = ", ".join(missing_keys)
|
|
162
|
-
raise ValueError(
|
|
163
|
-
f"GitHub adapter missing required configuration: {missing}"
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
# Get authentication token - support 'api_key' and 'token'
|
|
167
|
-
self.token = (
|
|
168
|
-
full_config.get("api_key")
|
|
169
|
-
or full_config.get("token")
|
|
170
|
-
or full_config.get("token")
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
# Get repository information
|
|
174
|
-
self.owner = full_config.get("owner")
|
|
175
|
-
self.repo = full_config.get("repo")
|
|
176
|
-
|
|
177
|
-
# API URLs
|
|
178
|
-
self.api_url = config.get("api_url", "https://api.github.com")
|
|
179
|
-
self.graphql_url = (
|
|
180
|
-
f"{self.api_url}/graphql"
|
|
181
|
-
if "github.com" in self.api_url
|
|
182
|
-
else f"{self.api_url}/api/graphql"
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
# Configuration options
|
|
186
|
-
self.use_projects_v2 = config.get("use_projects_v2", False)
|
|
187
|
-
self.custom_priority_scheme = config.get("custom_priority_scheme", {})
|
|
188
|
-
|
|
189
|
-
# HTTP client with authentication
|
|
190
|
-
self.headers = {
|
|
191
|
-
"Authorization": f"Bearer {self.token}",
|
|
192
|
-
"Accept": "application/vnd.github.v3+json",
|
|
193
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
self.client = httpx.AsyncClient(
|
|
197
|
-
base_url=self.api_url,
|
|
198
|
-
headers=self.headers,
|
|
199
|
-
timeout=30.0,
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
# Cache for labels and milestones
|
|
203
|
-
self._labels_cache: list[dict[str, Any]] | None = None
|
|
204
|
-
self._milestones_cache: list[dict[str, Any]] | None = None
|
|
205
|
-
self._rate_limit: dict[str, Any] = {}
|
|
206
|
-
|
|
207
|
-
def validate_credentials(self) -> tuple[bool, str]:
|
|
208
|
-
"""Validate that required credentials are present.
|
|
209
|
-
|
|
210
|
-
Returns:
|
|
211
|
-
(is_valid, error_message) - Tuple of validation result and error message
|
|
212
|
-
|
|
213
|
-
"""
|
|
214
|
-
if not self.token:
|
|
215
|
-
return (
|
|
216
|
-
False,
|
|
217
|
-
"GITHUB_TOKEN is required. Set it in .env.local or environment.",
|
|
218
|
-
)
|
|
219
|
-
if not self.owner:
|
|
220
|
-
return (
|
|
221
|
-
False,
|
|
222
|
-
"GitHub owner is required. Set GITHUB_OWNER in .env.local "
|
|
223
|
-
"or configure with 'mcp-ticketer init --adapter github "
|
|
224
|
-
"--github-owner <owner>'",
|
|
225
|
-
)
|
|
226
|
-
if not self.repo:
|
|
227
|
-
return (
|
|
228
|
-
False,
|
|
229
|
-
"GitHub repo is required. Set GITHUB_REPO in .env.local "
|
|
230
|
-
"or configure with 'mcp-ticketer init --adapter github "
|
|
231
|
-
"--github-repo <repo>'",
|
|
232
|
-
)
|
|
233
|
-
return True, ""
|
|
234
|
-
|
|
235
|
-
def _get_state_mapping(self) -> dict[TicketState, str]:
|
|
236
|
-
"""Map universal states to GitHub states."""
|
|
237
|
-
return {
|
|
238
|
-
TicketState.OPEN: GitHubStateMapping.OPEN,
|
|
239
|
-
TicketState.IN_PROGRESS: GitHubStateMapping.OPEN, # with label
|
|
240
|
-
TicketState.READY: GitHubStateMapping.OPEN, # with label
|
|
241
|
-
TicketState.TESTED: GitHubStateMapping.OPEN, # with label
|
|
242
|
-
TicketState.DONE: GitHubStateMapping.CLOSED,
|
|
243
|
-
TicketState.WAITING: GitHubStateMapping.OPEN, # with label
|
|
244
|
-
TicketState.BLOCKED: GitHubStateMapping.OPEN, # with label
|
|
245
|
-
TicketState.CLOSED: GitHubStateMapping.CLOSED,
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
def _get_state_label(self, state: TicketState) -> str | None:
|
|
249
|
-
"""Get the label name for extended states."""
|
|
250
|
-
return GitHubStateMapping.STATE_LABELS.get(state)
|
|
251
|
-
|
|
252
|
-
def _get_priority_from_labels(self, labels: list[str]) -> Priority:
|
|
253
|
-
"""Extract priority from issue labels."""
|
|
254
|
-
label_names = [label.lower() for label in labels]
|
|
255
|
-
|
|
256
|
-
# Check custom priority scheme first
|
|
257
|
-
if self.custom_priority_scheme:
|
|
258
|
-
for priority_str, label_patterns in self.custom_priority_scheme.items():
|
|
259
|
-
for pattern in label_patterns:
|
|
260
|
-
if any(pattern.lower() in label for label in label_names):
|
|
261
|
-
return Priority(priority_str)
|
|
262
|
-
|
|
263
|
-
# Check default priority labels
|
|
264
|
-
for priority, priority_labels in GitHubStateMapping.PRIORITY_LABELS.items():
|
|
265
|
-
for priority_label in priority_labels:
|
|
266
|
-
if priority_label.lower() in label_names:
|
|
267
|
-
return priority
|
|
268
|
-
|
|
269
|
-
return Priority.MEDIUM
|
|
270
|
-
|
|
271
|
-
def _get_priority_label(self, priority: Priority) -> str:
|
|
272
|
-
"""Get label name for a priority level."""
|
|
273
|
-
# Check custom scheme first
|
|
274
|
-
if self.custom_priority_scheme:
|
|
275
|
-
labels = self.custom_priority_scheme.get(priority.value, [])
|
|
276
|
-
if labels:
|
|
277
|
-
return labels[0]
|
|
278
|
-
|
|
279
|
-
# Use default labels
|
|
280
|
-
labels = GitHubStateMapping.PRIORITY_LABELS.get(priority, [])
|
|
281
|
-
return (
|
|
282
|
-
labels[0]
|
|
283
|
-
if labels
|
|
284
|
-
else f"P{['0', '1', '2', '3'][list(Priority).index(priority)]}"
|
|
285
|
-
)
|
|
286
|
-
|
|
287
|
-
def _milestone_to_epic(self, milestone: dict[str, Any]) -> Epic:
|
|
288
|
-
"""Convert GitHub milestone to Epic model.
|
|
289
|
-
|
|
290
|
-
Args:
|
|
291
|
-
milestone: GitHub milestone data
|
|
292
|
-
|
|
293
|
-
Returns:
|
|
294
|
-
Epic instance
|
|
295
|
-
|
|
296
|
-
"""
|
|
297
|
-
return Epic(
|
|
298
|
-
id=str(milestone["number"]),
|
|
299
|
-
title=milestone["title"],
|
|
300
|
-
description=milestone.get("description", ""),
|
|
301
|
-
state=(
|
|
302
|
-
TicketState.OPEN if milestone["state"] == "open" else TicketState.CLOSED
|
|
303
|
-
),
|
|
304
|
-
created_at=datetime.fromisoformat(
|
|
305
|
-
milestone["created_at"].replace("Z", "+00:00")
|
|
306
|
-
),
|
|
307
|
-
updated_at=datetime.fromisoformat(
|
|
308
|
-
milestone["updated_at"].replace("Z", "+00:00")
|
|
309
|
-
),
|
|
310
|
-
metadata={
|
|
311
|
-
"github": {
|
|
312
|
-
"number": milestone["number"],
|
|
313
|
-
"url": milestone.get("html_url"),
|
|
314
|
-
"open_issues": milestone.get("open_issues", 0),
|
|
315
|
-
"closed_issues": milestone.get("closed_issues", 0),
|
|
316
|
-
}
|
|
317
|
-
},
|
|
318
|
-
)
|
|
319
|
-
|
|
320
|
-
def _extract_state_from_issue(self, issue: dict[str, Any]) -> TicketState:
|
|
321
|
-
"""Extract ticket state from GitHub issue data."""
|
|
322
|
-
# Check if closed
|
|
323
|
-
if issue["state"] == "closed":
|
|
324
|
-
return TicketState.CLOSED
|
|
325
|
-
|
|
326
|
-
# Check labels for extended states
|
|
327
|
-
labels = []
|
|
328
|
-
if "labels" in issue:
|
|
329
|
-
if isinstance(issue["labels"], list):
|
|
330
|
-
labels = [
|
|
331
|
-
label.get("name", "") if isinstance(label, dict) else str(label)
|
|
332
|
-
for label in issue["labels"]
|
|
333
|
-
]
|
|
334
|
-
elif isinstance(issue["labels"], dict) and "nodes" in issue["labels"]:
|
|
335
|
-
labels = [label["name"] for label in issue["labels"]["nodes"]]
|
|
336
|
-
|
|
337
|
-
label_names = [label.lower() for label in labels]
|
|
338
|
-
|
|
339
|
-
# Check for extended state labels
|
|
340
|
-
for state, label_name in GitHubStateMapping.STATE_LABELS.items():
|
|
341
|
-
if label_name.lower() in label_names:
|
|
342
|
-
return state
|
|
343
|
-
|
|
344
|
-
return TicketState.OPEN
|
|
345
|
-
|
|
346
|
-
def _task_from_github_issue(self, issue: dict[str, Any]) -> Task:
|
|
347
|
-
"""Convert GitHub issue to universal Task."""
|
|
348
|
-
# Extract labels
|
|
349
|
-
labels = []
|
|
350
|
-
if "labels" in issue:
|
|
351
|
-
if isinstance(issue["labels"], list):
|
|
352
|
-
labels = [
|
|
353
|
-
label.get("name", "") if isinstance(label, dict) else str(label)
|
|
354
|
-
for label in issue["labels"]
|
|
355
|
-
]
|
|
356
|
-
elif isinstance(issue["labels"], dict) and "nodes" in issue["labels"]:
|
|
357
|
-
labels = [label["name"] for label in issue["labels"]["nodes"]]
|
|
358
|
-
|
|
359
|
-
# Extract state
|
|
360
|
-
state = self._extract_state_from_issue(issue)
|
|
361
|
-
|
|
362
|
-
# Extract priority
|
|
363
|
-
priority = self._get_priority_from_labels(labels)
|
|
364
|
-
|
|
365
|
-
# Extract assignee
|
|
366
|
-
assignee = None
|
|
367
|
-
if "assignees" in issue:
|
|
368
|
-
if isinstance(issue["assignees"], list) and issue["assignees"]:
|
|
369
|
-
assignee = issue["assignees"][0].get("login")
|
|
370
|
-
elif isinstance(issue["assignees"], dict) and "nodes" in issue["assignees"]:
|
|
371
|
-
nodes = issue["assignees"]["nodes"]
|
|
372
|
-
if nodes:
|
|
373
|
-
assignee = nodes[0].get("login")
|
|
374
|
-
elif "assignee" in issue and issue["assignee"]:
|
|
375
|
-
assignee = issue["assignee"].get("login")
|
|
376
|
-
|
|
377
|
-
# Extract parent epic (milestone)
|
|
378
|
-
parent_epic = None
|
|
379
|
-
if issue.get("milestone"):
|
|
380
|
-
parent_epic = str(issue["milestone"]["number"])
|
|
381
|
-
|
|
382
|
-
# Parse dates
|
|
383
|
-
created_at = None
|
|
384
|
-
if issue.get("created_at"):
|
|
385
|
-
created_at = datetime.fromisoformat(
|
|
386
|
-
issue["created_at"].replace("Z", "+00:00")
|
|
387
|
-
)
|
|
388
|
-
elif issue.get("createdAt"):
|
|
389
|
-
created_at = datetime.fromisoformat(
|
|
390
|
-
issue["createdAt"].replace("Z", "+00:00")
|
|
391
|
-
)
|
|
392
|
-
|
|
393
|
-
updated_at = None
|
|
394
|
-
if issue.get("updated_at"):
|
|
395
|
-
updated_at = datetime.fromisoformat(
|
|
396
|
-
issue["updated_at"].replace("Z", "+00:00")
|
|
397
|
-
)
|
|
398
|
-
elif issue.get("updatedAt"):
|
|
399
|
-
updated_at = datetime.fromisoformat(
|
|
400
|
-
issue["updatedAt"].replace("Z", "+00:00")
|
|
401
|
-
)
|
|
402
|
-
|
|
403
|
-
# Build metadata
|
|
404
|
-
metadata = {
|
|
405
|
-
"github": {
|
|
406
|
-
"number": issue.get("number"),
|
|
407
|
-
"url": issue.get("url") or issue.get("html_url"),
|
|
408
|
-
"author": (
|
|
409
|
-
issue.get("user", {}).get("login")
|
|
410
|
-
if "user" in issue
|
|
411
|
-
else issue.get("author", {}).get("login")
|
|
412
|
-
),
|
|
413
|
-
"labels": labels,
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
# Add projects v2 info if available
|
|
418
|
-
if "projectCards" in issue and issue["projectCards"].get("nodes"):
|
|
419
|
-
metadata["github"]["projects"] = [
|
|
420
|
-
{
|
|
421
|
-
"name": card["project"]["name"],
|
|
422
|
-
"column": card["column"]["name"],
|
|
423
|
-
"url": card["project"]["url"],
|
|
424
|
-
}
|
|
425
|
-
for card in issue["projectCards"]["nodes"]
|
|
426
|
-
]
|
|
427
|
-
|
|
428
|
-
return Task(
|
|
429
|
-
id=str(issue["number"]),
|
|
430
|
-
title=issue["title"],
|
|
431
|
-
description=issue.get("body") or issue.get("bodyText"),
|
|
432
|
-
state=state,
|
|
433
|
-
priority=priority,
|
|
434
|
-
tags=labels,
|
|
435
|
-
parent_epic=parent_epic,
|
|
436
|
-
assignee=assignee,
|
|
437
|
-
created_at=created_at,
|
|
438
|
-
updated_at=updated_at,
|
|
439
|
-
metadata=metadata,
|
|
440
|
-
)
|
|
441
|
-
|
|
442
|
-
async def _ensure_label_exists(
|
|
443
|
-
self, label_name: str, color: str = "0366d6"
|
|
444
|
-
) -> None:
|
|
445
|
-
"""Ensure a label exists in the repository."""
|
|
446
|
-
if not self._labels_cache:
|
|
447
|
-
response = await self.client.get(f"/repos/{self.owner}/{self.repo}/labels")
|
|
448
|
-
response.raise_for_status()
|
|
449
|
-
self._labels_cache = response.json()
|
|
450
|
-
|
|
451
|
-
# Check if label exists
|
|
452
|
-
existing_labels = [label["name"].lower() for label in self._labels_cache]
|
|
453
|
-
if label_name.lower() not in existing_labels:
|
|
454
|
-
# Create the label
|
|
455
|
-
response = await self.client.post(
|
|
456
|
-
f"/repos/{self.owner}/{self.repo}/labels",
|
|
457
|
-
json={"name": label_name, "color": color},
|
|
458
|
-
)
|
|
459
|
-
if response.status_code == 201:
|
|
460
|
-
self._labels_cache.append(response.json())
|
|
461
|
-
|
|
462
|
-
async def _graphql_request(
|
|
463
|
-
self, query: str, variables: dict[str, Any]
|
|
464
|
-
) -> dict[str, Any]:
|
|
465
|
-
"""Execute a GraphQL query."""
|
|
466
|
-
response = await self.client.post(
|
|
467
|
-
self.graphql_url, json={"query": query, "variables": variables}
|
|
468
|
-
)
|
|
469
|
-
response.raise_for_status()
|
|
470
|
-
|
|
471
|
-
data = response.json()
|
|
472
|
-
if "errors" in data:
|
|
473
|
-
raise ValueError(f"GraphQL errors: {data['errors']}")
|
|
474
|
-
|
|
475
|
-
return data["data"]
|
|
476
|
-
|
|
477
|
-
async def create(self, ticket: Task) -> Task:
|
|
478
|
-
"""Create a new GitHub issue."""
|
|
479
|
-
# Validate credentials before attempting operation
|
|
480
|
-
is_valid, error_message = self.validate_credentials()
|
|
481
|
-
if not is_valid:
|
|
482
|
-
raise ValueError(error_message)
|
|
483
|
-
|
|
484
|
-
# Prepare labels
|
|
485
|
-
labels = ticket.tags.copy() if ticket.tags else []
|
|
486
|
-
|
|
487
|
-
# Add state label if needed
|
|
488
|
-
state_label = self._get_state_label(ticket.state)
|
|
489
|
-
if state_label:
|
|
490
|
-
labels.append(state_label)
|
|
491
|
-
await self._ensure_label_exists(state_label, "fbca04")
|
|
492
|
-
|
|
493
|
-
# Add priority label
|
|
494
|
-
priority_label = self._get_priority_label(ticket.priority)
|
|
495
|
-
labels.append(priority_label)
|
|
496
|
-
await self._ensure_label_exists(priority_label, "d73a4a")
|
|
497
|
-
|
|
498
|
-
# Ensure all labels exist
|
|
499
|
-
for label in labels:
|
|
500
|
-
await self._ensure_label_exists(label)
|
|
501
|
-
|
|
502
|
-
# Build issue data
|
|
503
|
-
issue_data = {
|
|
504
|
-
"title": ticket.title,
|
|
505
|
-
"body": ticket.description or "",
|
|
506
|
-
"labels": labels,
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
# Add assignee if specified
|
|
510
|
-
if ticket.assignee:
|
|
511
|
-
issue_data["assignees"] = [ticket.assignee]
|
|
512
|
-
|
|
513
|
-
# Add milestone if parent_epic is specified
|
|
514
|
-
if ticket.parent_epic:
|
|
515
|
-
try:
|
|
516
|
-
milestone_number = int(ticket.parent_epic)
|
|
517
|
-
issue_data["milestone"] = milestone_number
|
|
518
|
-
except ValueError:
|
|
519
|
-
# Try to find milestone by title
|
|
520
|
-
if not self._milestones_cache:
|
|
521
|
-
response = await self.client.get(
|
|
522
|
-
f"/repos/{self.owner}/{self.repo}/milestones"
|
|
523
|
-
)
|
|
524
|
-
response.raise_for_status()
|
|
525
|
-
self._milestones_cache = response.json()
|
|
526
|
-
|
|
527
|
-
for milestone in self._milestones_cache:
|
|
528
|
-
if milestone["title"] == ticket.parent_epic:
|
|
529
|
-
issue_data["milestone"] = milestone["number"]
|
|
530
|
-
break
|
|
531
|
-
|
|
532
|
-
# Create the issue
|
|
533
|
-
response = await self.client.post(
|
|
534
|
-
f"/repos/{self.owner}/{self.repo}/issues", json=issue_data
|
|
535
|
-
)
|
|
536
|
-
response.raise_for_status()
|
|
537
|
-
|
|
538
|
-
created_issue = response.json()
|
|
539
|
-
|
|
540
|
-
# If state requires closing, close the issue
|
|
541
|
-
if ticket.state in [TicketState.DONE, TicketState.CLOSED]:
|
|
542
|
-
await self.client.patch(
|
|
543
|
-
f"/repos/{self.owner}/{self.repo}/issues/{created_issue['number']}",
|
|
544
|
-
json={"state": "closed"},
|
|
545
|
-
)
|
|
546
|
-
created_issue["state"] = "closed"
|
|
547
|
-
|
|
548
|
-
return self._task_from_github_issue(created_issue)
|
|
549
|
-
|
|
550
|
-
async def read(self, ticket_id: str) -> Task | None:
|
|
551
|
-
"""Read a GitHub issue by number."""
|
|
552
|
-
# Validate credentials before attempting operation
|
|
553
|
-
is_valid, error_message = self.validate_credentials()
|
|
554
|
-
if not is_valid:
|
|
555
|
-
raise ValueError(error_message)
|
|
556
|
-
|
|
557
|
-
try:
|
|
558
|
-
issue_number = int(ticket_id)
|
|
559
|
-
except ValueError:
|
|
560
|
-
return None
|
|
561
|
-
|
|
562
|
-
try:
|
|
563
|
-
response = await self.client.get(
|
|
564
|
-
f"/repos/{self.owner}/{self.repo}/issues/{issue_number}"
|
|
565
|
-
)
|
|
566
|
-
if response.status_code == 404:
|
|
567
|
-
return None
|
|
568
|
-
response.raise_for_status()
|
|
569
|
-
|
|
570
|
-
issue = response.json()
|
|
571
|
-
return self._task_from_github_issue(issue)
|
|
572
|
-
except httpx.HTTPError:
|
|
573
|
-
return None
|
|
574
|
-
|
|
575
|
-
async def update(self, ticket_id: str, updates: dict[str, Any]) -> Task | None:
|
|
576
|
-
"""Update a GitHub issue."""
|
|
577
|
-
# Validate credentials before attempting operation
|
|
578
|
-
is_valid, error_message = self.validate_credentials()
|
|
579
|
-
if not is_valid:
|
|
580
|
-
raise ValueError(error_message)
|
|
581
|
-
|
|
582
|
-
try:
|
|
583
|
-
issue_number = int(ticket_id)
|
|
584
|
-
except ValueError:
|
|
585
|
-
return None
|
|
586
|
-
|
|
587
|
-
# Get current issue to preserve labels
|
|
588
|
-
response = await self.client.get(
|
|
589
|
-
f"/repos/{self.owner}/{self.repo}/issues/{issue_number}"
|
|
590
|
-
)
|
|
591
|
-
if response.status_code == 404:
|
|
592
|
-
return None
|
|
593
|
-
response.raise_for_status()
|
|
594
|
-
|
|
595
|
-
current_issue = response.json()
|
|
596
|
-
current_labels = [label["name"] for label in current_issue.get("labels", [])]
|
|
597
|
-
|
|
598
|
-
# Build update data
|
|
599
|
-
update_data = {}
|
|
600
|
-
|
|
601
|
-
if "title" in updates:
|
|
602
|
-
update_data["title"] = updates["title"]
|
|
603
|
-
|
|
604
|
-
if "description" in updates:
|
|
605
|
-
update_data["body"] = updates["description"]
|
|
606
|
-
|
|
607
|
-
# Handle state updates
|
|
608
|
-
if "state" in updates:
|
|
609
|
-
new_state = updates["state"]
|
|
610
|
-
if isinstance(new_state, str):
|
|
611
|
-
new_state = TicketState(new_state)
|
|
612
|
-
|
|
613
|
-
# Remove old state labels
|
|
614
|
-
labels_to_update = [
|
|
615
|
-
label
|
|
616
|
-
for label in current_labels
|
|
617
|
-
if label.lower()
|
|
618
|
-
not in [sl.lower() for sl in GitHubStateMapping.STATE_LABELS.values()]
|
|
619
|
-
]
|
|
620
|
-
|
|
621
|
-
# Add new state label if needed
|
|
622
|
-
state_label = self._get_state_label(new_state)
|
|
623
|
-
if state_label:
|
|
624
|
-
await self._ensure_label_exists(state_label, "fbca04")
|
|
625
|
-
labels_to_update.append(state_label)
|
|
626
|
-
|
|
627
|
-
update_data["labels"] = labels_to_update
|
|
628
|
-
|
|
629
|
-
# Update issue state if needed
|
|
630
|
-
if new_state in [TicketState.DONE, TicketState.CLOSED]:
|
|
631
|
-
update_data["state"] = "closed"
|
|
632
|
-
else:
|
|
633
|
-
update_data["state"] = "open"
|
|
634
|
-
|
|
635
|
-
# Handle priority updates
|
|
636
|
-
if "priority" in updates:
|
|
637
|
-
new_priority = updates["priority"]
|
|
638
|
-
if isinstance(new_priority, str):
|
|
639
|
-
new_priority = Priority(new_priority)
|
|
640
|
-
|
|
641
|
-
# Remove old priority labels
|
|
642
|
-
labels_to_update = update_data.get("labels", current_labels)
|
|
643
|
-
all_priority_labels = []
|
|
644
|
-
for labels in GitHubStateMapping.PRIORITY_LABELS.values():
|
|
645
|
-
all_priority_labels.extend([label.lower() for label in labels])
|
|
646
|
-
|
|
647
|
-
labels_to_update = [
|
|
648
|
-
label
|
|
649
|
-
for label in labels_to_update
|
|
650
|
-
if label.lower() not in all_priority_labels
|
|
651
|
-
and not re.match(r"^P[0-3]$", label, re.IGNORECASE)
|
|
652
|
-
]
|
|
653
|
-
|
|
654
|
-
# Add new priority label
|
|
655
|
-
priority_label = self._get_priority_label(new_priority)
|
|
656
|
-
await self._ensure_label_exists(priority_label, "d73a4a")
|
|
657
|
-
labels_to_update.append(priority_label)
|
|
658
|
-
|
|
659
|
-
update_data["labels"] = labels_to_update
|
|
660
|
-
|
|
661
|
-
# Handle assignee updates
|
|
662
|
-
if "assignee" in updates:
|
|
663
|
-
if updates["assignee"]:
|
|
664
|
-
update_data["assignees"] = [updates["assignee"]]
|
|
665
|
-
else:
|
|
666
|
-
update_data["assignees"] = []
|
|
667
|
-
|
|
668
|
-
# Handle tags updates
|
|
669
|
-
if "tags" in updates:
|
|
670
|
-
# Preserve state and priority labels
|
|
671
|
-
preserved_labels = []
|
|
672
|
-
for label in current_labels:
|
|
673
|
-
if label.lower() in [
|
|
674
|
-
sl.lower() for sl in GitHubStateMapping.STATE_LABELS.values()
|
|
675
|
-
]:
|
|
676
|
-
preserved_labels.append(label)
|
|
677
|
-
elif any(
|
|
678
|
-
label.lower() in [pl.lower() for pl in labels]
|
|
679
|
-
for labels in GitHubStateMapping.PRIORITY_LABELS.values()
|
|
680
|
-
):
|
|
681
|
-
preserved_labels.append(label)
|
|
682
|
-
elif re.match(r"^P[0-3]$", label, re.IGNORECASE):
|
|
683
|
-
preserved_labels.append(label)
|
|
684
|
-
|
|
685
|
-
# Add new tags
|
|
686
|
-
for tag in updates["tags"]:
|
|
687
|
-
await self._ensure_label_exists(tag)
|
|
688
|
-
|
|
689
|
-
update_data["labels"] = preserved_labels + updates["tags"]
|
|
690
|
-
|
|
691
|
-
# Apply updates
|
|
692
|
-
if update_data:
|
|
693
|
-
response = await self.client.patch(
|
|
694
|
-
f"/repos/{self.owner}/{self.repo}/issues/{issue_number}",
|
|
695
|
-
json=update_data,
|
|
696
|
-
)
|
|
697
|
-
response.raise_for_status()
|
|
698
|
-
|
|
699
|
-
updated_issue = response.json()
|
|
700
|
-
return self._task_from_github_issue(updated_issue)
|
|
701
|
-
|
|
702
|
-
return await self.read(ticket_id)
|
|
703
|
-
|
|
704
|
-
async def delete(self, ticket_id: str) -> bool:
|
|
705
|
-
"""Delete (close) a GitHub issue."""
|
|
706
|
-
# Validate credentials before attempting operation
|
|
707
|
-
is_valid, error_message = self.validate_credentials()
|
|
708
|
-
if not is_valid:
|
|
709
|
-
raise ValueError(error_message)
|
|
710
|
-
|
|
711
|
-
try:
|
|
712
|
-
issue_number = int(ticket_id)
|
|
713
|
-
except ValueError:
|
|
714
|
-
return False
|
|
715
|
-
|
|
716
|
-
try:
|
|
717
|
-
response = await self.client.patch(
|
|
718
|
-
f"/repos/{self.owner}/{self.repo}/issues/{issue_number}",
|
|
719
|
-
json={"state": "closed", "state_reason": "not_planned"},
|
|
720
|
-
)
|
|
721
|
-
response.raise_for_status()
|
|
722
|
-
return True
|
|
723
|
-
except httpx.HTTPError:
|
|
724
|
-
return False
|
|
725
|
-
|
|
726
|
-
async def list(
|
|
727
|
-
self, limit: int = 10, offset: int = 0, filters: dict[str, Any] | None = None
|
|
728
|
-
) -> list[Task]:
|
|
729
|
-
"""List GitHub issues with filters."""
|
|
730
|
-
# Build query parameters
|
|
731
|
-
params: dict[str, Any] = {
|
|
732
|
-
"per_page": min(limit, 100), # GitHub max is 100
|
|
733
|
-
"page": (offset // limit) + 1 if limit > 0 else 1,
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
if filters:
|
|
737
|
-
# State filter
|
|
738
|
-
if "state" in filters:
|
|
739
|
-
state = filters["state"]
|
|
740
|
-
if isinstance(state, str):
|
|
741
|
-
state = TicketState(state)
|
|
742
|
-
|
|
743
|
-
if state in [TicketState.DONE, TicketState.CLOSED]:
|
|
744
|
-
params["state"] = "closed"
|
|
745
|
-
else:
|
|
746
|
-
params["state"] = "open"
|
|
747
|
-
# Add label filter for extended states
|
|
748
|
-
state_label = self._get_state_label(state)
|
|
749
|
-
if state_label:
|
|
750
|
-
params["labels"] = state_label
|
|
751
|
-
|
|
752
|
-
# Priority filter via labels
|
|
753
|
-
if "priority" in filters:
|
|
754
|
-
priority = filters["priority"]
|
|
755
|
-
if isinstance(priority, str):
|
|
756
|
-
priority = Priority(priority)
|
|
757
|
-
priority_label = self._get_priority_label(priority)
|
|
758
|
-
|
|
759
|
-
if "labels" in params:
|
|
760
|
-
params["labels"] += f",{priority_label}"
|
|
761
|
-
else:
|
|
762
|
-
params["labels"] = priority_label
|
|
763
|
-
|
|
764
|
-
# Assignee filter
|
|
765
|
-
if "assignee" in filters:
|
|
766
|
-
params["assignee"] = filters["assignee"]
|
|
767
|
-
|
|
768
|
-
# Milestone filter (parent_epic)
|
|
769
|
-
if "parent_epic" in filters:
|
|
770
|
-
params["milestone"] = filters["parent_epic"]
|
|
771
|
-
|
|
772
|
-
response = await self.client.get(
|
|
773
|
-
f"/repos/{self.owner}/{self.repo}/issues", params=params
|
|
774
|
-
)
|
|
775
|
-
response.raise_for_status()
|
|
776
|
-
|
|
777
|
-
issues = response.json()
|
|
778
|
-
|
|
779
|
-
# Store rate limit info
|
|
780
|
-
self._rate_limit = {
|
|
781
|
-
"limit": response.headers.get("X-RateLimit-Limit"),
|
|
782
|
-
"remaining": response.headers.get("X-RateLimit-Remaining"),
|
|
783
|
-
"reset": response.headers.get("X-RateLimit-Reset"),
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
# Filter out pull requests (they appear as issues in the API)
|
|
787
|
-
issues = [issue for issue in issues if "pull_request" not in issue]
|
|
788
|
-
|
|
789
|
-
return [self._task_from_github_issue(issue) for issue in issues]
|
|
790
|
-
|
|
791
|
-
async def search(self, query: SearchQuery) -> builtins.list[Task]:
|
|
792
|
-
"""Search GitHub issues using advanced search syntax."""
|
|
793
|
-
# Build GitHub search query
|
|
794
|
-
search_parts = [f"repo:{self.owner}/{self.repo}", "is:issue"]
|
|
795
|
-
|
|
796
|
-
# Text search
|
|
797
|
-
if query.query:
|
|
798
|
-
# Escape special characters for GitHub search
|
|
799
|
-
escaped_query = query.query.replace('"', '\\"')
|
|
800
|
-
search_parts.append(f'"{escaped_query}"')
|
|
801
|
-
|
|
802
|
-
# State filter
|
|
803
|
-
if query.state:
|
|
804
|
-
if query.state in [TicketState.DONE, TicketState.CLOSED]:
|
|
805
|
-
search_parts.append("is:closed")
|
|
806
|
-
else:
|
|
807
|
-
search_parts.append("is:open")
|
|
808
|
-
# Add label filter for extended states
|
|
809
|
-
state_label = self._get_state_label(query.state)
|
|
810
|
-
if state_label:
|
|
811
|
-
search_parts.append(f'label:"{state_label}"')
|
|
812
|
-
|
|
813
|
-
# Priority filter
|
|
814
|
-
if query.priority:
|
|
815
|
-
priority_label = self._get_priority_label(query.priority)
|
|
816
|
-
search_parts.append(f'label:"{priority_label}"')
|
|
817
|
-
|
|
818
|
-
# Assignee filter
|
|
819
|
-
if query.assignee:
|
|
820
|
-
search_parts.append(f"assignee:{query.assignee}")
|
|
821
|
-
|
|
822
|
-
# Tags filter
|
|
823
|
-
if query.tags:
|
|
824
|
-
for tag in query.tags:
|
|
825
|
-
search_parts.append(f'label:"{tag}"')
|
|
826
|
-
|
|
827
|
-
# Build final search query
|
|
828
|
-
github_query = " ".join(search_parts)
|
|
829
|
-
|
|
830
|
-
# Use GraphQL for better search capabilities
|
|
831
|
-
full_query = (
|
|
832
|
-
GitHubGraphQLQueries.ISSUE_FRAGMENT + GitHubGraphQLQueries.SEARCH_ISSUES
|
|
833
|
-
)
|
|
834
|
-
|
|
835
|
-
variables = {
|
|
836
|
-
"query": github_query,
|
|
837
|
-
"first": min(query.limit, 100),
|
|
838
|
-
"after": None,
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
# Handle pagination for offset
|
|
842
|
-
if query.offset > 0:
|
|
843
|
-
# We need to paginate through to get to the offset
|
|
844
|
-
# This is inefficient but GitHub doesn't support direct offset
|
|
845
|
-
pages_to_skip = query.offset // 100
|
|
846
|
-
for _ in range(pages_to_skip):
|
|
847
|
-
temp_result = await self._graphql_request(full_query, variables)
|
|
848
|
-
page_info = temp_result["search"]["pageInfo"]
|
|
849
|
-
if page_info["hasNextPage"]:
|
|
850
|
-
variables["after"] = page_info["endCursor"]
|
|
851
|
-
else:
|
|
852
|
-
return [] # Offset beyond available results
|
|
853
|
-
|
|
854
|
-
result = await self._graphql_request(full_query, variables)
|
|
855
|
-
|
|
856
|
-
issues = []
|
|
857
|
-
for node in result["search"]["nodes"]:
|
|
858
|
-
if node: # Some nodes might be null
|
|
859
|
-
# Convert GraphQL format to REST format for consistency
|
|
860
|
-
rest_format = {
|
|
861
|
-
"number": node["number"],
|
|
862
|
-
"title": node["title"],
|
|
863
|
-
"body": node["body"],
|
|
864
|
-
"state": node["state"].lower(),
|
|
865
|
-
"created_at": node["createdAt"],
|
|
866
|
-
"updated_at": node["updatedAt"],
|
|
867
|
-
"html_url": node["url"],
|
|
868
|
-
"labels": node.get("labels", {}).get("nodes", []),
|
|
869
|
-
"milestone": node.get("milestone"),
|
|
870
|
-
"assignees": node.get("assignees", {}).get("nodes", []),
|
|
871
|
-
"author": node.get("author"),
|
|
872
|
-
}
|
|
873
|
-
issues.append(self._task_from_github_issue(rest_format))
|
|
874
|
-
|
|
875
|
-
return issues
|
|
876
|
-
|
|
877
|
-
async def transition_state(
|
|
878
|
-
self, ticket_id: str, target_state: TicketState
|
|
879
|
-
) -> Task | None:
|
|
880
|
-
"""Transition GitHub issue to a new state."""
|
|
881
|
-
# Validate transition
|
|
882
|
-
if not await self.validate_transition(ticket_id, target_state):
|
|
883
|
-
return None
|
|
884
|
-
|
|
885
|
-
# Update state
|
|
886
|
-
return await self.update(ticket_id, {"state": target_state})
|
|
887
|
-
|
|
888
|
-
async def add_comment(self, comment: Comment) -> Comment:
|
|
889
|
-
"""Add a comment to a GitHub issue."""
|
|
890
|
-
try:
|
|
891
|
-
issue_number = int(comment.ticket_id)
|
|
892
|
-
except ValueError as e:
|
|
893
|
-
raise ValueError(f"Invalid issue number: {comment.ticket_id}") from e
|
|
894
|
-
|
|
895
|
-
# Create comment
|
|
896
|
-
response = await self.client.post(
|
|
897
|
-
f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
|
|
898
|
-
json={"body": comment.content},
|
|
899
|
-
)
|
|
900
|
-
response.raise_for_status()
|
|
901
|
-
|
|
902
|
-
created_comment = response.json()
|
|
903
|
-
|
|
904
|
-
return Comment(
|
|
905
|
-
id=str(created_comment["id"]),
|
|
906
|
-
ticket_id=comment.ticket_id,
|
|
907
|
-
author=created_comment["user"]["login"],
|
|
908
|
-
content=created_comment["body"],
|
|
909
|
-
created_at=datetime.fromisoformat(
|
|
910
|
-
created_comment["created_at"].replace("Z", "+00:00")
|
|
911
|
-
),
|
|
912
|
-
metadata={
|
|
913
|
-
"github": {
|
|
914
|
-
"id": created_comment["id"],
|
|
915
|
-
"url": created_comment["html_url"],
|
|
916
|
-
"author_avatar": created_comment["user"]["avatar_url"],
|
|
917
|
-
}
|
|
918
|
-
},
|
|
919
|
-
)
|
|
920
|
-
|
|
921
|
-
async def get_comments(
|
|
922
|
-
self, ticket_id: str, limit: int = 10, offset: int = 0
|
|
923
|
-
) -> builtins.list[Comment]:
|
|
924
|
-
"""Get comments for a GitHub issue."""
|
|
925
|
-
try:
|
|
926
|
-
issue_number = int(ticket_id)
|
|
927
|
-
except ValueError:
|
|
928
|
-
return []
|
|
929
|
-
|
|
930
|
-
params = {
|
|
931
|
-
"per_page": min(limit, 100),
|
|
932
|
-
"page": (offset // limit) + 1 if limit > 0 else 1,
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
try:
|
|
936
|
-
response = await self.client.get(
|
|
937
|
-
f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
|
|
938
|
-
params=params,
|
|
939
|
-
)
|
|
940
|
-
response.raise_for_status()
|
|
941
|
-
|
|
942
|
-
comments = []
|
|
943
|
-
for comment_data in response.json():
|
|
944
|
-
comments.append(
|
|
945
|
-
Comment(
|
|
946
|
-
id=str(comment_data["id"]),
|
|
947
|
-
ticket_id=ticket_id,
|
|
948
|
-
author=comment_data["user"]["login"],
|
|
949
|
-
content=comment_data["body"],
|
|
950
|
-
created_at=datetime.fromisoformat(
|
|
951
|
-
comment_data["created_at"].replace("Z", "+00:00")
|
|
952
|
-
),
|
|
953
|
-
metadata={
|
|
954
|
-
"github": {
|
|
955
|
-
"id": comment_data["id"],
|
|
956
|
-
"url": comment_data["html_url"],
|
|
957
|
-
"author_avatar": comment_data["user"]["avatar_url"],
|
|
958
|
-
}
|
|
959
|
-
},
|
|
960
|
-
)
|
|
961
|
-
)
|
|
962
|
-
|
|
963
|
-
return comments
|
|
964
|
-
except httpx.HTTPError:
|
|
965
|
-
return []
|
|
966
|
-
|
|
967
|
-
async def get_rate_limit(self) -> dict[str, Any]:
|
|
968
|
-
"""Get current rate limit status."""
|
|
969
|
-
response = await self.client.get("/rate_limit")
|
|
970
|
-
response.raise_for_status()
|
|
971
|
-
return response.json()
|
|
972
|
-
|
|
973
|
-
async def create_milestone(self, epic: Epic) -> Epic:
|
|
974
|
-
"""Create a GitHub milestone as an Epic."""
|
|
975
|
-
milestone_data = {
|
|
976
|
-
"title": epic.title,
|
|
977
|
-
"description": epic.description or "",
|
|
978
|
-
"state": "open" if epic.state != TicketState.CLOSED else "closed",
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
response = await self.client.post(
|
|
982
|
-
f"/repos/{self.owner}/{self.repo}/milestones", json=milestone_data
|
|
983
|
-
)
|
|
984
|
-
response.raise_for_status()
|
|
985
|
-
|
|
986
|
-
created_milestone = response.json()
|
|
987
|
-
return self._milestone_to_epic(created_milestone)
|
|
988
|
-
|
|
989
|
-
async def get_milestone(self, milestone_number: int) -> Epic | None:
|
|
990
|
-
"""Get a GitHub milestone as an Epic."""
|
|
991
|
-
try:
|
|
992
|
-
response = await self.client.get(
|
|
993
|
-
f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}"
|
|
994
|
-
)
|
|
995
|
-
if response.status_code == 404:
|
|
996
|
-
return None
|
|
997
|
-
response.raise_for_status()
|
|
998
|
-
|
|
999
|
-
milestone = response.json()
|
|
1000
|
-
return self._milestone_to_epic(milestone)
|
|
1001
|
-
except httpx.HTTPError:
|
|
1002
|
-
return None
|
|
1003
|
-
|
|
1004
|
-
async def list_milestones(
|
|
1005
|
-
self, state: str = "open", limit: int = 10, offset: int = 0
|
|
1006
|
-
) -> builtins.list[Epic]:
|
|
1007
|
-
"""List GitHub milestones as Epics."""
|
|
1008
|
-
params = {
|
|
1009
|
-
"state": state,
|
|
1010
|
-
"per_page": min(limit, 100),
|
|
1011
|
-
"page": (offset // limit) + 1 if limit > 0 else 1,
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
response = await self.client.get(
|
|
1015
|
-
f"/repos/{self.owner}/{self.repo}/milestones", params=params
|
|
1016
|
-
)
|
|
1017
|
-
response.raise_for_status()
|
|
1018
|
-
|
|
1019
|
-
return [self._milestone_to_epic(milestone) for milestone in response.json()]
|
|
1020
|
-
|
|
1021
|
-
async def link_to_pull_request(self, issue_number: int, pr_number: int) -> bool:
|
|
1022
|
-
"""Link an issue to a pull request using keywords."""
|
|
1023
|
-
# This is typically done through PR description keywords like "fixes #123"
|
|
1024
|
-
# We can add a comment to track the link
|
|
1025
|
-
comment = f"Linked to PR #{pr_number}"
|
|
1026
|
-
|
|
1027
|
-
response = await self.client.post(
|
|
1028
|
-
f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
|
|
1029
|
-
json={"body": comment},
|
|
1030
|
-
)
|
|
1031
|
-
|
|
1032
|
-
return response.status_code == 201
|
|
1033
|
-
|
|
1034
|
-
async def create_pull_request(
|
|
1035
|
-
self,
|
|
1036
|
-
ticket_id: str,
|
|
1037
|
-
base_branch: str = "main",
|
|
1038
|
-
head_branch: str | None = None,
|
|
1039
|
-
title: str | None = None,
|
|
1040
|
-
body: str | None = None,
|
|
1041
|
-
draft: bool = False,
|
|
1042
|
-
) -> dict[str, Any]:
|
|
1043
|
-
"""Create a pull request linked to an issue.
|
|
1044
|
-
|
|
1045
|
-
Args:
|
|
1046
|
-
ticket_id: Issue number to link the PR to
|
|
1047
|
-
base_branch: Target branch for the PR (default: main)
|
|
1048
|
-
head_branch: Source branch name (auto-generated if not provided)
|
|
1049
|
-
title: PR title (uses ticket title if not provided)
|
|
1050
|
-
body: PR description (auto-generated with issue link if not provided)
|
|
1051
|
-
draft: Create as draft PR
|
|
1052
|
-
|
|
1053
|
-
Returns:
|
|
1054
|
-
Dictionary with PR details including number, url, and branch
|
|
1055
|
-
|
|
1056
|
-
"""
|
|
1057
|
-
try:
|
|
1058
|
-
issue_number = int(ticket_id)
|
|
1059
|
-
except ValueError as e:
|
|
1060
|
-
raise ValueError(f"Invalid issue number: {ticket_id}") from e
|
|
1061
|
-
|
|
1062
|
-
# Get the issue details
|
|
1063
|
-
issue = await self.read(ticket_id)
|
|
1064
|
-
if not issue:
|
|
1065
|
-
raise ValueError(f"Issue #{ticket_id} not found")
|
|
1066
|
-
|
|
1067
|
-
# Auto-generate branch name if not provided
|
|
1068
|
-
if not head_branch:
|
|
1069
|
-
# Create branch name from issue number and title
|
|
1070
|
-
# e.g., "123-fix-authentication-bug"
|
|
1071
|
-
safe_title = "-".join(
|
|
1072
|
-
issue.title.lower()
|
|
1073
|
-
.replace("[", "")
|
|
1074
|
-
.replace("]", "")
|
|
1075
|
-
.replace("#", "")
|
|
1076
|
-
.replace("/", "-")
|
|
1077
|
-
.replace("\\", "-")
|
|
1078
|
-
.split()[:5] # Limit to 5 words
|
|
1079
|
-
)
|
|
1080
|
-
head_branch = f"{issue_number}-{safe_title}"
|
|
1081
|
-
|
|
1082
|
-
# Auto-generate title if not provided
|
|
1083
|
-
if not title:
|
|
1084
|
-
# Include issue number in PR title
|
|
1085
|
-
title = f"[#{issue_number}] {issue.title}"
|
|
1086
|
-
|
|
1087
|
-
# Auto-generate body if not provided
|
|
1088
|
-
if not body:
|
|
1089
|
-
body = f"""## Summary
|
|
1090
|
-
|
|
1091
|
-
This PR addresses issue #{issue_number}.
|
|
1092
|
-
|
|
1093
|
-
**Issue:** #{issue_number} - {issue.title}
|
|
1094
|
-
**Link:** {issue.metadata.get('github', {}).get('url', '')}
|
|
1095
|
-
|
|
1096
|
-
## Description
|
|
1097
|
-
|
|
1098
|
-
{issue.description or 'No description provided.'}
|
|
1099
|
-
|
|
1100
|
-
## Changes
|
|
1101
|
-
|
|
1102
|
-
- [ ] Implementation details to be added
|
|
1103
|
-
|
|
1104
|
-
## Testing
|
|
1105
|
-
|
|
1106
|
-
- [ ] Tests have been added/updated
|
|
1107
|
-
- [ ] All tests pass
|
|
1108
|
-
|
|
1109
|
-
## Checklist
|
|
1110
|
-
|
|
1111
|
-
- [ ] Code follows project style guidelines
|
|
1112
|
-
- [ ] Self-review completed
|
|
1113
|
-
- [ ] Documentation updated if needed
|
|
1114
|
-
|
|
1115
|
-
Fixes #{issue_number}
|
|
1116
|
-
"""
|
|
1117
|
-
|
|
1118
|
-
# Check if the head branch exists
|
|
1119
|
-
try:
|
|
1120
|
-
branch_response = await self.client.get(
|
|
1121
|
-
f"/repos/{self.owner}/{self.repo}/branches/{head_branch}"
|
|
1122
|
-
)
|
|
1123
|
-
branch_exists = branch_response.status_code == 200
|
|
1124
|
-
except httpx.HTTPError:
|
|
1125
|
-
branch_exists = False
|
|
1126
|
-
|
|
1127
|
-
if not branch_exists:
|
|
1128
|
-
# Get the base branch SHA
|
|
1129
|
-
base_response = await self.client.get(
|
|
1130
|
-
f"/repos/{self.owner}/{self.repo}/branches/{base_branch}"
|
|
1131
|
-
)
|
|
1132
|
-
base_response.raise_for_status()
|
|
1133
|
-
base_sha = base_response.json()["commit"]["sha"]
|
|
1134
|
-
|
|
1135
|
-
# Create the new branch
|
|
1136
|
-
ref_response = await self.client.post(
|
|
1137
|
-
f"/repos/{self.owner}/{self.repo}/git/refs",
|
|
1138
|
-
json={
|
|
1139
|
-
"ref": f"refs/heads/{head_branch}",
|
|
1140
|
-
"sha": base_sha,
|
|
1141
|
-
},
|
|
1142
|
-
)
|
|
1143
|
-
|
|
1144
|
-
if ref_response.status_code != 201:
|
|
1145
|
-
# Branch might already exist on remote, try to use it
|
|
1146
|
-
pass
|
|
1147
|
-
|
|
1148
|
-
# Create the pull request
|
|
1149
|
-
pr_data = {
|
|
1150
|
-
"title": title,
|
|
1151
|
-
"body": body,
|
|
1152
|
-
"head": head_branch,
|
|
1153
|
-
"base": base_branch,
|
|
1154
|
-
"draft": draft,
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
pr_response = await self.client.post(
|
|
1158
|
-
f"/repos/{self.owner}/{self.repo}/pulls", json=pr_data
|
|
1159
|
-
)
|
|
1160
|
-
|
|
1161
|
-
if pr_response.status_code == 422:
|
|
1162
|
-
# PR might already exist, try to get it
|
|
1163
|
-
search_response = await self.client.get(
|
|
1164
|
-
f"/repos/{self.owner}/{self.repo}/pulls",
|
|
1165
|
-
params={
|
|
1166
|
-
"head": f"{self.owner}:{head_branch}",
|
|
1167
|
-
"base": base_branch,
|
|
1168
|
-
"state": "open",
|
|
1169
|
-
},
|
|
1170
|
-
)
|
|
1171
|
-
|
|
1172
|
-
if search_response.status_code == 200:
|
|
1173
|
-
existing_prs = search_response.json()
|
|
1174
|
-
if existing_prs:
|
|
1175
|
-
pr = existing_prs[0]
|
|
1176
|
-
return {
|
|
1177
|
-
"number": pr["number"],
|
|
1178
|
-
"url": pr["html_url"],
|
|
1179
|
-
"api_url": pr["url"],
|
|
1180
|
-
"branch": head_branch,
|
|
1181
|
-
"state": pr["state"],
|
|
1182
|
-
"draft": pr.get("draft", False),
|
|
1183
|
-
"title": pr["title"],
|
|
1184
|
-
"existing": True,
|
|
1185
|
-
"linked_issue": issue_number,
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
raise ValueError(f"Failed to create PR: {pr_response.text}")
|
|
1189
|
-
|
|
1190
|
-
pr_response.raise_for_status()
|
|
1191
|
-
pr = pr_response.json()
|
|
1192
|
-
|
|
1193
|
-
# Add a comment to the issue about the PR
|
|
1194
|
-
pr_msg = f"Pull request #{pr['number']} has been created: " f"{pr['html_url']}"
|
|
1195
|
-
await self.add_comment(
|
|
1196
|
-
Comment(
|
|
1197
|
-
ticket_id=ticket_id,
|
|
1198
|
-
content=pr_msg,
|
|
1199
|
-
author="system",
|
|
1200
|
-
)
|
|
1201
|
-
)
|
|
1202
|
-
|
|
1203
|
-
return {
|
|
1204
|
-
"number": pr["number"],
|
|
1205
|
-
"url": pr["html_url"],
|
|
1206
|
-
"api_url": pr["url"],
|
|
1207
|
-
"branch": head_branch,
|
|
1208
|
-
"state": pr["state"],
|
|
1209
|
-
"draft": pr.get("draft", False),
|
|
1210
|
-
"title": pr["title"],
|
|
1211
|
-
"linked_issue": issue_number,
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
async def link_existing_pull_request(
|
|
1215
|
-
self,
|
|
1216
|
-
ticket_id: str,
|
|
1217
|
-
pr_url: str,
|
|
1218
|
-
) -> dict[str, Any]:
|
|
1219
|
-
"""Link an existing pull request to a ticket.
|
|
1220
|
-
|
|
1221
|
-
Args:
|
|
1222
|
-
ticket_id: Issue number to link the PR to
|
|
1223
|
-
pr_url: GitHub PR URL to link
|
|
1224
|
-
|
|
1225
|
-
Returns:
|
|
1226
|
-
Dictionary with link status and PR details
|
|
1227
|
-
|
|
1228
|
-
"""
|
|
1229
|
-
try:
|
|
1230
|
-
issue_number = int(ticket_id)
|
|
1231
|
-
except ValueError as e:
|
|
1232
|
-
raise ValueError(f"Invalid issue number: {ticket_id}") from e
|
|
1233
|
-
|
|
1234
|
-
# Parse PR URL to extract owner, repo, and PR number
|
|
1235
|
-
# Expected format: https://github.com/owner/repo/pull/123
|
|
1236
|
-
import re
|
|
1237
|
-
|
|
1238
|
-
pr_pattern = r"github\.com/([^/]+)/([^/]+)/pull/(\d+)"
|
|
1239
|
-
match = re.search(pr_pattern, pr_url)
|
|
1240
|
-
|
|
1241
|
-
if not match:
|
|
1242
|
-
raise ValueError(f"Invalid GitHub PR URL format: {pr_url}")
|
|
1243
|
-
|
|
1244
|
-
pr_owner, pr_repo, pr_number = match.groups()
|
|
1245
|
-
|
|
1246
|
-
# Verify the PR is from the same repository
|
|
1247
|
-
if pr_owner != self.owner or pr_repo != self.repo:
|
|
1248
|
-
raise ValueError(
|
|
1249
|
-
f"PR must be from the same repository ({self.owner}/{self.repo})"
|
|
1250
|
-
)
|
|
1251
|
-
|
|
1252
|
-
# Get PR details
|
|
1253
|
-
pr_response = await self.client.get(
|
|
1254
|
-
f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}"
|
|
1255
|
-
)
|
|
1256
|
-
|
|
1257
|
-
if pr_response.status_code == 404:
|
|
1258
|
-
raise ValueError(f"Pull request #{pr_number} not found")
|
|
1259
|
-
|
|
1260
|
-
pr_response.raise_for_status()
|
|
1261
|
-
pr = pr_response.json()
|
|
1262
|
-
|
|
1263
|
-
# Update PR body to include issue reference if not already present
|
|
1264
|
-
current_body = pr.get("body", "")
|
|
1265
|
-
issue_ref = f"#{issue_number}"
|
|
1266
|
-
|
|
1267
|
-
if issue_ref not in current_body:
|
|
1268
|
-
# Add issue reference to the body
|
|
1269
|
-
updated_body = current_body or ""
|
|
1270
|
-
if updated_body:
|
|
1271
|
-
updated_body += "\n\n"
|
|
1272
|
-
updated_body += f"Related to #{issue_number}"
|
|
1273
|
-
|
|
1274
|
-
# Update the PR
|
|
1275
|
-
update_response = await self.client.patch(
|
|
1276
|
-
f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}",
|
|
1277
|
-
json={"body": updated_body},
|
|
1278
|
-
)
|
|
1279
|
-
update_response.raise_for_status()
|
|
1280
|
-
|
|
1281
|
-
# Add a comment to the issue about the PR
|
|
1282
|
-
await self.add_comment(
|
|
1283
|
-
Comment(
|
|
1284
|
-
ticket_id=ticket_id,
|
|
1285
|
-
content=f"Linked to pull request #{pr_number}: {pr_url}",
|
|
1286
|
-
author="system",
|
|
1287
|
-
)
|
|
1288
|
-
)
|
|
1289
|
-
|
|
1290
|
-
return {
|
|
1291
|
-
"success": True,
|
|
1292
|
-
"pr_number": pr["number"],
|
|
1293
|
-
"pr_url": pr["html_url"],
|
|
1294
|
-
"pr_title": pr["title"],
|
|
1295
|
-
"pr_state": pr["state"],
|
|
1296
|
-
"linked_issue": issue_number,
|
|
1297
|
-
"message": f"Successfully linked PR #{pr_number} to issue #{issue_number}",
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
async def get_collaborators(self) -> builtins.list[dict[str, Any]]:
|
|
1301
|
-
"""Get repository collaborators."""
|
|
1302
|
-
response = await self.client.get(
|
|
1303
|
-
f"/repos/{self.owner}/{self.repo}/collaborators"
|
|
1304
|
-
)
|
|
1305
|
-
response.raise_for_status()
|
|
1306
|
-
return response.json()
|
|
1307
|
-
|
|
1308
|
-
async def get_current_user(self) -> dict[str, Any] | None:
|
|
1309
|
-
"""Get current authenticated user information."""
|
|
1310
|
-
response = await self.client.get("/user")
|
|
1311
|
-
response.raise_for_status()
|
|
1312
|
-
return response.json()
|
|
1313
|
-
|
|
1314
|
-
async def list_labels(self) -> builtins.list[dict[str, Any]]:
|
|
1315
|
-
"""List all labels available in the repository.
|
|
1316
|
-
|
|
1317
|
-
Returns:
|
|
1318
|
-
List of label dictionaries with 'id', 'name', and 'color' fields
|
|
1319
|
-
|
|
1320
|
-
"""
|
|
1321
|
-
if self._labels_cache:
|
|
1322
|
-
return self._labels_cache
|
|
1323
|
-
|
|
1324
|
-
response = await self.client.get(f"/repos/{self.owner}/{self.repo}/labels")
|
|
1325
|
-
response.raise_for_status()
|
|
1326
|
-
labels = response.json()
|
|
1327
|
-
|
|
1328
|
-
# Transform to standardized format
|
|
1329
|
-
standardized_labels = [
|
|
1330
|
-
{"id": label["name"], "name": label["name"], "color": label["color"]}
|
|
1331
|
-
for label in labels
|
|
1332
|
-
]
|
|
1333
|
-
|
|
1334
|
-
self._labels_cache = standardized_labels
|
|
1335
|
-
return standardized_labels
|
|
1336
|
-
|
|
1337
|
-
async def update_milestone(
|
|
1338
|
-
self, milestone_number: int, updates: dict[str, Any]
|
|
1339
|
-
) -> Epic | None:
|
|
1340
|
-
"""Update a GitHub milestone (Epic).
|
|
1341
|
-
|
|
1342
|
-
Args:
|
|
1343
|
-
milestone_number: Milestone number (not ID)
|
|
1344
|
-
updates: Dictionary with fields to update:
|
|
1345
|
-
- title: Milestone title
|
|
1346
|
-
- description: Milestone description (supports markdown)
|
|
1347
|
-
- state: TicketState value (maps to open/closed)
|
|
1348
|
-
- target_date: Due date in ISO format
|
|
1349
|
-
|
|
1350
|
-
Returns:
|
|
1351
|
-
Updated Epic object or None if not found
|
|
1352
|
-
|
|
1353
|
-
Raises:
|
|
1354
|
-
ValueError: If no fields to update
|
|
1355
|
-
httpx.HTTPError: If API request fails
|
|
1356
|
-
|
|
1357
|
-
"""
|
|
1358
|
-
update_data = {}
|
|
1359
|
-
|
|
1360
|
-
# Map title directly
|
|
1361
|
-
if "title" in updates:
|
|
1362
|
-
update_data["title"] = updates["title"]
|
|
1363
|
-
|
|
1364
|
-
# Map description (supports markdown)
|
|
1365
|
-
if "description" in updates:
|
|
1366
|
-
update_data["description"] = updates["description"]
|
|
1367
|
-
|
|
1368
|
-
# Map state to GitHub milestone state
|
|
1369
|
-
if "state" in updates:
|
|
1370
|
-
state = updates["state"]
|
|
1371
|
-
if isinstance(state, TicketState):
|
|
1372
|
-
# GitHub only has open/closed
|
|
1373
|
-
update_data["state"] = (
|
|
1374
|
-
"closed"
|
|
1375
|
-
if state in [TicketState.DONE, TicketState.CLOSED]
|
|
1376
|
-
else "open"
|
|
1377
|
-
)
|
|
1378
|
-
else:
|
|
1379
|
-
update_data["state"] = state
|
|
1380
|
-
|
|
1381
|
-
# Map target_date to due_on
|
|
1382
|
-
if "target_date" in updates:
|
|
1383
|
-
# GitHub expects ISO 8601 format
|
|
1384
|
-
target_date = updates["target_date"]
|
|
1385
|
-
if isinstance(target_date, str):
|
|
1386
|
-
update_data["due_on"] = target_date
|
|
1387
|
-
elif hasattr(target_date, "isoformat"):
|
|
1388
|
-
update_data["due_on"] = target_date.isoformat()
|
|
1389
|
-
|
|
1390
|
-
if not update_data:
|
|
1391
|
-
raise ValueError("At least one field must be updated")
|
|
1392
|
-
|
|
1393
|
-
# Make API request
|
|
1394
|
-
response = await self.client.patch(
|
|
1395
|
-
f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}",
|
|
1396
|
-
json=update_data,
|
|
1397
|
-
)
|
|
1398
|
-
response.raise_for_status()
|
|
1399
|
-
|
|
1400
|
-
# Convert response to Epic
|
|
1401
|
-
milestone_data = response.json()
|
|
1402
|
-
return self._milestone_to_epic(milestone_data)
|
|
1403
|
-
|
|
1404
|
-
async def update_epic(self, epic_id: str, updates: dict[str, Any]) -> Epic | None:
|
|
1405
|
-
"""Update a GitHub epic (milestone) by ID or number.
|
|
1406
|
-
|
|
1407
|
-
This is a convenience wrapper around update_milestone() that accepts
|
|
1408
|
-
either a milestone number or the epic ID from the Epic object.
|
|
1409
|
-
|
|
1410
|
-
Args:
|
|
1411
|
-
epic_id: Epic ID (e.g., "milestone-5") or milestone number as string
|
|
1412
|
-
updates: Dictionary with fields to update
|
|
1413
|
-
|
|
1414
|
-
Returns:
|
|
1415
|
-
Updated Epic object or None if not found
|
|
1416
|
-
|
|
1417
|
-
"""
|
|
1418
|
-
# Extract milestone number from ID
|
|
1419
|
-
if epic_id.startswith("milestone-"):
|
|
1420
|
-
milestone_number = int(epic_id.replace("milestone-", ""))
|
|
1421
|
-
else:
|
|
1422
|
-
milestone_number = int(epic_id)
|
|
1423
|
-
|
|
1424
|
-
return await self.update_milestone(milestone_number, updates)
|
|
1425
|
-
|
|
1426
|
-
async def add_attachment_to_issue(
|
|
1427
|
-
self, issue_number: int, file_path: str, comment: str | None = None
|
|
1428
|
-
) -> dict[str, Any]:
|
|
1429
|
-
"""Attach file to GitHub issue via comment.
|
|
1430
|
-
|
|
1431
|
-
GitHub doesn't have direct file attachment API. This method:
|
|
1432
|
-
1. Creates a comment with the file reference
|
|
1433
|
-
2. Returns metadata about the attachment
|
|
1434
|
-
|
|
1435
|
-
Note: GitHub's actual file upload in comments requires browser-based
|
|
1436
|
-
drag-and-drop or git-lfs. This method creates a placeholder comment
|
|
1437
|
-
that users can edit to add actual file attachments through the UI.
|
|
1438
|
-
|
|
1439
|
-
Args:
|
|
1440
|
-
issue_number: Issue number
|
|
1441
|
-
file_path: Path to file to attach
|
|
1442
|
-
comment: Optional comment text (defaults to "Attached: {filename}")
|
|
1443
|
-
|
|
1444
|
-
Returns:
|
|
1445
|
-
Dictionary with comment data and file info
|
|
1446
|
-
|
|
1447
|
-
Raises:
|
|
1448
|
-
FileNotFoundError: If file doesn't exist
|
|
1449
|
-
ValueError: If file too large (>25 MB)
|
|
1450
|
-
|
|
1451
|
-
Note:
|
|
1452
|
-
GitHub file size limit: 25 MB
|
|
1453
|
-
Supported: Images, videos, documents
|
|
1454
|
-
|
|
1455
|
-
"""
|
|
1456
|
-
file_path_obj = Path(file_path)
|
|
1457
|
-
if not file_path_obj.exists():
|
|
1458
|
-
raise FileNotFoundError(f"File not found: {file_path}")
|
|
1459
|
-
|
|
1460
|
-
# Check file size (25 MB limit)
|
|
1461
|
-
file_size = file_path_obj.stat().st_size
|
|
1462
|
-
if file_size > 25 * 1024 * 1024: # 25 MB
|
|
1463
|
-
raise ValueError(
|
|
1464
|
-
f"File too large: {file_size} bytes (max 25 MB). "
|
|
1465
|
-
"Upload file externally and reference URL instead."
|
|
1466
|
-
)
|
|
1467
|
-
|
|
1468
|
-
# Prepare comment body
|
|
1469
|
-
comment_body = comment or f"š Attached: `{file_path_obj.name}`"
|
|
1470
|
-
comment_body += (
|
|
1471
|
-
f"\n\n*Note: File `{file_path_obj.name}` ({file_size} bytes) "
|
|
1472
|
-
"needs to be manually uploaded through GitHub UI or referenced via URL.*"
|
|
1473
|
-
)
|
|
1474
|
-
|
|
1475
|
-
# Create comment with file reference
|
|
1476
|
-
response = await self.client.post(
|
|
1477
|
-
f"/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments",
|
|
1478
|
-
json={"body": comment_body},
|
|
1479
|
-
)
|
|
1480
|
-
response.raise_for_status()
|
|
1481
|
-
|
|
1482
|
-
comment_data = response.json()
|
|
1483
|
-
|
|
1484
|
-
return {
|
|
1485
|
-
"comment_id": comment_data["id"],
|
|
1486
|
-
"comment_url": comment_data["html_url"],
|
|
1487
|
-
"filename": file_path_obj.name,
|
|
1488
|
-
"file_size": file_size,
|
|
1489
|
-
"note": "File reference created. Upload file manually through GitHub UI.",
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
async def add_attachment_reference_to_milestone(
|
|
1493
|
-
self, milestone_number: int, file_url: str, description: str
|
|
1494
|
-
) -> Epic | None:
|
|
1495
|
-
"""Add file reference to milestone description.
|
|
1496
|
-
|
|
1497
|
-
Since GitHub milestones don't support direct file attachments,
|
|
1498
|
-
this method appends a markdown link to the milestone description.
|
|
1499
|
-
|
|
1500
|
-
Args:
|
|
1501
|
-
milestone_number: Milestone number
|
|
1502
|
-
file_url: URL to the file (external or GitHub-hosted)
|
|
1503
|
-
description: Description/title for the file
|
|
1504
|
-
|
|
1505
|
-
Returns:
|
|
1506
|
-
Updated Epic object
|
|
1507
|
-
|
|
1508
|
-
Example:
|
|
1509
|
-
await adapter.add_attachment_reference_to_milestone(
|
|
1510
|
-
5,
|
|
1511
|
-
"https://example.com/spec.pdf",
|
|
1512
|
-
"Technical Specification"
|
|
1513
|
-
)
|
|
1514
|
-
# Appends to description: "[Technical Specification](https://example.com/spec.pdf)"
|
|
1515
|
-
|
|
1516
|
-
"""
|
|
1517
|
-
# Get current milestone
|
|
1518
|
-
response = await self.client.get(
|
|
1519
|
-
f"/repos/{self.owner}/{self.repo}/milestones/{milestone_number}"
|
|
1520
|
-
)
|
|
1521
|
-
response.raise_for_status()
|
|
1522
|
-
milestone = response.json()
|
|
1523
|
-
|
|
1524
|
-
# Append file reference to description
|
|
1525
|
-
current_desc = milestone.get("description", "")
|
|
1526
|
-
attachment_markdown = f"\n\nš [{description}]({file_url})"
|
|
1527
|
-
new_description = current_desc + attachment_markdown
|
|
1528
|
-
|
|
1529
|
-
# Update milestone with new description
|
|
1530
|
-
return await self.update_milestone(
|
|
1531
|
-
milestone_number, {"description": new_description}
|
|
1532
|
-
)
|
|
1533
|
-
|
|
1534
|
-
async def add_attachment(
|
|
1535
|
-
self, ticket_id: str, file_path: str, description: str | None = None
|
|
1536
|
-
) -> dict[str, Any]:
|
|
1537
|
-
"""Add attachment to GitHub ticket (issue or milestone).
|
|
1538
|
-
|
|
1539
|
-
This method routes to appropriate attachment method based on ticket type:
|
|
1540
|
-
- Issues: Creates comment with file reference
|
|
1541
|
-
- Milestones: Not supported, raises NotImplementedError with guidance
|
|
1542
|
-
|
|
1543
|
-
Args:
|
|
1544
|
-
ticket_id: Ticket identifier (issue number or milestone ID)
|
|
1545
|
-
file_path: Path to file to attach
|
|
1546
|
-
description: Optional description
|
|
1547
|
-
|
|
1548
|
-
Returns:
|
|
1549
|
-
Attachment metadata
|
|
1550
|
-
|
|
1551
|
-
Raises:
|
|
1552
|
-
NotImplementedError: For milestones (no native support)
|
|
1553
|
-
FileNotFoundError: If file doesn't exist
|
|
1554
|
-
|
|
1555
|
-
"""
|
|
1556
|
-
# Determine ticket type from ID format
|
|
1557
|
-
if ticket_id.startswith("milestone-"):
|
|
1558
|
-
raise NotImplementedError(
|
|
1559
|
-
"GitHub milestones do not support direct file attachments. "
|
|
1560
|
-
"Workaround: Upload file externally and use "
|
|
1561
|
-
"add_attachment_reference_to_milestone() to add URL to description."
|
|
1562
|
-
)
|
|
1563
|
-
|
|
1564
|
-
# Assume it's an issue number
|
|
1565
|
-
issue_number = int(ticket_id.replace("issue-", ""))
|
|
1566
|
-
return await self.add_attachment_to_issue(issue_number, file_path, description)
|
|
1567
|
-
|
|
1568
|
-
async def close(self) -> None:
|
|
1569
|
-
"""Close the HTTP client connection."""
|
|
1570
|
-
await self.client.aclose()
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
# Register the adapter
|
|
1574
|
-
AdapterRegistry.register("github", GitHubAdapter)
|