Files
mcphub/plugins/gitea/client.py
airano-ir f203ca88de
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
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>
2026-04-25 16:25:58 +02:00

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")