mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(forge): Phases 2+2.1+3 — Gitea + GitLab providers, per-call routing, local-merge fallback (#575)
* feat(forge): Phase 2 — Gitea provider, per-call routing, host registry Gitea support lands behind the Phase-1 seam: - GiteaProvider (services/forge/gitea.py): Gitea v1 transport addressed by instance host (api base from the project's git_url). Where Gitea's wire contract diverges from GitHub's, the provider adapts responses back into the shapes GitService already classifies (ShapedResponse): `token` auth scheme, duplicate-PR 409→422 with the "already exists" text GitService keys on, commit statuses reshaped into check_runs / workflow_runs envelopes, APPROVE→APPROVED review mapping, Do-keyed POST merge, merge-method repo keys, label-color '#' prefix, client-side head/base PR filtering. Deliberate postures per the spec: zero-workflows fail-open (statuses-free repo → no_ci_configured) and merge_branch as a shaped 501 (env-sync cascade lands on missing_ref; the shared local-git fallback is Phase-2.1). - ForgeRouter (services/forge/router.py): GitService._forge now routes per call from RepoRef.host — every existing call site unchanged in shape. RepoRef gains an optional host; _parse_git_url returns the host-stamped ref and it is threaded through GitService/release executor instead of being rebuilt from strings (helpers re-signatured to take RepoRef). - Host registry (services/forge/registry.py): in-memory host→provider map, self-healing — ProjectService.get/get_by_slug re-register on every read; provider_for resolves gitea projects by git_url host. - Registration validation now accepts git_provider="gitea"; GitLab remains recognized-but-rejected. Panel: the read-only Forge badge becomes a real picker (Auto-detect / GitHub-GHE / Gitea / GitLab disabled). Plain git (clone/fetch/push) needs no changes — the Basic-auth extraheader works on Gitea unchanged. Gates: mypy 392 files, xenon A, full unit suite 6356 green, integration suite 2257 green. * feat(forge): live-Gitea contract suite + scheme support + slash-safe refs Hardening from running the provider against a real dockerized Gitea 1.22.6 (the spec's Phase-2 contract suite, now committed as the env-gated tests/e2e_smoke/test_gitea_live.py — self-seeding: creates its own repo, pushes real commits, and drives PR open → duplicate reshape → list/filter → diff → review → labels → commit-status CI reshapes → squash merge → branch delete → release, plus a live verification of the x-access-token Basic-auth git-CLI claim). Two real findings fixed: - Branch refs weren't URL-encoded — every RoboCo branch carries slashes (feature/backend/...), and Gitea's router 404s on the extra path segments. list_ci_runs + delete_branch_ref now quote the ref (regression-pinned in the unit suite). - The API base hardcoded https; a LAN instance serving plain http is a real deployment shape. GiteaProvider gains a scheme (recorded per host by the registry from the project's git_url). ShapedResponse moves to forge/shaping.py (shared by the upcoming GitLab transport, which needs its text override for diff reassembly). * feat(forge): Phase 3 GitLab provider + Phase 2.1 local-merge fallback GitLabProvider (services/forge/gitlab.py): GitLab v4 transport addressed by host+scheme, subgroup-safe (the MR project path packs into RepoRef.owner, URL-encoded per call). Adapters translate MR semantics into the GitHub shapes GitService classifies: iid→number, source/target_branch→head/base with a merged bool, per-file diffs reassembled into unified-diff text (ShapedResponse text override, 3-page cap), approve-vs-note review routing (GitLab has no request-changes verb), pipelines/statuses reshaped into workflow_runs/check_runs, merge-method repo-key mapping, duplicate-MR 409→422. Reviewer mirroring is skipped (needs numeric ids RoboCo doesn't store); provisioning stays Phase 4. gitlab.com now auto-detects at registration like github.com; self-hosted GitLab sets the provider explicitly (panel picker enabled). Phase 2.1: neither Gitea nor GitLab has GitHub's server-side merges API — their merge_branch returns a shaped 501 and GitService.sync_env_branch now runs the shared local-git fallback (_local_merge_branch: throwaway clone → ancestor check → merge → push; a conflict aborts with the remote untouched; same status vocabulary as the merges-API path). Also aligns the whole tree with the full gate's tests/-scoped mypy (provider-test responder typing, e2e_smoke's stale owner/repo shapes). Gates: mypy 1229 files clean, xenon A, unit suite 6393 green, forge suites 85 green, panel typecheck/lint clean. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user