Files
roboco/tests/unit/services/test_git_pr_target_scoping.py
T
96401f4c10 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>
2026-07-19 08:12:34 +02:00

144 lines
5.6 KiB
Python

"""pr_target must scope its task lookup by project_id — always.
GitHub numbers PRs per-repo, so ``pr_number`` is ambiguous across projects: a
backend repo's PR #132 and a frontend repo's PR #132 are different PRs. The
bare ``WHERE pr_number == N LIMIT 1`` query returns whichever task row comes
first — the wrong repo's task, whose ``_project_for_task`` then resolves the
wrong project and the GitHub fetch hits the wrong repo.
``project_id`` is therefore MANDATORY: the caller can never resolve a PR
without scoping it to a project, so a same-numbered PR in another project's
repo is unreachable by accident — the pattern ``pr_merge`` already
established (and ``close_pull_request`` follows).
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch
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:
from contextlib import AbstractContextManager
_PR_NUMBER = 132
def _make_session(recorder: list[object]) -> MagicMock:
session = MagicMock()
async def _execute(stmt: object) -> MagicMock:
recorder.append(stmt)
result = MagicMock()
result.scalar_one_or_none.return_value = None
return result
session.execute = AsyncMock(side_effect=_execute)
return session
def _service(recorder: list[object]) -> GitService:
return GitService(_make_session(recorder))
def _patch_project_service(project: object | None) -> AbstractContextManager[object]:
fake_service = MagicMock()
fake_service.get = AsyncMock(return_value=project)
fake_service.get_by_slug = AsyncMock(return_value=project)
return patch("roboco.services.git.get_project_service", return_value=fake_service)
def _compiled_sql(stmt: Any) -> str:
"""Render a SQLAlchemy stmt to literal-bound SQL for assertion."""
return str(stmt.compile(compile_kwargs={"literal_binds": True}))
@pytest.mark.asyncio
async def test_pr_target_scopes_task_lookup_by_project_id() -> None:
"""The task lookup WHERE clause filters on BOTH pr_number and project_id
— a same-numbered PR in another project's repo can't be resolved by
accident."""
recorder: list[object] = []
svc = _service(recorder)
project_id = uuid4()
# Task lookup returns None → NotFoundError, but we only care about the SQL
# the lookup was issued with.
with _patch_project_service(MagicMock(slug="roboco")), pytest.raises(NotFoundError):
await svc.pr_target(_PR_NUMBER, project_id=project_id)
assert len(recorder) == 1
sql = _compiled_sql(recorder[0])
assert "tasks.pr_number =" in sql
# The WHERE clause filters on project_id (the select list renders the
# column as ``tasks.project_id,`` with a trailing comma; the WHERE
# comparison renders as ``tasks.project_id =``).
assert "tasks.project_id =" in sql
@pytest.mark.asyncio
async def test_pr_target_requires_project_id() -> None:
"""``project_id`` is mandatory — a caller can NEVER resolve a PR without
scoping it to a project. Omitting it is a programming error (TypeError at
call time), not a silent unscoped lookup that could hit another project's
same-numbered PR (the cross-repo #132 collision)."""
recorder: list[object] = []
svc = _service(recorder)
# Bind to an Any-typed local so mypy doesn't flag the missing project_id;
# the call still reaches the runtime, where it raises TypeError as asserted.
target: Any = svc.pr_target
with pytest.raises(TypeError):
await target(_PR_NUMBER)
# The unscoped lookup was never issued — no SQL reached the session.
assert recorder == []
@pytest.mark.asyncio
async def test_pr_target_with_project_id_skips_wrong_repo_task() -> None:
"""Two tasks share pr_number #132 but live in different projects. With the
correct ``project_id`` the scoped query returns ONLY the matching task (the
other repo's task is filtered out), so the GitHub fetch hits the right
repo. (Here the scoped lookup finds nothing in the requested project →
NotFoundError, NOT a silent wrong-repo resolution.)"""
project_id = uuid4()
other_project_id = uuid4()
# The wrong-repo task (other project) shares the pr_number.
wrong_repo_task = MagicMock(
id=uuid4(), project_id=other_project_id, assigned_to=uuid4()
)
recorder: list[object] = []
session = MagicMock()
async def _execute(stmt: object) -> MagicMock:
recorder.append(stmt)
result = MagicMock()
# Simulate the DB applying the project_id filter: the scoped query
# finds no row in the requested project (the only matching pr_number
# belongs to the OTHER project).
result.scalar_one_or_none.return_value = None
return result
session.execute = AsyncMock(side_effect=_execute)
svc = GitService(session)
_bind = object.__setattr__
_bind(svc, "get_workspace", AsyncMock(return_value=Path("/tmp/ws")))
_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):
await svc.pr_target(_PR_NUMBER, project_id=project_id)
# The query WAS scoped by project_id (the wrong-repo task did not leak in).
sql = _compiled_sql(recorder[0])
assert "tasks.project_id =" in sql
_ = wrong_repo_task # exists to document the cross-repo collision scenario