mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* 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>
251 lines
8.5 KiB
Python
251 lines
8.5 KiB
Python
"""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}")
|