-
-
Notifications
You must be signed in to change notification settings - Fork 22
Expand file tree
/
Copy pathgithub_api.py
More file actions
437 lines (358 loc) · 14.5 KB
/
Copy pathgithub_api.py
File metadata and controls
437 lines (358 loc) · 14.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
"""
Service for interacting with GitHub API with rate limiting and error handling.
"""
from typing import List, Optional, Dict, Any, AsyncIterator, Callable
import datetime
import asyncio
import httpx
from github import Github, GithubException
# from github.Repository import Repository as GithubRepository
# from ..infrastructure.rate_limiter import RateLimiter
# from ..infrastructure.retry_manager import RetryManager
from ..infrastructure import (
handle_api_error,
RateLimitError,
RepositoryNotFoundError,
DownloadError,
RateLimiter,
RetryManager,
CacheManager,
RateLimitInfo
)
# from ..infrastructure.cache_manager import CacheManager
from ..models import RepositoryInfo, GitReference, RepositoryType, GitHubFile
from ..models.constants import USER_AGENT
from forklet.infrastructure.logger import logger
####
## GITHUB API SERVICE
#####
class GitHubAPIService:
"""
Async service for interacting with GitHub API with comprehensive error handling.
Focused solely on GitHub API interactions - no file system operations.
"""
BASE_URL = "https://api.github.com"
def __init__(
self,
rate_limiter: RateLimiter,
retry_manager: RetryManager,
auth_token: Optional[str] = None,
timeout: int = 30,
):
self.rate_limiter = rate_limiter
self.retry_manager = retry_manager
self.auth_token = auth_token
self.timeout = timeout
self._concurrency_adjustment_callback: Optional[
Callable[[RateLimitInfo], None]
] = None
# Set up rate limit callback to adjust concurrency
self.rate_limiter.set_rate_limit_callback(self._on_rate_limit_update)
def set_external_rate_limit_callback(
self, callback: Callable[[RateLimitInfo], None]
) -> None:
"""Set an external callback to be invoked when rate limit information is updated."""
self._external_rate_limit_callback = callback
def _on_rate_limit_update(self, rate_limit_info: RateLimitInfo) -> None:
"""Internal callback for rate limit updates - adjusts download concurrency based on rate limit status."""
# This is a placeholder - in a full implementation, this would adjust the
# concurrency settings in the DownloadOrchestrator based on rate limit status
# For now, we just log when we're getting low on rate limits
if rate_limit_info.remaining < 100:
logger.warning(
f"GitHub API rate limit low: {rate_limit_info.remaining}/{rate_limit_info.limit} remaining"
)
elif rate_limit_info.is_exhausted:
logger.warning(
f"GitHub API rate limit exhausted: {rate_limit_info.remaining}/{rate_limit_info.limit} remaining"
)
# Configure HTTP client
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": USER_AGENT}
if self.auth_token:
headers["Authorization"] = f"token {self.auth_token}"
self.http_client = httpx.AsyncClient(
headers=headers, timeout=httpx.Timeout(self.timeout)
)
# Sync client for PyGithub (used only for metadata)
self.github_client = (
Github(
self.auth_token, retry=self.retry_manager.max_retries, user_agent=USER_AGENT
)
if self.auth_token
else Github(retry=self.retry_manager.max_retries, user_agent=USER_AGENT)
)
async def update_rate_limit_info(self, headers: Dict[str, str]) -> None:
"""Update rate limit information from API response headers."""
async with self._lock:
try:
self._rate_limit_info.limit = int(
headers.get("x-ratelimit-limit", 5000)
)
self._rate_limit_info.remaining = int(
headers.get("x-ratelimit-remaining", 5000)
)
self._rate_limit_info.used = int(headers.get("x-ratelimit-used", 0))
reset_timestamp = headers.get("x-ratelimit-reset")
if reset_timestamp:
self._rate_limit_info.reset_time = datetime.fromtimestamp(
int(reset_timestamp)
)
# Track consecutive rate limit hits
if self._rate_limit_info.is_exhausted:
self._consecutive_limits += 1
else:
self._consecutive_limits = 0
# Invoke internal callback if set
if self._rate_limit_callback:
self._rate_limit_callback(self._rate_limit_info)
# Invoke external callback if set
if self._external_rate_limit_callback:
self._external_rate_limit_callback(self._rate_limit_info)
except (ValueError, KeyError) as e:
logger.warning(f"Failed to parse rate limit headers: {e}")
# Configure HTTP client
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": USER_AGENT}
if self.auth_token:
headers["Authorization"] = f"token {self.auth_token}"
self.http_client = httpx.AsyncClient(
headers=headers, timeout=httpx.Timeout(self.timeout)
)
# Sync client for PyGithub (used only for metadata)
self.github_client = (
Github(
self.auth_token, retry=self.retry_manager.max_retries, user_agent=USER_AGENT
)
if self.auth_token
else Github(retry=self.retry_manager.max_retries, user_agent=USER_AGENT)
)
async def __aenter__(self):
"""Async context manager entry."""
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.close()
async def close(self):
"""Close the HTTP client."""
await self.http_client.aclose()
@handle_api_error
async def get_repository_info(self, owner: str, repo: str) -> RepositoryInfo:
"""
Get comprehensive information about a repository.
Args:
owner: Repository owner
repo: Repository name
Returns:
RepositoryInfo object with repository metadata
Raises:
RepositoryNotFoundError: If repository doesn't exist
AuthenticationError: If authentication fails
"""
try:
# Use sync client for this operation as it's metadata-focused
await self.rate_limiter.acquire()
github_repo = await asyncio.to_thread(
lambda: self.github_client.get_repo(f"{owner}/{repo}")
)
return RepositoryInfo(
owner=owner,
name=repo,
full_name=github_repo.full_name,
url=github_repo.html_url,
default_branch=github_repo.default_branch,
repo_type=RepositoryType.PRIVATE
if github_repo.private
else RepositoryType.PUBLIC,
size=github_repo.size,
is_private=github_repo.private,
is_fork=github_repo.fork,
created_at=github_repo.created_at,
updated_at=github_repo.updated_at,
language=github_repo.language,
description=github_repo.description,
topics=github_repo.get_topics(),
)
except GithubException as e:
if e.status == 404:
raise RepositoryNotFoundError(f"Repository {owner}/{repo} not found")
raise
@handle_api_error
async def resolve_reference(self, owner: str, repo: str, ref: str) -> GitReference:
"""
Resolve a Git reference (branch, tag, or commit) to a specific commit SHA.
Args:
owner: Repository owner
repo: Repository name
ref: Branch name, tag name, or commit SHA
Returns:
GitReference object with resolved SHA
Raises:
ValueError: If reference cannot be resolved
"""
# Define reference types to try in order
reference_types = [
(
"branch",
lambda: self.github_client.get_repo(f"{owner}/{repo}").get_branch(ref),
),
("tag", lambda: self._get_tag(ref, owner, repo)),
(
"commit",
lambda: self.github_client.get_repo(f"{owner}/{repo}").get_commit(ref),
),
]
for ref_type, getter_func in reference_types:
try:
await self.rate_limiter.acquire()
github_obj = await asyncio.to_thread(getter_func)
return GitReference(
name=ref, ref_type=ref_type, sha=github_obj.commit.sha
)
except GithubException:
continue # Try next reference type
raise ValueError(
f"Could not resolve reference '{ref}' for repository {owner}/{repo}"
)
async def _get_tag(self, tag_name: str, owner: str, repo: str):
"""Helper to get a tag by name - needed because get_tags() returns a list."""
tags = await asyncio.to_thread(
lambda: list(self.github_client.get_repo(f"{owner}/{repo}").get_tags())
)
for tag in tags:
if tag.name == tag_name:
return tag
raise GithubException(status=404, data={"message": f"Tag {tag_name} not found"})
@handle_api_error
async def get_repository_tree(
self, owner: str, repo: str, ref: GitReference, recursive: bool = True
) -> List[GitHubFile]:
"""
Get the complete file tree for a repository at a specific reference.
Args:
owner: Repository owner
repo: Repository name
ref: GitReference object
recursive: Whether to get recursive tree
Returns:
List of GitHubFile objects
Raises:
RateLimitError: If rate limits are exceeded
"""
url = f"{self.BASE_URL}/repos/{owner}/{repo}/git/trees/{ref.sha}"
params = {"recursive": "1"} if recursive else {}
try:
await self.rate_limiter.acquire()
response = await self.retry_manager.execute(
lambda: self.http_client.get(url, params=params)
)
# Update rate limit info
await self.rate_limiter.update_rate_limit_info(response.headers)
response.raise_for_status()
tree_data = response.json()
files = []
for item in tree_data.get("tree", []):
# Only include files (blobs), not directories
if item["type"] == "blob":
files.append(
GitHubFile(
path=item["path"],
type=item["type"],
size=item.get("size", 0),
download_url=item.get("url"),
sha=item.get("sha"),
html_url=item.get("html_url"),
)
)
return files
except httpx.HTTPStatusError as e:
if e.response.status_code == 403:
raise RateLimitError("GitHub API rate limit exceeded")
raise
@handle_api_error
async def get_file_content(
self, download_url: str, stream: bool = False
) -> bytes | AsyncIterator[bytes]:
"""
Download file content from GitHub API.
Args:
download_url: GitHub API URL for the file content
Returns:
File content as bytes (already decoded from base64)
Raises:
DownloadError: If download fails
"""
try:
await self.rate_limiter.acquire()
response = await self.retry_manager.execute(
lambda: self.http_client.get(download_url)
)
response.raise_for_status()
# GitHub API returns base64 encoded content
content_data = response.json()
if "content" in content_data:
import base64
return base64.b64decode(content_data["content"])
else:
raise DownloadError(
f"No content found in API response for {download_url}"
)
except httpx.RequestError as e:
raise DownloadError(f"Failed to download file from {download_url}: {e}")
@handle_api_error
async def get_directory_content(
self, owner: str, repo: str, path: str, ref: GitReference
) -> List[GitHubFile]:
"""
Get content of a specific directory.
Args:
owner: Repository owner
repo: Repository name
path: Directory path
ref: GitReference object
Returns:
List of GitHubFile objects in the directory
"""
url = f"{self.BASE_URL}/repos/{owner}/{repo}/contents/{path}"
params = {"ref": ref.sha}
await self.rate_limiter.acquire()
response = await self.retry_manager.execute(
lambda: self.http_client.get(url, params=params)
)
response.raise_for_status()
contents = response.json()
files = []
for item in contents:
if item["type"] == "file": # Only include files
files.append(
GitHubFile(
path=item["path"],
type=item["type"],
size=item.get("size", 0),
download_url=item.get("download_url"),
sha=item.get("sha"),
html_url=item.get("html_url"),
)
)
return files
async def get_rate_limit_info(self) -> Dict[str, Any]:
"""
Get current rate limit information.
Returns:
Dictionary with rate limit information
"""
url = f"{self.BASE_URL}/rate_limit"
await self.rate_limiter.acquire()
response = await self.retry_manager.execute(lambda: self.http_client.get(url))
response.raise_for_status()
return response.json()
async def test_connection(self) -> bool:
"""
Test connection to GitHub API.
Returns:
True if connection is successful, False otherwise
"""
try:
await self.get_rate_limit_info()
return True
except Exception as e:
logger.error(f"GitHub API connection test failed: {e}")
return False