484 lines
15 KiB
Python
484 lines
15 KiB
Python
"""GitHub API client."""
|
|
|
|
import time
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any, Optional
|
|
|
|
from ..config.constants import GitHubAPI
|
|
from ..config.settings import GitHubConfig
|
|
from ..exceptions import GitHubError
|
|
from ..utils.logging import get_logger
|
|
from .base import BaseHTTPClient
|
|
|
|
logger = get_logger("clients.github")
|
|
|
|
|
|
@dataclass
|
|
class UserInfo:
|
|
"""GitHub user information."""
|
|
|
|
name: str
|
|
html_url: str
|
|
avatar_url: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class RepoInfo:
|
|
"""GitHub repository information."""
|
|
|
|
description: Optional[str]
|
|
stargazers_count: int
|
|
|
|
|
|
@dataclass
|
|
class ReleaseAsset:
|
|
"""GitHub release asset information."""
|
|
|
|
id: int
|
|
name: str
|
|
browser_download_url: str
|
|
content_type: str
|
|
updated_at: str
|
|
|
|
|
|
@dataclass
|
|
class ReleaseInfo:
|
|
"""GitHub release information."""
|
|
|
|
tag_name: str
|
|
prerelease: bool
|
|
published_at: str
|
|
assets: list[ReleaseAsset]
|
|
|
|
|
|
class GitHubClient(BaseHTTPClient):
|
|
"""GitHub API client with typed responses."""
|
|
|
|
def __init__(self, config: GitHubConfig):
|
|
"""Initialize GitHub client.
|
|
|
|
Args:
|
|
config: GitHub configuration.
|
|
"""
|
|
super().__init__(
|
|
timeout=config.timeout,
|
|
max_retries=config.max_retries,
|
|
backoff_factor=config.retry_delay,
|
|
)
|
|
self.config = config
|
|
self._headers = self._build_headers()
|
|
|
|
def _build_headers(self) -> dict[str, str]:
|
|
"""Build request headers."""
|
|
headers = {
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": self.config.api_version,
|
|
}
|
|
if self.config.token:
|
|
headers["Authorization"] = f"token {self.config.token}"
|
|
return headers
|
|
|
|
def get_user(self, username: str) -> Optional[UserInfo]:
|
|
"""Get user information.
|
|
|
|
Args:
|
|
username: GitHub username.
|
|
|
|
Returns:
|
|
UserInfo or None if failed.
|
|
"""
|
|
url = GitHubAPI.user(username)
|
|
try:
|
|
response = self.get(url, headers=self._headers)
|
|
data = response.json()
|
|
return UserInfo(
|
|
name=data.get("name") or username,
|
|
html_url=data.get("html_url", ""),
|
|
avatar_url=data.get("avatar_url"),
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to get user {username}: {e}")
|
|
return None
|
|
|
|
def get_repo(self, owner: str, repo: str) -> Optional[RepoInfo]:
|
|
"""Get repository information.
|
|
|
|
Args:
|
|
owner: Repository owner.
|
|
repo: Repository name.
|
|
|
|
Returns:
|
|
RepoInfo or None if failed.
|
|
"""
|
|
url = GitHubAPI.repo(owner, repo)
|
|
try:
|
|
response = self.get(url, headers=self._headers)
|
|
data = response.json()
|
|
return RepoInfo(
|
|
description=data.get("description"),
|
|
stargazers_count=data.get("stargazers_count", 0),
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to get repo {owner}/{repo}: {e}")
|
|
return None
|
|
|
|
def get_releases(
|
|
self, owner: str, repo: str, per_page: int = 100
|
|
) -> list[dict[str, Any]]:
|
|
"""Get releases for a repository (first page only).
|
|
|
|
Args:
|
|
owner: Repository owner.
|
|
repo: Repository name.
|
|
per_page: Number of releases to fetch (max 100).
|
|
|
|
Returns:
|
|
List of release data dictionaries.
|
|
"""
|
|
url = GitHubAPI.releases(owner, repo)
|
|
try:
|
|
response = self.get(
|
|
url,
|
|
headers=self._headers,
|
|
params={"per_page": per_page},
|
|
)
|
|
return response.json()
|
|
except Exception as e:
|
|
logger.warning(f"Failed to get releases for {owner}/{repo}: {e}")
|
|
return []
|
|
|
|
def get_release(
|
|
self, owner: str, repo: str, tag: str
|
|
) -> Optional[ReleaseInfo]:
|
|
"""Get release by tag.
|
|
|
|
Args:
|
|
owner: Repository owner.
|
|
repo: Repository name.
|
|
tag: Tag name ("latest", "pre", or specific tag).
|
|
|
|
Returns:
|
|
ReleaseInfo or None if not found.
|
|
"""
|
|
try:
|
|
if tag == "latest":
|
|
url = GitHubAPI.release_latest(owner, repo)
|
|
response = self.get(url, headers=self._headers)
|
|
data = response.json()
|
|
elif tag == "pre":
|
|
# Find first prerelease
|
|
releases = self.get_releases(owner, repo)
|
|
prereleases = [r for r in releases if r.get("prerelease")]
|
|
if not prereleases:
|
|
return None
|
|
data = prereleases[0]
|
|
else:
|
|
url = GitHubAPI.release_by_tag(owner, repo, tag)
|
|
response = self.get(url, headers=self._headers)
|
|
data = response.json()
|
|
|
|
assets = [
|
|
ReleaseAsset(
|
|
id=a["id"],
|
|
name=a["name"],
|
|
browser_download_url=a["browser_download_url"],
|
|
content_type=a.get("content_type", ""),
|
|
updated_at=a.get("updated_at", ""),
|
|
)
|
|
for a in data.get("assets", [])
|
|
]
|
|
|
|
return ReleaseInfo(
|
|
tag_name=data.get("tag_name", tag),
|
|
prerelease=data.get("prerelease", False),
|
|
published_at=data.get("published_at", ""),
|
|
assets=assets,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to get release {owner}/{repo}@{tag}: {e}")
|
|
return None
|
|
|
|
def create_release(
|
|
self,
|
|
owner: str,
|
|
repo: str,
|
|
tag_name: Optional[str] = None,
|
|
target_commitish: str = "publish",
|
|
) -> Optional[int]:
|
|
"""Create a new release.
|
|
|
|
Args:
|
|
owner: Repository owner.
|
|
repo: Repository name.
|
|
tag_name: Tag name (defaults to current timestamp).
|
|
target_commitish: Target branch/commit.
|
|
|
|
Returns:
|
|
Release ID or None if failed.
|
|
"""
|
|
url = GitHubAPI.releases(owner, repo)
|
|
cur_time = int(time.time())
|
|
tag = tag_name or str(cur_time)
|
|
|
|
payload = {
|
|
"tag_name": tag,
|
|
"target_commitish": target_commitish,
|
|
"name": tag,
|
|
"body": (
|
|
f"\npublish addon_infos.json"
|
|
),
|
|
"draft": False,
|
|
"prerelease": False,
|
|
"generate_release_notes": False,
|
|
}
|
|
|
|
try:
|
|
response = self.post(url, headers=self._headers, json=payload)
|
|
if response.status_code == 201:
|
|
release_id = response.json().get("id")
|
|
logger.info(f"Created release {tag} with ID {release_id}")
|
|
return release_id
|
|
logger.error(f"Create release failed: {response.status_code}")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Create release failed: {e}")
|
|
return None
|
|
|
|
def upload_release_asset(
|
|
self,
|
|
owner: str,
|
|
repo: str,
|
|
release_id: int,
|
|
filename: str,
|
|
filepath: str,
|
|
) -> bool:
|
|
"""Upload asset to release.
|
|
|
|
Args:
|
|
owner: Repository owner.
|
|
repo: Repository name.
|
|
release_id: Release ID.
|
|
filename: Asset filename.
|
|
filepath: Local file path.
|
|
|
|
Returns:
|
|
True if successful.
|
|
"""
|
|
url = GitHubAPI.upload_asset(owner, repo, release_id, filename)
|
|
headers = {**self._headers, "Content-Type": "application/octet-stream"}
|
|
|
|
try:
|
|
with open(filepath, "rb") as f:
|
|
response = self.post(url, headers=headers, data=f)
|
|
if response.status_code == 201:
|
|
logger.info(f"Uploaded asset {filename}")
|
|
return True
|
|
logger.error(f"Upload asset failed: {response.status_code}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Upload asset failed: {e}")
|
|
return False
|
|
|
|
def create_issue(
|
|
self,
|
|
owner: str,
|
|
repo: str,
|
|
title: str,
|
|
body: str,
|
|
check_duplicate_id: Optional[str] = None,
|
|
) -> bool:
|
|
"""Create an issue with optional duplicate check.
|
|
|
|
Args:
|
|
owner: Repository owner.
|
|
repo: Repository name.
|
|
title: Issue title.
|
|
body: Issue body.
|
|
check_duplicate_id: Optional ID to check for duplicates.
|
|
|
|
Returns:
|
|
True if created (or skipped as duplicate).
|
|
"""
|
|
if check_duplicate_id:
|
|
body = f"{body}\n----{check_duplicate_id}"
|
|
if self._issue_exists(owner, repo, check_duplicate_id):
|
|
logger.debug(f"Issue already exists: {check_duplicate_id}")
|
|
return True
|
|
|
|
url = GitHubAPI.issues(owner, repo)
|
|
try:
|
|
response = self.post(
|
|
url,
|
|
headers=self._headers,
|
|
json={"title": title, "body": body},
|
|
)
|
|
if response.status_code == 201:
|
|
issue_url = response.json().get("html_url")
|
|
logger.info(f"Issue created: {issue_url}")
|
|
return True
|
|
logger.error(f"Create issue failed: {response.status_code}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Create issue failed: {e}")
|
|
return False
|
|
|
|
def _issue_exists(self, owner: str, repo: str, check_id: str) -> bool:
|
|
"""Check if an issue with given ID already exists."""
|
|
url = GitHubAPI.issues(owner, repo)
|
|
try:
|
|
since = (datetime.now(UTC) - timedelta(days=10)).isoformat() + "Z"
|
|
response = self.get(
|
|
url,
|
|
headers=self._headers,
|
|
params={"state": "open", "since": since, "per_page": 100},
|
|
)
|
|
issues = response.json()
|
|
return any(
|
|
issue.get("body", "").endswith(f"----{check_id}")
|
|
for issue in issues
|
|
)
|
|
except Exception:
|
|
return False
|
|
|
|
def delete_old_releases(
|
|
self, owner: str, repo: str, keep_count: int = 2
|
|
) -> None:
|
|
"""Delete old releases, keeping the most recent ones.
|
|
|
|
Args:
|
|
owner: Repository owner.
|
|
repo: Repository name.
|
|
keep_count: Number of releases to keep.
|
|
"""
|
|
url = GitHubAPI.releases(owner, repo)
|
|
try:
|
|
response = self.get(
|
|
url, headers=self._headers, params={"per_page": 100}
|
|
)
|
|
releases = response.json()
|
|
if len(releases) <= keep_count:
|
|
return
|
|
|
|
# Sort by tag_name descending
|
|
releases.sort(key=lambda r: r.get("tag_name", ""), reverse=True)
|
|
|
|
for release in releases[keep_count:]:
|
|
release_id = release.get("id")
|
|
tag = release.get("tag_name")
|
|
if release_id:
|
|
delete_url = f"{url}/{release_id}"
|
|
try:
|
|
resp = self.delete(delete_url, headers=self._headers)
|
|
if resp.status_code == 204:
|
|
logger.info(f"Deleted release: {tag}")
|
|
else:
|
|
logger.warning(
|
|
f"Failed to delete release {tag}: {resp.text}"
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to delete release {tag}: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Delete old releases failed: {e}")
|
|
|
|
def delete_old_tags(
|
|
self, owner: str, repo: str, keep_count: int = 2
|
|
) -> None:
|
|
"""Delete old timestamp-based tags.
|
|
|
|
Args:
|
|
owner: Repository owner.
|
|
repo: Repository name.
|
|
keep_count: Number of tags to keep.
|
|
"""
|
|
url = GitHubAPI.tags(owner, repo)
|
|
try:
|
|
response = self.get(url, headers=self._headers)
|
|
tags = response.json()
|
|
if len(tags) <= keep_count:
|
|
return
|
|
|
|
# Sort by ref descending
|
|
tags.sort(key=lambda t: t.get("ref", ""), reverse=True)
|
|
|
|
for tag in tags[keep_count:]:
|
|
ref = tag.get("ref", "").replace("refs/tags/", "")
|
|
# Only delete timestamp tags (numeric, >= 10 digits)
|
|
if len(ref) < 10:
|
|
continue
|
|
try:
|
|
int(ref) # Verify it's a timestamp
|
|
except ValueError:
|
|
continue
|
|
|
|
delete_url = f"{url}/{ref}"
|
|
try:
|
|
resp = self.delete(delete_url, headers=self._headers)
|
|
if resp.status_code == 204:
|
|
logger.info(f"Deleted tag: {ref}")
|
|
else:
|
|
logger.warning(f"Failed to delete tag {ref}: {resp.text}")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to delete tag {ref}: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Delete old tags failed: {e}")
|
|
|
|
def delete_old_caches(
|
|
self, owner: str, repo: str, keep_count: int = 2
|
|
) -> None:
|
|
"""Delete old action caches.
|
|
|
|
Args:
|
|
owner: Repository owner.
|
|
repo: Repository name.
|
|
keep_count: Number of caches to keep.
|
|
"""
|
|
url = GitHubAPI.caches(owner, repo)
|
|
try:
|
|
response = self.get(
|
|
url,
|
|
headers=self._headers,
|
|
params={
|
|
"per_page": 100,
|
|
"sort": "last_accessed_at",
|
|
"direction": "desc",
|
|
},
|
|
)
|
|
data = response.json()
|
|
if data.get("total_count", 0) <= keep_count:
|
|
return
|
|
|
|
for cache in data.get("actions_caches", [])[keep_count:]:
|
|
cache_id = cache.get("id")
|
|
cache_key = cache.get("key")
|
|
if cache_id:
|
|
delete_url = f"{url}/{cache_id}"
|
|
try:
|
|
resp = self.delete(delete_url, headers=self._headers)
|
|
if resp.status_code == 204:
|
|
logger.info(f"Deleted cache: {cache_key}")
|
|
else:
|
|
logger.warning(
|
|
f"Failed to delete cache {cache_key}: {resp.text}"
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to delete cache {cache_key}: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Delete old caches failed: {e}")
|
|
|
|
def get_rate_limit(self) -> Optional[dict[str, Any]]:
|
|
"""Get current rate limit status.
|
|
|
|
Returns:
|
|
Rate limit info or None if failed.
|
|
"""
|
|
try:
|
|
response = self.get(GitHubAPI.rate_limit(), headers=self._headers)
|
|
rate = response.json().get("rate")
|
|
logger.info(f"Rate limit: {rate}")
|
|
return rate
|
|
except Exception as e:
|
|
logger.error(f"Get rate limit failed: {e}")
|
|
return None
|