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,250 @@
|
||||
"""Live-Gitea contract test for GiteaProvider (forge Phase 2).
|
||||
|
||||
Runs the real wire contract against a live Gitea instance — the spec's
|
||||
"contract suite recorded against a dockerized gitea/gitea" — and is fully
|
||||
self-seeding: it creates its own uniquely-named repo, pushes real commits,
|
||||
and exercises the provider end to end (PR open → duplicate reshape →
|
||||
list/filter → diff → comment review → commit-status CI reshape → squash
|
||||
merge → branch delete → release), plus the git-CLI Basic-auth extraheader
|
||||
claim the provider docstring makes.
|
||||
|
||||
Skipped unless both env vars are set:
|
||||
|
||||
ROBOCO_GITEA_E2E_URL e.g. http://localhost:3310
|
||||
ROBOCO_GITEA_E2E_TOKEN an admin PAT (scopes: all)
|
||||
|
||||
Local run: `docker run -d -p 3310:3000 -e GITEA__security__INSTALL_LOCK=true
|
||||
gitea/gitea:1.22`, create an admin + token (`gitea admin user create` /
|
||||
`generate-access-token`), export the two vars, run this file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from roboco.services.forge.base import RepoRef
|
||||
from roboco.services.forge.gitea import GiteaProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_URL = os.environ.get("ROBOCO_GITEA_E2E_URL", "")
|
||||
_TOKEN = os.environ.get("ROBOCO_GITEA_E2E_TOKEN", "")
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.asyncio,
|
||||
pytest.mark.skipif(
|
||||
not (_URL and _TOKEN),
|
||||
reason="ROBOCO_GITEA_E2E_URL / ROBOCO_GITEA_E2E_TOKEN not set",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _split_url() -> tuple[str, str]:
|
||||
parts = urlsplit(_URL)
|
||||
host = parts.netloc or parts.path
|
||||
return (parts.scheme or "http"), host
|
||||
|
||||
|
||||
def _api(method: str, path: str, **kwargs: object) -> httpx.Response:
|
||||
scheme, host = _split_url()
|
||||
return httpx.request(
|
||||
method,
|
||||
f"{scheme}://{host}/api/v1{path}",
|
||||
headers={"Authorization": f"token {_TOKEN}"},
|
||||
timeout=15.0,
|
||||
**kwargs, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args], cwd=cwd, check=True, capture_output=True, text=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _seed_repo(tmp_path: Path, repo_name: str) -> str:
|
||||
"""Create the repo via API, push a feature commit; return its sha."""
|
||||
resp = _api(
|
||||
"post",
|
||||
"/user/repos",
|
||||
json={"name": repo_name, "auto_init": True, "default_branch": "main"},
|
||||
)
|
||||
assert resp.status_code == httpx.codes.CREATED, resp.text
|
||||
login = _api("get", "/user").json()["username"]
|
||||
scheme, host = _split_url()
|
||||
clone_url = f"{scheme}://{login}:{_TOKEN}@{host}/{login}/{repo_name}.git"
|
||||
clone = tmp_path / "clone"
|
||||
subprocess.run(
|
||||
["git", "clone", clone_url, str(clone)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
_git(clone, "config", "user.email", "e2e@example.com")
|
||||
_git(clone, "config", "user.name", "E2E")
|
||||
_git(clone, "config", "commit.gpgsign", "false")
|
||||
_git(clone, "checkout", "-b", "feat/e2e-change")
|
||||
(clone / "widget.txt").write_text("widget v2\n")
|
||||
_git(clone, "add", "widget.txt")
|
||||
_git(clone, "commit", "-m", "add widget")
|
||||
_git(clone, "push", "-u", "origin", "feat/e2e-change")
|
||||
return _git(clone, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
async def _verify_pr_flow(provider: GiteaProvider, ref: RepoRef, head_sha: str) -> int:
|
||||
"""create → duplicate reshape → list/filter → get → diff → review →
|
||||
labels; returns the PR number."""
|
||||
created = await provider.create_pr(
|
||||
ref,
|
||||
_TOKEN,
|
||||
head="feat/e2e-change",
|
||||
base="main",
|
||||
title="E2E change",
|
||||
body="live contract test",
|
||||
)
|
||||
assert created.is_success, created.text
|
||||
pr = created.json()
|
||||
pr_number: int = pr["number"]
|
||||
assert pr["html_url"]
|
||||
|
||||
duplicate = await provider.create_pr(
|
||||
ref,
|
||||
_TOKEN,
|
||||
head="feat/e2e-change",
|
||||
base="main",
|
||||
title="E2E change",
|
||||
body="dup",
|
||||
)
|
||||
assert duplicate.status_code == httpx.codes.UNPROCESSABLE_ENTITY
|
||||
assert "already exists" in duplicate.text.lower()
|
||||
|
||||
listed = await provider.list_pulls(ref, _TOKEN, head="feat/e2e-change", base="main")
|
||||
pulls = listed.json()
|
||||
assert [p["number"] for p in pulls] == [pr_number]
|
||||
assert pulls[0]["author_association"] == "NONE"
|
||||
|
||||
fetched = (await provider.get_pr(ref, _TOKEN, pr_number)).json()
|
||||
assert fetched["head"]["sha"] == head_sha
|
||||
assert fetched["base"]["ref"] == "main"
|
||||
assert not fetched.get("merged")
|
||||
|
||||
diff = await provider.get_pr_diff(ref, _TOKEN, pr_number)
|
||||
assert "widget.txt" in diff.text
|
||||
|
||||
# COMMENT — self-review approve/request-changes is refused by Gitea.
|
||||
review = await provider.post_review(
|
||||
ref, _TOKEN, pr_number, body="looks fine", event="COMMENT"
|
||||
)
|
||||
assert review.is_success, review.text
|
||||
|
||||
label = await provider.ensure_label(ref, _TOKEN, "cell/backend", "8250df")
|
||||
assert label.is_success or label.status_code in (409, 422), label.text
|
||||
attach = await provider.add_labels(ref, _TOKEN, pr_number, ["cell/backend"])
|
||||
assert attach.is_success, attach.text
|
||||
return pr_number
|
||||
|
||||
|
||||
async def _verify_ci_reshapes(
|
||||
provider: GiteaProvider, ref: RepoRef, head_sha: str
|
||||
) -> None:
|
||||
"""A real commit status classifies through both GitHub-shaped views."""
|
||||
status = _api(
|
||||
"post",
|
||||
f"/repos/{ref.owner}/{ref.repo}/statuses/{head_sha}",
|
||||
json={"state": "success", "context": "ci/e2e", "description": "ok"},
|
||||
)
|
||||
assert status.status_code == httpx.codes.CREATED, status.text
|
||||
check_runs = (
|
||||
await provider.list_check_runs(ref, _TOKEN, head_sha, per_page=50)
|
||||
).json()["check_runs"]
|
||||
assert check_runs and check_runs[0]["conclusion"] == "success"
|
||||
assert check_runs[0]["status"] == "completed"
|
||||
runs = (
|
||||
await provider.list_ci_runs(
|
||||
ref,
|
||||
_TOKEN,
|
||||
workflow=None,
|
||||
branch="feat/e2e-change",
|
||||
head_sha=None,
|
||||
per_page=5,
|
||||
)
|
||||
).json()["workflow_runs"]
|
||||
assert runs and runs[0]["conclusion"] == "success"
|
||||
assert runs[0]["head_sha"] == head_sha
|
||||
|
||||
|
||||
async def _verify_merge_publish_cli(
|
||||
provider: GiteaProvider, ref: RepoRef, pr_number: int, scheme: str
|
||||
) -> None:
|
||||
"""Squash merge → merged flag → branch delete → release → CLI auth."""
|
||||
merged = await provider.merge_pr(ref, _TOKEN, pr_number, merge_method="squash")
|
||||
assert merged.is_success, merged.text
|
||||
for _ in range(10):
|
||||
if (await provider.get_pr(ref, _TOKEN, pr_number)).json().get("merged"):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
assert (await provider.get_pr(ref, _TOKEN, pr_number)).json()["merged"] is True
|
||||
|
||||
deleted = await provider.delete_branch_ref(ref, _TOKEN, "feat/e2e-change")
|
||||
assert deleted.status_code in (204, 200), deleted.text
|
||||
|
||||
release = await provider.create_release(
|
||||
ref,
|
||||
_TOKEN,
|
||||
tag_name="v0.0.1-e2e",
|
||||
name="v0.0.1-e2e",
|
||||
body="live",
|
||||
target_commitish="main",
|
||||
)
|
||||
assert release.status_code == httpx.codes.CREATED, release.text
|
||||
assert release.json().get("html_url")
|
||||
|
||||
# The provider docstring's git-CLI claim: Basic auth with the
|
||||
# x-access-token username + PAT password works against Gitea.
|
||||
basic = base64.b64encode(f"x-access-token:{_TOKEN}".encode()).decode()
|
||||
ls = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
f"http.extraheader=Authorization: Basic {basic}",
|
||||
"ls-remote",
|
||||
f"{scheme}://{ref.host}/{ref.owner}/{ref.repo}.git",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert ls.returncode == 0, ls.stderr
|
||||
assert "refs/heads/main" in ls.stdout
|
||||
|
||||
|
||||
async def test_gitea_live_contract(tmp_path: Path) -> None:
|
||||
scheme, host = _split_url()
|
||||
repo_name = f"e2e-{uuid4().hex[:8]}"
|
||||
head_sha = _seed_repo(tmp_path, repo_name)
|
||||
login = _api("get", "/user").json()["username"]
|
||||
ref = RepoRef(login, repo_name, host=host)
|
||||
provider = GiteaProvider(host, scheme=scheme)
|
||||
|
||||
repo_resp = await provider.get_repo(ref, _TOKEN)
|
||||
assert repo_resp.is_success
|
||||
repo_json = repo_resp.json()
|
||||
assert repo_json["full_name"] == f"{login}/{repo_name}"
|
||||
assert "allow_merge_commit" in repo_json
|
||||
assert "allow_squash_merge" in repo_json
|
||||
|
||||
pr_number = await _verify_pr_flow(provider, ref, head_sha)
|
||||
await _verify_ci_reshapes(provider, ref, head_sha)
|
||||
await _verify_merge_publish_cli(provider, ref, pr_number, scheme)
|
||||
|
||||
_api("delete", f"/repos/{login}/{repo_name}")
|
||||
Reference in New Issue
Block a user