feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
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

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:
2026-04-25 16:25:58 +02:00
parent 788439e377
commit f203ca88de
140 changed files with 23802 additions and 2253 deletions

View File

@@ -0,0 +1,307 @@
"""F.17 — Gitea ergonomics: batch files, tree, search, compare, releases, fork.
Tests cover both the client-level additions and the handler wrappers.
All network calls are mocked through ``client.request`` so the tests
exercise validation, error shaping, and payload construction without
hitting a real Gitea instance.
"""
from __future__ import annotations
import base64
import json
from unittest.mock import AsyncMock
import pytest
from plugins.gitea.client import GiteaClient
from plugins.gitea.handlers import repositories as repo_handlers
@pytest.fixture
def client():
"""A GiteaClient with its ``request`` method replaced by an AsyncMock."""
c = GiteaClient(site_url="https://git.example.com", token="ghs_test")
c.request = AsyncMock() # type: ignore[assignment]
return c
# ---------------------------------------------------------------------------
# _normalise_file_content: error messages + round-trip
# ---------------------------------------------------------------------------
class TestNormaliseFileContent:
@pytest.mark.unit
def test_plaintext_roundtrips(self):
out = GiteaClient._normalise_file_content("hello world", False)
assert base64.b64decode(out) == b"hello world"
@pytest.mark.unit
def test_bytes_input_accepted(self):
out = GiteaClient._normalise_file_content(b"\x00\x01\x02", False)
assert base64.b64decode(out) == b"\x00\x01\x02"
@pytest.mark.unit
def test_base64_input_validates_roundtrip(self):
b64 = base64.b64encode(b"precise").decode()
out = GiteaClient._normalise_file_content(b64, True)
assert base64.b64decode(out) == b"precise"
@pytest.mark.unit
def test_invalid_base64_raises_actionable_error(self):
# F.17 feedback #10: make the error message tell the user how to
# recover rather than just echoing the decoder's byte offset.
with pytest.raises(ValueError) as exc:
GiteaClient._normalise_file_content("###not base64###", True)
msg = str(exc.value)
assert "not valid base64" in msg
assert "content_is_base64=False" in msg
@pytest.mark.unit
def test_data_url_prefix_rejected_with_hint(self):
with pytest.raises(ValueError) as exc:
GiteaClient._normalise_file_content("data:text/plain;base64,aGVsbG8=", True)
assert "data:" in str(exc.value)
assert "Strip" in str(exc.value)
@pytest.mark.unit
def test_non_string_non_bytes_rejected(self):
with pytest.raises(ValueError):
GiteaClient._normalise_file_content(12345, False) # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# Client-level endpoints
# ---------------------------------------------------------------------------
class TestClientEndpoints:
@pytest.mark.asyncio
async def test_change_files_forwards_to_batch_endpoint(self, client):
client.request.return_value = {"commit": {"sha": "abc"}}
payload = {"message": "m", "files": [{"operation": "create", "path": "x"}]}
await client.change_files("o", "r", payload)
client.request.assert_called_once_with("POST", "repos/o/r/contents", json_data=payload)
@pytest.mark.asyncio
async def test_get_tree_passes_recursive_flag(self, client):
client.request.return_value = {"tree": []}
await client.get_tree("o", "r", "main", recursive=True, page=2, per_page=50)
call = client.request.call_args
assert call.args == ("GET", "repos/o/r/git/trees/main")
assert call.kwargs["params"] == {"page": 2, "per_page": 50, "recursive": "true"}
@pytest.mark.asyncio
async def test_get_tree_omits_recursive_when_false(self, client):
client.request.return_value = {"tree": []}
await client.get_tree("o", "r")
params = client.request.call_args.kwargs["params"]
assert "recursive" not in params
@pytest.mark.asyncio
async def test_search_code_scoped_to_repo(self, client):
client.request.return_value = {"ok": True, "data": []}
await client.search_code(keyword="func", owner="o", repo="r")
assert client.request.call_args.args == ("GET", "repos/o/r/search/code")
@pytest.mark.asyncio
async def test_search_code_instance_wide(self, client):
client.request.return_value = {"ok": True, "data": []}
await client.search_code(keyword="func")
assert client.request.call_args.args == ("GET", "repos/search/code")
@pytest.mark.asyncio
async def test_compare_uses_triple_dot_separator(self, client):
client.request.return_value = {"commits": []}
await client.compare("o", "r", "main", "feature-x")
assert client.request.call_args.args == ("GET", "repos/o/r/compare/main...feature-x")
@pytest.mark.asyncio
async def test_create_release_forwards_payload(self, client):
client.request.return_value = {"id": 1}
await client.create_release("o", "r", {"tag_name": "v1.0", "draft": False})
assert client.request.call_args.args == ("POST", "repos/o/r/releases")
@pytest.mark.asyncio
async def test_fork_payload_omits_none_fields(self, client):
client.request.return_value = {"id": 1}
await client.fork_repository("o", "r")
# Empty payload — no organization, no name.
assert client.request.call_args.kwargs["json_data"] == {}
client.request.reset_mock()
await client.fork_repository("o", "r", organization="new-org", name="new-name")
assert client.request.call_args.kwargs["json_data"] == {
"organization": "new-org",
"name": "new-name",
}
# ---------------------------------------------------------------------------
# Handlers: validation + JSON shape
# ---------------------------------------------------------------------------
class TestCreateFilesHandler:
@pytest.mark.asyncio
async def test_happy_path_batches_operations(self, client):
client.request.return_value = {"commit": {"sha": "abc"}}
out = json.loads(
await repo_handlers.create_files(
client,
owner="o",
repo="r",
files=[
{"operation": "create", "path": "a.txt", "content": "hi"},
{
"operation": "update",
"path": "b.txt",
"content": "bye",
"sha": "deadbeef",
},
{"operation": "delete", "path": "c.txt", "sha": "cafebabe"},
],
message="batch commit",
branch="main",
)
)
assert out["success"] is True
assert "Batched 3 file" in out["message"]
# Client got a single batch call.
body = client.request.call_args.kwargs["json_data"]
assert body["branch"] == "main"
assert body["message"] == "batch commit"
assert len(body["files"]) == 3
# content is base64-encoded on the way out.
for f in body["files"][:2]:
assert base64.b64decode(f["content"])
@pytest.mark.asyncio
async def test_rejects_invalid_operation(self, client):
out = json.loads(
await repo_handlers.create_files(
client,
owner="o",
repo="r",
files=[{"operation": "rename", "path": "a.txt", "content": "x"}],
message="m",
)
)
assert out["success"] is False
assert out["errors"][0]["error"].startswith("invalid_operation")
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_update_requires_sha(self, client):
out = json.loads(
await repo_handlers.create_files(
client,
owner="o",
repo="r",
files=[{"operation": "update", "path": "a.txt", "content": "x"}],
message="m",
)
)
assert out["success"] is False
assert out["errors"][0]["error"] == "missing_sha_for_update"
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_delete_requires_sha(self, client):
out = json.loads(
await repo_handlers.create_files(
client,
owner="o",
repo="r",
files=[{"operation": "delete", "path": "a.txt"}],
message="m",
)
)
assert out["success"] is False
assert out["errors"][0]["error"] == "missing_sha_for_delete"
@pytest.mark.asyncio
async def test_bad_base64_surfaces_actionable_error(self, client):
out = json.loads(
await repo_handlers.create_files(
client,
owner="o",
repo="r",
files=[
{
"operation": "create",
"path": "a.txt",
"content": "###garbage###",
"content_is_base64": True,
}
],
message="m",
)
)
assert out["success"] is False
assert "content_is_base64=False" in out["errors"][0]["error"]
class TestTreeAndSearchHandlers:
@pytest.mark.asyncio
async def test_get_tree_passthrough(self, client):
client.request.return_value = {"tree": [{"path": "x"}]}
out = json.loads(await repo_handlers.get_tree(client, "o", "r", sha="main", recursive=True))
assert out["success"] is True
assert out["tree"]["tree"][0]["path"] == "x"
@pytest.mark.asyncio
async def test_search_code_passthrough(self, client):
client.request.return_value = {"ok": True, "data": [{"path": "hit"}]}
out = json.loads(
await repo_handlers.search_code(client, keyword="foo", owner="o", repo="r")
)
assert out["success"] is True
assert out["result"]["data"][0]["path"] == "hit"
@pytest.mark.asyncio
async def test_compare_passthrough(self, client):
client.request.return_value = {"commits": [{"sha": "c1"}]}
out = json.loads(await repo_handlers.compare(client, "o", "r", "main", "x"))
assert out["success"] is True
assert out["compare"]["commits"][0]["sha"] == "c1"
class TestReleaseAndForkHandlers:
@pytest.mark.asyncio
async def test_list_releases_passthrough(self, client):
client.request.return_value = [{"tag_name": "v1"}]
out = json.loads(await repo_handlers.list_releases(client, "o", "r"))
assert out["success"] is True
assert out["releases"][0]["tag_name"] == "v1"
@pytest.mark.asyncio
async def test_create_release_forwards_optional_fields(self, client):
client.request.return_value = {"id": 42}
await repo_handlers.create_release(
client, "o", "r", tag_name="v1.0", name="1.0", body="hi", prerelease=True
)
body = client.request.call_args.kwargs["json_data"]
assert body["tag_name"] == "v1.0"
assert body["name"] == "1.0"
assert body["body"] == "hi"
assert body["prerelease"] is True
@pytest.mark.asyncio
async def test_create_release_without_optional_fields(self, client):
client.request.return_value = {"id": 42}
await repo_handlers.create_release(client, "o", "r", tag_name="v1.0")
body = client.request.call_args.kwargs["json_data"]
assert body["tag_name"] == "v1.0"
assert "name" not in body
assert "body" not in body
@pytest.mark.asyncio
async def test_fork_repository_with_org(self, client):
client.request.return_value = {"full_name": "neworg/r"}
out = json.loads(
await repo_handlers.fork_repository(client, "o", "r", organization="neworg")
)
assert out["success"] is True
assert out["fork"]["full_name"] == "neworg/r"