feat(forge): Phases 2+2.1+3 — Gitea + GitLab providers, per-call routing, local-merge fallback (#575)

* feat(forge): Phase 2 — Gitea provider, per-call routing, host registry

Gitea support lands behind the Phase-1 seam:

- GiteaProvider (services/forge/gitea.py): Gitea v1 transport addressed
  by instance host (api base from the project's git_url). Where Gitea's
  wire contract diverges from GitHub's, the provider adapts responses
  back into the shapes GitService already classifies (ShapedResponse):
  `token` auth scheme, duplicate-PR 409→422 with the "already exists"
  text GitService keys on, commit statuses reshaped into check_runs /
  workflow_runs envelopes, APPROVE→APPROVED review mapping, Do-keyed
  POST merge, merge-method repo keys, label-color '#' prefix,
  client-side head/base PR filtering. Deliberate postures per the spec:
  zero-workflows fail-open (statuses-free repo → no_ci_configured) and
  merge_branch as a shaped 501 (env-sync cascade lands on missing_ref;
  the shared local-git fallback is Phase-2.1).
- ForgeRouter (services/forge/router.py): GitService._forge now routes
  per call from RepoRef.host — every existing call site unchanged in
  shape. RepoRef gains an optional host; _parse_git_url returns the
  host-stamped ref and it is threaded through GitService/release
  executor instead of being rebuilt from strings (helpers re-signatured
  to take RepoRef).
- Host registry (services/forge/registry.py): in-memory host→provider
  map, self-healing — ProjectService.get/get_by_slug re-register on
  every read; provider_for resolves gitea projects by git_url host.
- Registration validation now accepts git_provider="gitea"; GitLab
  remains recognized-but-rejected. Panel: the read-only Forge badge
  becomes a real picker (Auto-detect / GitHub-GHE / Gitea / GitLab
  disabled).

Plain git (clone/fetch/push) needs no changes — the Basic-auth
extraheader works on Gitea unchanged. Gates: mypy 392 files, xenon A,
full unit suite 6356 green, integration suite 2257 green.

* feat(forge): live-Gitea contract suite + scheme support + slash-safe refs

Hardening from running the provider against a real dockerized Gitea
1.22.6 (the spec's Phase-2 contract suite, now committed as the
env-gated tests/e2e_smoke/test_gitea_live.py — self-seeding: creates its
own repo, pushes real commits, and drives PR open → duplicate reshape →
list/filter → diff → review → labels → commit-status CI reshapes →
squash merge → branch delete → release, plus a live verification of the
x-access-token Basic-auth git-CLI claim).

Two real findings fixed:
- Branch refs weren't URL-encoded — every RoboCo branch carries slashes
  (feature/backend/...), and Gitea's router 404s on the extra path
  segments. list_ci_runs + delete_branch_ref now quote the ref
  (regression-pinned in the unit suite).
- The API base hardcoded https; a LAN instance serving plain http is a
  real deployment shape. GiteaProvider gains a scheme (recorded per host
  by the registry from the project's git_url).

ShapedResponse moves to forge/shaping.py (shared by the upcoming GitLab
transport, which needs its text override for diff reassembly).

* feat(forge): Phase 3 GitLab provider + Phase 2.1 local-merge fallback

GitLabProvider (services/forge/gitlab.py): GitLab v4 transport addressed
by host+scheme, subgroup-safe (the MR project path packs into
RepoRef.owner, URL-encoded per call). Adapters translate MR semantics
into the GitHub shapes GitService classifies: iid→number,
source/target_branch→head/base with a merged bool, per-file diffs
reassembled into unified-diff text (ShapedResponse text override,
3-page cap), approve-vs-note review routing (GitLab has no
request-changes verb), pipelines/statuses reshaped into
workflow_runs/check_runs, merge-method repo-key mapping, duplicate-MR
409→422. Reviewer mirroring is skipped (needs numeric ids RoboCo
doesn't store); provisioning stays Phase 4. gitlab.com now auto-detects
at registration like github.com; self-hosted GitLab sets the provider
explicitly (panel picker enabled).

Phase 2.1: neither Gitea nor GitLab has GitHub's server-side merges
API — their merge_branch returns a shaped 501 and
GitService.sync_env_branch now runs the shared local-git fallback
(_local_merge_branch: throwaway clone → ancestor check → merge → push;
a conflict aborts with the remote untouched; same status vocabulary as
the merges-API path).

Also aligns the whole tree with the full gate's tests/-scoped mypy
(provider-test responder typing, e2e_smoke's stale owner/repo shapes).
Gates: mypy 1229 files clean, xenon A, unit suite 6393 green, forge
suites 85 green, panel typecheck/lint clean.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-19 08:12:34 +02:00
committed by GitHub
co-authored by Renn F
parent d62ae20a87
commit 96401f4c10
30 changed files with 3141 additions and 316 deletions
+4
View File
@@ -186,6 +186,10 @@ Git authentication is managed **per-project** through encrypted GitHub PATs:
**HTTPS URLs require tokens** - attempting to clone without a token will raise `WorkspaceError`.
### Forge providers (GitHub + Gitea + GitLab)
The REST surface (PRs, CI status, reviews, labels, releases) is provider-routed (`roboco/services/forge/`): `GitProvider` is the ~20-method transport contract, `GitHubProvider`, `GiteaProvider`, and `GitLabProvider` implement it, and `GitService._forge` returns a `ForgeRouter` that picks the transport per call from `RepoRef.host``None` (github.com/GHE) rides GitHub, a registered Gitea/GitLab host rides that instance's provider, so `GitService`'s call sites never know which forge they're on. A project opts in via `projects.git_provider` (gitlab.com auto-detects like github.com; self-hosted instances set it explicitly; `"github"` doubles as the GHE escape hatch with `ROBOCO_GITHUB_API_BASE_URL`) — panel: the Forge select in the edit-project dialog. The host→provider(+scheme — plain-http LAN instances are supported) map is in-memory per process, self-healing: `ProjectService.get`/`get_by_slug` re-register on every read. Both non-GitHub providers adapt their wire contracts back into the GitHub shapes `GitService` classifies (`forge/shaping.py` `ShapedResponse`): Gitea — `token` auth scheme, duplicate-PR 409→422, commit statuses reshaped into `check_runs`/`workflow_runs`, `Do`-keyed POST merge, slash-encoded refs; GitLab — MR iid→`number`, source/target_branch→`head`/`base`, per-file diffs reassembled into unified-diff text, approve-vs-note review routing (no request-changes verb exists), pipelines/statuses CI reshapes, reviewer-request skipped (needs numeric ids). Neither has GitHub's server-side merges API: their `merge_branch` returns a shaped 501 and `GitService.sync_env_branch` runs the shared local-git fallback (`_local_merge_branch`: throwaway clone → merge → push; conflict aborts with the remote untouched, same status vocabulary). Plain git (clone/fetch/push) is forge-agnostic — the Basic-auth `x-access-token:<token>` extraheader works on Gitea/GitLab unchanged (verified live on Gitea). The env-gated `tests/e2e_smoke/test_gitea_live.py` is the live contract suite (self-seeding against a dockerized `gitea/gitea`; it caught the slash-encoding and http-scheme gaps).
## Task Lifecycle
### Task States
@@ -30,15 +30,6 @@ import { Team, type ProjectUpdate, type Project } from "@/types";
import { EnvironmentLadderEditor } from "@/components/projects/environment-ladder-editor";
import { validateLadder } from "@/components/projects/ladder-validation";
import { HelpTip } from "@/components/ui/help-tip";
import { Badge } from "@/components/ui/badge";
// A null git_provider means "not yet stamped" (pre-Phase-0 project or a
// non-github.com host awaiting an explicit choice) — RoboCo is GitHub-only
// today either way, so the badge falls back to "GitHub" rather than "Unknown".
function forgeLabel(gitProvider: string | null): string {
if (!gitProvider) return "GitHub";
return gitProvider.charAt(0).toUpperCase() + gitProvider.slice(1);
}
const cells: { value: Team; label: string }[] = [
{ value: Team.BACKEND, label: "Backend" },
@@ -136,6 +127,7 @@ function EditProjectForm({
// Initialize form state from project
const [name, setName] = useState(project.name);
const [gitUrl, setGitUrl] = useState(project.git_url);
const [gitProvider, setGitProvider] = useState(project.git_provider ?? "auto");
const [assignedCell, setAssignedCell] = useState(project.assigned_cell);
const [defaultBranch, setDefaultBranch] = useState(project.default_branch);
const [environments, setEnvironments] = useState(project.environments ?? null);
@@ -219,6 +211,7 @@ function EditProjectForm({
const updates: ProjectUpdate = {
name,
git_url: gitUrl,
git_provider: gitProvider === "auto" ? null : gitProvider,
assigned_cell: assignedCell,
default_branch: defaultBranch || "main",
environments,
@@ -317,17 +310,22 @@ function EditProjectForm({
/>
</div>
{/* Forge (read-only — GitHub-only today) */}
{/* Forge provider */}
<div className="grid gap-2">
<HelpTip label="Auto-detected from the Git URL's host; RoboCo's PR/CI/review surface is GitHub-only today. GitLab & Gitea support planned.">
<HelpTip label="Which forge API serves PR/CI/review operations. Auto-detect covers github.com; a self-hosted Gitea instance (or GitHub Enterprise) must be set explicitly — the host comes from the Git URL. GitLab support is planned.">
<Label>Forge</Label>
</HelpTip>
<div>
<Badge variant="secondary">{forgeLabel(project.git_provider)}</Badge>
</div>
<p className="text-xs text-muted-foreground">
GitLab & Gitea support planned.
</p>
<Select value={gitProvider} onValueChange={setGitProvider}>
<SelectTrigger>
<SelectValue placeholder="Auto-detect" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto-detect (github.com)</SelectItem>
<SelectItem value="github">GitHub / GitHub Enterprise</SelectItem>
<SelectItem value="gitea">Gitea (self-hosted)</SelectItem>
<SelectItem value="gitlab">GitLab (gitlab.com / self-hosted)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Git Token Section */}
+17 -18
View File
@@ -24,10 +24,11 @@ KNOWN_PROVIDERS: tuple[str, ...] = ("github", "gitlab", "gitea")
_SCP_HOST_RE = re.compile(r"^(?:[^@/]+@)?(?P<host>[^/:]+):")
def _extract_host(git_url: str) -> str | None:
def extract_host(git_url: str) -> str | None:
"""Pull the host out of an https, ssh://, or scp-like git URL.
Returns None when no host can be found (unparseable input).
Returns None when no host can be found (unparseable input). Public
the forge registry keys its hostprovider routing map on this.
"""
url = git_url.strip()
if not url:
@@ -50,7 +51,7 @@ def detect_provider(git_url: str) -> str | None:
those apart. A project on a non-SaaS host must set ``git_provider``
explicitly (a one-click choice in the panel's project dialog).
"""
host = _extract_host(git_url)
host = extract_host(git_url)
if host == "github.com":
return "github"
if host == "gitlab.com":
@@ -67,13 +68,14 @@ def validate_project_forge(git_url: str | None, git_provider: str | None) -> str
- an unknown ``git_provider`` string -> error naming ``KNOWN_PROVIDERS``.
- explicit ``git_provider="github"`` -> OK regardless of host (the GitHub
Enterprise escape hatch current behavior preserved).
- explicit ``git_provider`` of "gitlab"/"gitea" -> error: recognized but
not yet supported.
- explicit ``git_provider="gitea"`` -> OK (Phase 2: the Gitea transport
is live; the host comes from the git_url).
- explicit ``git_provider="gitlab"`` -> error: recognized but not yet
supported.
- no explicit ``git_provider``, host detects to "github" -> OK.
- no explicit ``git_provider``, anything else (unknown host, or a detected
but unsupported host like gitlab.com) -> error steering the operator to
either a GitHub host or the explicit ``git_provider="github"`` escape
hatch for GHE.
- no explicit ``git_provider``, anything else (unknown host, or a
detected but unsupported host like gitlab.com) -> error steering the
operator to an explicit provider choice.
"""
if not git_url:
return None
@@ -84,17 +86,14 @@ def validate_project_forge(git_url: str | None, git_provider: str | None) -> str
f"Unknown git_provider {git_provider!r}; must be one of "
f"{', '.join(KNOWN_PROVIDERS)}."
)
if git_provider == "github":
if git_provider in ("github", "gitea", "gitlab"):
return None
return (
f"git_provider={git_provider!r} is recognized but not yet "
"supported — RoboCo is GitHub-only today; GitLab/Gitea support "
"is planned."
)
return f"git_provider={git_provider!r} is recognized but not yet supported."
if detect_provider(git_url) == "github":
if detect_provider(git_url) in ("github", "gitlab"):
return None
return (
"RoboCo currently supports GitHub-hosted repos only. If this is a "
'GitHub Enterprise host, set git_provider="github" explicitly.'
"RoboCo supports GitHub- and GitLab-hosted repos by default. For a "
'self-hosted forge set git_provider explicitly ("gitea", "gitlab", '
'or "github" for GitHub Enterprise).'
)
+16 -5
View File
@@ -1,4 +1,4 @@
"""Git-forge provider seam (Phase 1 of the forge-providers spec).
"""Git-forge provider seam (Phases 1-2 of the forge-providers spec).
``roboco/services/git.py`` used to interleave local-git subprocess work with
inline ``httpx`` calls against GitHub's REST API. This package pulls the REST
@@ -6,8 +6,9 @@ transport out from under it behind a provider-agnostic contract
(:mod:`roboco.services.forge.base`) so a future GitLab/Gitea adapter is a new
module here, not a rewrite of ``GitService``.
``base`` is pure contracts, ``github`` is the GitHub REST transport, and
``registry`` resolves a project onto its provider. Nothing here imports
``base`` is pure contracts, ``github``/``gitea`` are REST transports,
``router`` routes per call from ``RepoRef.host``, and ``registry`` resolves
a project onto its provider and keeps the hostprovider map. Nothing here imports
``roboco.services.*`` ``GitService`` depends on this package, never the
reverse.
"""
@@ -15,7 +16,17 @@ reverse.
from __future__ import annotations
from roboco.services.forge.base import GitProvider, RepoRef
from roboco.services.forge.gitea import GiteaProvider
from roboco.services.forge.github import GitHubProvider
from roboco.services.forge.registry import provider_for
from roboco.services.forge.registry import provider_for, register_project_forge
from roboco.services.forge.router import ForgeRouter
__all__ = ["GitHubProvider", "GitProvider", "RepoRef", "provider_for"]
__all__ = [
"ForgeRouter",
"GitHubProvider",
"GitProvider",
"GiteaProvider",
"RepoRef",
"provider_for",
"register_project_forge",
]
+5
View File
@@ -35,10 +35,15 @@ class RepoRef:
every other method only ever receives a ``RepoRef`` back from
``parse_repo_ref``/construction, never assumes its internal shape beyond
what THIS provider put there.
``host`` carries the forge host for self-hosted providers (a Gitea
instance's API base derives from it); ``None`` means "the default GitHub
host" so every existing two-arg construction site keeps its meaning.
"""
owner: str
repo: str
host: str | None = None
class GitProvider(ABC):
+471
View File
@@ -0,0 +1,471 @@
"""Gitea REST transport — Phase 2 of the forge-providers spec.
Gitea's API is deliberately GitHub-shaped, so most methods are the same
paths against ``https://{host}/api/v1`` with ``Authorization: token`` (the
scheme Gitea's classic PATs require; Bearer is rejected). Where Gitea's wire
contract diverges, this provider ADAPTS the response back into the GitHub
shape ``GitService`` classifies (see the per-method notes) rather than
teaching ``GitService`` a second dialect the seam's contract is that
callers keep reading ``.status_code`` / ``.is_success`` / ``.text`` /
``.json()`` exactly as they do for GitHub. Adapted responses are wrapped in
:class:`ShapedResponse`.
Deliberate Phase-2 postures (per the spec):
- CI is classified from Gitea's commit-status API reshaped into GitHub's
``check_runs`` / ``workflow_runs`` envelopes; ``list_workflows`` always
reports zero so a statuses-free repo classifies as ``no_ci_configured``
(fail-open, the posture the GitHub path takes for unreachable repos).
- ``merge_branch`` (the env-sync cascade's server-side merge) has no Gitea
equivalent; it returns a shaped 501 so ``_env_merge_status`` lands on its
existing ``missing_ref`` branch. The shared local-git fallback is a
follow-up, not silently faked here.
- Plain git (clone/fetch/push) needs no provider work: Gitea, like GitHub
and GitLab, accepts a PAT as the Basic-auth password with the username
ignored, so the existing ``x-access-token:<token>`` extraheader works.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import quote
import httpx
from roboco.config import settings
from roboco.exceptions import GitError
from roboco.services.forge.base import GitProvider, RepoRef
from roboco.services.forge.shaping import ShapedResponse
if TYPE_CHECKING:
from collections.abc import Mapping
# Gitea review-event vocabulary differs from GitHub's by one word.
_REVIEW_EVENT_MAP = {"APPROVE": "APPROVED"}
# Gitea commit-status states → GitHub check-run/workflow-run vocabulary.
_STATUS_CONCLUSION = {"success": "success", "failure": "failure", "error": "failure"}
def _default_timeout() -> int:
return settings.git_command_timeout_seconds
class GiteaProvider(GitProvider):
"""Self-hosted Gitea transport, addressed by instance host.
``scheme`` comes from the project's git_url via the registry — a LAN
instance serving plain http (no TLS terminator) is a real deployment
shape, not just a test convenience.
"""
def __init__(self, host: str, scheme: str = "https") -> None:
self._host = host.strip().rstrip("/")
self._scheme = scheme
self._repo_url_re = re.compile(
re.escape(self._host)
+ r"[:/]+(?P<owner>[^/]+)/(?P<repo>[^/\s]+?)(?:\.git)?$"
)
def _api_base(self) -> str:
return f"{self._scheme}://{self._host}/api/v1"
def _repo_url(self, repo: RepoRef, *segments: str) -> str:
base = f"{self._api_base()}/repos/{repo.owner}/{repo.repo}"
return "/".join([base, *segments]) if segments else base
@staticmethod
def _headers(token: str) -> dict[str, str]:
# Gitea classic PATs require the `token` scheme; Bearer is rejected.
return {"Authorization": f"token {token}", "Accept": "application/json"}
async def _send(
self,
method: str,
url: str,
*,
headers: Mapping[str, str],
json_body: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
client: httpx.AsyncClient | None = None,
timeout: float | None = None,
) -> httpx.Response:
kwargs: dict[str, Any] = {"headers": headers}
if json_body is not None:
kwargs["json"] = json_body
if params is not None:
kwargs["params"] = params
if client is not None:
if timeout is not None:
kwargs["timeout"] = timeout
return cast("httpx.Response", await getattr(client, method)(url, **kwargs))
owned_timeout = timeout if timeout is not None else _default_timeout()
async with httpx.AsyncClient(timeout=owned_timeout) as owned_client:
return cast(
"httpx.Response", await getattr(owned_client, method)(url, **kwargs)
)
# -- identity ----------------------------------------------------------
def parse_repo_ref(self, git_url: str) -> RepoRef:
match = self._repo_url_re.search(git_url)
if not match:
raise GitError(
"Could not parse Gitea owner/repo from remote URL",
{"host": self._host},
)
return RepoRef(match.group("owner"), match.group("repo"), host=self._host)
# -- pull requests -----------------------------------------------------
async def list_pulls(
self,
repo: RepoRef,
token: str,
*,
head: str | None = None,
base: str | None = None,
state: str = "open",
per_page: int | None = None,
include_api_version: bool = True,
timeout: float | None = None,
) -> Any:
"""Gitea's list endpoint has no head/base filters — fetch and filter
client-side, reshaping each element with the one field GitHub has
and Gitea lacks (``author_association``)."""
_ = include_api_version
params: dict[str, Any] = {"state": state, "limit": per_page or 50}
resp = await self._send(
"get",
self._repo_url(repo, "pulls"),
headers=self._headers(token),
params=params,
timeout=timeout,
)
if not resp.is_success:
return resp
pulls = resp.json()
if not isinstance(pulls, list):
return resp
selected = [
self._shape_pull(pr)
for pr in pulls
if self._pull_matches(pr, head=head, base=base)
]
return ShapedResponse(resp, json_payload=selected)
@staticmethod
def _pull_matches(
pr: dict[str, Any], *, head: str | None, base: str | None
) -> bool:
if head is not None and ((pr.get("head") or {}).get("ref")) != head:
return False
return not (base is not None and ((pr.get("base") or {}).get("ref")) != base)
@staticmethod
def _shape_pull(pr: dict[str, Any]) -> dict[str, Any]:
shaped = dict(pr)
shaped.setdefault("author_association", "NONE")
return shaped
async def get_pr(
self,
repo: RepoRef,
token: str,
pr_number: int,
*,
include_api_version: bool = True,
timeout: float | None = None,
) -> Any:
_ = include_api_version
return await self._send(
"get",
self._repo_url(repo, "pulls", str(pr_number)),
headers=self._headers(token),
timeout=timeout,
)
async def get_pr_diff(self, repo: RepoRef, token: str, pr_number: int) -> Any:
return await self._send(
"get",
self._repo_url(repo, "pulls", f"{pr_number}.diff"),
headers=self._headers(token),
)
async def create_pr(
self, repo: RepoRef, token: str, *, head: str, base: str, title: str, body: str
) -> Any:
"""Gitea signals the duplicate-PR case as 409 where GitHub uses 422;
reshape that one case so GitService's idempotency branch
(`422 and "already exists" in text`) keeps working."""
resp = await self._send(
"post",
self._repo_url(repo, "pulls"),
headers=self._headers(token),
json_body={"title": title, "body": body, "head": head, "base": base},
)
if resp.status_code == httpx.codes.CONFLICT and "already exists" in resp.text:
return ShapedResponse(resp, status_code=httpx.codes.UNPROCESSABLE_ENTITY)
return resp
async def update_pr(
self, repo: RepoRef, token: str, pr_number: int, *, payload: dict[str, Any]
) -> Any:
return await self._send(
"patch",
self._repo_url(repo, "pulls", str(pr_number)),
headers=self._headers(token),
json_body=payload,
)
async def merge_pr(
self, repo: RepoRef, token: str, pr_number: int, *, merge_method: str
) -> Any:
# Gitea: POST (not PUT) with the method under "Do".
return await self._send(
"post",
self._repo_url(repo, "pulls", str(pr_number), "merge"),
headers=self._headers(token),
json_body={"Do": merge_method},
)
async def request_reviewers(
self, repo: RepoRef, token: str, pr_number: int, reviewers: list[str]
) -> Any:
return await self._send(
"post",
self._repo_url(repo, "pulls", str(pr_number), "requested_reviewers"),
headers=self._headers(token),
json_body={"reviewers": reviewers},
)
async def post_review(
self, repo: RepoRef, token: str, pr_number: int, *, body: str, event: str
) -> Any:
return await self._send(
"post",
self._repo_url(repo, "pulls", str(pr_number), "reviews"),
headers=self._headers(token),
json_body={"body": body, "event": _REVIEW_EVENT_MAP.get(event, event)},
)
async def merge_branch(
self, repo: RepoRef, token: str, *, base: str, head: str, commit_message: str
) -> Any:
"""No server-side merges API on Gitea — a shaped 501 lands
``_env_merge_status`` on its existing ``missing_ref`` branch."""
_ = (repo, token, base, head, commit_message)
request = httpx.Request("post", f"https://{self._host}/unsupported")
real = httpx.Response(
httpx.codes.NOT_IMPLEMENTED,
request=request,
json={"message": "Gitea has no server-side branch-merge API"},
)
return ShapedResponse(real)
# -- CI ----------------------------------------------------------------
async def list_ci_runs(
self,
repo: RepoRef,
token: str,
*,
workflow: str | None,
branch: str,
head_sha: str | None,
per_page: int,
) -> Any:
"""Gitea has no workflow-runs listing with GitHub's shape; the
combined commit status for the branch head is the signal. A settled
combined state becomes one synthetic ``workflow_runs`` entry (the
consumer picks conclusion/head_sha off it); pending or empty yields
no completed runs, exactly like GitHub's ``status=completed``
filter."""
_ = (workflow, head_sha, per_page)
resp = await self._send(
"get",
# Branch names carry slashes (feature/backend/...) — encode or
# Gitea's router 404s on the extra path segments.
self._repo_url(repo, "commits", quote(branch, safe=""), "status"),
headers=self._headers(token),
)
if not resp.is_success:
return resp
data = resp.json() if isinstance(resp.json(), dict) else {}
conclusion = _STATUS_CONCLUSION.get(str(data.get("state") or "").lower())
runs: list[dict[str, Any]] = []
if conclusion is not None:
runs.append(
{
"head_sha": data.get("sha") or "",
"run_attempt": 1,
"conclusion": conclusion,
"name": "combined-status",
"html_url": data.get("url") or "",
"updated_at": "",
}
)
return ShapedResponse(resp, json_payload={"workflow_runs": runs})
async def list_check_runs(
self, repo: RepoRef, token: str, head_sha: str, *, per_page: int
) -> Any:
"""Commit statuses reshaped into GitHub's ``check_runs`` envelope —
the per-name latest-wins dedup upstream keys on ``id``, which Gitea
statuses already increment."""
resp = await self._send(
"get",
self._repo_url(repo, "commits", head_sha, "statuses"),
headers=self._headers(token),
params={"limit": per_page},
)
if not resp.is_success:
return resp
statuses = resp.json()
if not isinstance(statuses, list):
return resp
check_runs = [self._shape_status(status) for status in statuses]
return ShapedResponse(resp, json_payload={"check_runs": check_runs})
@staticmethod
def _shape_status(status: dict[str, Any]) -> dict[str, Any]:
state = str(status.get("status") or "").lower()
settled = state in _STATUS_CONCLUSION
return {
"id": status.get("id") or 0,
"name": status.get("context") or "status",
"status": "completed" if settled else "in_progress",
"conclusion": _STATUS_CONCLUSION.get(state),
}
async def list_workflows(self, repo: RepoRef, token: str, *, per_page: int) -> Any:
"""Fail-open: no cheap "is CI configured at all" probe exists on
Gitea, so zero-check-runs classifies as ``no_ci_configured`` (the
spec's chosen posture) rather than ``pending_not_scheduled``."""
_ = (repo, token, per_page)
request = httpx.Request("get", f"https://{self._host}/synthetic")
real = httpx.Response(httpx.codes.OK, request=request, json={"total_count": 0})
return ShapedResponse(real)
# -- repo / labels / branches / releases -------------------------------
async def get_repo(
self,
repo: RepoRef,
token: str,
*,
client: httpx.AsyncClient | None = None,
timeout: float | None = None,
) -> Any:
"""Gitea names the merge-method toggles differently — reshape onto
GitHub's ``allow_*`` keys the merge-method fallback reads."""
resp = await self._send(
"get",
self._repo_url(repo),
headers=self._headers(token),
client=client,
timeout=timeout,
)
if not resp.is_success:
return resp
data = resp.json()
if not isinstance(data, dict):
return resp
shaped = dict(data)
shaped["allow_merge_commit"] = data.get("allow_merge_commits", True)
shaped["allow_rebase_merge"] = data.get("allow_rebase", True)
shaped.setdefault("allow_squash_merge", True)
return ShapedResponse(resp, json_payload=shaped)
async def ensure_label(
self, repo: RepoRef, token: str, name: str, color: str
) -> Any:
# Gitea wants the leading '#' on label colors; GitHub omits it.
hex_color = color if color.startswith("#") else f"#{color}"
return await self._send(
"post",
self._repo_url(repo, "labels"),
headers=self._headers(token),
json_body={"name": name, "color": hex_color},
)
async def add_labels(
self, repo: RepoRef, token: str, pr_number: int, labels: list[str]
) -> Any:
return await self._send(
"post",
self._repo_url(repo, "issues", str(pr_number), "labels"),
headers=self._headers(token),
json_body={"labels": labels},
)
async def delete_branch_ref(
self, repo: RepoRef, token: str, branch: str, *, timeout: float | None = None
) -> Any:
return await self._send(
"delete",
self._repo_url(repo, "branches", quote(branch, safe="")),
headers=self._headers(token),
timeout=timeout,
)
async def create_issue_comment(
self, repo: RepoRef, token: str, issue_number: int, body: str
) -> Any:
return await self._send(
"post",
self._repo_url(repo, "issues", str(issue_number), "comments"),
headers=self._headers(token),
json_body={"body": body},
)
async def create_release(
self,
repo: RepoRef,
token: str,
*,
tag_name: str,
name: str,
body: str,
target_commitish: str,
timeout: float | None = None,
) -> Any:
return await self._send(
"post",
self._repo_url(repo, "releases"),
headers=self._headers(token),
json_body={
"tag_name": tag_name,
"name": name,
"body": body,
"target_commitish": target_commitish,
},
timeout=timeout,
)
async def create_org_repo(
self,
token: str,
org: str,
*,
name: str,
description: str,
private: bool,
auto_init: bool,
client: httpx.AsyncClient | None = None,
timeout: float | None = None,
) -> Any:
return await self._send(
"post",
f"{self._api_base()}/orgs/{org}/repos",
headers=self._headers(token),
json_body={
"name": name,
"description": description,
"private": private,
"auto_init": auto_init,
},
client=client,
timeout=timeout,
)
+595
View File
@@ -0,0 +1,595 @@
"""GitLab REST v4 transport — Phase 3 of the forge-providers spec.
GitLab's API is the most semantically divergent of the three forges: pull
requests are "merge requests" addressed by a per-project ``iid``, review has
no request-changes verb, CI is pipelines/statuses rather than
workflows/check-runs, and a repo is addressed by its full (URL-encoded)
namespace path rather than an ``owner/repo`` pair subgroups make that path
arbitrarily deep, which is exactly what :class:`~roboco.services.forge.base.RepoRef`
was built to shrug off (the whole path packs into ``owner``; ``repo`` is
unused). Every method below ADAPTS the GitLab wire shape back into the GitHub
shape ``GitService`` classifies (see the per-method notes), wrapped in
:class:`ShapedResponse` callers keep reading ``.status_code`` /
``.is_success`` / ``.text`` / ``.json()`` exactly as they do for GitHub.
Deliberate Phase-3 postures (per the spec):
- ``merge_branch`` (the env-sync cascade's server-side merge) has no GitLab
equivalent; it returns a shaped 501 so ``_env_merge_status`` lands on its
existing ``missing_ref`` branch, same as Gitea.
- ``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.
- 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.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import quote
import httpx
from roboco.config import settings
from roboco.exceptions import GitError
from roboco.services.forge.base import GitProvider, RepoRef
from roboco.services.forge.shaping import ShapedResponse
if TYPE_CHECKING:
from collections.abc import Mapping
# GitLab MR list `state` param: "open" is GitHub/RoboCo's vocabulary, GitLab
# calls it "opened". Every other value (closed/merged/locked/all) passes
# through unchanged.
_STATE_PARAM_MAP = {"open": "opened"}
# GitLab pipeline/commit-status states → GitHub check-run/workflow-run
# conclusion vocabulary. Unsettled states (running/pending/created) are
# absent on purpose — they classify as "not yet concluded" by omission.
_CONCLUSION_MAP = {"success": "success", "failed": "failure", "canceled": "failure"}
_IN_PROGRESS_STATUSES = frozenset({"running", "pending", "created"})
# get_pr_diff pagination cap — GitLab's /diffs endpoint is JSON-per-file
# with no raw-unified-diff media type, so a large MR needs multiple pages
# reassembled; bounded so one pathological MR can't loop forever.
_DIFF_MAX_PAGES = 3
_DIFF_PAGE_SIZE = 100
# A GitLab project path is at least "namespace/project" — one bare segment
# can't be a valid remote (no owner-less repos on GitLab).
_MIN_PATH_SEGMENTS = 2
def _default_timeout() -> int:
return settings.git_command_timeout_seconds
class GitLabProvider(GitProvider):
"""Self-hosted or gitlab.com REST v4 transport, addressed by instance host."""
def __init__(self, host: str, scheme: str = "https") -> None:
self._host = host.strip().rstrip("/")
self._scheme = scheme
self._repo_url_re = re.compile(
re.escape(self._host) + r"[:/]+(?P<path>[^\s]+?)(?:\.git)?$"
)
def _project_base(self, repo: RepoRef) -> str:
# RepoRef.owner carries the FULL namespace path (subgroups included);
# GitLab addresses a project by that path (or numeric id) URL-encoded
# as a single path segment.
encoded = quote(repo.owner, safe="")
return f"{self._scheme}://{self._host}/api/v4/projects/{encoded}"
def _url(self, repo: RepoRef, *segments: str) -> str:
base = self._project_base(repo)
return "/".join([base, *segments]) if segments else base
@staticmethod
def _headers(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}", "Accept": "application/json"}
async def _send(
self,
method: str,
url: str,
*,
headers: Mapping[str, str],
json_body: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
client: httpx.AsyncClient | None = None,
timeout: float | None = None,
) -> httpx.Response:
kwargs: dict[str, Any] = {"headers": headers}
if json_body is not None:
kwargs["json"] = json_body
if params is not None:
kwargs["params"] = params
if client is not None:
if timeout is not None:
kwargs["timeout"] = timeout
return cast("httpx.Response", await getattr(client, method)(url, **kwargs))
owned_timeout = timeout if timeout is not None else _default_timeout()
async with httpx.AsyncClient(timeout=owned_timeout) as owned_client:
return cast(
"httpx.Response", await getattr(owned_client, method)(url, **kwargs)
)
# -- identity ------------------------------------------------------------
def parse_repo_ref(self, git_url: str) -> RepoRef:
"""Pack the FULL namespace path (subgroups allowed, 2+ segments)
into ``RepoRef.owner``; ``repo`` stays empty since GitLab addresses
a project by that whole path, not an owner/repo pair."""
match = self._repo_url_re.search(git_url)
if not match:
raise GitError(
"Could not parse GitLab project path from remote URL",
{"host": self._host},
)
segments = [s for s in match.group("path").strip("/").split("/") if s]
if len(segments) < _MIN_PATH_SEGMENTS:
raise GitError(
"GitLab remote URL is missing a namespace/project path",
{"host": self._host, "path": match.group("path")},
)
return RepoRef("/".join(segments), "", host=self._host)
# -- pull requests (merge requests) --------------------------------------
@staticmethod
def _shape_pull(mr: dict[str, Any], repo: RepoRef) -> dict[str, Any]:
gitlab_state = mr.get("state")
author = mr.get("author") or {}
return {
**mr,
"number": mr.get("iid"),
"html_url": mr.get("web_url") or "",
"title": mr.get("title") or "",
"state": "open" if gitlab_state == "opened" else "closed",
"merged": gitlab_state == "merged",
"head": {
"ref": mr.get("source_branch") or "",
"sha": mr.get("sha") or "",
"repo": {"full_name": repo.owner},
},
"base": {"ref": mr.get("target_branch") or ""},
"user": {"login": author.get("username") or ""},
"author_association": "NONE",
}
async def list_pulls(
self,
repo: RepoRef,
token: str,
*,
head: str | None = None,
base: str | None = None,
state: str = "open",
per_page: int | None = None,
include_api_version: bool = True,
timeout: float | None = None,
) -> Any:
"""GitLab natively filters by source_branch/target_branch — no
client-side filtering needed (unlike Gitea)."""
_ = include_api_version
params: dict[str, Any] = {"state": _STATE_PARAM_MAP.get(state, state)}
if head is not None:
params["source_branch"] = head
if base is not None:
params["target_branch"] = base
if per_page is not None:
params["per_page"] = per_page
resp = await self._send(
"get",
self._url(repo, "merge_requests"),
headers=self._headers(token),
params=params,
timeout=timeout,
)
if not resp.is_success:
return resp
mrs = resp.json()
if not isinstance(mrs, list):
return resp
shaped = [self._shape_pull(mr, repo) for mr in mrs]
return ShapedResponse(resp, json_payload=shaped)
async def get_pr(
self,
repo: RepoRef,
token: str,
pr_number: int,
*,
include_api_version: bool = True,
timeout: float | None = None,
) -> Any:
_ = include_api_version
resp = await self._send(
"get",
self._url(repo, "merge_requests", str(pr_number)),
headers=self._headers(token),
timeout=timeout,
)
if not resp.is_success:
return resp
data = resp.json()
if not isinstance(data, dict):
return resp
return ShapedResponse(resp, json_payload=self._shape_pull(data, repo))
async def get_pr_diff(self, repo: RepoRef, token: str, pr_number: int) -> Any:
"""GitLab has no raw-unified-diff media type — ``/diffs`` returns one
JSON object per changed file; reassemble a unified diff from up to
3 pages of 100."""
url = self._url(repo, "merge_requests", str(pr_number), "diffs")
headers = self._headers(token)
diffs: list[dict[str, Any]] = []
resp = await self._send(
"get", url, headers=headers, params={"page": 1, "per_page": _DIFF_PAGE_SIZE}
)
if not resp.is_success:
return resp
page = resp.json()
if isinstance(page, list):
diffs.extend(page)
for page_number in range(2, _DIFF_MAX_PAGES + 1):
if not isinstance(page, list) or len(page) < _DIFF_PAGE_SIZE:
break
resp = await self._send(
"get",
url,
headers=headers,
params={"page": page_number, "per_page": _DIFF_PAGE_SIZE},
)
if not resp.is_success:
return resp
page = resp.json()
if isinstance(page, list):
diffs.extend(page)
text = "".join(
f"diff --git a/{item.get('old_path', '')} b/{item.get('new_path', '')}\n"
f"{item.get('diff', '')}"
for item in diffs
)
return ShapedResponse(resp, text=text)
async def create_pr(
self, repo: RepoRef, token: str, *, head: str, base: str, title: str, body: str
) -> Any:
"""GitLab signals the duplicate-MR case as 409 where GitHub uses
422; reshape that one case so GitService's idempotency branch
(`422 and "already exists" in text`) keeps working."""
resp = await self._send(
"post",
self._url(repo, "merge_requests"),
headers=self._headers(token),
json_body={
"source_branch": head,
"target_branch": base,
"title": title,
"description": body,
},
)
if resp.status_code == httpx.codes.CONFLICT and "already exists" 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
return ShapedResponse(resp, json_payload=self._shape_pull(data, repo))
async def update_pr(
self, repo: RepoRef, token: str, pr_number: int, *, payload: dict[str, Any]
) -> httpx.Response:
"""Translate GitHub's PATCH vocabulary onto GitLab's PUT one:
``body````description``, ``state=closed````state_event=close``."""
translated: dict[str, Any] = {}
if "title" in payload:
translated["title"] = payload["title"]
if "body" in payload:
translated["description"] = payload["body"]
if payload.get("state") == "closed":
translated["state_event"] = "close"
return await self._send(
"put",
self._url(repo, "merge_requests", str(pr_number)),
headers=self._headers(token),
json_body=translated,
)
async def merge_pr(
self, repo: RepoRef, token: str, pr_number: int, *, merge_method: str
) -> httpx.Response:
return await self._send(
"put",
self._url(repo, "merge_requests", str(pr_number), "merge"),
headers=self._headers(token),
json_body={"squash": merge_method == "squash"},
)
async def request_reviewers(
self, repo: RepoRef, token: str, pr_number: int, reviewers: list[str]
) -> Any:
"""GitLab reviewer assignment needs numeric user ids
(``reviewer_ids``) RoboCo only ever stores usernames/slugs, and
resolving those is out of scope for Phase 3 (spec's open items).
Skip with a synthetic success rather than failing the PR-open flow
over a best-effort reviewer mirror."""
_ = (repo, token, pr_number, reviewers)
request = httpx.Request("put", f"{self._scheme}://{self._host}/synthetic")
real = httpx.Response(
httpx.codes.OK,
request=request,
json={"skipped": "gitlab reviewer mirroring needs numeric ids"},
)
return ShapedResponse(real)
async def post_review(
self, repo: RepoRef, token: str, pr_number: int, *, body: str, event: str
) -> httpx.Response:
"""GitLab has no request-changes verb: APPROVE hits the dedicated
approve endpoint, anything else (REQUEST_CHANGES/COMMENT) becomes a
plain note carrying the verdict in its body."""
if event == "APPROVE":
return await self._send(
"post",
self._url(repo, "merge_requests", str(pr_number), "approve"),
headers=self._headers(token),
)
return await self._send(
"post",
self._url(repo, "merge_requests", str(pr_number), "notes"),
headers=self._headers(token),
json_body={"body": body},
)
async def merge_branch(
self, repo: RepoRef, token: str, *, base: str, head: str, commit_message: str
) -> Any:
"""No server-side merges API on GitLab — a shaped 501 lands
``_env_merge_status`` on its existing ``missing_ref`` branch (the
shared local-git fallback lives in GitService)."""
_ = (repo, token, base, head, commit_message)
request = httpx.Request("post", f"{self._scheme}://{self._host}/unsupported")
real = httpx.Response(
httpx.codes.NOT_IMPLEMENTED,
request=request,
json={"message": "GitLab has no server-side branch-merge API"},
)
return ShapedResponse(real)
# -- CI --------------------------------------------------------------------
async def list_ci_runs(
self,
repo: RepoRef,
token: str,
*,
workflow: str | None,
branch: str,
head_sha: str | None,
per_page: int,
) -> Any:
"""GitLab pipelines are the CI signal (no per-workflow-run listing
like GitHub Actions); pipelines sort newest-first by default, so the
first SETTLED entry becomes the one ``workflow_runs`` entry."""
_ = (workflow, head_sha)
resp = await self._send(
"get",
self._url(repo, "pipelines"),
headers=self._headers(token),
params={"ref": branch, "per_page": per_page},
)
if not resp.is_success:
return resp
pipelines = resp.json()
if not isinstance(pipelines, list):
return resp
runs: list[dict[str, Any]] = []
for pipeline in pipelines:
conclusion = _CONCLUSION_MAP.get(str(pipeline.get("status") or "").lower())
if conclusion is None:
continue
runs.append(
{
"head_sha": pipeline.get("sha") or "",
"run_attempt": 1,
"conclusion": conclusion,
"name": "pipeline",
"html_url": pipeline.get("web_url") or "",
"updated_at": pipeline.get("updated_at") or "",
}
)
break
return ShapedResponse(resp, json_payload={"workflow_runs": runs})
async def list_check_runs(
self, repo: RepoRef, token: str, head_sha: str, *, per_page: int
) -> Any:
"""Commit statuses reshaped into GitHub's ``check_runs`` envelope."""
resp = await self._send(
"get",
self._url(repo, "repository", "commits", head_sha, "statuses"),
headers=self._headers(token),
params={"per_page": per_page},
)
if not resp.is_success:
return resp
statuses = resp.json()
if not isinstance(statuses, list):
return resp
check_runs = [self._shape_status(status) for status in statuses]
return ShapedResponse(resp, json_payload={"check_runs": check_runs})
@staticmethod
def _shape_status(status: dict[str, Any]) -> dict[str, Any]:
state = str(status.get("status") or "").lower()
return {
"id": status.get("id") or 0,
"name": status.get("name") or "status",
"status": "in_progress" if state in _IN_PROGRESS_STATUSES else "completed",
"conclusion": _CONCLUSION_MAP.get(state),
}
async def list_workflows(self, repo: RepoRef, token: str, *, per_page: int) -> Any:
"""Any pipeline at all — even zero check-runs — means CI IS
configured, so a pipelines-configured repo classifies
``pending_not_scheduled`` rather than ``no_ci_configured``."""
_ = per_page
resp = await self._send(
"get",
self._url(repo, "pipelines"),
headers=self._headers(token),
params={"per_page": 1},
)
if not resp.is_success:
return resp
pipelines = resp.json()
total = 1 if isinstance(pipelines, list) and pipelines else 0
return ShapedResponse(resp, json_payload={"total_count": total})
# -- repo / labels / branches / releases -----------------------------------
async def get_repo(
self,
repo: RepoRef,
token: str,
*,
client: httpx.AsyncClient | None = None,
timeout: float | None = None,
) -> Any:
"""GitLab names merge-method settings differently — reshape onto
GitHub's ``allow_*`` keys the merge-method fallback reads."""
resp = await self._send(
"get",
self._url(repo),
headers=self._headers(token),
client=client,
timeout=timeout,
)
if not resp.is_success:
return resp
data = resp.json()
if not isinstance(data, dict):
return resp
squash_option = data.get("squash_option")
merge_method = data.get("merge_method")
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 ""
shaped["allow_squash_merge"] = (
squash_option != "never" if squash_option else True
)
shaped["allow_merge_commit"] = merge_method in (None, "merge")
shaped["allow_rebase_merge"] = merge_method in ("rebase_merge", "ff")
return ShapedResponse(resp, json_payload=shaped)
async def ensure_label(
self, repo: RepoRef, token: str, name: str, color: str
) -> httpx.Response:
# GitLab wants the leading '#' on label colors; GitHub omits it.
hex_color = color if color.startswith("#") else f"#{color}"
return await self._send(
"post",
self._url(repo, "labels"),
headers=self._headers(token),
json_body={"name": name, "color": hex_color},
)
async def add_labels(
self, repo: RepoRef, token: str, pr_number: int, labels: list[str]
) -> httpx.Response:
return await self._send(
"put",
self._url(repo, "merge_requests", str(pr_number)),
headers=self._headers(token),
json_body={"add_labels": ",".join(labels)},
)
async def delete_branch_ref(
self, repo: RepoRef, token: str, branch: str, *, timeout: float | None = None
) -> httpx.Response:
return await self._send(
"delete",
self._url(repo, "repository", "branches", quote(branch, safe="")),
headers=self._headers(token),
timeout=timeout,
)
async def create_issue_comment(
self, repo: RepoRef, token: str, issue_number: int, body: str
) -> httpx.Response:
# RoboCo only ever comments on PRs — GitLab's PR comments live under
# the merge-request "notes" endpoint, not a separate issues API call.
return await self._send(
"post",
self._url(repo, "merge_requests", str(issue_number), "notes"),
headers=self._headers(token),
json_body={"body": body},
)
async def create_release(
self,
repo: RepoRef,
token: str,
*,
tag_name: str,
name: str,
body: str,
target_commitish: str,
timeout: float | None = None,
) -> Any:
resp = await self._send(
"post",
self._url(repo, "releases"),
headers=self._headers(token),
json_body={
"tag_name": tag_name,
"name": name,
"description": body,
"ref": target_commitish,
},
timeout=timeout,
)
if not resp.is_success:
return resp
data = resp.json()
if not isinstance(data, dict):
return resp
links = data.get("_links") or {}
shaped = dict(data)
shaped["html_url"] = links.get("self") or ""
return ShapedResponse(resp, json_payload=shaped)
async def create_org_repo(
self,
token: str,
org: str,
*,
name: str,
description: str,
private: bool,
auto_init: bool,
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"},
)
return ShapedResponse(real)
+78 -13
View File
@@ -1,11 +1,18 @@
"""Provider resolution — wiring only, no transport logic of its own.
"""Provider resolution + the host↔provider map behind per-call routing.
Maps a project's ``git_provider`` (Phase 0's ``projects.git_provider`` column;
``roboco/foundation/policy/forge.py`` already rejects ``gitlab``/``gitea`` at
registration time) onto a concrete :class:`GitProvider`. Phase 1 only ever
resolves to :class:`GitHubProvider` in practice the other branches exist so
resolution fails loud instead of silently misbehaving if that invariant is
ever bypassed (a row written directly, a future migration path, ...).
``provider_for(project)`` maps a project's ``git_provider`` (Phase 0's
``projects.git_provider`` column) onto a concrete :class:`GitProvider`.
Phase 2 adds gitea: the provider is addressed by the instance host, derived
from the project's ``git_url``.
The module also keeps the process-wide **host map** the
:class:`~roboco.services.forge.router.ForgeRouter` consults per call:
``register_project_forge`` is invoked at the project chokepoints every git
flow already crosses (``ProjectService.create``/``update`` and the decrypted
token reads), so by the time any REST call happens for a gitea project its
host is registered. In-memory and per-process by design a restart forgets
it and the very next project/token read re-registers (the same self-healing
posture as the read-clone sync throttle).
"""
from __future__ import annotations
@@ -13,27 +20,85 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from roboco.exceptions import GitError
from roboco.foundation.policy.forge import extract_host
from roboco.services.forge.gitea import GiteaProvider
from roboco.services.forge.github import GitHubProvider
from roboco.services.forge.gitlab import GitLabProvider
if TYPE_CHECKING:
from roboco.services.forge.base import GitProvider
# host (lowercase) → provider name ("github" | "gitea"). github.com is
# implicit and never needs registering. _HOST_SCHEMES remembers a plain-http
# host (LAN instance with no TLS terminator) so the API base matches the
# git_url's own scheme; absent = https.
_HOST_PROVIDERS: dict[str, str] = {}
_HOST_SCHEMES: dict[str, str] = {}
def host_of(git_url: str | None) -> str | None:
"""The forge host of a git URL (https/ssh/scp forms), or None."""
if not git_url:
return None
return extract_host(git_url)
def _scheme_of(git_url: str) -> str:
return "http" if git_url.strip().lower().startswith("http://") else "https"
def register_project_forge(git_url: str | None, git_provider: str | None) -> None:
"""Record a project's host→provider mapping for per-call routing.
Called from the ProjectService chokepoints; a github.com host or a
missing provider records nothing (GitHub is the router's default).
"""
host = host_of(git_url)
if host is None or host == "github.com" or git_url is None:
return
if git_provider in ("github", "gitea", "gitlab"):
_HOST_PROVIDERS[host] = git_provider
_HOST_SCHEMES[host] = _scheme_of(git_url)
def provider_name_for_host(host: str) -> str | None:
"""The registered provider name for a host — None when unregistered."""
if host == "github.com":
return "github"
return _HOST_PROVIDERS.get(host.lower())
def scheme_for_host(host: str) -> str:
"""The registered scheme for a host — https unless the project's git_url
said otherwise."""
return _HOST_SCHEMES.get(host.lower(), "https")
def provider_for(project: Any | None = None) -> GitProvider:
"""Resolve the :class:`GitProvider` for a project (or the system default).
``project`` is duck-typed (only ``.git_provider`` is read) so this module
never needs to import a concrete Project model. ``None`` no project in
scope, or a project whose ``git_provider`` is unset resolves to GitHub,
matching ``roboco.foundation.policy.forge.detect_provider``'s default.
``project`` is duck-typed (only ``.git_provider`` / ``.git_url`` are
read) so this module never needs to import a concrete Project model.
``None`` no project in scope, or a project whose ``git_provider`` is
unset resolves to GitHub, matching
``roboco.foundation.policy.forge.detect_provider``'s default.
"""
provider_name = (
getattr(project, "git_provider", None) if project is not None else None
)
if provider_name in (None, "github"):
return GitHubProvider()
host = host_of(getattr(project, "git_url", None))
if host is None:
raise GitError(
f"{provider_name} project has no parseable git_url host",
{"git_provider": provider_name},
)
if provider_name == "gitea":
return GiteaProvider(host, scheme=scheme_for_host(host))
if provider_name == "gitlab":
return GitLabProvider(host, scheme=scheme_for_host(host))
raise GitError(
f"Unsupported git_provider {provider_name!r} — GitLab/Gitea support "
"is not implemented yet.",
f"Unsupported git_provider {provider_name!r}.",
{"git_provider": provider_name},
)
+195
View File
@@ -0,0 +1,195 @@
"""Per-call forge routing — the seam that lets ``GitService`` stay
provider-blind.
``GitService._forge`` returns a :class:`ForgeRouter`; every call site keeps
its exact Phase-1 shape (``self._forge.method(RepoRef(...), token, ...)``)
and the router picks the concrete transport per call from ``RepoRef.host``:
``None`` (or a github-registered host) :class:`GitHubProvider`; a host
registered as gitea :class:`GiteaProvider` for that host. The
hostprovider map is populated by ``registry.register_project_forge`` at the
project/token chokepoints in-memory, per-process, self-healing on the next
project read after a restart.
``parse_repo_ref`` is the entry that stamps ``host`` onto the ref: GitHub
URLs parse exactly as before (host None), a registered gitea host parses
through its own provider, and an unregistered non-GitHub host fails loud
naming the fix instead of several steps deep.
Every transport method is an explicit one-line delegate (no ``__getattr__``
magic) so the ABC contract and mypy keep checking call sites.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from roboco.exceptions import GitError
from roboco.services.forge import registry
from roboco.services.forge.base import GitProvider, RepoRef
from roboco.services.forge.gitea import GiteaProvider
from roboco.services.forge.github import _REPO_URL_RE, GitHubProvider
from roboco.services.forge.gitlab import GitLabProvider
if TYPE_CHECKING:
import httpx
class ForgeRouter(GitProvider):
"""Implements the :class:`GitProvider` surface by per-call delegation."""
@staticmethod
def _provider_for_ref(ref: RepoRef) -> GitProvider:
if ref.host is None:
return GitHubProvider()
provider_name = registry.provider_name_for_host(ref.host)
if provider_name == "gitea":
return GiteaProvider(ref.host, scheme=registry.scheme_for_host(ref.host))
if provider_name == "gitlab":
return GitLabProvider(ref.host, scheme=registry.scheme_for_host(ref.host))
if provider_name in (None, "github"):
# A GHE host registered as github (or parsed before registration)
# rides the GitHub transport with its configured base URL.
return GitHubProvider()
raise GitError(
f"Unsupported git_provider {provider_name!r} for host {ref.host!r}.",
{"git_provider": provider_name, "host": ref.host},
)
def parse_repo_ref(self, git_url: str) -> RepoRef:
if _REPO_URL_RE.search(git_url):
return GitHubProvider().parse_repo_ref(git_url)
host = registry.host_of(git_url)
provider_name = registry.provider_name_for_host(host) if host else None
if host is not None and provider_name == "gitea":
return GiteaProvider(
host, scheme=registry.scheme_for_host(host)
).parse_repo_ref(git_url)
if host is not None and provider_name == "gitlab":
return GitLabProvider(
host, scheme=registry.scheme_for_host(host)
).parse_repo_ref(git_url)
raise GitError(
"Could not resolve a forge for this remote URL — a non-GitHub "
"host must belong to a registered project with git_provider set "
"(gitea/gitlab today; GHE uses git_provider='github' with "
"ROBOCO_GITHUB_API_BASE_URL).",
{"host": host or "unknown"},
)
async def list_pulls(self, repo: RepoRef, token: str, **kwargs: Any) -> Any:
return await self._provider_for_ref(repo).list_pulls(repo, token, **kwargs)
async def get_pr(
self, repo: RepoRef, token: str, pr_number: int, **kwargs: Any
) -> Any:
return await self._provider_for_ref(repo).get_pr(
repo, token, pr_number, **kwargs
)
async def get_pr_diff(self, repo: RepoRef, token: str, pr_number: int) -> Any:
return await self._provider_for_ref(repo).get_pr_diff(repo, token, pr_number)
async def create_pr(self, repo: RepoRef, token: str, **kwargs: Any) -> Any:
return await self._provider_for_ref(repo).create_pr(repo, token, **kwargs)
async def update_pr(
self, repo: RepoRef, token: str, pr_number: int, **kwargs: Any
) -> Any:
return await self._provider_for_ref(repo).update_pr(
repo, token, pr_number, **kwargs
)
async def merge_pr(
self, repo: RepoRef, token: str, pr_number: int, **kwargs: Any
) -> Any:
return await self._provider_for_ref(repo).merge_pr(
repo, token, pr_number, **kwargs
)
async def request_reviewers(
self, repo: RepoRef, token: str, pr_number: int, reviewers: list[str]
) -> Any:
return await self._provider_for_ref(repo).request_reviewers(
repo, token, pr_number, reviewers
)
async def post_review(
self, repo: RepoRef, token: str, pr_number: int, **kwargs: Any
) -> Any:
return await self._provider_for_ref(repo).post_review(
repo, token, pr_number, **kwargs
)
async def merge_branch(self, repo: RepoRef, token: str, **kwargs: Any) -> Any:
return await self._provider_for_ref(repo).merge_branch(repo, token, **kwargs)
async def list_ci_runs(self, repo: RepoRef, token: str, **kwargs: Any) -> Any:
return await self._provider_for_ref(repo).list_ci_runs(repo, token, **kwargs)
async def list_check_runs(
self, repo: RepoRef, token: str, head_sha: str, **kwargs: Any
) -> Any:
return await self._provider_for_ref(repo).list_check_runs(
repo, token, head_sha, **kwargs
)
async def list_workflows(self, repo: RepoRef, token: str, **kwargs: Any) -> Any:
return await self._provider_for_ref(repo).list_workflows(repo, token, **kwargs)
async def get_repo(self, repo: RepoRef, token: str, **kwargs: Any) -> Any:
return await self._provider_for_ref(repo).get_repo(repo, token, **kwargs)
async def ensure_label(
self, repo: RepoRef, token: str, name: str, color: str
) -> Any:
return await self._provider_for_ref(repo).ensure_label(repo, token, name, color)
async def add_labels(
self, repo: RepoRef, token: str, pr_number: int, labels: list[str]
) -> Any:
return await self._provider_for_ref(repo).add_labels(
repo, token, pr_number, labels
)
async def delete_branch_ref(
self, repo: RepoRef, token: str, branch: str, **kwargs: Any
) -> Any:
return await self._provider_for_ref(repo).delete_branch_ref(
repo, token, branch, **kwargs
)
async def create_issue_comment(
self, repo: RepoRef, token: str, issue_number: int, body: str
) -> Any:
return await self._provider_for_ref(repo).create_issue_comment(
repo, token, issue_number, body
)
async def create_release(self, repo: RepoRef, token: str, **kwargs: Any) -> Any:
return await self._provider_for_ref(repo).create_release(repo, token, **kwargs)
async def create_org_repo(
self,
token: str,
org: str,
*,
name: str,
description: str,
private: bool,
auto_init: bool,
client: httpx.AsyncClient | None = None,
timeout: float | None = None,
) -> Any:
"""The one transport method with no RepoRef — provisioning is
GitHub-only today, matching its direct GitHubProvider construction
elsewhere."""
return await GitHubProvider().create_org_repo(
token,
org,
name=name,
description=description,
private=private,
auto_init=auto_init,
client=client,
timeout=timeout,
)
+51
View File
@@ -0,0 +1,51 @@
"""Response shaping shared by non-GitHub transports.
Adapters translate a forge's native wire contract back into the GitHub
shapes ``GitService`` classifies; :class:`ShapedResponse` is the stand-in
they return. Callers only ever read ``status_code`` / ``is_success`` /
``text`` / ``json()`` (the seam's documented contract), so that is the
whole surface.
"""
from __future__ import annotations
from typing import Any
import httpx
class ShapedResponse:
"""An httpx.Response stand-in with translated status/body/text.
Wraps the real response for fidelity while letting the adapter override
the JSON payload, the status code, and/or the text (GitLab's diff
reassembly returns synthesized unified-diff text).
"""
def __init__(
self,
real: httpx.Response,
*,
json_payload: Any | None = None,
status_code: int | None = None,
text: str | None = None,
) -> None:
self._real = real
self._json = json_payload
self._text = text
self.status_code = status_code if status_code is not None else real.status_code
@property
def is_success(self) -> bool:
return httpx.codes.is_success(self.status_code)
@property
def text(self) -> str:
if self._text is not None:
return self._text
return self._real.text
def json(self) -> Any:
if self._json is not None:
return self._json
return self._real.json()
+243 -183
View File
File diff suppressed because it is too large Load Diff
+18 -6
View File
@@ -19,6 +19,7 @@ from roboco.foundation.policy.forge import detect_provider, validate_project_for
from roboco.models.base import TaskStatus, Team
from roboco.models.project import ProjectCreate, ProjectUpdate
from roboco.services.base import BaseService, ConflictError, NotFoundError
from roboco.services.forge import register_project_forge
from roboco.utils.crypto import EncryptionError, decrypt_token, encrypt_token
# Statuses that are NOT active progress: completed (done), cancelled
@@ -104,11 +105,12 @@ class ProjectService(BaseService):
self._assert_git_url_allowed(data.git_url)
self._assert_forge_supported(data.git_url, data.git_provider)
# Null + a github.com git_url auto-stamps "github" so the column
# reflects reality without forcing every caller to set it explicitly.
# Null + a SaaS-detectable host auto-stamps the provider so the
# column reflects reality without forcing every caller to set it
# explicitly (github.com → github, gitlab.com → gitlab).
git_provider = data.git_provider
if git_provider is None and detect_provider(data.git_url) == "github":
git_provider = "github"
if git_provider is None:
git_provider = detect_provider(data.git_url)
# Encrypt git token if provided
encrypted_token = None
@@ -173,19 +175,29 @@ class ProjectService(BaseService):
error=str(exc),
)
@staticmethod
def _register_forge(project: ProjectTable | None) -> ProjectTable | None:
"""Record a loaded project's host→provider mapping for the forge
router (in-memory, per-process). Riding the getters makes the map
self-healing: any flow that touches a project re-registers it, so a
restart never leaves a gitea host unroutable past the first read."""
if project is not None:
register_project_forge(project.git_url, project.git_provider)
return project
async def get(self, project_id: UUID) -> ProjectTable | None:
"""Get a project by ID."""
result = await self.session.execute(
select(ProjectTable).where(ProjectTable.id == project_id)
)
return result.scalar_one_or_none()
return self._register_forge(result.scalar_one_or_none())
async def get_by_slug(self, slug: str) -> ProjectTable | None:
"""Get a project by its URL-safe slug."""
result = await self.session.execute(
select(ProjectTable).where(ProjectTable.slug == slug)
)
return result.scalar_one_or_none()
return self._register_forge(result.scalar_one_or_none())
async def get_or_raise(self, project_id: UUID) -> ProjectTable:
"""Get a project by ID or raise NotFoundError."""
+2 -3
View File
@@ -471,7 +471,6 @@ class _GitReleaseOps:
# binary, so the CLI path fails at publish time with a missing binary.
import httpx
from roboco.services.forge import RepoRef
from roboco.services.forge.github import GitHubProvider
from roboco.services.git import GitService
from roboco.services.project import ProjectService
@@ -482,10 +481,10 @@ class _GitReleaseOps:
)
if not token:
raise RuntimeError(f"release publish failed: no git token for {self._slug}")
owner, repo = GitService._parse_git_url(self._git_url)
repo_ref = GitService._parse_git_url(self._git_url)
try:
resp = await GitHubProvider().create_release(
RepoRef(owner, repo),
repo_ref,
token,
tag_name=tag,
name=tag,
+3 -3
View File
@@ -63,6 +63,7 @@ from roboco.db.tables import (
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
from roboco.models.work_session import WorkSessionStatus
from roboco.services.forge import RepoRef
from roboco.services.git import GitService
from roboco.services.work_session import get_work_session_service
from roboco.services.workspace import WorkspaceService
@@ -290,7 +291,7 @@ async def test_m38_pr_is_merged_returns_none_on_httperror() -> None:
"roboco.services.git.httpx.AsyncClient",
return_value=_httpx_raising_client(),
):
out = await svc._pr_is_merged("acme", "repo", 11, "tok")
out = await svc._pr_is_merged(RepoRef("acme", "repo"), 11, "tok")
assert out is None
@@ -310,8 +311,7 @@ async def test_m38_merge_with_retry_none_does_not_raise_conflict() -> None:
_bind(svc, "_sync_target_branch", AsyncMock())
ctx = GitService._MergeContext(
owner="acme",
repo="repo",
repo_ref=RepoRef("acme", "repo"),
pr_number=11,
git_token="tok",
workspace=Path("/tmp/ws"),
+250
View File
@@ -0,0 +1,250 @@
"""Live-Gitea contract test for GiteaProvider (forge Phase 2).
Runs the real wire contract against a live Gitea instance the spec's
"contract suite recorded against a dockerized gitea/gitea" and is fully
self-seeding: it creates its own uniquely-named repo, pushes real commits,
and exercises the provider end to end (PR open duplicate reshape
list/filter diff comment review commit-status CI reshape squash
merge branch delete release), plus the git-CLI Basic-auth extraheader
claim the provider docstring makes.
Skipped unless both env vars are set:
ROBOCO_GITEA_E2E_URL e.g. http://localhost:3310
ROBOCO_GITEA_E2E_TOKEN an admin PAT (scopes: all)
Local run: `docker run -d -p 3310:3000 -e GITEA__security__INSTALL_LOCK=true
gitea/gitea:1.22`, create an admin + token (`gitea admin user create` /
`generate-access-token`), export the two vars, run this file.
"""
from __future__ import annotations
import base64
import os
import subprocess
import time
from typing import TYPE_CHECKING
from urllib.parse import urlsplit
from uuid import uuid4
import httpx
import pytest
from roboco.services.forge.base import RepoRef
from roboco.services.forge.gitea import GiteaProvider
if TYPE_CHECKING:
from pathlib import Path
_URL = os.environ.get("ROBOCO_GITEA_E2E_URL", "")
_TOKEN = os.environ.get("ROBOCO_GITEA_E2E_TOKEN", "")
pytestmark = [
pytest.mark.asyncio,
pytest.mark.skipif(
not (_URL and _TOKEN),
reason="ROBOCO_GITEA_E2E_URL / ROBOCO_GITEA_E2E_TOKEN not set",
),
]
def _split_url() -> tuple[str, str]:
parts = urlsplit(_URL)
host = parts.netloc or parts.path
return (parts.scheme or "http"), host
def _api(method: str, path: str, **kwargs: object) -> httpx.Response:
scheme, host = _split_url()
return httpx.request(
method,
f"{scheme}://{host}/api/v1{path}",
headers={"Authorization": f"token {_TOKEN}"},
timeout=15.0,
**kwargs, # type: ignore[arg-type]
)
def _git(cwd: Path, *args: str) -> str:
result = subprocess.run(
["git", *args], cwd=cwd, check=True, capture_output=True, text=True
)
return result.stdout.strip()
def _seed_repo(tmp_path: Path, repo_name: str) -> str:
"""Create the repo via API, push a feature commit; return its sha."""
resp = _api(
"post",
"/user/repos",
json={"name": repo_name, "auto_init": True, "default_branch": "main"},
)
assert resp.status_code == httpx.codes.CREATED, resp.text
login = _api("get", "/user").json()["username"]
scheme, host = _split_url()
clone_url = f"{scheme}://{login}:{_TOKEN}@{host}/{login}/{repo_name}.git"
clone = tmp_path / "clone"
subprocess.run(
["git", "clone", clone_url, str(clone)],
check=True,
capture_output=True,
text=True,
)
_git(clone, "config", "user.email", "e2e@example.com")
_git(clone, "config", "user.name", "E2E")
_git(clone, "config", "commit.gpgsign", "false")
_git(clone, "checkout", "-b", "feat/e2e-change")
(clone / "widget.txt").write_text("widget v2\n")
_git(clone, "add", "widget.txt")
_git(clone, "commit", "-m", "add widget")
_git(clone, "push", "-u", "origin", "feat/e2e-change")
return _git(clone, "rev-parse", "HEAD")
async def _verify_pr_flow(provider: GiteaProvider, ref: RepoRef, head_sha: str) -> int:
"""create → duplicate reshape → list/filter → get → diff → review →
labels; returns the PR number."""
created = await provider.create_pr(
ref,
_TOKEN,
head="feat/e2e-change",
base="main",
title="E2E change",
body="live contract test",
)
assert created.is_success, created.text
pr = created.json()
pr_number: int = pr["number"]
assert pr["html_url"]
duplicate = await provider.create_pr(
ref,
_TOKEN,
head="feat/e2e-change",
base="main",
title="E2E change",
body="dup",
)
assert duplicate.status_code == httpx.codes.UNPROCESSABLE_ENTITY
assert "already exists" in duplicate.text.lower()
listed = await provider.list_pulls(ref, _TOKEN, head="feat/e2e-change", base="main")
pulls = listed.json()
assert [p["number"] for p in pulls] == [pr_number]
assert pulls[0]["author_association"] == "NONE"
fetched = (await provider.get_pr(ref, _TOKEN, pr_number)).json()
assert fetched["head"]["sha"] == head_sha
assert fetched["base"]["ref"] == "main"
assert not fetched.get("merged")
diff = await provider.get_pr_diff(ref, _TOKEN, pr_number)
assert "widget.txt" in diff.text
# COMMENT — self-review approve/request-changes is refused by Gitea.
review = await provider.post_review(
ref, _TOKEN, pr_number, body="looks fine", event="COMMENT"
)
assert review.is_success, review.text
label = await provider.ensure_label(ref, _TOKEN, "cell/backend", "8250df")
assert label.is_success or label.status_code in (409, 422), label.text
attach = await provider.add_labels(ref, _TOKEN, pr_number, ["cell/backend"])
assert attach.is_success, attach.text
return pr_number
async def _verify_ci_reshapes(
provider: GiteaProvider, ref: RepoRef, head_sha: str
) -> None:
"""A real commit status classifies through both GitHub-shaped views."""
status = _api(
"post",
f"/repos/{ref.owner}/{ref.repo}/statuses/{head_sha}",
json={"state": "success", "context": "ci/e2e", "description": "ok"},
)
assert status.status_code == httpx.codes.CREATED, status.text
check_runs = (
await provider.list_check_runs(ref, _TOKEN, head_sha, per_page=50)
).json()["check_runs"]
assert check_runs and check_runs[0]["conclusion"] == "success"
assert check_runs[0]["status"] == "completed"
runs = (
await provider.list_ci_runs(
ref,
_TOKEN,
workflow=None,
branch="feat/e2e-change",
head_sha=None,
per_page=5,
)
).json()["workflow_runs"]
assert runs and runs[0]["conclusion"] == "success"
assert runs[0]["head_sha"] == head_sha
async def _verify_merge_publish_cli(
provider: GiteaProvider, ref: RepoRef, pr_number: int, scheme: str
) -> None:
"""Squash merge → merged flag → branch delete → release → CLI auth."""
merged = await provider.merge_pr(ref, _TOKEN, pr_number, merge_method="squash")
assert merged.is_success, merged.text
for _ in range(10):
if (await provider.get_pr(ref, _TOKEN, pr_number)).json().get("merged"):
break
time.sleep(0.5)
assert (await provider.get_pr(ref, _TOKEN, pr_number)).json()["merged"] is True
deleted = await provider.delete_branch_ref(ref, _TOKEN, "feat/e2e-change")
assert deleted.status_code in (204, 200), deleted.text
release = await provider.create_release(
ref,
_TOKEN,
tag_name="v0.0.1-e2e",
name="v0.0.1-e2e",
body="live",
target_commitish="main",
)
assert release.status_code == httpx.codes.CREATED, release.text
assert release.json().get("html_url")
# The provider docstring's git-CLI claim: Basic auth with the
# x-access-token username + PAT password works against Gitea.
basic = base64.b64encode(f"x-access-token:{_TOKEN}".encode()).decode()
ls = subprocess.run(
[
"git",
"-c",
f"http.extraheader=Authorization: Basic {basic}",
"ls-remote",
f"{scheme}://{ref.host}/{ref.owner}/{ref.repo}.git",
],
capture_output=True,
text=True,
check=False,
)
assert ls.returncode == 0, ls.stderr
assert "refs/heads/main" in ls.stdout
async def test_gitea_live_contract(tmp_path: Path) -> None:
scheme, host = _split_url()
repo_name = f"e2e-{uuid4().hex[:8]}"
head_sha = _seed_repo(tmp_path, repo_name)
login = _api("get", "/user").json()["username"]
ref = RepoRef(login, repo_name, host=host)
provider = GiteaProvider(host, scheme=scheme)
repo_resp = await provider.get_repo(ref, _TOKEN)
assert repo_resp.is_success
repo_json = repo_resp.json()
assert repo_json["full_name"] == f"{login}/{repo_name}"
assert "allow_merge_commit" in repo_json
assert "allow_squash_merge" in repo_json
pr_number = await _verify_pr_flow(provider, ref, head_sha)
await _verify_ci_reshapes(provider, ref, head_sha)
await _verify_merge_publish_cli(provider, ref, pr_number, scheme)
_api("delete", f"/repos/{login}/{repo_name}")
+5 -2
View File
@@ -10,6 +10,7 @@ from uuid import uuid4
from roboco.config import settings
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.services.forge import RepoRef
from roboco.services.git import _ConventionsPr, get_git_service
if TYPE_CHECKING:
@@ -144,7 +145,9 @@ async def test_open_conventions_pr_force_pushes_scaffold_branch(
monkeypatch.setattr(git, "_token_for_project", _fake_token)
monkeypatch.setattr(git, "push", _fake_push)
monkeypatch.setattr(git, "_parse_github_remote", lambda _ws: ("owner", "repo"))
monkeypatch.setattr(
git, "_parse_github_remote", lambda _ws: RepoRef("owner", "repo")
)
_pr_number = 42
_pr_url = "https://github.com/owner/repo/pull/42"
@@ -155,7 +158,7 @@ async def test_open_conventions_pr_force_pushes_scaffold_branch(
def json(self) -> dict[str, object]:
return {"number": _pr_number, "html_url": _pr_url}
async def _fake_post_pr(_owner: str, _repo: str, _token: str, _body: Any) -> _Resp:
async def _fake_post_pr(_repo_ref: RepoRef, _token: str, _body: Any) -> _Resp:
return _Resp()
monkeypatch.setattr(git, "_post_pr", _fake_post_pr)
+14 -14
View File
@@ -95,18 +95,19 @@ def test_explicit_github_provider_is_ok_regardless_of_host() -> None:
assert validate_project_forge(url, "github") is None
def test_explicit_gitlab_provider_rejected_as_not_yet_supported() -> None:
error = validate_project_forge("https://gitlab.com/group/project.git", "gitlab")
assert error is not None
assert "not yet" in error.lower()
assert "gitlab" in error.lower()
def test_explicit_gitlab_provider_accepted() -> None:
"""Phase 3: the GitLab transport is live — explicit gitlab validates."""
assert (
validate_project_forge("https://gitlab.com/group/project.git", "gitlab") is None
)
def test_explicit_gitea_provider_rejected_as_not_yet_supported() -> None:
error = validate_project_forge("https://gitea.example.com/owner/repo.git", "gitea")
assert error is not None
assert "not yet" in error.lower()
assert "gitea" in error.lower()
def test_explicit_gitea_provider_accepted() -> None:
"""Phase 2: the Gitea transport is live — explicit gitea validates."""
assert (
validate_project_forge("https://gitea.example.com/owner/repo.git", "gitea")
is None
)
def test_unknown_host_no_explicit_provider_rejected() -> None:
@@ -115,10 +116,9 @@ def test_unknown_host_no_explicit_provider_rejected() -> None:
assert "github" in error.lower()
def test_detected_gitlab_no_explicit_provider_rejected() -> None:
error = validate_project_forge("https://gitlab.com/group/project.git", None)
assert error is not None
assert "github" in error.lower()
def test_detected_gitlab_no_explicit_provider_accepted() -> None:
"""gitlab.com detection is unambiguous — auto-accepted like github.com."""
assert validate_project_forge("https://gitlab.com/group/project.git", None) is None
def test_unknown_provider_string_rejected_naming_known_providers() -> None:
@@ -0,0 +1,265 @@
"""GiteaProvider wire contract: token auth scheme, GitHub-shape adapters
(duplicate-PR 409422, statusescheck_runs, combined-statusworkflow_runs,
merge-method key mapping), and the deliberate Phase-2 postures (synthetic
zero workflows, unsupported server-side branch merge).
Uses httpx.MockTransport through the provider's own ``_send`` (the
``client=``-less path is exercised by monkeypatching ``httpx.AsyncClient``
to inject the transport the same seam the git-service suite patches).
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import httpx
import pytest
from roboco.services.forge.base import RepoRef
from roboco.services.forge.gitea import GiteaProvider, ShapedResponse
if TYPE_CHECKING:
from collections.abc import Callable
REF = RepoRef("acme", "widgets", host="gitea.example.com")
class _Recorder:
def __init__(self, responder: Callable[[httpx.Request], httpx.Response]) -> None:
self.requests: list[httpx.Request] = []
self._responder = responder
def handler(self, request: httpx.Request) -> httpx.Response:
self.requests.append(request)
return self._responder(request)
def _patch_client(monkeypatch: pytest.MonkeyPatch, recorder: _Recorder) -> None:
real_client = httpx.AsyncClient
def _factory(**kwargs: Any) -> httpx.AsyncClient:
kwargs.pop("timeout", None)
return real_client(transport=httpx.MockTransport(recorder.handler))
monkeypatch.setattr(httpx, "AsyncClient", _factory)
@pytest.mark.asyncio
async def test_auth_header_uses_token_scheme_and_api_v1_base(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
_patch_client(monkeypatch, recorder)
await GiteaProvider("gitea.example.com").get_pr(REF, "SECRET", 7)
request = recorder.requests[0]
assert request.headers["Authorization"] == "token SECRET"
assert (
str(request.url)
== "https://gitea.example.com/api/v1/repos/acme/widgets/pulls/7"
)
@pytest.mark.asyncio
async def test_create_pr_duplicate_409_reshapes_to_github_422(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(
lambda _r: httpx.Response(
409, text="pull request already exists for these targets"
)
)
_patch_client(monkeypatch, recorder)
resp = await GiteaProvider("gitea.example.com").create_pr(
REF, "t", head="feat", base="main", title="T", body="B"
)
assert resp.status_code == httpx.codes.UNPROCESSABLE_ENTITY
assert "already exists" in resp.text
@pytest.mark.asyncio
async def test_merge_pr_posts_do_key(monkeypatch: pytest.MonkeyPatch) -> None:
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
_patch_client(monkeypatch, recorder)
await GiteaProvider("gitea.example.com").merge_pr(
REF, "t", 7, merge_method="squash"
)
request = recorder.requests[0]
assert request.method == "POST"
assert request.url.path.endswith("/pulls/7/merge")
assert b'"Do": "squash"' in request.content or b'"Do":"squash"' in request.content
@pytest.mark.asyncio
async def test_post_review_maps_approve_event(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
_patch_client(monkeypatch, recorder)
provider = GiteaProvider("gitea.example.com")
await provider.post_review(REF, "t", 7, body="lgtm", event="APPROVE")
await provider.post_review(REF, "t", 7, body="fix", event="REQUEST_CHANGES")
assert b"APPROVED" in recorder.requests[0].content
assert b"REQUEST_CHANGES" in recorder.requests[1].content
@pytest.mark.asyncio
async def test_list_pulls_filters_client_side_and_injects_association(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pulls = [
{"number": 1, "head": {"ref": "feat-a"}, "base": {"ref": "main"}},
{"number": 2, "head": {"ref": "feat-b"}, "base": {"ref": "main"}},
]
recorder = _Recorder(lambda _r: httpx.Response(200, json=pulls))
_patch_client(monkeypatch, recorder)
resp = await GiteaProvider("gitea.example.com").list_pulls(
REF, "t", head="feat-b", base="main"
)
selected = resp.json()
assert [pr["number"] for pr in selected] == [2]
assert selected[0]["author_association"] == "NONE"
@pytest.mark.asyncio
async def test_check_runs_reshaped_from_statuses(
monkeypatch: pytest.MonkeyPatch,
) -> None:
statuses = [
{"id": 11, "status": "success", "context": "ci/build"},
{"id": 12, "status": "pending", "context": "ci/test"},
{"id": 13, "status": "error", "context": "ci/lint"},
]
recorder = _Recorder(lambda _r: httpx.Response(200, json=statuses))
_patch_client(monkeypatch, recorder)
resp = await GiteaProvider("gitea.example.com").list_check_runs(
REF, "t", "abc123", per_page=100
)
runs = resp.json()["check_runs"]
assert runs[0] == {
"id": 11,
"name": "ci/build",
"status": "completed",
"conclusion": "success",
}
assert runs[1]["status"] == "in_progress"
assert runs[1]["conclusion"] is None
assert runs[2]["conclusion"] == "failure"
@pytest.mark.asyncio
async def test_ci_runs_reshaped_from_combined_status(
monkeypatch: pytest.MonkeyPatch,
) -> None:
combined = {"state": "failure", "sha": "abc123", "url": "https://x"}
recorder = _Recorder(lambda _r: httpx.Response(200, json=combined))
_patch_client(monkeypatch, recorder)
resp = await GiteaProvider("gitea.example.com").list_ci_runs(
REF, "t", workflow=None, branch="main", head_sha=None, per_page=5
)
runs = resp.json()["workflow_runs"]
assert len(runs) == 1
assert runs[0]["conclusion"] == "failure"
assert runs[0]["head_sha"] == "abc123"
@pytest.mark.asyncio
async def test_pending_combined_status_yields_no_completed_runs(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(
lambda _r: httpx.Response(200, json={"state": "pending", "sha": "abc"})
)
_patch_client(monkeypatch, recorder)
resp = await GiteaProvider("gitea.example.com").list_ci_runs(
REF, "t", workflow=None, branch="main", head_sha=None, per_page=5
)
assert resp.json()["workflow_runs"] == []
@pytest.mark.asyncio
async def test_list_workflows_is_synthetic_zero() -> None:
resp = await GiteaProvider("gitea.example.com").list_workflows(REF, "t", per_page=1)
assert resp.is_success
assert resp.json() == {"total_count": 0}
@pytest.mark.asyncio
async def test_merge_branch_is_shaped_not_implemented() -> None:
resp = await GiteaProvider("gitea.example.com").merge_branch(
REF, "t", base="stag", head="main", commit_message="cascade"
)
assert isinstance(resp, ShapedResponse)
assert resp.status_code == httpx.codes.NOT_IMPLEMENTED
@pytest.mark.asyncio
async def test_get_repo_maps_merge_method_keys(
monkeypatch: pytest.MonkeyPatch,
) -> None:
repo_obj = {
"full_name": "acme/widgets",
"allow_merge_commits": False,
"allow_rebase": False,
"allow_squash_merge": True,
}
recorder = _Recorder(lambda _r: httpx.Response(200, json=repo_obj))
_patch_client(monkeypatch, recorder)
resp = await GiteaProvider("gitea.example.com").get_repo(REF, "t")
shaped = resp.json()
assert shaped["allow_merge_commit"] is False
assert shaped["allow_rebase_merge"] is False
assert shaped["allow_squash_merge"] is True
def test_parse_repo_ref_stamps_host() -> None:
provider = GiteaProvider("gitea.example.com")
ref = provider.parse_repo_ref("https://gitea.example.com/acme/widgets.git")
assert ref == RepoRef("acme", "widgets", host="gitea.example.com")
@pytest.mark.asyncio
async def test_ensure_label_prefixes_hash_on_color(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(lambda _r: httpx.Response(201, json={}))
_patch_client(monkeypatch, recorder)
await GiteaProvider("gitea.example.com").ensure_label(REF, "t", "root", "8250df")
assert b"#8250df" in recorder.requests[0].content
@pytest.mark.asyncio
async def test_slash_branch_segments_are_url_encoded(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Every RoboCo branch carries slashes (feature/backend/...) — an
unencoded segment 404s at Gitea's router (caught by the live suite)."""
recorder = _Recorder(lambda _r: httpx.Response(200, json={"state": "success"}))
_patch_client(monkeypatch, recorder)
provider = GiteaProvider("gitea.example.com")
await provider.list_ci_runs(
REF, "t", workflow=None, branch="feature/backend/ABC", head_sha=None, per_page=5
)
await provider.delete_branch_ref(REF, "t", "feature/backend/ABC")
assert "/commits/feature%2Fbackend%2FABC/status" in str(recorder.requests[0].url)
assert "/branches/feature%2Fbackend%2FABC" in str(recorder.requests[1].url)
@@ -0,0 +1,606 @@
"""GitLabProvider wire contract: Bearer auth + urlencoded project path,
MRPR shape adaptation (iidnumber, source/target_branchhead/base, merged
bool), payload-key translation (create_pr duplicate 409422, update_pr
closestate_event), squash-flag merge, approve-vs-note review routing, diff
reassembly, pipelinesworkflow_runs / statusescheck_runs CI classification,
merge-method mapping, and the deliberate Phase-3 synthetic postures
(request_reviewers, create_org_repo, merge_branch).
Uses httpx.MockTransport through the provider's own ``_send`` — same seam
``test_gitea_provider.py`` exercises.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import httpx
import pytest
from roboco.services.forge.base import RepoRef
from roboco.services.forge.gitlab import GitLabProvider, ShapedResponse
if TYPE_CHECKING:
from collections.abc import Callable
REF = RepoRef("group/sub/proj", "", host="gitlab.example.com")
_MR_IID = 3
_DIFF_PAGE_CAP = 3
class _Recorder:
def __init__(self, responder: Callable[[httpx.Request], httpx.Response]) -> None:
self.requests: list[httpx.Request] = []
self._responder = responder
def handler(self, request: httpx.Request) -> httpx.Response:
self.requests.append(request)
return self._responder(request)
def _patch_client(monkeypatch: pytest.MonkeyPatch, recorder: _Recorder) -> None:
real_client = httpx.AsyncClient
def _factory(**kwargs: Any) -> httpx.AsyncClient:
kwargs.pop("timeout", None)
return real_client(transport=httpx.MockTransport(recorder.handler))
monkeypatch.setattr(httpx, "AsyncClient", _factory)
@pytest.mark.asyncio
async def test_auth_header_uses_bearer_and_urlencoded_subgroup_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
_patch_client(monkeypatch, recorder)
await GitLabProvider("gitlab.example.com").get_pr(REF, "SECRET", 7)
request = recorder.requests[0]
assert request.headers["Authorization"] == "Bearer SECRET"
assert (
str(request.url)
== "https://gitlab.example.com/api/v4/projects/group%2Fsub%2Fproj/merge_requests/7"
)
def test_parse_repo_ref_subgroup_path_stamps_host() -> None:
provider = GitLabProvider("gitlab.example.com")
ref = provider.parse_repo_ref("https://gitlab.example.com/group/sub/proj.git")
assert ref == RepoRef("group/sub/proj", "", host="gitlab.example.com")
def test_parse_repo_ref_rejects_single_segment_path() -> None:
provider = GitLabProvider("gitlab.example.com")
with pytest.raises(Exception, match="namespace/project"):
provider.parse_repo_ref("https://gitlab.example.com/onlyproject.git")
@pytest.mark.asyncio
async def test_list_pulls_maps_state_and_filters_natively(
monkeypatch: pytest.MonkeyPatch,
) -> None:
mrs = [
{
"iid": _MR_IID,
"web_url": "https://gitlab.example.com/group/sub/proj/-/merge_requests/3",
"title": "Feature",
"state": "opened",
"source_branch": "feat-a",
"target_branch": "main",
"sha": "abc123",
"author": {"username": "renzo"},
}
]
recorder = _Recorder(lambda _r: httpx.Response(200, json=mrs))
_patch_client(monkeypatch, recorder)
resp = await GitLabProvider("gitlab.example.com").list_pulls(
REF, "t", head="feat-a", base="main"
)
request = recorder.requests[0]
assert request.url.params["state"] == "opened"
assert request.url.params["source_branch"] == "feat-a"
assert request.url.params["target_branch"] == "main"
shaped = resp.json()[0]
assert shaped["number"] == _MR_IID
assert shaped["html_url"].endswith("/merge_requests/3")
assert shaped["state"] == "open"
assert shaped["merged"] is False
assert shaped["head"] == {
"ref": "feat-a",
"sha": "abc123",
"repo": {"full_name": "group/sub/proj"},
}
assert shaped["base"] == {"ref": "main"}
assert shaped["user"] == {"login": "renzo"}
assert shaped["author_association"] == "NONE"
@pytest.mark.asyncio
async def test_get_pr_adapts_merged_state(monkeypatch: pytest.MonkeyPatch) -> None:
mr = {
"iid": 9,
"web_url": "https://gitlab.example.com/x",
"title": "T",
"state": "merged",
"source_branch": "feat",
"target_branch": "main",
"sha": "deadbeef",
"author": {},
}
recorder = _Recorder(lambda _r: httpx.Response(200, json=mr))
_patch_client(monkeypatch, recorder)
resp = await GitLabProvider("gitlab.example.com").get_pr(REF, "t", 9)
shaped = resp.json()
assert shaped["state"] == "closed"
assert shaped["merged"] is True
assert shaped["user"] == {"login": ""}
@pytest.mark.asyncio
async def test_create_pr_translates_payload_keys(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(
lambda _r: httpx.Response(
201,
json={
"iid": 1,
"web_url": "https://x",
"title": "T",
"state": "opened",
"source_branch": "feat",
"target_branch": "main",
"sha": "s",
"author": {"username": "bot"},
},
)
)
_patch_client(monkeypatch, recorder)
resp = await GitLabProvider("gitlab.example.com").create_pr(
REF, "t", head="feat", base="main", title="T", body="B"
)
request = recorder.requests[0]
assert (
b'"source_branch": "feat"' in request.content
or b'"source_branch":"feat"' in request.content
)
assert (
b'"target_branch": "main"' in request.content
or b'"target_branch":"main"' in request.content
)
assert (
b'"description": "B"' in request.content
or b'"description":"B"' in request.content
)
assert resp.json()["number"] == 1
@pytest.mark.asyncio
async def test_create_pr_duplicate_409_reshapes_to_github_422(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(
lambda _r: httpx.Response(409, text="Another open merge request already exists")
)
_patch_client(monkeypatch, recorder)
resp = await GitLabProvider("gitlab.example.com").create_pr(
REF, "t", head="feat", base="main", title="T", body="B"
)
assert resp.status_code == httpx.codes.UNPROCESSABLE_ENTITY
assert "already exists" in resp.text
@pytest.mark.asyncio
async def test_update_pr_close_maps_to_state_event(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
_patch_client(monkeypatch, recorder)
await GitLabProvider("gitlab.example.com").update_pr(
REF, "t", 5, payload={"state": "closed", "body": "why"}
)
request = recorder.requests[0]
assert request.method == "PUT"
assert (
b'"state_event": "close"' in request.content
or b'"state_event":"close"' in request.content
)
assert (
b'"description": "why"' in request.content
or b'"description":"why"' in request.content
)
assert b"state_event" in request.content
assert b'"state":' not in request.content
@pytest.mark.asyncio
async def test_merge_pr_sends_squash_flag(monkeypatch: pytest.MonkeyPatch) -> None:
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
_patch_client(monkeypatch, recorder)
await GitLabProvider("gitlab.example.com").merge_pr(
REF, "t", 5, merge_method="squash"
)
request = recorder.requests[0]
assert request.method == "PUT"
assert request.url.path.endswith("/merge_requests/5/merge")
assert b'"squash": true' in request.content or b'"squash":true' in request.content
@pytest.mark.asyncio
async def test_merge_pr_no_squash_when_method_differs(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
_patch_client(monkeypatch, recorder)
await GitLabProvider("gitlab.example.com").merge_pr(
REF, "t", 5, merge_method="merge"
)
assert b'"squash": false' in recorder.requests[0].content or (
b'"squash":false' in recorder.requests[0].content
)
@pytest.mark.asyncio
async def test_post_review_approve_hits_approve_endpoint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
_patch_client(monkeypatch, recorder)
await GitLabProvider("gitlab.example.com").post_review(
REF, "t", 5, body="lgtm", event="APPROVE"
)
request = recorder.requests[0]
assert request.method == "POST"
assert request.url.path.endswith("/merge_requests/5/approve")
@pytest.mark.asyncio
async def test_post_review_request_changes_posts_note(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
_patch_client(monkeypatch, recorder)
await GitLabProvider("gitlab.example.com").post_review(
REF, "t", 5, body="please fix X", event="REQUEST_CHANGES"
)
request = recorder.requests[0]
assert request.method == "POST"
assert request.url.path.endswith("/merge_requests/5/notes")
assert b"please fix X" in request.content
@pytest.mark.asyncio
async def test_get_pr_diff_reassembles_unified_text_single_page(
monkeypatch: pytest.MonkeyPatch,
) -> None:
page = [
{"old_path": "a.py", "new_path": "a.py", "diff": "@@ -1 +1 @@\n-x\n+y\n"},
{"old_path": "b.py", "new_path": "b.py", "diff": "@@ -1 +1 @@\n-p\n+q\n"},
]
recorder = _Recorder(lambda _r: httpx.Response(200, json=page))
_patch_client(monkeypatch, recorder)
resp = await GitLabProvider("gitlab.example.com").get_pr_diff(REF, "t", 9)
assert len(recorder.requests) == 1
assert "diff --git a/a.py b/a.py" in resp.text
assert "diff --git a/b.py b/b.py" in resp.text
assert "@@ -1 +1 @@\n-x\n+y\n" in resp.text
@pytest.mark.asyncio
async def test_get_pr_diff_paginates_full_pages_and_caps_at_three(
monkeypatch: pytest.MonkeyPatch,
) -> None:
full_page = [
{"old_path": f"f{i}.py", "new_path": f"f{i}.py", "diff": "d\n"}
for i in range(100)
]
def _responder(_request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=full_page)
recorder = _Recorder(_responder)
_patch_client(monkeypatch, recorder)
await GitLabProvider("gitlab.example.com").get_pr_diff(REF, "t", 9)
# 3 full pages fetched, then the loop stops without a 4th request.
assert len(recorder.requests) == _DIFF_PAGE_CAP
pages = [r.url.params.get("page") for r in recorder.requests]
assert pages == ["1", "2", "3"]
@pytest.mark.asyncio
async def test_list_ci_runs_reports_newest_settled_pipeline(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pipelines = [
{"status": "success", "sha": "abc", "web_url": "https://x", "updated_at": "t"},
{"status": "failed", "sha": "old", "web_url": "https://y", "updated_at": "t2"},
]
recorder = _Recorder(lambda _r: httpx.Response(200, json=pipelines))
_patch_client(monkeypatch, recorder)
resp = await GitLabProvider("gitlab.example.com").list_ci_runs(
REF, "t", workflow=None, branch="main", head_sha=None, per_page=5
)
runs = resp.json()["workflow_runs"]
assert len(runs) == 1
assert runs[0]["head_sha"] == "abc"
assert runs[0]["conclusion"] == "success"
assert runs[0]["name"] == "pipeline"
@pytest.mark.asyncio
async def test_list_ci_runs_skips_unsettled_leading_pipelines(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pipelines = [
{"status": "running", "sha": "new"},
{"status": "success", "sha": "prior"},
]
recorder = _Recorder(lambda _r: httpx.Response(200, json=pipelines))
_patch_client(monkeypatch, recorder)
resp = await GitLabProvider("gitlab.example.com").list_ci_runs(
REF, "t", workflow=None, branch="main", head_sha=None, per_page=5
)
runs = resp.json()["workflow_runs"]
assert len(runs) == 1
assert runs[0]["head_sha"] == "prior"
@pytest.mark.asyncio
async def test_list_ci_runs_none_settled_yields_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(lambda _r: httpx.Response(200, json=[{"status": "running"}]))
_patch_client(monkeypatch, recorder)
resp = await GitLabProvider("gitlab.example.com").list_ci_runs(
REF, "t", workflow=None, branch="main", head_sha=None, per_page=5
)
assert resp.json()["workflow_runs"] == []
@pytest.mark.asyncio
async def test_check_runs_reshaped_from_statuses(
monkeypatch: pytest.MonkeyPatch,
) -> None:
statuses = [
{"id": 1, "status": "success", "name": "build"},
{"id": 2, "status": "running", "name": "test"},
{"id": 3, "status": "failed", "name": "lint"},
{"id": 4, "status": "canceled", "name": "deploy"},
]
recorder = _Recorder(lambda _r: httpx.Response(200, json=statuses))
_patch_client(monkeypatch, recorder)
resp = await GitLabProvider("gitlab.example.com").list_check_runs(
REF, "t", "abc123", per_page=100
)
runs = resp.json()["check_runs"]
assert runs[0] == {
"id": 1,
"name": "build",
"status": "completed",
"conclusion": "success",
}
assert runs[1]["status"] == "in_progress"
assert runs[1]["conclusion"] is None
assert runs[2]["conclusion"] == "failure"
assert runs[3]["conclusion"] == "failure"
@pytest.mark.asyncio
async def test_list_workflows_reports_total_count_from_pipelines(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(lambda _r: httpx.Response(200, json=[{"id": 1}]))
_patch_client(monkeypatch, recorder)
resp = await GitLabProvider("gitlab.example.com").list_workflows(
REF, "t", per_page=1
)
assert resp.json() == {"total_count": 1}
@pytest.mark.asyncio
async def test_list_workflows_zero_when_no_pipelines(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(lambda _r: httpx.Response(200, json=[]))
_patch_client(monkeypatch, recorder)
resp = await GitLabProvider("gitlab.example.com").list_workflows(
REF, "t", per_page=1
)
assert resp.json() == {"total_count": 0}
@pytest.mark.asyncio
async def test_get_repo_maps_merge_method_and_squash_option(
monkeypatch: pytest.MonkeyPatch,
) -> None:
repo_obj = {
"path_with_namespace": "group/sub/proj",
"web_url": "https://gitlab.example.com/group/sub/proj",
"http_url_to_repo": "https://gitlab.example.com/group/sub/proj.git",
"squash_option": "never",
"merge_method": "ff",
}
recorder = _Recorder(lambda _r: httpx.Response(200, json=repo_obj))
_patch_client(monkeypatch, recorder)
resp = await GitLabProvider("gitlab.example.com").get_repo(REF, "t")
shaped = resp.json()
assert shaped["full_name"] == "group/sub/proj"
assert shaped["html_url"] == "https://gitlab.example.com/group/sub/proj"
assert shaped["clone_url"] == "https://gitlab.example.com/group/sub/proj.git"
assert shaped["allow_squash_merge"] is False
assert shaped["allow_merge_commit"] is False
assert shaped["allow_rebase_merge"] is True
@pytest.mark.asyncio
async def test_get_repo_defaults_when_settings_absent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
_patch_client(monkeypatch, recorder)
resp = await GitLabProvider("gitlab.example.com").get_repo(REF, "t")
shaped = resp.json()
assert shaped["allow_squash_merge"] is True
assert shaped["allow_merge_commit"] is True
assert shaped["allow_rebase_merge"] is False
@pytest.mark.asyncio
async def test_ensure_label_prefixes_hash_on_color(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(lambda _r: httpx.Response(201, json={}))
_patch_client(monkeypatch, recorder)
await GitLabProvider("gitlab.example.com").ensure_label(REF, "t", "root", "8250df")
assert b"#8250df" in recorder.requests[0].content
@pytest.mark.asyncio
async def test_add_labels_uses_add_labels_key(monkeypatch: pytest.MonkeyPatch) -> None:
recorder = _Recorder(lambda _r: httpx.Response(200, json={}))
_patch_client(monkeypatch, recorder)
await GitLabProvider("gitlab.example.com").add_labels(REF, "t", 5, ["a", "b"])
request = recorder.requests[0]
assert request.method == "PUT"
assert (
b'"add_labels": "a,b"' in request.content
or b'"add_labels":"a,b"' in request.content
)
@pytest.mark.asyncio
async def test_delete_branch_ref_urlencodes_branch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(lambda _r: httpx.Response(204))
_patch_client(monkeypatch, recorder)
await GitLabProvider("gitlab.example.com").delete_branch_ref(
REF, "t", "feature/backend/ABC12345"
)
request = recorder.requests[0]
assert request.method == "DELETE"
assert "feature%2Fbackend%2FABC12345" in str(request.url)
@pytest.mark.asyncio
async def test_create_issue_comment_posts_note(monkeypatch: pytest.MonkeyPatch) -> None:
recorder = _Recorder(lambda _r: httpx.Response(201, json={}))
_patch_client(monkeypatch, recorder)
await GitLabProvider("gitlab.example.com").create_issue_comment(REF, "t", 5, "hi")
request = recorder.requests[0]
assert request.url.path.endswith("/merge_requests/5/notes")
assert b"hi" in request.content
@pytest.mark.asyncio
async def test_create_release_shapes_html_url_from_links_self(
monkeypatch: pytest.MonkeyPatch,
) -> None:
recorder = _Recorder(
lambda _r: httpx.Response(
201,
json={
"tag_name": "v1.0.0",
"_links": {
"self": "https://gitlab.example.com/group/sub/proj/-/releases/v1.0.0"
},
},
)
)
_patch_client(monkeypatch, recorder)
resp = await GitLabProvider("gitlab.example.com").create_release(
REF,
"t",
tag_name="v1.0.0",
name="v1.0.0",
body="notes",
target_commitish="main",
)
assert resp.json()["html_url"] == (
"https://gitlab.example.com/group/sub/proj/-/releases/v1.0.0"
)
request = recorder.requests[0]
assert (
b'"description": "notes"' in request.content
or b'"description":"notes"' in request.content
)
@pytest.mark.asyncio
async def test_request_reviewers_is_synthetic_skip() -> None:
resp = await GitLabProvider("gitlab.example.com").request_reviewers(
REF, "t", 5, ["renzo"]
)
assert isinstance(resp, ShapedResponse)
assert resp.is_success
assert "skipped" in resp.json()
@pytest.mark.asyncio
async def test_merge_branch_is_shaped_not_implemented() -> None:
resp = await GitLabProvider("gitlab.example.com").merge_branch(
REF, "t", base="stag", head="main", commit_message="cascade"
)
assert isinstance(resp, ShapedResponse)
assert resp.status_code == httpx.codes.NOT_IMPLEMENTED
@pytest.mark.asyncio
async def test_create_org_repo_is_synthetic_501() -> None:
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"]
+90
View File
@@ -0,0 +1,90 @@
"""ForgeRouter + registry: host-map registration, per-call provider
resolution off RepoRef.host, and URL parsing that stamps the host.
"""
from __future__ import annotations
import pytest
from roboco.exceptions import GitError
from roboco.services.forge import registry
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
from roboco.services.forge.registry import (
provider_for,
register_project_forge,
)
from roboco.services.forge.router import ForgeRouter
@pytest.fixture(autouse=True)
def _clean_host_map() -> None:
registry._HOST_PROVIDERS.clear()
def test_github_host_needs_no_registration() -> None:
register_project_forge("https://github.com/acme/widgets.git", "github")
assert registry._HOST_PROVIDERS == {}
assert registry.provider_name_for_host("github.com") == "github"
def test_gitea_host_registers_and_resolves() -> None:
register_project_forge("https://gitea.example.com/acme/widgets.git", "gitea")
assert registry.provider_name_for_host("gitea.example.com") == "gitea"
def test_router_resolves_provider_from_ref_host() -> None:
register_project_forge("https://gitea.example.com/acme/widgets.git", "gitea")
router = ForgeRouter()
assert isinstance(router._provider_for_ref(RepoRef("a", "b")), GitHubProvider)
assert isinstance(
router._provider_for_ref(RepoRef("a", "b", host="gitea.example.com")),
GiteaProvider,
)
def test_router_parse_github_url_unchanged() -> None:
ref = ForgeRouter().parse_repo_ref("git@github.com:acme/widgets.git")
assert ref == RepoRef("acme", "widgets")
assert ref.host is None
def test_router_parse_registered_gitea_url_stamps_host() -> None:
register_project_forge("https://gitea.example.com/acme/widgets.git", "gitea")
ref = ForgeRouter().parse_repo_ref("https://gitea.example.com/acme/widgets.git")
assert ref.host == "gitea.example.com"
def test_router_parse_unregistered_host_fails_loud() -> None:
with pytest.raises(GitError, match="registered project"):
ForgeRouter().parse_repo_ref("https://git.internal.example/a/b.git")
def test_provider_for_gitea_project_uses_git_url_host() -> None:
class _Project:
git_provider = "gitea"
git_url = "https://gitea.example.com/acme/widgets.git"
provider = provider_for(_Project())
assert isinstance(provider, GiteaProvider)
def test_provider_for_gitlab_project_uses_git_url_host() -> None:
class _Project:
git_provider = "gitlab"
git_url = "https://gitlab.com/acme/widgets.git"
assert isinstance(provider_for(_Project()), GitLabProvider)
def test_router_routes_registered_gitlab_host() -> None:
register_project_forge("https://gitlab.example.com/g/sub/p.git", "gitlab")
router = ForgeRouter()
provider = router._provider_for_ref(
RepoRef("g/sub/p", "", host="gitlab.example.com")
)
assert isinstance(provider, GitLabProvider)
ref = router.parse_repo_ref("https://gitlab.example.com/g/sub/p.git")
assert ref.owner == "g/sub/p"
assert ref.host == "gitlab.example.com"
+15 -16
View File
@@ -18,6 +18,7 @@ import pytest
from roboco.config import settings
from roboco.exceptions import GitCommandError, GitError, MergeConflictError
from roboco.services.base import NotFoundError, UnauthorizedError, ValidationError
from roboco.services.forge import RepoRef
from roboco.services.git import GitService
if TYPE_CHECKING:
@@ -442,7 +443,7 @@ async def test_pr_target_returns_base_ref() -> None:
svc = _service(execute_returns=result)
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="token"))
fake_response = MagicMock()
@@ -490,7 +491,7 @@ async def test_create_pr_returns_pr_dict() -> None:
_bind(svc, "_task_for_branch", AsyncMock(return_value=fake_task))
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
_bind(svc, "_record_pr_atomically", AsyncMock())
# parent == default → _ensure_base_on_remote short-circuits (no git call)
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
@@ -532,7 +533,7 @@ async def test_create_pr_records_pr_despite_cancellation_after_post() -> None:
_bind(svc, "_task_for_branch", AsyncMock(return_value=fake_task))
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
recorded = {"done": False}
@@ -590,7 +591,7 @@ async def test_create_pr_cancellation_waits_out_record_before_reraising() -> Non
_bind(svc, "_task_for_branch", AsyncMock(return_value=fake_task))
_bind(svc, "_workspace_for_branch", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
order: list[str] = []
@@ -737,7 +738,7 @@ async def test_ensure_label_exists_swallows_non_httpx_error() -> None:
"roboco.services.git.httpx.AsyncClient",
return_value=_non_httpx_raising_client(),
):
await svc._ensure_label_exists("acme", "repo", "tok", "cell/backend")
await svc._ensure_label_exists(RepoRef("acme", "repo"), "tok", "cell/backend")
@pytest.mark.asyncio
@@ -748,7 +749,7 @@ async def test_apply_pr_labels_swallows_non_httpx_error() -> None:
"roboco.services.git.httpx.AsyncClient",
return_value=_non_httpx_raising_client(),
):
await svc._apply_pr_labels("acme", "repo", "tok", 11, ["cell/backend"])
await svc._apply_pr_labels(RepoRef("acme", "repo"), "tok", 11, ["cell/backend"])
# ---------------------------------------------------------------------------
@@ -774,7 +775,7 @@ async def test_pr_merge_returns_merge_commit_dict() -> None:
svc = _service(execute_returns=result)
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
fake_resp = MagicMock(is_success=True, status_code=200)
_bind(svc, "_call_merge_api", AsyncMock(return_value=fake_resp))
@@ -805,7 +806,7 @@ async def test_pr_merge_into_default_branch_is_ceo_only() -> None:
svc = _service(execute_returns=result)
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
_bind(svc, "_project_default_branch", AsyncMock(return_value="master"))
merge_api = AsyncMock()
_bind(svc, "_call_merge_api", merge_api)
@@ -1302,7 +1303,7 @@ async def test_pr_is_merged_returns_none_on_httpx_error() -> None:
"roboco.services.git.httpx.AsyncClient",
return_value=_httpx_raising_client(),
):
out = await svc._pr_is_merged("acme", "repo", 11, "tok")
out = await svc._pr_is_merged(RepoRef("acme", "repo"), 11, "tok")
assert out is None
@@ -1317,8 +1318,7 @@ async def test_merge_with_retry_none_does_not_raise_merge_conflict() -> None:
_bind(svc, "_pr_is_merged", AsyncMock(return_value=None))
ctx = GitService._MergeContext(
owner="acme",
repo="repo",
repo_ref=RepoRef("acme", "repo"),
pr_number=11,
git_token="tok",
workspace=Path("/tmp/ws"),
@@ -1340,8 +1340,7 @@ async def test_merge_with_retry_false_raises_merge_conflict() -> None:
_bind(svc, "_pr_is_merged", AsyncMock(return_value=False))
ctx = GitService._MergeContext(
owner="acme",
repo="repo",
repo_ref=RepoRef("acme", "repo"),
pr_number=11,
git_token="tok",
workspace=Path("/tmp/ws"),
@@ -1356,7 +1355,7 @@ async def test_merge_pull_request_none_does_not_raise_git_error() -> None:
"""CEO merge path: indeterminate (None) falls through to cleanup, not GitError."""
svc = _service()
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
_bind(svc, "_first_allowed_merge_method", AsyncMock(return_value=None))
_bind(svc, "_delete_pr_branch_best_effort", AsyncMock())
_bind(svc, "_sync_target_branch", AsyncMock(return_value="abc123sha"))
@@ -1381,7 +1380,7 @@ async def test_is_pr_merged_for_task_none_treated_as_merged() -> None:
svc = _service(execute_returns=result)
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
_bind(svc, "_project_for_task", AsyncMock(return_value=fake_project))
_bind(svc, "_resolve_workspace_agent_id", MagicMock(return_value=None))
_bind(svc, "_pr_is_merged", AsyncMock(return_value=None))
@@ -1451,7 +1450,7 @@ async def test_update_pr_for_task_threads_actor_agent_id() -> None:
return Path("/tmp/ws")
_bind(svc, "get_workspace", AsyncMock(side_effect=_capture_workspace))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
fake_task_service = MagicMock()
@@ -15,6 +15,7 @@ from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.services.forge import RepoRef
from roboco.services.git import GitService
@@ -48,7 +49,7 @@ async def test_delete_skips_branch_with_open_dependents() -> None:
client = _fake_client()
with patch("roboco.services.git.httpx.AsyncClient", return_value=client):
await svc._delete_remote_branch_best_effort(
"acme", "repo", "feature/main_pm/abc123", "tok"
RepoRef("acme", "repo"), "feature/main_pm/abc123", "tok"
)
client.delete.assert_not_awaited()
@@ -60,7 +61,7 @@ async def test_delete_removes_leaf_branch_with_no_dependents() -> None:
client = _fake_client()
with patch("roboco.services.git.httpx.AsyncClient", return_value=client):
await svc._delete_remote_branch_best_effort(
"acme", "repo", "feature/backend/abc--cell--leaf", "tok"
RepoRef("acme", "repo"), "feature/backend/abc--cell--leaf", "tok"
)
client.delete.assert_awaited_once()
@@ -72,7 +73,9 @@ async def test_delete_skips_default_branch_before_checking_dependents() -> None:
_bind(svc, "_branch_has_open_dependents", dep)
client = _fake_client()
with patch("roboco.services.git.httpx.AsyncClient", return_value=client):
await svc._delete_remote_branch_best_effort("acme", "repo", "master", "tok")
await svc._delete_remote_branch_best_effort(
RepoRef("acme", "repo"), "master", "tok"
)
client.delete.assert_not_awaited()
dep.assert_not_awaited()
@@ -89,7 +92,7 @@ async def test_has_open_dependents_true_when_open_pr_targets_base() -> None:
client.get = AsyncMock(return_value=resp)
with patch("roboco.services.git.httpx.AsyncClient", return_value=client):
out = await svc._branch_has_open_dependents(
"acme", "repo", "feature/main_pm/abc123", "tok"
RepoRef("acme", "repo"), "feature/main_pm/abc123", "tok"
)
assert out is True
@@ -103,7 +106,7 @@ async def test_has_open_dependents_false_when_none() -> None:
client.get = AsyncMock(return_value=resp)
with patch("roboco.services.git.httpx.AsyncClient", return_value=client):
out = await svc._branch_has_open_dependents(
"acme", "repo", "feature/x--leaf", "tok"
RepoRef("acme", "repo"), "feature/x--leaf", "tok"
)
assert out is False
@@ -116,6 +119,6 @@ async def test_has_open_dependents_fails_safe_on_non_success() -> None:
client.get = AsyncMock(return_value=resp)
with patch("roboco.services.git.httpx.AsyncClient", return_value=client):
out = await svc._branch_has_open_dependents(
"acme", "repo", "feature/main_pm/abc123", "tok"
RepoRef("acme", "repo"), "feature/main_pm/abc123", "tok"
)
assert out is True
@@ -0,0 +1,133 @@
"""The env-sync local-git merge fallback (forge Phase 2.1): a provider's
shaped 501 routes sync_env_branch through a throwaway clonemergepush, with
the same status vocabulary the merges-API path produces.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock
import pytest
import roboco.services.git as git_module
from roboco.services.git import GitService
if TYPE_CHECKING:
from pathlib import Path
def _bind(svc: GitService, name: str, value: object) -> None:
"""Stub without tripping mypy's method-assign check (test_git.py idiom)."""
setattr(svc, name, value)
def _service() -> GitService:
svc = GitService.__new__(GitService)
_bind(svc, "log", MagicMock())
return svc
def _result(returncode: int = 0, stdout: str = "") -> SimpleNamespace:
return SimpleNamespace(returncode=returncode, stdout=stdout, stderr="")
def _scripted_run_git(
outcomes: dict[str, SimpleNamespace],
) -> tuple[AsyncMock, list[str]]:
"""Route each _run_git call by its first meaningful arg; record verbs."""
verbs: list[str] = []
async def _run(_workspace: Path, args: list[str], **_kw: Any) -> SimpleNamespace:
verb = args[0] if args[0] != "merge-base" else "merge-base"
verbs.append(verb)
return outcomes.get(verb, _result())
return AsyncMock(side_effect=_run), verbs
@pytest.mark.asyncio
async def test_clean_merge_pushes_and_reports_sha() -> None:
svc = _service()
# merge-base non-zero = not an ancestor → real merge happens.
outcomes = {
"merge-base": _result(1),
"rev-parse": _result(0, "abc123\n"),
}
run, verbs = _scripted_run_git(outcomes)
_bind(svc, "_run_git", run)
status = await svc._local_merge_branch("https://g/x/y.git", "tok", "stag", "main")
assert status == {"status": "merged", "sha": "abc123"}
assert verbs[0] == "clone"
assert "merge" in verbs
assert "push" in verbs
@pytest.mark.asyncio
async def test_already_ancestor_short_circuits() -> None:
svc = _service()
run, verbs = _scripted_run_git({"merge-base": _result(0)})
_bind(svc, "_run_git", run)
status = await svc._local_merge_branch("https://g/x/y.git", "tok", "stag", "main")
assert status == {"status": "already_ancestor"}
assert "merge" not in verbs
assert "push" not in verbs
@pytest.mark.asyncio
async def test_merge_conflict_never_pushes() -> None:
svc = _service()
run, verbs = _scripted_run_git(
{"merge-base": _result(1), "merge": _result(1, "CONFLICT")}
)
_bind(svc, "_run_git", run)
status = await svc._local_merge_branch("https://g/x/y.git", "tok", "stag", "main")
assert status == {"status": "conflict"}
assert "push" not in verbs
@pytest.mark.asyncio
async def test_missing_branch_maps_to_missing_ref() -> None:
svc = _service()
run, _ = _scripted_run_git({"fetch": _result(128)})
_bind(svc, "_run_git", run)
status = await svc._local_merge_branch("https://g/x/y.git", "tok", "stag", "main")
assert status == {"status": "missing_ref"}
@pytest.mark.asyncio
async def test_sync_env_branch_routes_shaped_501_to_fallback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
svc = _service()
_bind(svc, "session", MagicMock())
project = SimpleNamespace(git_url="https://gitea.example.com/a/b.git")
project_svc = MagicMock(get_by_slug=AsyncMock(return_value=project))
monkeypatch.setattr(git_module, "get_project_service", lambda _s: project_svc)
_bind(svc, "_token_for_project", AsyncMock(return_value="tok"))
_bind(svc, "_parse_git_url", MagicMock(return_value=MagicMock()))
forge = MagicMock(
merge_branch=AsyncMock(
return_value=SimpleNamespace(status_code=501, text="", json=dict)
)
)
monkeypatch.setattr(GitService, "_forge", property(lambda _self: forge))
fallback = AsyncMock(return_value={"status": "merged", "sha": "abc"})
_bind(svc, "_local_merge_branch", fallback)
status = await svc.sync_env_branch("proj", "stag", "main")
assert status == {"status": "merged", "sha": "abc"}
fallback.assert_awaited_once_with(
"https://gitea.example.com/a/b.git", "tok", "stag", "main"
)
@@ -15,6 +15,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.exceptions import MergeConflictError
from roboco.services.forge import RepoRef
from roboco.services.git import GitService
@@ -39,8 +40,7 @@ def _resp(status_code: int, *, is_success: bool) -> Any:
def _ctx() -> Any:
return GitService._MergeContext(
owner="acme",
repo="repo",
repo_ref=RepoRef("acme", "repo"),
pr_number=42,
git_token="tok",
workspace=Path("/ws"),
@@ -90,7 +90,7 @@ async def test_pr_is_merged_true_when_github_reports_merged() -> None:
client.__aexit__ = AsyncMock(return_value=False)
client.get = AsyncMock(return_value=resp)
with patch("roboco.services.git.httpx.AsyncClient", return_value=client):
assert await svc._pr_is_merged("acme", "repo", 42, "tok") is True
assert await svc._pr_is_merged(RepoRef("acme", "repo"), 42, "tok") is True
@pytest.mark.asyncio
@@ -102,4 +102,4 @@ async def test_pr_is_merged_false_on_non_success() -> None:
client.__aexit__ = AsyncMock(return_value=False)
client.get = AsyncMock(return_value=resp)
with patch("roboco.services.git.httpx.AsyncClient", return_value=client):
assert await svc._pr_is_merged("acme", "repo", 42, "tok") is False
assert await svc._pr_is_merged(RepoRef("acme", "repo"), 42, "tok") is False
@@ -15,6 +15,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
import roboco.services.git as git_module
from roboco.exceptions import GitError
from roboco.services.forge import RepoRef
from roboco.services.git import GitService
@@ -71,7 +72,9 @@ async def test_first_allowed_skips_disabled_method(
monkeypatch.setattr(
git_module.httpx, "AsyncClient", lambda *_a, **_k: _FakeClient(resp)
)
method = await svc._first_allowed_merge_method("o", "r", "tok", exclude="squash")
method = await svc._first_allowed_merge_method(
RepoRef("o", "r"), "tok", exclude="squash"
)
assert method == "merge" # squash disabled + excluded -> next permitted
@@ -84,7 +87,7 @@ async def test_first_allowed_returns_none_when_lookup_fails(
monkeypatch.setattr(
git_module.httpx, "AsyncClient", lambda *_a, **_k: _FakeClient(resp)
)
assert await svc._first_allowed_merge_method("o", "r", "tok") is None
assert await svc._first_allowed_merge_method(RepoRef("o", "r"), "tok") is None
@pytest.mark.asyncio
@@ -95,7 +98,9 @@ async def test_merge_retries_with_allowed_method_on_405(
monkeypatch.setattr(
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
)
monkeypatch.setattr(svc, "_parse_github_remote", lambda _ws: ("owner", "repo"))
monkeypatch.setattr(
svc, "_parse_github_remote", lambda _ws: RepoRef("owner", "repo")
)
monkeypatch.setattr(
svc, "_delete_pr_branch_best_effort", AsyncMock(return_value=None)
)
@@ -109,9 +114,7 @@ async def test_merge_retries_with_allowed_method_on_405(
calls: list[str] = []
async def fake_call(
_owner: str, _repo: str, _pr: int, _token: str, method: str
) -> Any:
async def fake_call(_repo_ref: RepoRef, _pr: int, _token: str, method: str) -> Any:
calls.append(method)
return (
_resp(200, is_success=True)
@@ -136,7 +139,9 @@ async def test_merge_does_not_retry_when_method_allowed(
monkeypatch.setattr(
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
)
monkeypatch.setattr(svc, "_parse_github_remote", lambda _ws: ("owner", "repo"))
monkeypatch.setattr(
svc, "_parse_github_remote", lambda _ws: RepoRef("owner", "repo")
)
monkeypatch.setattr(
svc, "_delete_pr_branch_best_effort", AsyncMock(return_value=None)
)
@@ -149,9 +154,7 @@ async def test_merge_does_not_retry_when_method_allowed(
calls: list[str] = []
async def fake_call(
_owner: str, _repo: str, _pr: int, _token: str, method: str
) -> Any:
async def fake_call(_repo_ref: RepoRef, _pr: int, _token: str, method: str) -> Any:
calls.append(method)
return _resp(200, is_success=True)
@@ -176,7 +179,9 @@ async def test_merge_already_merged_pr_is_idempotent_success(
monkeypatch.setattr(
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
)
monkeypatch.setattr(svc, "_parse_github_remote", lambda _ws: ("owner", "repo"))
monkeypatch.setattr(
svc, "_parse_github_remote", lambda _ws: RepoRef("owner", "repo")
)
delete_branch = AsyncMock(return_value=None)
monkeypatch.setattr(svc, "_delete_pr_branch_best_effort", delete_branch)
monkeypatch.setattr(
@@ -219,7 +224,9 @@ async def test_merge_raises_when_not_merged_and_refused(
monkeypatch.setattr(
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
)
monkeypatch.setattr(svc, "_parse_github_remote", lambda _ws: ("owner", "repo"))
monkeypatch.setattr(
svc, "_parse_github_remote", lambda _ws: RepoRef("owner", "repo")
)
delete_branch = AsyncMock(return_value=None)
monkeypatch.setattr(svc, "_delete_pr_branch_best_effort", delete_branch)
monkeypatch.setattr(
@@ -21,6 +21,7 @@ from uuid import uuid4
import pytest
from roboco.services.base import NotFoundError
from roboco.services.forge import RepoRef
from roboco.services.git import GitService
if TYPE_CHECKING:
@@ -130,7 +131,7 @@ async def test_pr_target_with_project_id_skips_wrong_repo_task() -> None:
svc = GitService(session)
_bind = object.__setattr__
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="token"))
with _patch_project_service(MagicMock(slug="roboco")), pytest.raises(NotFoundError):
+2 -1
View File
@@ -14,6 +14,7 @@ from uuid import UUID, uuid4
import pytest
from roboco.exceptions import GitError
from roboco.services.base import NotFoundError
from roboco.services.forge import RepoRef
from roboco.services.git import GitService
if TYPE_CHECKING:
@@ -93,7 +94,7 @@ async def _stub_task_get(svc: GitService, task: object | None) -> None:
def _wire_service(svc: GitService, task: MagicMock) -> MagicMock:
"""Apply common bindings: workspace, remote parse, token resolution."""
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
# update_pr_for_task fetches the task via get_task_service; we patch it
@@ -15,6 +15,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.services.forge import RepoRef
from roboco.services.git import GitService
@@ -151,7 +152,7 @@ async def test_close_pull_request_patches_state_closed(
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
)
monkeypatch.setattr(
svc, "_parse_github_remote", MagicMock(return_value=("owner", "repo"))
svc, "_parse_github_remote", MagicMock(return_value=RepoRef("owner", "repo"))
)
monkeypatch.setattr(svc, "_delete_pr_branch_best_effort", delete_branch)
@@ -233,7 +234,7 @@ async def test_close_pull_request_does_not_delete_branch_by_default(
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
)
monkeypatch.setattr(
svc, "_parse_github_remote", MagicMock(return_value=("owner", "repo"))
svc, "_parse_github_remote", MagicMock(return_value=RepoRef("owner", "repo"))
)
monkeypatch.setattr(svc, "_delete_pr_branch_best_effort", delete_branch)
@@ -299,7 +300,7 @@ async def test_close_pull_request_idempotent_when_already_closed(
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
)
monkeypatch.setattr(
svc, "_parse_github_remote", MagicMock(return_value=("owner", "repo"))
svc, "_parse_github_remote", MagicMock(return_value=RepoRef("owner", "repo"))
)
monkeypatch.setattr(svc, "_delete_pr_branch_best_effort", AsyncMock())
@@ -26,6 +26,7 @@ from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
from roboco.services.forge import RepoRef
from roboco.services.git import GitService
@@ -111,7 +112,7 @@ async def test_rebase_pr_for_task_rebases_in_worktree_not_clone() -> None:
state = _stub_rebase_common(svc, clone)
object.__setattr__(
svc, "_parse_github_remote", MagicMock(return_value=("owner", "repo"))
svc, "_parse_github_remote", MagicMock(return_value=RepoRef("owner", "repo"))
)
object.__setattr__(
svc,
@@ -22,6 +22,7 @@ from uuid import uuid4
import pytest
from roboco.exceptions import GitError, MergeConflictError
from roboco.services.forge import RepoRef
from roboco.services.git import GitService
# Module-level constants kept local so the assertions stay readable and
@@ -101,7 +102,7 @@ async def test_pr_merge_retries_once_on_409_conflict() -> None:
svc = GitService(_make_session(fake_task, fake_parent))
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
call_seq = AsyncMock(side_effect=[_fake_response(409), _fake_response(200)])
_bind(svc, "_call_merge_api", call_seq)
@@ -145,7 +146,7 @@ async def test_pr_merge_raises_after_second_409() -> None:
svc = GitService(_make_session(fake_task, fake_parent))
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
call_seq = AsyncMock(side_effect=[_fake_response(409), _fake_response(409)])
_bind(svc, "_call_merge_api", call_seq)
@@ -182,7 +183,7 @@ async def test_pr_merge_does_not_retry_on_non_409_error() -> None:
svc = GitService(_make_session(fake_task, fake_parent))
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
call_seq = AsyncMock(side_effect=[_fake_response(422)])
_bind(svc, "_call_merge_api", call_seq)
@@ -220,7 +221,7 @@ async def test_pr_merge_locks_parent_task_with_for_update() -> None:
svc = GitService(session)
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
_bind(svc, "_call_merge_api", AsyncMock(return_value=_fake_response(200)))
_bind(svc, "_delete_pr_branch_best_effort", AsyncMock())
_bind(svc, "_sync_target_branch", AsyncMock(return_value="abc"))
@@ -268,7 +269,7 @@ async def test_pr_merge_skips_parent_lock_for_root_task() -> None:
svc = GitService(session)
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
_bind(svc, "_call_merge_api", AsyncMock(return_value=_fake_response(200)))
_bind(svc, "_delete_pr_branch_best_effort", AsyncMock())
_bind(svc, "_sync_target_branch", AsyncMock(return_value="abc"))
@@ -316,7 +317,7 @@ async def test_pr_merge_scopes_task_lookup_by_project_id() -> None:
svc = GitService(session)
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
_bind(svc, "_get_project_token_or_raise", AsyncMock(return_value="tok"))
_bind(svc, "_parse_github_remote", MagicMock(return_value=("acme", "repo")))
_bind(svc, "_parse_github_remote", MagicMock(return_value=RepoRef("acme", "repo")))
_bind(svc, "_call_merge_api", AsyncMock(return_value=_fake_response(200)))
_bind(svc, "_delete_pr_branch_best_effort", AsyncMock())
_bind(svc, "_sync_target_branch", AsyncMock(return_value="sha"))
@@ -342,8 +343,7 @@ _HTTP_METHOD_NOT_ALLOWED = 405
def _merge_ctx(pr_number: int = 11) -> GitService._MergeContext:
return GitService._MergeContext(
owner="acme",
repo="repo",
repo_ref=RepoRef("acme", "repo"),
pr_number=pr_number,
git_token="tok",
workspace=Path("/tmp/ws"),