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>
This commit is contained in:
@@ -206,25 +206,10 @@ class GiteaClient:
|
||||
|
||||
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["content"] = self._normalise_file_content(
|
||||
data["content"], data.get("content_is_base64", False)
|
||||
)
|
||||
data.pop("content_is_base64", None)
|
||||
|
||||
return await self.request(
|
||||
@@ -233,31 +218,63 @@ class GiteaClient:
|
||||
|
||||
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["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,
|
||||
@@ -275,6 +292,186 @@ class GiteaClient:
|
||||
"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"""
|
||||
|
||||
@@ -482,6 +482,202 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# === F.17 ergonomics: batch files, tree, search, compare, releases, fork ===
|
||||
{
|
||||
"name": "create_files",
|
||||
"method_name": "create_files",
|
||||
"description": (
|
||||
"Create or update multiple files in a single commit. Uses Gitea's "
|
||||
"``/repos/{owner}/{repo}/contents`` batch endpoint. Per-file operations: "
|
||||
"``create`` | ``update`` | ``delete``. For create/update, pass raw UTF-8 "
|
||||
"``content`` (default) or a base64 string with ``content_is_base64=true`` "
|
||||
"per file. Delete requires each file's current ``sha``."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "minLength": 1},
|
||||
"repo": {"type": "string", "minLength": 1},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Single commit message for the whole batch.",
|
||||
"minLength": 1,
|
||||
},
|
||||
"branch": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"new_branch": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "If set, commit on a new branch created from ``branch``.",
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["create", "update", "delete"],
|
||||
},
|
||||
"path": {"type": "string", "minLength": 1},
|
||||
"content": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Required for create/update.",
|
||||
},
|
||||
"sha": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Required for update/delete.",
|
||||
},
|
||||
"content_is_base64": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": ["operation", "path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "files", "message"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "get_tree",
|
||||
"method_name": "get_tree",
|
||||
"description": (
|
||||
"List the file tree of a Gitea repository. ``sha`` may be a branch "
|
||||
"name, tag, or commit (default: HEAD). ``recursive=true`` returns "
|
||||
"the entire tree in one payload. Use this instead of issuing many "
|
||||
"``get_file`` calls when you don't know the paths ahead of time."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "minLength": 1},
|
||||
"repo": {"type": "string", "minLength": 1},
|
||||
"sha": {"type": "string", "default": "HEAD"},
|
||||
"recursive": {"type": "boolean", "default": False},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"per_page": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "search_code",
|
||||
"method_name": "search_code",
|
||||
"description": (
|
||||
"Search code inside Gitea. When ``owner`` and ``repo`` are set, the "
|
||||
"search scopes to that repository; otherwise it's an instance-wide "
|
||||
"code search. Returns Gitea's raw ``{ok, data: [...]}`` shape."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"keyword": {"type": "string", "minLength": 1},
|
||||
"owner": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"repo": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"per_page": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 50,
|
||||
"default": 30,
|
||||
},
|
||||
},
|
||||
"required": ["keyword"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "compare",
|
||||
"method_name": "compare",
|
||||
"description": (
|
||||
"Compare two commits / branches / tags in a Gitea repository. Returns "
|
||||
"commits + file diffs between ``base`` and ``head`` (the Gitea "
|
||||
"``compare`` endpoint)."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "minLength": 1},
|
||||
"repo": {"type": "string", "minLength": 1},
|
||||
"base": {"type": "string", "minLength": 1},
|
||||
"head": {"type": "string", "minLength": 1},
|
||||
},
|
||||
"required": ["owner", "repo", "base", "head"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "list_releases",
|
||||
"method_name": "list_releases",
|
||||
"description": "List releases of a Gitea repository (paginated).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "minLength": 1},
|
||||
"repo": {"type": "string", "minLength": 1},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"per_page": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 50,
|
||||
"default": 30,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_release",
|
||||
"method_name": "create_release",
|
||||
"description": (
|
||||
"Create a release (tag + release metadata) on a Gitea repository. "
|
||||
"Set ``draft`` to hide it from users until published."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "minLength": 1},
|
||||
"repo": {"type": "string", "minLength": 1},
|
||||
"tag_name": {"type": "string", "minLength": 1},
|
||||
"name": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"body": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"target_commitish": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Branch or commit to tag (default: default branch).",
|
||||
},
|
||||
"draft": {"type": "boolean", "default": False},
|
||||
"prerelease": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": ["owner", "repo", "tag_name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "fork_repository",
|
||||
"method_name": "fork_repository",
|
||||
"description": (
|
||||
"Fork a Gitea repository. Without ``organization``, forks under the "
|
||||
"calling user. ``name`` optionally renames the fork."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "minLength": 1},
|
||||
"repo": {"type": "string", "minLength": 1},
|
||||
"organization": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"name": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -724,3 +920,180 @@ async def delete_file(
|
||||
await client.delete_file(owner, repo, path, sha, message, branch)
|
||||
result = {"success": True, "message": f"File '{path}' deleted successfully"}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F.17 ergonomics: batch files, tree, search, compare, releases, fork
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def create_files(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
files: list[dict[str, Any]],
|
||||
message: str,
|
||||
branch: str | None = None,
|
||||
new_branch: str | None = None,
|
||||
) -> str:
|
||||
"""Apply a batch of create / update / delete file operations in a single commit.
|
||||
|
||||
Each entry in ``files`` is normalised so Gitea's batch endpoint
|
||||
receives base64-encoded content with the ``content_is_base64`` flag
|
||||
stripped (the server expects base64). Validation:
|
||||
|
||||
* ``operation`` is ``create`` / ``update`` / ``delete``.
|
||||
* ``path`` required on every entry.
|
||||
* ``content`` required on create / update.
|
||||
* ``sha`` required on update / delete (current file SHA).
|
||||
"""
|
||||
prepared: list[dict[str, Any]] = []
|
||||
errors: list[dict[str, Any]] = []
|
||||
|
||||
for idx, f in enumerate(files or []):
|
||||
op = (f.get("operation") or "").strip().lower()
|
||||
path = f.get("path")
|
||||
if op not in {"create", "update", "delete"}:
|
||||
errors.append({"index": idx, "path": path, "error": f"invalid_operation:{op!r}"})
|
||||
continue
|
||||
if not path:
|
||||
errors.append({"index": idx, "error": "missing_path"})
|
||||
continue
|
||||
if op in {"update", "delete"} and not f.get("sha"):
|
||||
errors.append({"index": idx, "path": path, "error": f"missing_sha_for_{op}"})
|
||||
continue
|
||||
|
||||
entry: dict[str, Any] = {"operation": op, "path": path}
|
||||
if op in {"create", "update"}:
|
||||
content = f.get("content")
|
||||
if content is None:
|
||||
errors.append({"index": idx, "path": path, "error": "missing_content"})
|
||||
continue
|
||||
try:
|
||||
entry["content"] = client._normalise_file_content(
|
||||
content, bool(f.get("content_is_base64", False))
|
||||
)
|
||||
except ValueError as exc:
|
||||
errors.append({"index": idx, "path": path, "error": str(exc)})
|
||||
continue
|
||||
if f.get("sha"):
|
||||
entry["sha"] = f["sha"]
|
||||
if f.get("from_path"): # Gitea also supports rename via from_path
|
||||
entry["from_path"] = f["from_path"]
|
||||
prepared.append(entry)
|
||||
|
||||
if errors:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "validation_failed",
|
||||
"errors": errors,
|
||||
"total": len(files or []),
|
||||
"prepared": len(prepared),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = {"message": message, "files": prepared}
|
||||
if branch:
|
||||
payload["branch"] = branch
|
||||
if new_branch:
|
||||
payload["new_branch"] = new_branch
|
||||
|
||||
result = await client.change_files(owner, repo, payload)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Batched {len(prepared)} file operation(s) into one commit",
|
||||
"result": result,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
|
||||
async def get_tree(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
sha: str = "HEAD",
|
||||
recursive: bool = False,
|
||||
page: int = 1,
|
||||
per_page: int = 100,
|
||||
) -> str:
|
||||
tree = await client.get_tree(
|
||||
owner, repo, sha, recursive=recursive, page=page, per_page=per_page
|
||||
)
|
||||
return json.dumps({"success": True, "tree": tree}, indent=2)
|
||||
|
||||
|
||||
async def search_code(
|
||||
client: GiteaClient,
|
||||
keyword: str,
|
||||
owner: str | None = None,
|
||||
repo: str | None = None,
|
||||
page: int = 1,
|
||||
per_page: int = 30,
|
||||
) -> str:
|
||||
result = await client.search_code(
|
||||
keyword=keyword, owner=owner, repo=repo, page=page, per_page=per_page
|
||||
)
|
||||
return json.dumps({"success": True, "result": result}, indent=2)
|
||||
|
||||
|
||||
async def compare(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
base: str,
|
||||
head: str,
|
||||
) -> str:
|
||||
diff = await client.compare(owner, repo, base, head)
|
||||
return json.dumps({"success": True, "compare": diff}, indent=2)
|
||||
|
||||
|
||||
async def list_releases(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
page: int = 1,
|
||||
per_page: int = 30,
|
||||
) -> str:
|
||||
releases = await client.list_releases(owner, repo, page=page, per_page=per_page)
|
||||
return json.dumps({"success": True, "releases": releases}, indent=2)
|
||||
|
||||
|
||||
async def create_release(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
tag_name: str,
|
||||
name: str | None = None,
|
||||
body: str | None = None,
|
||||
target_commitish: str | None = None,
|
||||
draft: bool = False,
|
||||
prerelease: bool = False,
|
||||
) -> str:
|
||||
data: dict[str, Any] = {
|
||||
"tag_name": tag_name,
|
||||
"draft": draft,
|
||||
"prerelease": prerelease,
|
||||
}
|
||||
if name is not None:
|
||||
data["name"] = name
|
||||
if body is not None:
|
||||
data["body"] = body
|
||||
if target_commitish is not None:
|
||||
data["target_commitish"] = target_commitish
|
||||
release = await client.create_release(owner, repo, data)
|
||||
return json.dumps({"success": True, "release": release}, indent=2)
|
||||
|
||||
|
||||
async def fork_repository(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
organization: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> str:
|
||||
fork = await client.fork_repository(owner, repo, organization=organization, name=name)
|
||||
return json.dumps({"success": True, "fork": fork}, indent=2)
|
||||
|
||||
@@ -7,6 +7,8 @@ Modular handlers for better organization and maintainability.
|
||||
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from plugins.base import BasePlugin
|
||||
from plugins.gitea import handlers
|
||||
from plugins.gitea.client import GiteaClient
|
||||
@@ -160,3 +162,61 @@ class GiteaPlugin(BasePlugin):
|
||||
return {"healthy": True, "message": "Gitea instance is accessible"}
|
||||
except Exception as e:
|
||||
return {"healthy": False, "message": f"Gitea health check failed: {str(e)}"}
|
||||
|
||||
async def probe_credential_capabilities(self) -> dict[str, Any]:
|
||||
"""F.7e — report what the Gitea access token grants.
|
||||
|
||||
Gitea tokens carry OAuth-style scope labels. Scopes are surfaced
|
||||
two ways (different Gitea versions disagree) and we accept both:
|
||||
|
||||
* ``X-OAuth-Scopes`` header on the response of any authenticated
|
||||
endpoint — the format used by GitHub-compatible clients.
|
||||
* ``capabilities`` key in ``meta`` (rare; ignored here — the
|
||||
header is the universal path).
|
||||
|
||||
We hit ``GET /api/v1/user`` which every token that can do
|
||||
anything useful on Gitea is allowed to call. Failures return
|
||||
``probe_available=False`` with a reason rather than raising,
|
||||
so the badge falls back to "probe unavailable" cleanly.
|
||||
"""
|
||||
url = f"{self.client.api_base}/user"
|
||||
headers = self.client._get_headers()
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=15)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
if resp.status >= 400:
|
||||
body = (await resp.text())[:200]
|
||||
return {
|
||||
"probe_available": False,
|
||||
"granted": [],
|
||||
"source": "gitea_oauth_scopes",
|
||||
"reason": f"user_endpoint_http_{resp.status}: {body}",
|
||||
}
|
||||
scopes_header = resp.headers.get("X-OAuth-Scopes", "")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"probe_available": False,
|
||||
"granted": [],
|
||||
"source": "gitea_oauth_scopes",
|
||||
"reason": f"probe_failed: {exc}",
|
||||
}
|
||||
|
||||
granted = [s.strip() for s in scopes_header.split(",") if s.strip()]
|
||||
|
||||
if not granted:
|
||||
# Gitea instances without per-token scopes (unset header)
|
||||
# grant full access to whatever the user account itself
|
||||
# can do. Report that honestly rather than claiming nothing.
|
||||
return {
|
||||
"probe_available": False,
|
||||
"granted": [],
|
||||
"source": "gitea_oauth_scopes",
|
||||
"reason": "scopes_header_absent_or_empty",
|
||||
}
|
||||
|
||||
return {
|
||||
"probe_available": True,
|
||||
"granted": sorted(granted),
|
||||
"source": "gitea_oauth_scopes",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user