- Fix Python formatting (sync no longer strips blank lines from .py files) - Remove test_community_build.py (tests private sync module) - Fix ruff warnings in test files - Add CODE_OF_CONDUCT.md - Add .github/PULL_REQUEST_TEMPLATE.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
453 lines
18 KiB
Python
453 lines
18 KiB
Python
"""
|
|
Gitea REST API Client
|
|
|
|
Handles all HTTP communication with Gitea REST API.
|
|
Separates API communication from business logic.
|
|
"""
|
|
|
|
import base64
|
|
import logging
|
|
from typing import Any
|
|
|
|
import aiohttp
|
|
|
|
|
|
class GiteaClient:
|
|
"""
|
|
Gitea REST API client for HTTP communication.
|
|
|
|
Handles authentication, request formatting, and error handling
|
|
for all Gitea API endpoints.
|
|
"""
|
|
|
|
def __init__(self, site_url: str, token: str | None = None, oauth_enabled: bool = False):
|
|
"""
|
|
Initialize Gitea API client.
|
|
|
|
Args:
|
|
site_url: Gitea instance URL (e.g., https://gitea.example.com)
|
|
token: Personal access token for authentication
|
|
oauth_enabled: Whether OAuth is enabled for this site
|
|
"""
|
|
self.site_url = site_url.rstrip("/")
|
|
self.api_base = f"{self.site_url}/api/v1"
|
|
self.token = token
|
|
self.oauth_enabled = oauth_enabled
|
|
|
|
# Initialize logger
|
|
self.logger = logging.getLogger(f"GiteaClient.{site_url}")
|
|
|
|
def _get_headers(self, additional_headers: dict | None = None) -> dict[str, str]:
|
|
"""
|
|
Get request headers with authentication.
|
|
|
|
Args:
|
|
additional_headers: Additional headers to include
|
|
|
|
Returns:
|
|
Dict: Headers with authentication
|
|
"""
|
|
headers = {"Content-Type": "application/json", "accept": "application/json"}
|
|
|
|
# Add token authentication if available
|
|
if self.token:
|
|
headers["Authorization"] = f"token {self.token}"
|
|
|
|
# Merge additional headers
|
|
if additional_headers:
|
|
headers.update(additional_headers)
|
|
|
|
return headers
|
|
|
|
async def request(
|
|
self,
|
|
method: str,
|
|
endpoint: str,
|
|
params: dict | None = None,
|
|
json_data: dict | None = None,
|
|
headers_override: dict | None = None,
|
|
) -> Any:
|
|
"""
|
|
Make authenticated request to Gitea REST API.
|
|
|
|
Args:
|
|
method: HTTP method (GET, POST, PUT, DELETE, PATCH)
|
|
endpoint: API endpoint (without base URL)
|
|
params: Query parameters
|
|
json_data: JSON body data
|
|
headers_override: Override default headers
|
|
|
|
Returns:
|
|
API response (dict, list, or None)
|
|
|
|
Raises:
|
|
Exception: On API errors with status code and message
|
|
"""
|
|
# Build full URL
|
|
url = f"{self.api_base}/{endpoint.lstrip('/')}"
|
|
|
|
# Setup headers
|
|
headers = self._get_headers(headers_override)
|
|
|
|
# Filter out None values from params
|
|
if params:
|
|
params = {k: v for k, v in params.items() if v is not None}
|
|
|
|
# Filter None values from JSON data
|
|
if json_data:
|
|
json_data = {k: v for k, v in json_data.items() if v is not None}
|
|
|
|
# Make request
|
|
self.logger.debug(f"{method} {url}")
|
|
self.logger.debug(f"Params: {params}")
|
|
self.logger.debug(f"Data: {json_data}")
|
|
|
|
async with (
|
|
aiohttp.ClientSession() as session,
|
|
session.request(
|
|
method=method, url=url, params=params, json=json_data, headers=headers
|
|
) as response,
|
|
):
|
|
# Log response
|
|
self.logger.debug(f"Response status: {response.status}")
|
|
|
|
# Handle empty responses (e.g., 204 No Content)
|
|
if response.status == 204:
|
|
return {"success": True, "message": "Operation completed successfully"}
|
|
|
|
# Try to parse JSON response
|
|
try:
|
|
response_data = await response.json()
|
|
except Exception:
|
|
response_text = await response.text()
|
|
if response.status >= 400:
|
|
raise Exception(f"Gitea API error (status {response.status}): {response_text}")
|
|
return {"success": True, "message": response_text}
|
|
|
|
# Check for errors
|
|
if response.status >= 400:
|
|
error_msg = response_data.get("message", "Unknown error")
|
|
raise Exception(f"Gitea API error (status {response.status}): {error_msg}")
|
|
|
|
return response_data
|
|
|
|
# Repository endpoints
|
|
async def list_repositories(
|
|
self, owner: str | None = None, page: int = 1, limit: int = 30
|
|
) -> list[dict]:
|
|
"""List repositories for a user/org or current user"""
|
|
if owner:
|
|
endpoint = f"users/{owner}/repos"
|
|
else:
|
|
endpoint = "user/repos"
|
|
|
|
params = {"page": page, "limit": limit}
|
|
return await self.request("GET", endpoint, params=params)
|
|
|
|
async def get_repository(self, owner: str, repo: str) -> dict:
|
|
"""Get repository details"""
|
|
return await self.request("GET", f"repos/{owner}/{repo}")
|
|
|
|
async def create_repository(self, data: dict, org: str | None = None) -> dict:
|
|
"""Create a new repository"""
|
|
if org:
|
|
endpoint = f"orgs/{org}/repos"
|
|
else:
|
|
endpoint = "user/repos"
|
|
return await self.request("POST", endpoint, json_data=data)
|
|
|
|
async def update_repository(self, owner: str, repo: str, data: dict) -> dict:
|
|
"""Update repository settings"""
|
|
return await self.request("PATCH", f"repos/{owner}/{repo}", json_data=data)
|
|
|
|
async def delete_repository(self, owner: str, repo: str) -> dict:
|
|
"""Delete a repository"""
|
|
return await self.request("DELETE", f"repos/{owner}/{repo}")
|
|
|
|
# Branch endpoints
|
|
async def list_branches(
|
|
self, owner: str, repo: str, page: int = 1, limit: int = 30
|
|
) -> list[dict]:
|
|
"""List repository branches"""
|
|
params = {"page": page, "limit": limit}
|
|
return await self.request("GET", f"repos/{owner}/{repo}/branches", params=params)
|
|
|
|
async def get_branch(self, owner: str, repo: str, branch: str) -> dict:
|
|
"""Get branch details"""
|
|
return await self.request("GET", f"repos/{owner}/{repo}/branches/{branch}")
|
|
|
|
async def create_branch(self, owner: str, repo: str, data: dict) -> dict:
|
|
"""Create a new branch"""
|
|
return await self.request("POST", f"repos/{owner}/{repo}/branches", json_data=data)
|
|
|
|
async def delete_branch(self, owner: str, repo: str, branch: str) -> dict:
|
|
"""Delete a branch"""
|
|
return await self.request("DELETE", f"repos/{owner}/{repo}/branches/{branch}")
|
|
|
|
# Tag endpoints
|
|
async def list_tags(self, owner: str, repo: str, page: int = 1, limit: int = 30) -> list[dict]:
|
|
"""List repository tags"""
|
|
params = {"page": page, "limit": limit}
|
|
return await self.request("GET", f"repos/{owner}/{repo}/tags", params=params)
|
|
|
|
async def create_tag(self, owner: str, repo: str, data: dict) -> dict:
|
|
"""Create a new tag"""
|
|
return await self.request("POST", f"repos/{owner}/{repo}/tags", json_data=data)
|
|
|
|
async def delete_tag(self, owner: str, repo: str, tag: str) -> dict:
|
|
"""Delete a tag"""
|
|
return await self.request("DELETE", f"repos/{owner}/{repo}/tags/{tag}")
|
|
|
|
# File endpoints
|
|
async def get_file(self, owner: str, repo: str, filepath: str, ref: str | None = None) -> dict:
|
|
"""Get file contents"""
|
|
params = {"ref": ref} if ref else {}
|
|
return await self.request("GET", f"repos/{owner}/{repo}/contents/{filepath}", params=params)
|
|
|
|
async def create_file(self, owner: str, repo: str, filepath: str, data: dict) -> dict:
|
|
"""Create a file"""
|
|
# Encode content to base64 if not already encoded
|
|
if "content" in data:
|
|
content = data["content"]
|
|
|
|
# Check if content is already base64 encoded
|
|
is_already_base64 = data.get("content_is_base64", False)
|
|
|
|
if not is_already_base64:
|
|
# Plain text content - encode to base64
|
|
try:
|
|
data["content"] = base64.b64encode(content.encode()).decode()
|
|
except (AttributeError, UnicodeDecodeError):
|
|
# If content is already bytes or has encoding issues, try direct encoding
|
|
if isinstance(content, bytes):
|
|
data["content"] = base64.b64encode(content).decode()
|
|
else:
|
|
raise ValueError("Content must be a string or bytes")
|
|
|
|
# Remove the flag before sending to API
|
|
data.pop("content_is_base64", None)
|
|
|
|
return await self.request(
|
|
"POST", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data
|
|
)
|
|
|
|
async def update_file(self, owner: str, repo: str, filepath: str, data: dict) -> dict:
|
|
"""Update a file"""
|
|
# Encode content to base64 if not already encoded
|
|
if "content" in data:
|
|
content = data["content"]
|
|
|
|
# Check if content is already base64 encoded
|
|
is_already_base64 = data.get("content_is_base64", False)
|
|
|
|
if not is_already_base64:
|
|
# Plain text content - encode to base64
|
|
try:
|
|
data["content"] = base64.b64encode(content.encode()).decode()
|
|
except (AttributeError, UnicodeDecodeError):
|
|
# If content is already bytes or has encoding issues, try direct encoding
|
|
if isinstance(content, bytes):
|
|
data["content"] = base64.b64encode(content).decode()
|
|
else:
|
|
raise ValueError("Content must be a string or bytes")
|
|
|
|
# Remove the flag before sending to API
|
|
data.pop("content_is_base64", None)
|
|
|
|
return await self.request(
|
|
"PUT", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data
|
|
)
|
|
|
|
async def delete_file(
|
|
self,
|
|
owner: str,
|
|
repo: str,
|
|
filepath: str,
|
|
sha: str,
|
|
message: str,
|
|
branch: str | None = None,
|
|
) -> dict:
|
|
"""Delete a file"""
|
|
data = {"sha": sha, "message": message}
|
|
if branch:
|
|
data["branch"] = branch
|
|
return await self.request(
|
|
"DELETE", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data
|
|
)
|
|
|
|
# Issue endpoints
|
|
async def list_issues(self, owner: str, repo: str, params: dict) -> list[dict]:
|
|
"""List repository issues"""
|
|
return await self.request("GET", f"repos/{owner}/{repo}/issues", params=params)
|
|
|
|
async def get_issue(self, owner: str, repo: str, index: int) -> dict:
|
|
"""Get issue details"""
|
|
return await self.request("GET", f"repos/{owner}/{repo}/issues/{index}")
|
|
|
|
async def create_issue(self, owner: str, repo: str, data: dict) -> dict:
|
|
"""Create a new issue"""
|
|
return await self.request("POST", f"repos/{owner}/{repo}/issues", json_data=data)
|
|
|
|
async def update_issue(self, owner: str, repo: str, index: int, data: dict) -> dict:
|
|
"""Update an issue"""
|
|
return await self.request("PATCH", f"repos/{owner}/{repo}/issues/{index}", json_data=data)
|
|
|
|
async def list_issue_comments(self, owner: str, repo: str, index: int) -> list[dict]:
|
|
"""List issue comments"""
|
|
return await self.request("GET", f"repos/{owner}/{repo}/issues/{index}/comments")
|
|
|
|
async def create_issue_comment(self, owner: str, repo: str, index: int, data: dict) -> dict:
|
|
"""Create issue comment"""
|
|
return await self.request(
|
|
"POST", f"repos/{owner}/{repo}/issues/{index}/comments", json_data=data
|
|
)
|
|
|
|
# Label endpoints
|
|
async def list_labels(self, owner: str, repo: str) -> list[dict]:
|
|
"""List repository labels"""
|
|
return await self.request("GET", f"repos/{owner}/{repo}/labels")
|
|
|
|
async def create_label(self, owner: str, repo: str, data: dict) -> dict:
|
|
"""Create a label"""
|
|
return await self.request("POST", f"repos/{owner}/{repo}/labels", json_data=data)
|
|
|
|
async def delete_label(self, owner: str, repo: str, label_id: int) -> dict:
|
|
"""Delete a label"""
|
|
return await self.request("DELETE", f"repos/{owner}/{repo}/labels/{label_id}")
|
|
|
|
# Milestone endpoints
|
|
async def list_milestones(self, owner: str, repo: str, state: str | None = None) -> list[dict]:
|
|
"""List repository milestones"""
|
|
params = {"state": state} if state else {}
|
|
return await self.request("GET", f"repos/{owner}/{repo}/milestones", params=params)
|
|
|
|
async def create_milestone(self, owner: str, repo: str, data: dict) -> dict:
|
|
"""Create a milestone"""
|
|
return await self.request("POST", f"repos/{owner}/{repo}/milestones", json_data=data)
|
|
|
|
# Pull Request endpoints
|
|
async def list_pull_requests(self, owner: str, repo: str, params: dict) -> list[dict]:
|
|
"""List repository pull requests"""
|
|
return await self.request("GET", f"repos/{owner}/{repo}/pulls", params=params)
|
|
|
|
async def get_pull_request(self, owner: str, repo: str, index: int) -> dict:
|
|
"""Get pull request details"""
|
|
return await self.request("GET", f"repos/{owner}/{repo}/pulls/{index}")
|
|
|
|
async def create_pull_request(self, owner: str, repo: str, data: dict) -> dict:
|
|
"""Create a new pull request"""
|
|
return await self.request("POST", f"repos/{owner}/{repo}/pulls", json_data=data)
|
|
|
|
async def update_pull_request(self, owner: str, repo: str, index: int, data: dict) -> dict:
|
|
"""Update a pull request"""
|
|
return await self.request("PATCH", f"repos/{owner}/{repo}/pulls/{index}", json_data=data)
|
|
|
|
async def merge_pull_request(self, owner: str, repo: str, index: int, data: dict) -> dict:
|
|
"""Merge a pull request"""
|
|
return await self.request(
|
|
"POST", f"repos/{owner}/{repo}/pulls/{index}/merge", json_data=data
|
|
)
|
|
|
|
async def list_pr_commits(self, owner: str, repo: str, index: int) -> list[dict]:
|
|
"""List pull request commits"""
|
|
return await self.request("GET", f"repos/{owner}/{repo}/pulls/{index}/commits")
|
|
|
|
async def list_pr_files(self, owner: str, repo: str, index: int) -> list[dict]:
|
|
"""List pull request files"""
|
|
return await self.request("GET", f"repos/{owner}/{repo}/pulls/{index}/files")
|
|
|
|
async def get_pr_diff(self, owner: str, repo: str, index: int) -> str:
|
|
"""Get pull request diff"""
|
|
# Override accept header for diff
|
|
headers = {"accept": "text/plain"}
|
|
response = await self.request(
|
|
"GET", f"repos/{owner}/{repo}/pulls/{index}.diff", headers_override=headers
|
|
)
|
|
return response
|
|
|
|
async def list_pr_reviews(self, owner: str, repo: str, index: int) -> list[dict]:
|
|
"""List pull request reviews"""
|
|
return await self.request("GET", f"repos/{owner}/{repo}/pulls/{index}/reviews")
|
|
|
|
async def create_pr_review(self, owner: str, repo: str, index: int, data: dict) -> dict:
|
|
"""Create pull request review"""
|
|
return await self.request(
|
|
"POST", f"repos/{owner}/{repo}/pulls/{index}/reviews", json_data=data
|
|
)
|
|
|
|
async def request_pr_reviewers(self, owner: str, repo: str, index: int, data: dict) -> dict:
|
|
"""Request pull request reviewers"""
|
|
return await self.request(
|
|
"POST", f"repos/{owner}/{repo}/pulls/{index}/requested_reviewers", json_data=data
|
|
)
|
|
|
|
# User endpoints
|
|
async def get_user(self, username: str) -> dict:
|
|
"""Get user information"""
|
|
return await self.request("GET", f"users/{username}")
|
|
|
|
async def list_user_repos(self, username: str, page: int = 1, limit: int = 30) -> list[dict]:
|
|
"""List user repositories"""
|
|
params = {"page": page, "limit": limit}
|
|
return await self.request("GET", f"users/{username}/repos", params=params)
|
|
|
|
async def search_users(self, query: str | None = None, uid: int | None = None) -> list[dict]:
|
|
"""Search users"""
|
|
params = {}
|
|
if query:
|
|
params["q"] = query
|
|
if uid:
|
|
params["uid"] = uid
|
|
response = await self.request("GET", "users/search", params=params)
|
|
return response.get("data", [])
|
|
|
|
# Organization endpoints
|
|
async def list_organizations(self, page: int = 1, limit: int = 30) -> list[dict]:
|
|
"""List current user's organizations"""
|
|
params = {"page": page, "limit": limit}
|
|
return await self.request("GET", "user/orgs", params=params)
|
|
|
|
async def get_organization(self, org: str) -> dict:
|
|
"""Get organization information"""
|
|
return await self.request("GET", f"orgs/{org}")
|
|
|
|
async def list_org_repos(self, org: str, page: int = 1, limit: int = 30) -> list[dict]:
|
|
"""List organization repositories"""
|
|
params = {"page": page, "limit": limit}
|
|
return await self.request("GET", f"orgs/{org}/repos", params=params)
|
|
|
|
async def list_org_teams(self, org: str, page: int = 1, limit: int = 30) -> list[dict]:
|
|
"""List organization teams"""
|
|
params = {"page": page, "limit": limit}
|
|
return await self.request("GET", f"orgs/{org}/teams", params=params)
|
|
|
|
async def list_team_members(self, team_id: int, page: int = 1, limit: int = 30) -> list[dict]:
|
|
"""List team members"""
|
|
params = {"page": page, "limit": limit}
|
|
return await self.request("GET", f"teams/{team_id}/members", params=params)
|
|
|
|
# Webhook endpoints
|
|
async def list_webhooks(self, owner: str, repo: str) -> list[dict]:
|
|
"""List repository webhooks"""
|
|
return await self.request("GET", f"repos/{owner}/{repo}/hooks")
|
|
|
|
async def create_webhook(self, owner: str, repo: str, data: dict) -> dict:
|
|
"""Create a webhook"""
|
|
return await self.request("POST", f"repos/{owner}/{repo}/hooks", json_data=data)
|
|
|
|
async def get_webhook(self, owner: str, repo: str, hook_id: int) -> dict:
|
|
"""Get webhook details"""
|
|
return await self.request("GET", f"repos/{owner}/{repo}/hooks/{hook_id}")
|
|
|
|
async def update_webhook(self, owner: str, repo: str, hook_id: int, data: dict) -> dict:
|
|
"""Update a webhook"""
|
|
return await self.request("PATCH", f"repos/{owner}/{repo}/hooks/{hook_id}", json_data=data)
|
|
|
|
async def delete_webhook(self, owner: str, repo: str, hook_id: int) -> dict:
|
|
"""Delete a webhook"""
|
|
return await self.request("DELETE", f"repos/{owner}/{repo}/hooks/{hook_id}")
|
|
|
|
async def test_webhook(self, owner: str, repo: str, hook_id: int) -> dict:
|
|
"""Test a webhook"""
|
|
return await self.request("POST", f"repos/{owner}/{repo}/hooks/{hook_id}/tests")
|