Files
roboco/tests/unit/services/forge/test_gitea_provider.py
T
5f32d8760a feat(forge): Phase 4 — GitLab + Gitea repo-provisioning parity (#581)
GitLab's create_org_repo (services/forge/gitlab.py) replaces the Phase-3
synthetic 501 with a real implementation: resolves org (a group's full
path, subgroups included) to a numeric namespace id via GET
/groups/{path}, falling back to the token's own namespace on a 404
(personal-namespace projects); POSTs /projects with the
name/path/description/visibility/initialize_with_readme payload
(visibility mapped private->"private"/"internal"); reshapes the 201
onto the GitHub fields callers read (full_name/clone_url/html_url) and
GitLab's duplicate-path 400 "has already been taken" onto GitHub's 422
shape, text preserved. Gitea's create_org_repo was already real but
untested — added transport-level coverage.

GitHubProvisioningService (services/github_provisioning.py) is now
provider-aware: ROBOCO_PROVISIONING_PROVIDER (github default / gitlab /
gitea) and ROBOCO_PROVISIONING_HOST (self-hosted instance, required for
gitlab/gitea or the service stays disabled exactly like a missing
token/org) pick the target forge; the class/factory names stay
GitHub-flavored for backward compatibility (pitch.py and existing
imports untouched). A shared _is_already_exists() helper recognizes
GitHub's "already exists" (422), Gitea's (409/422 "already exists"),
and GitLab's reshaped "has already been taken" (422). The existing-repo
re-fetch now builds a provider-aware RepoRef (GitLab packs org/name
into the owner field; GitHub/Gitea keep the owner,repo pair). Default
behavior (no new env set) is byte-for-byte the Phase-1 GitHub path,
pinned by a regression test.

Gates: ruff format/check clean, mypy roboco/+tests/ clean (1235 files),
xenon A/A/B clean, targeted suite (forge + provisioning + pitch) 79/79
green.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-19 11:50:47 +02:00

298 lines
10 KiB
Python

"""GiteaProvider wire contract: token auth scheme, GitHub-shape adapters
(duplicate-PR 409→422, statuses→check_runs, combined-status→workflow_runs,
merge-method key mapping), and the deliberate Phase-2 postures (synthetic
zero workflows, unsupported server-side branch merge).
Uses httpx.MockTransport through the provider's own ``_send`` (the
``client=``-less path is exercised by monkeypatching ``httpx.AsyncClient``
to inject the transport — the same seam the git-service suite patches).
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import httpx
import pytest
from roboco.services.forge.base import RepoRef
from roboco.services.forge.gitea import GiteaProvider, ShapedResponse
if TYPE_CHECKING:
from collections.abc import Callable
REF = RepoRef("acme", "widgets", host="gitea.example.com")
class _Recorder:
def __init__(self, responder: Callable[[httpx.Request], httpx.Response]) -> None:
self.requests: list[httpx.Request] = []
self._responder = responder
def handler(self, request: httpx.Request) -> httpx.Response:
self.requests.append(request)
return self._responder(request)
def _patch_client(monkeypatch: pytest.MonkeyPatch, recorder: _Recorder) -> None:
real_client = httpx.AsyncClient
def _factory(**kwargs: Any) -> httpx.AsyncClient:
kwargs.pop("timeout", None)
return real_client(transport=httpx.MockTransport(recorder.handler))
monkeypatch.setattr(httpx, "AsyncClient", _factory)
@pytest.mark.asyncio
async def test_auth_header_uses_token_scheme_and_api_v1_base(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
_patch_client(monkeypatch, recorder)
await GiteaProvider("gitea.example.com").get_pr(REF, "SECRET", 7)
request = recorder.requests[0]
assert request.headers["Authorization"] == "token SECRET"
assert (
str(request.url)
== "https://gitea.example.com/api/v1/repos/acme/widgets/pulls/7"
)
@pytest.mark.asyncio
async def test_create_pr_duplicate_409_reshapes_to_github_422(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(
lambda _r: httpx.Response(
409, text="pull request already exists for these targets"
)
)
_patch_client(monkeypatch, recorder)
resp = await GiteaProvider("gitea.example.com").create_pr(
REF, "t", head="feat", base="main", title="T", body="B"
)
assert resp.status_code == httpx.codes.UNPROCESSABLE_ENTITY
assert "already exists" in resp.text
@pytest.mark.asyncio
async def test_merge_pr_posts_do_key(monkeypatch: pytest.MonkeyPatch) -> None:
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
_patch_client(monkeypatch, recorder)
await GiteaProvider("gitea.example.com").merge_pr(
REF, "t", 7, merge_method="squash"
)
request = recorder.requests[0]
assert request.method == "POST"
assert request.url.path.endswith("/pulls/7/merge")
assert b'"Do": "squash"' in request.content or b'"Do":"squash"' in request.content
@pytest.mark.asyncio
async def test_post_review_maps_approve_event(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
_patch_client(monkeypatch, recorder)
provider = GiteaProvider("gitea.example.com")
await provider.post_review(REF, "t", 7, body="lgtm", event="APPROVE")
await provider.post_review(REF, "t", 7, body="fix", event="REQUEST_CHANGES")
assert b"APPROVED" in recorder.requests[0].content
assert b"REQUEST_CHANGES" in recorder.requests[1].content
@pytest.mark.asyncio
async def test_list_pulls_filters_client_side_and_injects_association(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pulls = [
{"number": 1, "head": {"ref": "feat-a"}, "base": {"ref": "main"}},
{"number": 2, "head": {"ref": "feat-b"}, "base": {"ref": "main"}},
]
recorder = _Recorder(lambda _r: httpx.Response(200, json=pulls))
_patch_client(monkeypatch, recorder)
resp = await GiteaProvider("gitea.example.com").list_pulls(
REF, "t", head="feat-b", base="main"
)
selected = resp.json()
assert [pr["number"] for pr in selected] == [2]
assert selected[0]["author_association"] == "NONE"
@pytest.mark.asyncio
async def test_check_runs_reshaped_from_statuses(
monkeypatch: pytest.MonkeyPatch,
) -> None:
statuses = [
{"id": 11, "status": "success", "context": "ci/build"},
{"id": 12, "status": "pending", "context": "ci/test"},
{"id": 13, "status": "error", "context": "ci/lint"},
]
recorder = _Recorder(lambda _r: httpx.Response(200, json=statuses))
_patch_client(monkeypatch, recorder)
resp = await GiteaProvider("gitea.example.com").list_check_runs(
REF, "t", "abc123", per_page=100
)
runs = resp.json()["check_runs"]
assert runs[0] == {
"id": 11,
"name": "ci/build",
"status": "completed",
"conclusion": "success",
}
assert runs[1]["status"] == "in_progress"
assert runs[1]["conclusion"] is None
assert runs[2]["conclusion"] == "failure"
@pytest.mark.asyncio
async def test_ci_runs_reshaped_from_combined_status(
monkeypatch: pytest.MonkeyPatch,
) -> None:
combined = {"state": "failure", "sha": "abc123", "url": "https://x"}
recorder = _Recorder(lambda _r: httpx.Response(200, json=combined))
_patch_client(monkeypatch, recorder)
resp = await GiteaProvider("gitea.example.com").list_ci_runs(
REF, "t", workflow=None, branch="main", head_sha=None, per_page=5
)
runs = resp.json()["workflow_runs"]
assert len(runs) == 1
assert runs[0]["conclusion"] == "failure"
assert runs[0]["head_sha"] == "abc123"
@pytest.mark.asyncio
async def test_pending_combined_status_yields_no_completed_runs(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(
lambda _r: httpx.Response(200, json={"state": "pending", "sha": "abc"})
)
_patch_client(monkeypatch, recorder)
resp = await GiteaProvider("gitea.example.com").list_ci_runs(
REF, "t", workflow=None, branch="main", head_sha=None, per_page=5
)
assert resp.json()["workflow_runs"] == []
@pytest.mark.asyncio
async def test_list_workflows_is_synthetic_zero() -> None:
resp = await GiteaProvider("gitea.example.com").list_workflows(REF, "t", per_page=1)
assert resp.is_success
assert resp.json() == {"total_count": 0}
@pytest.mark.asyncio
async def test_merge_branch_is_shaped_not_implemented() -> None:
resp = await GiteaProvider("gitea.example.com").merge_branch(
REF, "t", base="stag", head="main", commit_message="cascade"
)
assert isinstance(resp, ShapedResponse)
assert resp.status_code == httpx.codes.NOT_IMPLEMENTED
@pytest.mark.asyncio
async def test_get_repo_maps_merge_method_keys(
monkeypatch: pytest.MonkeyPatch,
) -> None:
repo_obj = {
"full_name": "acme/widgets",
"allow_merge_commits": False,
"allow_rebase": False,
"allow_squash_merge": True,
}
recorder = _Recorder(lambda _r: httpx.Response(200, json=repo_obj))
_patch_client(monkeypatch, recorder)
resp = await GiteaProvider("gitea.example.com").get_repo(REF, "t")
shaped = resp.json()
assert shaped["allow_merge_commit"] is False
assert shaped["allow_rebase_merge"] is False
assert shaped["allow_squash_merge"] is True
def test_parse_repo_ref_stamps_host() -> None:
provider = GiteaProvider("gitea.example.com")
ref = provider.parse_repo_ref("https://gitea.example.com/acme/widgets.git")
assert ref == RepoRef("acme", "widgets", host="gitea.example.com")
@pytest.mark.asyncio
async def test_ensure_label_prefixes_hash_on_color(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(lambda _r: httpx.Response(201, json={}))
_patch_client(monkeypatch, recorder)
await GiteaProvider("gitea.example.com").ensure_label(REF, "t", "root", "8250df")
assert b"#8250df" in recorder.requests[0].content
@pytest.mark.asyncio
async def test_slash_branch_segments_are_url_encoded(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Every RoboCo branch carries slashes (feature/backend/...) — an
unencoded segment 404s at Gitea's router (caught by the live suite)."""
recorder = _Recorder(lambda _r: httpx.Response(200, json={"state": "success"}))
_patch_client(monkeypatch, recorder)
provider = GiteaProvider("gitea.example.com")
await provider.list_ci_runs(
REF, "t", workflow=None, branch="feature/backend/ABC", head_sha=None, per_page=5
)
await provider.delete_branch_ref(REF, "t", "feature/backend/ABC")
assert "/commits/feature%2Fbackend%2FABC/status" in str(recorder.requests[0].url)
assert "/branches/feature%2Fbackend%2FABC" in str(recorder.requests[1].url)
@pytest.mark.asyncio
async def test_create_org_repo_posts_to_org_repos_with_token_auth(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(
lambda _r: httpx.Response(
201,
json={
"full_name": "acme/widgets",
"clone_url": "https://gitea.example.com/acme/widgets.git",
"html_url": "https://gitea.example.com/acme/widgets",
},
)
)
_patch_client(monkeypatch, recorder)
resp = await GiteaProvider("gitea.example.com").create_org_repo(
"SECRET", "acme", name="widgets", description="d", private=True, auto_init=True
)
request = recorder.requests[0]
assert request.method == "POST"
assert str(request.url) == "https://gitea.example.com/api/v1/orgs/acme/repos"
assert request.headers["Authorization"] == "token SECRET"
body = request.content
assert b'"name": "widgets"' in body or b'"name":"widgets"' in body
assert b'"description": "d"' in body or b'"description":"d"' in body
assert b'"private": true' in body or b'"private":true' in body
assert b'"auto_init": true' in body or b'"auto_init":true' in body
assert resp.json()["full_name"] == "acme/widgets"