mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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>
This commit is contained in:
@@ -263,3 +263,35 @@ async def test_slash_branch_segments_are_url_encoded(
|
||||
|
||||
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"
|
||||
|
||||
@@ -3,8 +3,10 @@ MR→PR shape adaptation (iid→number, source/target_branch→head/base, merged
|
||||
bool), payload-key translation (create_pr duplicate 409→422, update_pr
|
||||
close→state_event), squash-flag merge, approve-vs-note review routing, diff
|
||||
reassembly, pipelines→workflow_runs / statuses→check_runs CI classification,
|
||||
merge-method mapping, and the deliberate Phase-3 synthetic postures
|
||||
(request_reviewers, create_org_repo, merge_branch).
|
||||
merge-method mapping, Phase-4 project provisioning (group→namespace_id
|
||||
resolution, user-namespace fallback, visibility mapping, duplicate-path
|
||||
400→422 reshape), and the deliberate Phase-3 synthetic postures
|
||||
(request_reviewers, merge_branch).
|
||||
|
||||
Uses httpx.MockTransport through the provider's own ``_send`` — same seam
|
||||
``test_gitea_provider.py`` exercises.
|
||||
@@ -597,10 +599,115 @@ async def test_merge_branch_is_shaped_not_implemented() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_repo_is_synthetic_501() -> None:
|
||||
async def test_create_org_repo_resolves_group_and_posts_project(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def responder(request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "GET":
|
||||
assert str(request.url).endswith("/api/v4/groups/acme%2Fsub")
|
||||
return httpx.Response(200, json={"id": 42})
|
||||
assert request.url.path == "/api/v4/projects"
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"path_with_namespace": "acme/sub/widgets",
|
||||
"web_url": "https://gitlab.example.com/acme/sub/widgets",
|
||||
"http_url_to_repo": "https://gitlab.example.com/acme/sub/widgets.git",
|
||||
},
|
||||
)
|
||||
|
||||
recorder = _Recorder(responder)
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GitLabProvider("gitlab.example.com").create_org_repo(
|
||||
"t", "acme/sub", name="Widgets", description="d", private=True, auto_init=True
|
||||
)
|
||||
|
||||
post_body = recorder.requests[1].content
|
||||
assert b'"name": "Widgets"' in post_body or b'"name":"Widgets"' in post_body
|
||||
assert b'"path": "widgets"' in post_body or b'"path":"widgets"' in post_body
|
||||
assert b'"namespace_id": 42' in post_body or b'"namespace_id":42' in post_body
|
||||
assert (
|
||||
b'"visibility": "private"' in post_body
|
||||
or b'"visibility":"private"' in post_body
|
||||
)
|
||||
assert (
|
||||
b'"initialize_with_readme": true' in post_body
|
||||
or b'"initialize_with_readme":true' in post_body
|
||||
)
|
||||
shaped = resp.json()
|
||||
assert shaped["full_name"] == "acme/sub/widgets"
|
||||
assert shaped["clone_url"].endswith("widgets.git")
|
||||
assert shaped["html_url"] == "https://gitlab.example.com/acme/sub/widgets"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_repo_falls_back_to_user_namespace_on_missing_group(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def responder(request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "GET":
|
||||
return httpx.Response(404)
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"path_with_namespace": "renzo/widgets",
|
||||
"web_url": "https://gitlab.example.com/renzo/widgets",
|
||||
"http_url_to_repo": "https://gitlab.example.com/renzo/widgets.git",
|
||||
},
|
||||
)
|
||||
|
||||
recorder = _Recorder(responder)
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GitLabProvider("gitlab.example.com").create_org_repo(
|
||||
"t", "renzo", name="widgets", description="", private=True, auto_init=True
|
||||
)
|
||||
|
||||
post_body = recorder.requests[1].content
|
||||
assert b"namespace_id" not in post_body
|
||||
assert resp.json()["full_name"] == "renzo/widgets"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_repo_visibility_maps_public_to_internal(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = _Recorder(
|
||||
lambda r: (
|
||||
httpx.Response(404) if r.method == "GET" else httpx.Response(201, json={})
|
||||
)
|
||||
)
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
await GitLabProvider("gitlab.example.com").create_org_repo(
|
||||
"t", "acme", name="widgets", description="", private=False, auto_init=False
|
||||
)
|
||||
|
||||
post_body = recorder.requests[1].content
|
||||
assert (
|
||||
b'"visibility": "internal"' in post_body
|
||||
or b'"visibility":"internal"' in post_body
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_repo_duplicate_reshapes_400_to_422(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def responder(request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={"id": 1})
|
||||
return httpx.Response(
|
||||
400, json={"message": {"path": ["has already been taken"]}}
|
||||
)
|
||||
|
||||
recorder = _Recorder(responder)
|
||||
_patch_client(monkeypatch, recorder)
|
||||
|
||||
resp = await GitLabProvider("gitlab.example.com").create_org_repo(
|
||||
"t", "acme", name="widgets", description="", private=True, auto_init=True
|
||||
)
|
||||
assert isinstance(resp, ShapedResponse)
|
||||
assert resp.status_code == httpx.codes.NOT_IMPLEMENTED
|
||||
assert "Phase 4" in resp.json()["message"]
|
||||
|
||||
assert resp.status_code == httpx.codes.UNPROCESSABLE_ENTITY
|
||||
assert "has already been taken" in resp.text
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
"""roboco.services.github_provisioning — repo creation against MockTransport."""
|
||||
"""roboco.services.github_provisioning — repo creation against MockTransport.
|
||||
|
||||
Provider-aware since Phase 4: the same service now also targets GitLab/Gitea
|
||||
(``provider_name=``/``host=``), so this file covers github (unchanged default)
|
||||
plus one success + one idempotent-reuse case per additional forge, and a
|
||||
config-default regression guard (no provider set => GitHubProvider).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,6 +14,7 @@ from collections.abc import Callable
|
||||
import httpx
|
||||
import pytest
|
||||
from roboco.config import settings
|
||||
from roboco.services.forge.github import GitHubProvider
|
||||
from roboco.services.github_provisioning import (
|
||||
GitHubProvisioningService,
|
||||
ProvisioningDisabledError,
|
||||
@@ -140,3 +147,164 @@ async def test_create_repo_other_422_still_raises(
|
||||
)
|
||||
with pytest.raises(ProvisioningError):
|
||||
await svc.create_repo("dup")
|
||||
|
||||
|
||||
def test_default_provider_builds_github_provider() -> None:
|
||||
"""Regression guard: no provider_name/ROBOCO_PROVISIONING_PROVIDER override
|
||||
=> the service still builds a plain GitHubProvider, byte-for-byte the
|
||||
Phase-1 default."""
|
||||
svc = GitHubProvisioningService(token="tok", org="acme")
|
||||
assert isinstance(svc._provider, GitHubProvider)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_provider_creates_project_and_reshapes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "provisioning_enabled", True)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "GET":
|
||||
assert request.url.path == "/api/v4/groups/acme"
|
||||
return httpx.Response(200, json={"id": 7})
|
||||
assert request.url.path == "/api/v4/projects"
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"path_with_namespace": "acme/newrepo",
|
||||
"web_url": "https://gitlab.example.com/acme/newrepo",
|
||||
"http_url_to_repo": "https://gitlab.example.com/acme/newrepo.git",
|
||||
},
|
||||
)
|
||||
|
||||
svc = GitHubProvisioningService(
|
||||
token="tok",
|
||||
org="acme",
|
||||
provider_name="gitlab",
|
||||
host="gitlab.example.com",
|
||||
client=_client(handler),
|
||||
)
|
||||
assert svc.enabled is True
|
||||
repo = await svc.create_repo("newrepo", "desc")
|
||||
assert repo.full_name == "acme/newrepo"
|
||||
assert repo.clone_url.endswith("newrepo.git")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_provider_reuses_already_existing_project(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""GitLab's duplicate-path 400 ('has already been taken') reshapes to
|
||||
422 so the service's already-exists branch fires exactly like GitHub's."""
|
||||
monkeypatch.setattr(settings, "provisioning_enabled", True)
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append((request.method, str(request.url)))
|
||||
if request.method == "GET" and request.url.path == "/api/v4/groups/acme":
|
||||
return httpx.Response(200, json={"id": 7})
|
||||
if request.method == "POST" and request.url.path == "/api/v4/projects":
|
||||
return httpx.Response(
|
||||
400, json={"message": {"path": ["has already been taken"]}}
|
||||
)
|
||||
if str(request.url).endswith("/api/v4/projects/acme%2Forphan"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"path_with_namespace": "acme/orphan",
|
||||
"web_url": "https://gitlab.example.com/acme/orphan",
|
||||
"http_url_to_repo": "https://gitlab.example.com/acme/orphan.git",
|
||||
},
|
||||
)
|
||||
return httpx.Response(404)
|
||||
|
||||
svc = GitHubProvisioningService(
|
||||
token="tok",
|
||||
org="acme",
|
||||
provider_name="gitlab",
|
||||
host="gitlab.example.com",
|
||||
client=_client(handler),
|
||||
)
|
||||
repo = await svc.create_repo("orphan", "desc")
|
||||
assert repo.full_name == "acme/orphan"
|
||||
assert repo.clone_url.endswith("orphan.git")
|
||||
assert calls[-1][0] == "GET"
|
||||
assert calls[-1][1].endswith("/api/v4/projects/acme%2Forphan")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_provider_disabled_without_host(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "provisioning_enabled", True)
|
||||
svc = GitHubProvisioningService(
|
||||
token="tok",
|
||||
org="acme",
|
||||
provider_name="gitlab",
|
||||
host="",
|
||||
client=_client(lambda _r: httpx.Response(201)),
|
||||
)
|
||||
assert svc.enabled is False
|
||||
with pytest.raises(ProvisioningDisabledError):
|
||||
await svc.create_repo("x")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitea_provider_creates_repo(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "provisioning_enabled", True)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/api/v1/orgs/acme/repos"
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"full_name": "acme/newrepo",
|
||||
"clone_url": "https://gitea.example.com/acme/newrepo.git",
|
||||
"html_url": "https://gitea.example.com/acme/newrepo",
|
||||
},
|
||||
)
|
||||
|
||||
svc = GitHubProvisioningService(
|
||||
token="tok",
|
||||
org="acme",
|
||||
provider_name="gitea",
|
||||
host="gitea.example.com",
|
||||
client=_client(handler),
|
||||
)
|
||||
assert svc.enabled is True
|
||||
repo = await svc.create_repo("newrepo", "desc")
|
||||
assert repo.full_name == "acme/newrepo"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitea_provider_reuses_already_existing_repo(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "provisioning_enabled", True)
|
||||
calls: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request.url.path)
|
||||
if request.url.path == "/api/v1/orgs/acme/repos":
|
||||
return httpx.Response(409, text="repository already exists")
|
||||
if request.url.path == "/api/v1/repos/acme/orphan":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"full_name": "acme/orphan",
|
||||
"clone_url": "https://gitea.example.com/acme/orphan.git",
|
||||
"html_url": "https://gitea.example.com/acme/orphan",
|
||||
},
|
||||
)
|
||||
return httpx.Response(404)
|
||||
|
||||
svc = GitHubProvisioningService(
|
||||
token="tok",
|
||||
org="acme",
|
||||
provider_name="gitea",
|
||||
host="gitea.example.com",
|
||||
client=_client(handler),
|
||||
)
|
||||
repo = await svc.create_repo("orphan", "desc")
|
||||
assert repo.full_name == "acme/orphan"
|
||||
assert calls == ["/api/v1/orgs/acme/repos", "/api/v1/repos/acme/orphan"]
|
||||
|
||||
Reference in New Issue
Block a user