mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* 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>
345 lines
12 KiB
Python
345 lines
12 KiB
Python
"""GitService PR-divergence primitives: rebase_onto_base + close_pull_request.
|
|
|
|
These back both the sequence-ordered merge (rebase a later sibling onto the
|
|
prior one's merged result) and the conflict resolver (rebase a wedged PR,
|
|
then close-if-superseded / re-merge / escalate). The classification a rebase
|
|
yields — superseded vs rebased vs conflicts — drives the whole resolution, so
|
|
each branch is pinned here against a mocked git.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
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
|
|
|
|
|
|
def _git_service() -> GitService:
|
|
return GitService.__new__(GitService)
|
|
|
|
|
|
def _result(returncode: int = 0, stdout: str = "") -> Any:
|
|
return type("R", (), {"returncode": returncode, "stdout": stdout})()
|
|
|
|
|
|
_HEAD = "feature/frontend/root--cell--leaf"
|
|
_BASE = "feature/frontend/root--cell"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebase_superseded_when_no_unique_commits() -> None:
|
|
"""Clean rebase + zero commits ahead of base => superseded (safe to close)."""
|
|
svc = _git_service()
|
|
pushed: list[list[str]] = []
|
|
|
|
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
|
|
if args[0] == "push":
|
|
pushed.append(args)
|
|
if args[:2] == ["rev-list", "--count"]:
|
|
return _result(stdout="0\n")
|
|
return _result()
|
|
|
|
with patch.object(svc, "_run_git", new=fake_run):
|
|
out = await svc.rebase_onto_base(
|
|
Path("/tmp/ws"), head_branch=_HEAD, base_branch=_BASE, git_token="tok"
|
|
)
|
|
assert out == {"status": "superseded"}
|
|
# A superseded branch must NOT be force-pushed — nothing changed.
|
|
assert pushed == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebase_rebased_force_pushes_when_unique_commits() -> None:
|
|
"""Clean rebase + commits ahead of base => rebased + force-push the head."""
|
|
svc = _git_service()
|
|
pushed: list[list[str]] = []
|
|
|
|
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
|
|
if args[0] == "push":
|
|
pushed.append(args)
|
|
return _result()
|
|
if args[:2] == ["rev-list", "--count"]:
|
|
return _result(stdout="3\n")
|
|
return _result()
|
|
|
|
with patch.object(svc, "_run_git", new=fake_run):
|
|
out = await svc.rebase_onto_base(
|
|
Path("/tmp/ws"), head_branch=_HEAD, base_branch=_BASE, git_token="tok"
|
|
)
|
|
assert out == {"status": "rebased", "unique_commits": 3}
|
|
# Only the head branch is force-pushed, with lease, never the base.
|
|
assert pushed == [["push", "--force-with-lease", "origin", f"HEAD:{_HEAD}"]]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebase_conflicts_aborts_and_reports_files() -> None:
|
|
"""A failed rebase is aborted and the conflicting files reported."""
|
|
svc = _git_service()
|
|
aborted = False
|
|
|
|
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
|
|
nonlocal aborted
|
|
if args == ["rebase", f"origin/{_BASE}"]:
|
|
return _result(returncode=1)
|
|
if args[:2] == ["diff", "--name-only"]:
|
|
return _result(stdout="src/a.tsx\nsrc/b.tsx\n")
|
|
if args == ["rebase", "--abort"]:
|
|
aborted = True
|
|
return _result()
|
|
return _result()
|
|
|
|
with patch.object(svc, "_run_git", new=fake_run):
|
|
out = await svc.rebase_onto_base(
|
|
Path("/tmp/ws"), head_branch=_HEAD, base_branch=_BASE, git_token="tok"
|
|
)
|
|
assert out == {"status": "conflicts", "files": ["src/a.tsx", "src/b.tsx"]}
|
|
assert aborted is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebase_never_force_pushes_on_conflict() -> None:
|
|
"""Guard: the destructive force-push must not fire when a rebase conflicts."""
|
|
svc = _git_service()
|
|
pushed: list[list[str]] = []
|
|
|
|
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
|
|
if args[0] == "push":
|
|
pushed.append(args)
|
|
if args == ["rebase", f"origin/{_BASE}"]:
|
|
return _result(returncode=1)
|
|
if args[:2] == ["diff", "--name-only"]:
|
|
return _result(stdout="")
|
|
return _result()
|
|
|
|
with patch.object(svc, "_run_git", new=fake_run):
|
|
await svc.rebase_onto_base(
|
|
Path("/tmp/ws"), head_branch=_HEAD, base_branch=_BASE, git_token="tok"
|
|
)
|
|
assert pushed == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_pull_request_patches_state_closed(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""close_pull_request issues a PATCH state=closed (and an optional comment)."""
|
|
svc = _git_service()
|
|
# Stub the task/project/workspace/token/remote resolution chain via
|
|
# monkeypatch.setattr (not direct assignment) so mypy's method-assign check
|
|
# stays satisfied without silencing it.
|
|
task = type("T", (), {"id": "t", "assigned_to": None, "created_by": None})()
|
|
session = AsyncMock()
|
|
session.execute = AsyncMock(
|
|
return_value=type("Res", (), {"scalar_one_or_none": lambda _self: task})()
|
|
)
|
|
delete_branch = AsyncMock()
|
|
monkeypatch.setattr(svc, "session", session, raising=False)
|
|
monkeypatch.setattr(
|
|
svc,
|
|
"_project_for_task",
|
|
AsyncMock(return_value=type("P", (), {"slug": "proj"})()),
|
|
)
|
|
monkeypatch.setattr(
|
|
svc, "_resolve_workspace_agent_id", MagicMock(return_value=None)
|
|
)
|
|
monkeypatch.setattr(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
|
monkeypatch.setattr(
|
|
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
|
|
)
|
|
monkeypatch.setattr(
|
|
svc, "_parse_github_remote", MagicMock(return_value=RepoRef("owner", "repo"))
|
|
)
|
|
monkeypatch.setattr(svc, "_delete_pr_branch_best_effort", delete_branch)
|
|
|
|
calls: list[tuple[str, str]] = []
|
|
|
|
class _Resp:
|
|
is_success = True
|
|
status_code = 200
|
|
text = ""
|
|
|
|
def json(self) -> dict[str, str]:
|
|
return {"state": "open"}
|
|
|
|
class _Client:
|
|
async def __aenter__(self) -> _Client:
|
|
return self
|
|
|
|
async def __aexit__(self, *_a: Any) -> None:
|
|
return None
|
|
|
|
async def get(self, url: str, **_kw: Any) -> _Resp:
|
|
calls.append(("GET", url))
|
|
return _Resp()
|
|
|
|
async def post(self, url: str, **_kw: Any) -> _Resp:
|
|
calls.append(("POST", url))
|
|
return _Resp()
|
|
|
|
async def patch(self, url: str, **_kw: Any) -> _Resp:
|
|
calls.append(("PATCH", url))
|
|
return _Resp()
|
|
|
|
with patch("roboco.services.git.httpx.AsyncClient", return_value=_Client()):
|
|
await svc.close_pull_request(
|
|
159,
|
|
project_id=uuid4(),
|
|
comment="superseded by #158",
|
|
delete_branch=True,
|
|
)
|
|
|
|
assert (
|
|
"POST",
|
|
"https://api.github.com/repos/owner/repo/issues/159/comments",
|
|
) in calls
|
|
assert ("PATCH", "https://api.github.com/repos/owner/repo/pulls/159") in calls
|
|
delete_branch.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_pull_request_does_not_delete_branch_by_default(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""#109: close_pull_request defaulted delete_branch=True, so the
|
|
choreographer supersede path deleted a superseded PR's branch while the
|
|
orchestrator supersede path explicitly preserved it — the two disagreed,
|
|
and the destructive default ran on the "close the dead PR" path where the
|
|
branch may still be referenced / useful for audit. The default is now
|
|
False (opt-in deletion); a superseded-PR close preserves the branch unless
|
|
the caller explicitly asks to delete. The PATCH still fires.
|
|
"""
|
|
svc = _git_service()
|
|
task = type("T", (), {"id": "t", "assigned_to": None, "created_by": None})()
|
|
session = AsyncMock()
|
|
session.execute = AsyncMock(
|
|
return_value=type("Res", (), {"scalar_one_or_none": lambda _self: task})()
|
|
)
|
|
delete_branch = AsyncMock()
|
|
monkeypatch.setattr(svc, "session", session, raising=False)
|
|
monkeypatch.setattr(
|
|
svc,
|
|
"_project_for_task",
|
|
AsyncMock(return_value=type("P", (), {"slug": "proj"})()),
|
|
)
|
|
monkeypatch.setattr(
|
|
svc, "_resolve_workspace_agent_id", MagicMock(return_value=None)
|
|
)
|
|
monkeypatch.setattr(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
|
monkeypatch.setattr(
|
|
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
|
|
)
|
|
monkeypatch.setattr(
|
|
svc, "_parse_github_remote", MagicMock(return_value=RepoRef("owner", "repo"))
|
|
)
|
|
monkeypatch.setattr(svc, "_delete_pr_branch_best_effort", delete_branch)
|
|
|
|
class _Resp:
|
|
is_success = True
|
|
status_code = 200
|
|
text = ""
|
|
|
|
def json(self) -> dict[str, str]:
|
|
return {"state": "open"}
|
|
|
|
class _Client:
|
|
async def __aenter__(self) -> _Client:
|
|
return self
|
|
|
|
async def __aexit__(self, *_a: Any) -> None:
|
|
return None
|
|
|
|
async def get(self, _url: str, **_kw: Any) -> _Resp:
|
|
return _Resp()
|
|
|
|
async def post(self, _url: str, **_kw: Any) -> _Resp:
|
|
return _Resp()
|
|
|
|
async def patch(self, _url: str, **_kw: Any) -> _Resp:
|
|
return _Resp()
|
|
|
|
with patch("roboco.services.git.httpx.AsyncClient", return_value=_Client()):
|
|
# No delete_branch kwarg → default must preserve the branch.
|
|
await svc.close_pull_request(159, project_id=uuid4(), comment="superseded")
|
|
|
|
# The close PATCH still fires (the PR is closed); the branch is NOT deleted.
|
|
delete_branch.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_pull_request_idempotent_when_already_closed(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""An already-closed PR is a no-op: no duplicate comment, no PATCH.
|
|
|
|
Guards the close-on-land retry path — a transient failure between the
|
|
comment POST and the close PATCH must not re-post the explanatory comment
|
|
on the next sweep.
|
|
"""
|
|
svc = _git_service()
|
|
task = type("T", (), {"id": "t", "assigned_to": None, "created_by": None})()
|
|
session = AsyncMock()
|
|
session.execute = AsyncMock(
|
|
return_value=type("Res", (), {"scalar_one_or_none": lambda _self: task})()
|
|
)
|
|
monkeypatch.setattr(svc, "session", session, raising=False)
|
|
monkeypatch.setattr(
|
|
svc,
|
|
"_project_for_task",
|
|
AsyncMock(return_value=type("P", (), {"slug": "proj"})()),
|
|
)
|
|
monkeypatch.setattr(
|
|
svc, "_resolve_workspace_agent_id", MagicMock(return_value=None)
|
|
)
|
|
monkeypatch.setattr(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
|
|
monkeypatch.setattr(
|
|
svc, "_get_project_token_or_raise", AsyncMock(return_value="tok")
|
|
)
|
|
monkeypatch.setattr(
|
|
svc, "_parse_github_remote", MagicMock(return_value=RepoRef("owner", "repo"))
|
|
)
|
|
monkeypatch.setattr(svc, "_delete_pr_branch_best_effort", AsyncMock())
|
|
|
|
calls: list[tuple[str, str]] = []
|
|
|
|
class _Resp:
|
|
is_success = True
|
|
status_code = 200
|
|
text = ""
|
|
|
|
def json(self) -> dict[str, str]:
|
|
return {"state": "closed"}
|
|
|
|
class _Client:
|
|
async def __aenter__(self) -> _Client:
|
|
return self
|
|
|
|
async def __aexit__(self, *_a: Any) -> None:
|
|
return None
|
|
|
|
async def get(self, url: str, **_kw: Any) -> _Resp:
|
|
calls.append(("GET", url))
|
|
return _Resp()
|
|
|
|
async def post(self, url: str, **_kw: Any) -> _Resp:
|
|
calls.append(("POST", url))
|
|
return _Resp()
|
|
|
|
async def patch(self, url: str, **_kw: Any) -> _Resp:
|
|
calls.append(("PATCH", url))
|
|
return _Resp()
|
|
|
|
with patch("roboco.services.git.httpx.AsyncClient", return_value=_Client()):
|
|
await svc.close_pull_request(
|
|
159,
|
|
project_id=uuid4(),
|
|
comment="superseded by #158",
|
|
delete_branch=False,
|
|
)
|
|
|
|
assert [c[0] for c in calls] == ["GET"] # no POST comment, no PATCH
|