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
+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 409→422, statuses→check_runs, combined-status→workflow_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"),