Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X. WordPress media pipeline - Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries. Capability discovery (F.7e) - Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error. Companion plugin overhaul - Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge. - Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach. - wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL. Other - Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork. - Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8). - n8n refactor: structured errors, capability probe, missing tools backfilled. - Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites. Docs - README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it. - Tools/Tests badges removed in favour of "Plugins: 10". Deployment - PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant. CI - Black + Ruff clean on Python 3.12; pytest tests/ green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
650 lines
25 KiB
Python
650 lines
25 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"""
|
|
if "content" in data:
|
|
data["content"] = self._normalise_file_content(
|
|
data["content"], data.get("content_is_base64", False)
|
|
)
|
|
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"""
|
|
if "content" in data:
|
|
data["content"] = self._normalise_file_content(
|
|
data["content"], data.get("content_is_base64", False)
|
|
)
|
|
data.pop("content_is_base64", None)
|
|
|
|
return await self.request(
|
|
"PUT", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data
|
|
)
|
|
|
|
@staticmethod
|
|
def _normalise_file_content(content: Any, is_already_base64: bool) -> str:
|
|
"""F.17 ergonomics: turn ``content`` into a base64 string suitable
|
|
for Gitea's contents endpoints, with actionable error messages.
|
|
|
|
``is_already_base64=True`` validates that ``content`` decodes
|
|
cleanly and re-emits it stripped of whitespace; if the decode
|
|
fails, raise a message that tells the caller exactly how to
|
|
recover (drop ``data:`` prefix, use ``content_is_base64=False``
|
|
for raw text). ``is_already_base64=False`` UTF-8 + base64
|
|
encodes the string.
|
|
"""
|
|
if is_already_base64:
|
|
if not isinstance(content, str):
|
|
raise ValueError(
|
|
"content_is_base64=True but content is not a string. "
|
|
"Pass the raw base64 text; do not wrap it in bytes / dicts."
|
|
)
|
|
stripped = content.strip()
|
|
if stripped.lower().startswith("data:"):
|
|
raise ValueError(
|
|
"content looks like a data: URL (starts with 'data:'). "
|
|
"Strip the 'data:<mime>;base64,' prefix before sending — "
|
|
"Gitea expects the base64 payload only."
|
|
)
|
|
try:
|
|
# Validate round-trip and normalise whitespace.
|
|
raw = base64.b64decode(stripped, validate=True)
|
|
return base64.b64encode(raw).decode()
|
|
except Exception as exc: # noqa: BLE001
|
|
raise ValueError(
|
|
"content_is_base64=True but content is not valid base64. "
|
|
"If you meant to send raw text, set content_is_base64=False "
|
|
"(the client will base64-encode it for you). Original "
|
|
f"decoder error: {exc}"
|
|
) from exc
|
|
|
|
# Plain text / bytes → base64-encode for the caller.
|
|
if isinstance(content, bytes):
|
|
return base64.b64encode(content).decode()
|
|
if isinstance(content, str):
|
|
return base64.b64encode(content.encode("utf-8")).decode()
|
|
raise ValueError(
|
|
"content must be a string or bytes when content_is_base64=False. "
|
|
f"Got {type(content).__name__}."
|
|
)
|
|
|
|
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
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# F.17 — ergonomics: batch file write, tree listing, search, compare,
|
|
# releases, fork.
|
|
# ------------------------------------------------------------------
|
|
|
|
async def change_files(
|
|
self,
|
|
owner: str,
|
|
repo: str,
|
|
data: dict,
|
|
) -> dict:
|
|
"""POST /repos/{owner}/{repo}/contents — apply a batch of file
|
|
create/update/delete operations in a single commit.
|
|
|
|
Gitea's ``files`` endpoint takes a list of ``{operation, path,
|
|
content, sha}`` entries. Operations: ``create`` | ``update`` |
|
|
``delete``. ``content`` must be base64 for create/update.
|
|
"""
|
|
return await self.request("POST", f"repos/{owner}/{repo}/contents", json_data=data)
|
|
|
|
async def get_tree(
|
|
self,
|
|
owner: str,
|
|
repo: str,
|
|
sha: str = "HEAD",
|
|
*,
|
|
recursive: bool = False,
|
|
page: int = 1,
|
|
per_page: int = 100,
|
|
) -> dict:
|
|
"""GET /repos/{owner}/{repo}/git/trees/{sha}
|
|
|
|
Returns ``{sha, url, tree: [{path, mode, type, size, sha, url}],
|
|
truncated}``. Set ``recursive=True`` to get the entire tree.
|
|
"""
|
|
params: dict[str, Any] = {"page": page, "per_page": per_page}
|
|
if recursive:
|
|
params["recursive"] = "true"
|
|
return await self.request("GET", f"repos/{owner}/{repo}/git/trees/{sha}", params=params)
|
|
|
|
async def search_code(
|
|
self,
|
|
*,
|
|
keyword: str,
|
|
owner: str | None = None,
|
|
repo: str | None = None,
|
|
page: int = 1,
|
|
per_page: int = 30,
|
|
) -> dict:
|
|
"""Search code either across all repos (``/repos/search/code``)
|
|
or within a specific repo (``/repos/{owner}/{repo}/search/code``).
|
|
|
|
Returns the raw Gitea payload: ``{ok, data: [...]}``.
|
|
"""
|
|
params: dict[str, Any] = {"q": keyword, "page": page, "per_page": per_page}
|
|
if owner and repo:
|
|
path = f"repos/{owner}/{repo}/search/code"
|
|
else:
|
|
path = "repos/search/code"
|
|
return await self.request("GET", path, params=params)
|
|
|
|
async def compare(
|
|
self,
|
|
owner: str,
|
|
repo: str,
|
|
base: str,
|
|
head: str,
|
|
) -> dict:
|
|
"""GET /repos/{owner}/{repo}/compare/{base}...{head}"""
|
|
# Gitea's compare endpoint uses ``...`` as the separator. The HTTP
|
|
# client URL-encodes path params, so we pre-join instead of
|
|
# passing them as separate path parameters.
|
|
spec = f"{base}...{head}"
|
|
return await self.request("GET", f"repos/{owner}/{repo}/compare/{spec}")
|
|
|
|
async def list_releases(
|
|
self,
|
|
owner: str,
|
|
repo: str,
|
|
*,
|
|
page: int = 1,
|
|
per_page: int = 30,
|
|
) -> list[dict]:
|
|
"""GET /repos/{owner}/{repo}/releases"""
|
|
return await self.request(
|
|
"GET",
|
|
f"repos/{owner}/{repo}/releases",
|
|
params={"page": page, "per_page": per_page},
|
|
)
|
|
|
|
async def create_release(
|
|
self,
|
|
owner: str,
|
|
repo: str,
|
|
data: dict,
|
|
) -> dict:
|
|
"""POST /repos/{owner}/{repo}/releases"""
|
|
return await self.request("POST", f"repos/{owner}/{repo}/releases", json_data=data)
|
|
|
|
async def get_release(self, owner: str, repo: str, release_id: int) -> dict:
|
|
return await self.request("GET", f"repos/{owner}/{repo}/releases/{release_id}")
|
|
|
|
async def delete_release(self, owner: str, repo: str, release_id: int) -> dict:
|
|
return await self.request("DELETE", f"repos/{owner}/{repo}/releases/{release_id}")
|
|
|
|
async def upload_release_asset(
|
|
self,
|
|
owner: str,
|
|
repo: str,
|
|
release_id: int,
|
|
*,
|
|
filename: str,
|
|
content_b64: str,
|
|
) -> dict:
|
|
"""Upload a release asset.
|
|
|
|
Gitea's API accepts multipart/form-data on
|
|
``POST /repos/{owner}/{repo}/releases/{id}/assets?name=FILE``.
|
|
We accept base64 content so the tool schema stays JSON-friendly;
|
|
callers supply ``content_b64``. Decoded bytes go into the
|
|
multipart body. This bypasses ``self.request`` because that
|
|
helper only understands JSON bodies.
|
|
"""
|
|
import aiohttp
|
|
|
|
try:
|
|
raw = base64.b64decode(content_b64, validate=True)
|
|
except Exception as exc: # noqa: BLE001
|
|
raise ValueError(
|
|
"content_b64 must be a valid base64 string. "
|
|
"For small text files, base64-encode the UTF-8 bytes; "
|
|
"do not include a 'data:' URL prefix."
|
|
) from exc
|
|
|
|
url = f"{self.api_base}/repos/{owner}/{repo}/releases/{release_id}/assets"
|
|
headers = self._get_headers()
|
|
# Requests library-style: multipart/form-data with attachment field.
|
|
form = aiohttp.FormData()
|
|
form.add_field(
|
|
"attachment",
|
|
raw,
|
|
filename=filename,
|
|
content_type="application/octet-stream",
|
|
)
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(
|
|
url, params={"name": filename}, data=form, headers=headers
|
|
) as resp:
|
|
text = await resp.text()
|
|
if resp.status >= 400:
|
|
raise Exception(
|
|
f"Gitea upload_release_asset failed ({resp.status}): {text[:500]}"
|
|
)
|
|
try:
|
|
import json as _json
|
|
|
|
return _json.loads(text) if text else {"success": True}
|
|
except Exception:
|
|
return {"success": True, "raw": text[:500]}
|
|
|
|
async def fork_repository(
|
|
self,
|
|
owner: str,
|
|
repo: str,
|
|
*,
|
|
organization: str | None = None,
|
|
name: str | None = None,
|
|
) -> dict:
|
|
"""POST /repos/{owner}/{repo}/forks
|
|
|
|
``organization``: target org (omit to fork under the caller).
|
|
``name``: custom name for the fork.
|
|
"""
|
|
payload: dict[str, Any] = {}
|
|
if organization:
|
|
payload["organization"] = organization
|
|
if name:
|
|
payload["name"] = name
|
|
return await self.request("POST", f"repos/{owner}/{repo}/forks", json_data=payload)
|
|
|
|
# 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")
|