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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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