Files
roboco/tests/unit/services/test_git_inbound_pr.py
T
f8b4a6755c fix(pr-review): fleet PRs are ours by branch ownership, not author identity (#668)
With a GitHub App bound, fleet PRs are authored by <app-slug>[bot] whose
author_association is NONE — the inbound classifier's author heuristics
read that as an outsider and ingested the org's own dev-stream PR as
external_pr for adversarial review (2026-07-23 live: PR #667). The
repo-owner author check only ever covered the PAT era.

_ingest_pr_if_reviewable now skips any same-repo PR whose head branch an
active task owns BEFORE the author-based classification, and
active_task_owns_branch widens from the single polled project to every
project sharing its git_url (the poll collapses a monorepo's
cell-projects to one canonical project, so a sibling cell's ownership
must count — the same sibling scope external_review_task_exists already
uses, now shared via _repo_sibling_project_ids). A deleted-fork head
(GitHub sends head.repo=null) now classifies as fork, failing closed to
review instead of risking a silent ownership skip on a branch-name
collision. Residual, documented: an org PR whose task went terminal with
the PR left open falls through to the author heuristics.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-23 21:54:45 +02:00

377 lines
13 KiB
Python

"""GitService inbound-PR reads — list_open_prs + get_pr_diff (httpx fully mocked).
``list_open_prs`` is the discovery surface for the inbound-PR reviewer: it lists
ALL open PRs and normalizes each (number, url, title, head_ref, head_sha,
is_fork, user_login, author_association). ``get_pr_diff`` fetches a PR's unified
diff read-only. Both return safe empties ([] / "") on a missing token or any
GitHub error so the poll/claim never crash.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.services.git import _CI_RUN_WINDOW, GitService
_PR = 7
def _service() -> GitService:
session = MagicMock()
session.execute = AsyncMock()
svc = GitService(session)
object.__setattr__(svc, "_token_for_project", AsyncMock(return_value="tok"))
return svc
def _resp(status_code: int, *, json_payload: Any = None, text: str = "") -> MagicMock:
resp = MagicMock()
resp.status_code = status_code
resp.is_success = 200 <= status_code < 300 # noqa: PLR2004
resp.json.return_value = json_payload
resp.text = text
return resp
def _client(get_resp: MagicMock) -> MagicMock:
client = MagicMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
client.get = AsyncMock(return_value=get_resp)
return client
def _patch_project() -> Any:
fake = MagicMock()
fake.get_by_slug = AsyncMock(
return_value=MagicMock(git_url="https://github.com/acme/repo.git")
)
return patch("roboco.services.git.get_project_service", return_value=fake)
def _pr(*, number: int, head_full: str, login: str, assoc: str) -> dict[str, Any]:
return {
"number": number,
"html_url": f"https://github.com/acme/repo/pull/{number}",
"title": f"PR {number}",
"head": {
"ref": "feature-x",
"sha": "deadbeef",
"repo": {"full_name": head_full},
},
"user": {"login": login},
"author_association": assoc,
}
# ---------------------------------------------------------------------------
# list_open_prs
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_open_prs_normalizes_and_flags_fork() -> None:
svc = _service()
payload = [
_pr(number=7, head_full="forkowner/repo", login="corey", assoc="CONTRIBUTOR"),
_pr(number=8, head_full="acme/repo", login="be-dev-1", assoc="MEMBER"),
]
client = _client(_resp(200, json_payload=payload))
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await svc.list_open_prs("roboco")
assert len(out) == 2 # noqa: PLR2004
fork, internal = out
assert fork == {
"number": 7,
"url": "https://github.com/acme/repo/pull/7",
"title": "PR 7",
"head_ref": "feature-x",
"head_sha": "deadbeef",
"is_fork": True,
"user_login": "corey",
"author_is_owner": False,
"author_association": "CONTRIBUTOR",
}
# Same-repo head → not a fork.
assert internal["is_fork"] is False
assert internal["author_association"] == "MEMBER"
# A contributor (not the repo owner) is not flagged as the owner.
assert internal["author_is_owner"] is False
def test_normalize_deleted_fork_head_is_fork() -> None:
"""GitHub sends head.repo=null when a fork was deleted — that head is NOT
ours, so it must classify as a fork (fail-closed to review) rather than
fall into the same-repo path where a branch-name collision could skip it."""
pr = {
"number": 11,
"html_url": "https://github.com/acme/repo/pull/11",
"title": "ghost fork",
"head": {"ref": "feature-x", "sha": "cafebabe", "repo": None},
"user": {"login": "ghost"},
"author_association": "NONE",
}
out = GitService._normalize_open_pr(pr, "acme/repo")
assert out["is_fork"] is True
@pytest.mark.asyncio
async def test_list_open_prs_flags_owner_authored_pr() -> None:
"""A PR opened by the repo-owner account is flagged author_is_owner."""
svc = _service()
payload = [
# Author login == repo owner ("acme") → the org's own PR.
_pr(number=9, head_full="acme/repo", login="acme", assoc="OWNER"),
]
client = _client(_resp(200, json_payload=payload))
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await svc.list_open_prs("roboco")
assert out[0]["author_is_owner"] is True
@pytest.mark.asyncio
async def test_list_open_prs_empty_on_missing_token() -> None:
svc = _service()
object.__setattr__(svc, "_token_for_project", AsyncMock(return_value=None))
with _patch_project():
assert await svc.list_open_prs("roboco") == []
@pytest.mark.asyncio
async def test_list_open_prs_empty_on_github_error() -> None:
svc = _service()
client = _client(_resp(403, text="forbidden"))
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
assert await svc.list_open_prs("roboco") == []
# ---------------------------------------------------------------------------
# get_pr_diff
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_pr_diff_returns_diff_text() -> None:
svc = _service()
diff = "diff --git a/x b/x\n+added line\n"
client = _client(_resp(200, text=diff))
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await svc.get_pr_diff("roboco", _PR)
assert out == diff
call = client.get.await_args
assert f"/pulls/{_PR}" in call.args[0]
assert call.kwargs["headers"]["Accept"] == "application/vnd.github.v3.diff"
@pytest.mark.asyncio
async def test_get_pr_diff_empty_on_missing_token() -> None:
svc = _service()
object.__setattr__(svc, "_token_for_project", AsyncMock(return_value=None))
with _patch_project():
assert await svc.get_pr_diff("roboco", _PR) == ""
@pytest.mark.asyncio
async def test_get_pr_diff_empty_on_non_2xx() -> None:
svc = _service()
client = _client(_resp(404, text="not found"))
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
assert await svc.get_pr_diff("roboco", _PR) == ""
# ---------------------------------------------------------------------------
# get_latest_ci_conclusion — the self-heal CI telemetry signal
# ---------------------------------------------------------------------------
def _patch_project_ci() -> Any:
fake = MagicMock()
fake.get_by_slug = AsyncMock(
return_value=MagicMock(
git_url="https://github.com/acme/repo.git", default_branch="master"
)
)
return patch("roboco.services.git.get_project_service", return_value=fake)
def _run(
conclusion: str, *, head_sha: str = "abc123", attempt: int = 1
) -> dict[str, Any]:
return {
"conclusion": conclusion,
"head_sha": head_sha,
"html_url": "https://github.com/acme/repo/actions/runs/99",
"name": "CI",
"updated_at": "2026-06-17T00:00:00Z",
"run_attempt": attempt,
}
@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_normalizes_and_requests_correctly() -> None:
svc = _service()
client = _client(_resp(200, json_payload={"workflow_runs": [_run("failure")]}))
with (
_patch_project_ci(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await svc.get_latest_ci_conclusion("roboco")
assert out == {
"conclusion": "failure",
"head_sha": "abc123",
"run_url": "https://github.com/acme/repo/actions/runs/99",
"run_name": "CI",
"branch": "master",
"completed_at": "2026-06-17T00:00:00Z",
}
call = client.get.await_args
assert call.args[0].endswith("/repos/acme/repo/actions/runs")
assert call.kwargs["params"] == {
"branch": "master",
"status": "completed",
"per_page": _CI_RUN_WINDOW,
}
assert call.kwargs["headers"]["Authorization"] == "Bearer tok"
@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_none_on_missing_token() -> None:
svc = _service()
object.__setattr__(svc, "_token_for_project", AsyncMock(return_value=None))
with _patch_project_ci():
assert await svc.get_latest_ci_conclusion("roboco") is None
@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_none_on_github_error() -> None:
svc = _service()
client = _client(_resp(403, text="forbidden"))
with (
_patch_project_ci(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
assert await svc.get_latest_ci_conclusion("roboco") is None
@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_none_when_no_runs() -> None:
# A repo with no Actions runs (e.g. doesn't use GitHub Actions) → no signal.
svc = _service()
client = _client(_resp(200, json_payload={"workflow_runs": []}))
with (
_patch_project_ci(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
assert await svc.get_latest_ci_conclusion("roboco") is None
@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_scopes_to_workflow() -> None:
# With a workflow file given, hit the workflow-scoped endpoint (precise signal).
svc = _service()
client = _client(_resp(200, json_payload={"workflow_runs": [_run("success")]}))
with (
_patch_project_ci(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await svc.get_latest_ci_conclusion("roboco", workflow="ci.yml")
assert out is not None
assert out["conclusion"] == "success"
assert client.get.await_args.args[0].endswith("/actions/workflows/ci.yml/runs")
@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_requests_a_window() -> None:
# The signal pulls a window of recent runs, not just the single newest, so a
# green run on an older commit can't mask the HEAD's failure.
svc = _service()
client = _client(_resp(200, json_payload={"workflow_runs": [_run("failure")]}))
with (
_patch_project_ci(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
await svc.get_latest_ci_conclusion("roboco", workflow="ci.yml")
assert client.get.await_args.kwargs["params"]["per_page"] == _CI_RUN_WINDOW
@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_red_head_not_masked_by_older_green() -> None:
# Newest commit's run FAILED; older commits' runs (different sha) are green.
# The signal must follow the newest commit — not whatever completed last — so
# a stale/unrelated green run cannot mask the regression.
svc = _service()
payload = {
"workflow_runs": [
_run("failure", head_sha="newsha"),
_run("success", head_sha="oldsha1"),
_run("success", head_sha="oldsha2"),
]
}
client = _client(_resp(200, json_payload=payload))
with (
_patch_project_ci(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await svc.get_latest_ci_conclusion("roboco", workflow="ci.yml")
assert out is not None
assert out["conclusion"] == "failure"
assert out["head_sha"] == "newsha"
@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_green_rerun_supersedes_failure() -> None:
# Same commit re-run green after a red first attempt → reads green.
svc = _service()
payload = {
"workflow_runs": [
_run("success", head_sha="sha", attempt=2),
_run("failure", head_sha="sha", attempt=1),
]
}
client = _client(_resp(200, json_payload=payload))
with (
_patch_project_ci(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await svc.get_latest_ci_conclusion("roboco", workflow="ci.yml")
assert out is not None
assert out["conclusion"] == "success"
@pytest.mark.asyncio
async def test_get_latest_ci_conclusion_retries_transient_5xx() -> None:
# A transient 503 must not silently skip the cycle — retry, then succeed.
svc = _service()
ok = _resp(200, json_payload={"workflow_runs": [_run("failure")]})
client = MagicMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
client.get = AsyncMock(side_effect=[_resp(503, text="busy"), ok])
with (
_patch_project_ci(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
patch("roboco.services.git.asyncio.sleep", AsyncMock()),
):
out = await svc.get_latest_ci_conclusion("roboco", workflow="ci.yml")
assert out is not None
assert out["conclusion"] == "failure"
expected_get_calls = 2
assert client.get.await_count == expected_get_calls