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:
Renzo F
2026-07-19 11:50:47 +02:00
committed by GitHub
co-authored by Renn F
parent a072b980bc
commit 5f32d8760a
7 changed files with 536 additions and 60 deletions
+5
View File
@@ -163,6 +163,11 @@ select = [
# by the release/spotlight/on-demand callers — same "bundling would just
# relocate the same named fields behind one hop" rationale as prompter.py.
"roboco/services/video_engine.py" = ["PLR0913"]
# GitHubProvisioningService.__init__ is a provider-selection constructor
# (token/org/base_url/timeout/client plus Phase-4's provider_name/host) —
# every kwarg is independently override-able by callers/tests, same
# bundling-adds-no-clarity rationale as prompter.py above.
"roboco/services/github_provisioning.py" = ["PLR0913"]
# Gateway methods are typed verb surfaces — agent-facing kwargs reflect the
# verb contract (task title, description, acceptance criteria, assignee, etc.). Bundling
# into a dataclass hides the field-by-field schema the LLM needs at the
+17
View File
@@ -567,6 +567,23 @@ class Settings(BaseSettings):
default=True,
description="Whether provisioned repos are created private.",
)
provisioning_provider: Literal["github", "gitlab", "gitea"] = Field(
default="github",
description=(
"Forge that pitch auto-provisioning targets (default 'github', "
"byte-for-byte unchanged behavior). 'gitlab'/'gitea' additionally "
"require ROBOCO_PROVISIONING_HOST — without it provisioning stays "
"disabled exactly like a missing token/org."
),
)
provisioning_host: str = Field(
default="",
description=(
"Self-hosted forge instance host for gitlab/gitea provisioning "
"(e.g. 'gitlab.example.com'). Ignored when provisioning_provider "
"is 'github'."
),
)
# ==========================================================================
# Autonomous strategy engine ("engine 2") — DORMANT by default
+97 -13
View File
@@ -20,9 +20,11 @@ Deliberate Phase-3 postures (per the spec):
- ``request_reviewers`` needs numeric GitLab user ids RoboCo does not store
(only usernames/slugs) — mirroring is skipped with a synthetic 200 rather
than failing the PR-open flow over a best-effort reviewer nudge.
- ``create_org_repo`` (provisioning) is explicitly out of Phase 3 scope
(Phase 4) — a synthetic 501 keeps the interface implementable without
faking GitLab's namespace-model POST here.
- ``create_org_repo`` (provisioning, Phase 4) resolves ``org`` — a group's
full path, subgroups included — to a numeric namespace id via ``GET
/groups/{path}`` and POSTs ``/projects`` under it; a 404 group lookup
falls back to the token's own (user) namespace by omitting
``namespace_id``.
- Plain git (clone/fetch/push) needs no provider work: GitLab, like GitHub
and Gitea, accepts a PAT as the Basic-auth password with the username
ignored, so the existing ``x-access-token:<token>`` extraheader works.
@@ -65,11 +67,21 @@ _DIFF_PAGE_SIZE = 100
# can't be a valid remote (no owner-less repos on GitLab).
_MIN_PATH_SEGMENTS = 2
# GitLab project ``path`` charset is stricter than a display ``name`` —
# lowercase alnum/dot/underscore/dash. RoboCo repo names are already slugs
# (pitch.slug-derived) so this is normally a no-op.
_PATH_INVALID_CHARS_RE = re.compile(r"[^a-z0-9._-]+")
def _default_timeout() -> int:
return settings.git_command_timeout_seconds
def _project_path(name: str) -> str:
slug = _PATH_INVALID_CHARS_RE.sub("-", name.strip().lower()).strip("-")
return slug or "project"
class GitLabProvider(GitProvider):
"""Self-hosted or gitlab.com REST v4 transport, addressed by instance host."""
@@ -582,14 +594,86 @@ class GitLabProvider(GitProvider):
client: httpx.AsyncClient | None = None,
timeout: float | None = None,
) -> Any:
"""GitLab project provisioning (the namespace-model POST) is Phase 4
— a synthetic 501 keeps the interface implementable without faking
it here."""
_ = (token, org, name, description, private, auto_init, client, timeout)
request = httpx.Request("post", f"{self._scheme}://{self._host}/synthetic")
real = httpx.Response(
httpx.codes.NOT_IMPLEMENTED,
request=request,
json={"message": "GitLab provisioning is Phase 4"},
"""GitLab has no ``/orgs/{org}/repos`` — resolve ``org`` (a group's
full path, subgroups included) to its numeric namespace id and POST
``/projects`` under it. A 404 group lookup means ``org`` is a
personal namespace instead: the project lands under the token's own
namespace by omitting ``namespace_id`` entirely.
Reshapes the 201 body onto the GitHub fields callers read
(``full_name``/``clone_url``/``html_url``), mirroring ``get_repo``.
GitLab signals a duplicate project path as 400 "has already been
taken" where GitHub uses 422 "already exists" — reshaped to 422 so
the status code lines up; the text is left untouched (the
provisioning service's already-exists match covers both phrases).
"""
headers = self._headers(token)
base = f"{self._scheme}://{self._host}/api/v4"
namespace_id = await self._resolve_namespace_id(
org, headers=headers, client=client, timeout=timeout
)
return ShapedResponse(real)
if isinstance(namespace_id, httpx.Response | ShapedResponse):
return namespace_id # group lookup itself errored (not a 404)
payload: dict[str, Any] = {
"name": name,
"path": _project_path(name),
"description": description,
"visibility": "private" if private else "internal",
"initialize_with_readme": auto_init,
}
if namespace_id is not None:
payload["namespace_id"] = namespace_id
resp = await self._send(
"post",
f"{base}/projects",
headers=headers,
json_body=payload,
client=client,
timeout=timeout,
)
return self._shape_create_project(resp)
async def _resolve_namespace_id(
self,
org: str,
*,
headers: dict[str, str],
client: httpx.AsyncClient | None,
timeout: float | None,
) -> int | None | httpx.Response | ShapedResponse:
"""The group's numeric id, ``None`` for a personal (404) namespace,
or the raw error response for anything else."""
group_resp = await self._send(
"get",
f"{self._scheme}://{self._host}/api/v4/groups/{quote(org, safe='')}",
headers=headers,
client=client,
timeout=timeout,
)
if group_resp.is_success:
group_data = group_resp.json()
return group_data.get("id") if isinstance(group_data, dict) else None
if group_resp.status_code == httpx.codes.NOT_FOUND:
return None
return group_resp
@staticmethod
def _shape_create_project(resp: httpx.Response) -> Any:
"""Reshape a project-create response onto the GitHub fields callers
read, and GitLab's duplicate-path 400 onto GitHub's 422."""
if (
resp.status_code == httpx.codes.BAD_REQUEST
and "has already been taken" in resp.text
):
return ShapedResponse(resp, status_code=httpx.codes.UNPROCESSABLE_ENTITY)
if not resp.is_success:
return resp
data = resp.json()
if not isinstance(data, dict):
return resp
shaped = dict(data)
shaped["full_name"] = data.get("path_with_namespace") or ""
shaped["html_url"] = data.get("web_url") or ""
shaped["clone_url"] = data.get("http_url_to_repo") or ""
return ShapedResponse(resp, json_payload=shaped)
+103 -40
View File
@@ -1,11 +1,19 @@
"""GitHub repository provisioning — create new repos in a dedicated org.
"""Repository provisioning — create new repos in a dedicated org/group.
This is the ONE place that *creates* GitHub repositories; everywhere else the
This is the ONE place that *creates* forge repositories; everywhere else the
system only clones/branches/PRs repos that already exist. Used by the pitch
approval flow to auto-provision a repo per target cell.
The provisioning token + org live only in server-side config and are never
injected into an agent container. When unconfigured the service reports
GitHub is the default and only forge that needs no extra config (Phase 1).
Phase 4 adds GitLab/Gitea parity: ``ROBOCO_PROVISIONING_PROVIDER`` selects the
forge and, for a self-hosted GitLab/Gitea instance, ``ROBOCO_PROVISIONING_HOST``
names it — the class/module names stay GitHub-flavored for backward
compatibility (``pitch.py`` and existing imports read them unchanged), but the
service now dispatches to whichever :class:`~roboco.services.forge.base.GitProvider`
is configured.
The provisioning token + org/group live only in server-side config and are
never injected into an agent container. When unconfigured the service reports
``enabled = False`` and ``create_repo`` raises ``ProvisioningDisabledError`` —
so on a default deployment the whole pitch→provision path is inert and nothing
is created until the CEO sets the token. That keeps the capability additive.
@@ -14,12 +22,15 @@ is created until the CEO sets the token. That keeps the capability additive.
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from roboco.config import settings
from roboco.services.forge import RepoRef
from roboco.services.forge.base import RepoRef
from roboco.services.forge.gitea import GiteaProvider
from roboco.services.forge.github import GitHubProvider
from roboco.services.forge.gitlab import GitLabProvider
class ProvisioningError(Exception):
@@ -32,20 +43,48 @@ class ProvisioningDisabledError(ProvisioningError):
@dataclass(frozen=True)
class ProvisionedRepo:
"""The pieces of a freshly-created GitHub repo we need downstream."""
"""The pieces of a freshly-created repo we need downstream."""
full_name: str
clone_url: str
html_url: str
# GitHub's "name already exists on this account" reply on a duplicate repo
# create — the orphaned-repo signal treated idempotently (#83/#84).
_GITHUB_REPO_EXISTS_STATUS = 422
# A duplicate repo/project create is treated idempotently (fetch + reuse
# instead of erroring — #83/#84): GitHub replies 422 "name already exists",
# Gitea 409/422 "already exists", GitLab 400→(reshaped)422 "has already been
# taken". Matched on status + either phrase, case-insensitively.
_ALREADY_EXISTS_STATUSES = frozenset({409, 422})
_ALREADY_EXISTS_PHRASES = ("already exists", "has already been taken")
def _is_already_exists(resp: Any) -> bool:
if resp.status_code not in _ALREADY_EXISTS_STATUSES:
return False
text = (resp.text or "").lower()
return any(phrase in text for phrase in _ALREADY_EXISTS_PHRASES)
def _build_provider(
provider_name: str, *, base_url: str, host: str
) -> GitHubProvider | GitLabProvider | GiteaProvider:
# Union, not the ``GitProvider`` ABC: create_org_repo/get_repo take
# client=/timeout= kwargs the ABC doesn't declare (git.py's own forge
# calls never need them), and every concrete provider here does.
if provider_name == "gitlab":
return GitLabProvider(host)
if provider_name == "gitea":
return GiteaProvider(host)
return GitHubProvider(base_url=base_url)
class GitHubProvisioningService:
"""Create private repos in the configured org via the GitHub REST API."""
"""Create private repos in the configured org/group via the forge's REST API.
Despite the name (kept for backward compatibility), the target forge is
provider-aware: ``ROBOCO_PROVISIONING_PROVIDER`` picks github (default) /
gitlab / gitea, dispatching to the matching ``GitProvider``.
"""
def __init__(
self,
@@ -55,6 +94,8 @@ class GitHubProvisioningService:
base_url: str | None = None,
timeout: float | None = None,
client: httpx.AsyncClient | None = None,
provider_name: str | None = None,
host: str | None = None,
) -> None:
self._token = token if token is not None else settings.provisioning_token
self._org = org if org is not None else settings.provisioning_org
@@ -64,12 +105,29 @@ class GitHubProvisioningService:
)
self._client = client
self._owns_client = client is None
self._provider = GitHubProvider(base_url=self._base_url)
self._provider_name = (
(
provider_name
if provider_name is not None
else settings.provisioning_provider
)
.strip()
.lower()
)
self._host = (host if host is not None else settings.provisioning_host) or ""
self._provider = _build_provider(
self._provider_name, base_url=self._base_url, host=self._host
)
@property
def enabled(self) -> bool:
"""True only when the master switch + token + org are all configured."""
return bool(settings.provisioning_enabled and self._token and self._org)
"""True only when the master switch + token + org are configured
a self-hosted target (gitlab/gitea) additionally needs the instance
host set."""
base_ok = bool(settings.provisioning_enabled and self._token and self._org)
if self._provider_name in ("gitlab", "gitea"):
return base_ok and bool(self._host)
return base_ok
async def _http(self) -> httpx.AsyncClient:
if self._client is None:
@@ -86,19 +144,15 @@ class GitHubProvisioningService:
) -> ProvisionedRepo:
"""Create ``org/name`` (auto-initialised so it is immediately cloneable).
Idempotent by GitHub name: if a prior partially-rolled-back approval left
``org/name`` on GitHub (the DB transaction rolled back but the repo did
not), GitHub replies 422 ``name already exists on this account``. Instead
of erroring and orphaning the re-approval, fetch and return the existing
repo so the caller reuses its ``clone_url`` to (re)register the Project
(#83/#84).
Idempotent by name: if a prior partially-rolled-back approval left
``org/name`` on the forge (the DB transaction rolled back but the repo
did not), the forge replies with an already-exists status (see
:func:`_is_already_exists`). Instead of erroring and orphaning the
re-approval, fetch and return the existing repo so the caller reuses
its ``clone_url`` to (re)register the Project (#83/#84).
"""
if not self.enabled:
msg = (
"GitHub provisioning is not configured. Set "
"ROBOCO_PROVISIONING_TOKEN and ROBOCO_PROVISIONING_ORG."
)
raise ProvisioningDisabledError(msg)
raise ProvisioningDisabledError(self._disabled_message())
client = await self._http()
try:
resp = await self._provider.create_org_repo(
@@ -112,21 +166,15 @@ class GitHubProvisioningService:
timeout=self._timeout,
)
except httpx.HTTPError as exc:
msg = f"GitHub repo creation failed for '{name}': {exc}"
msg = f"Repo creation failed for '{name}': {exc}"
raise ProvisioningError(msg) from exc
if (
resp.status_code == _GITHUB_REPO_EXISTS_STATUS
and "already exists" in (resp.text or "").lower()
):
# The repo is already on GitHub from a rolled-back prior attempt —
# reuse it instead of orphaning the re-approval.
if _is_already_exists(resp):
# The repo is already on the forge from a rolled-back prior
# attempt — reuse it instead of orphaning the re-approval.
return await self._fetch_existing_repo(name)
if not resp.is_success:
detail = resp.text[:200] if resp.text else "no body"
msg = (
f"GitHub repo creation failed for '{name}' "
f"({resp.status_code}): {detail}"
)
msg = f"Repo creation failed for '{name}' ({resp.status_code}): {detail}"
raise ProvisioningError(msg)
body = resp.json()
return ProvisionedRepo(
@@ -135,24 +183,39 @@ class GitHubProvisioningService:
html_url=str(body.get("html_url", "")),
)
def _disabled_message(self) -> str:
base = (
f"{self._provider_name.capitalize()} provisioning is not configured. "
"Set ROBOCO_PROVISIONING_TOKEN and ROBOCO_PROVISIONING_ORG"
)
if self._provider_name in ("gitlab", "gitea"):
return f"{base} and ROBOCO_PROVISIONING_HOST."
return f"{base}."
def _existing_repo_ref(self, name: str) -> RepoRef:
"""The repo's identity for a ``get_repo`` re-fetch. GitLab addresses
a project by its FULL namespace path (``org/name``) packed into
``RepoRef.owner``; GitHub/Gitea use the plain ``owner, repo`` pair."""
if self._provider_name == "gitlab":
return RepoRef(f"{self._org}/{name}", "", host=self._host or None)
return RepoRef(self._org, name, host=self._host or None)
async def _fetch_existing_repo(self, name: str) -> ProvisionedRepo:
"""GET ``org/name`` and rebuild a ProvisionedRepo (idempotent re-create)."""
client = await self._http()
try:
resp = await self._provider.get_repo(
RepoRef(self._org, name),
self._existing_repo_ref(name),
self._token,
client=client,
timeout=self._timeout,
)
except httpx.HTTPError as exc:
msg = f"GitHub repo fetch failed for '{name}': {exc}"
msg = f"Repo fetch failed for '{name}': {exc}"
raise ProvisioningError(msg) from exc
if not resp.is_success:
detail = resp.text[:200] if resp.text else "no body"
msg = (
f"GitHub repo fetch failed for '{name}' ({resp.status_code}): {detail}"
)
msg = f"Repo fetch failed for '{name}' ({resp.status_code}): {detail}"
raise ProvisioningError(msg)
body = resp.json()
return ProvisionedRepo(
@@ -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
+169 -1
View File
@@ -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"]