mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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"),
|
||||
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
MR→PR shape adaptation (iid→number, source/target_branch→head/base, merged
|
||||
bool), payload-key translation (create_pr duplicate 409→422, update_pr
|
||||
close→state_event), squash-flag merge, approve-vs-note review routing, diff
|
||||
reassembly, pipelines→workflow_runs / statuses→check_runs CI classification,
|
||||
merge-method mapping, and the deliberate Phase-3 synthetic postures
|
||||
(request_reviewers, create_org_repo, merge_branch).
|
||||
|
||||
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"]
|
||||
@@ -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"
|
||||
@@ -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 clone→merge→push, 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):
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user