fix(orchestrator): repo-aware external-PR polling (monorepo no longer triplicates)

Multiple projects can map to ONE repo — a monorepo product's backend/
frontend/ux cells each have their own Project pointing at the same git_url.
The poll ingested per-project with a per-(project,pr) dedup, so one external
PR (e.g. #170 on github.com/rennf93/roboco) created one review task per cell
project — three identical reviews for the same PR.

Collapse active projects to one canonical project per distinct repo before
polling (_projects_one_per_repo, deterministic by slug so the pick is stable
across polls). A monorepo product now yields ONE review per external PR;
genuinely separate repos (multi-repo) each still get polled.
This commit is contained in:
Renn F
2026-06-17 01:39:39 +02:00
parent 79dcba1431
commit 6d02b75c15
2 changed files with 69 additions and 5 deletions
+37 -5
View File
@@ -4645,11 +4645,43 @@ Start by:
except Exception:
logger.exception("external-PR poll cycle failed")
async def _poll_external_prs_once(self, db: "AsyncSession") -> int:
"""One discovery pass across active projects; returns tasks ingested.
@staticmethod
def _repo_key(git_url: str) -> str:
"""Normalized repo identity (case/.git/trailing-slash insensitive)."""
return git_url.lower().rstrip("/").removesuffix(".git")
Lists each active project's open PRs, keeps the external ones, and
ingests a de-duped review task for each. Commits once at the end.
@classmethod
def _projects_one_per_repo(cls, projects: list[Any]) -> list[Any]:
"""One canonical project per distinct repo.
Many projects can point at the SAME repo a monorepo product's
backend/frontend/ux cells each have their own Project mapping to one
git_url. Polling per-project would then ingest one review task per cell
for a single external PR (the per-(project,pr) dedup can't see across
projects). Collapse to one canonical project per repo (deterministic by
slug so the pick is stable across polls); genuinely separate repos
(multi-repo) each keep their own. Projects without a git_url are skipped.
"""
seen: set[str] = set()
canonical: list[Any] = []
for project in sorted(projects, key=lambda p: str(p.slug)):
git_url = getattr(project, "git_url", None)
if not git_url:
continue
key = cls._repo_key(git_url)
if key in seen:
continue
seen.add(key)
canonical.append(project)
return canonical
async def _poll_external_prs_once(self, db: "AsyncSession") -> int:
"""One discovery pass across active repos; returns tasks ingested.
Repo-aware: collapses active projects to one canonical project per
distinct repo (so a monorepo product yields ONE review per PR, not one
per cell-project), lists each repo's open PRs, keeps the external ones,
and ingests a de-duped review task for each. Commits once at the end.
"""
from roboco.services.git import GitService
from roboco.services.project import get_project_service
@@ -4661,7 +4693,7 @@ Start by:
system_id = _foundation.AGENTS["system"].uuid
allowlist = {a.lower() for a in settings.external_pr_author_allowlist}
ingested = 0
for project in projects:
for project in self._projects_one_per_repo(projects):
for pr in await git.list_open_prs(project.slug):
number = pr.get("number")
if number is None or not self._is_external_pr(pr):
@@ -7,10 +7,42 @@ side) for anything it does not positively recognize as internal.
from __future__ import annotations
from types import SimpleNamespace
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
def _proj(slug: str, git_url: str | None) -> SimpleNamespace:
return SimpleNamespace(slug=slug, git_url=git_url)
def test_projects_one_per_repo_collapses_monorepo() -> None:
# Three cell-projects all pointing at the same repo (a monorepo product)
# collapse to ONE canonical project; a genuinely separate repo is kept.
projects = [
_proj("roboco-uix", "https://github.com/rennf93/roboco.git"),
_proj("roboco-api", "https://github.com/rennf93/roboco.git"),
_proj("roboco-panel", "https://github.com/rennf93/roboco.git"),
_proj("other", "https://github.com/rennf93/other-repo.git"),
]
out = AgentOrchestrator._projects_one_per_repo(projects)
slugs = [p.slug for p in out]
# one per distinct repo; canonical pick is deterministic (first by slug).
assert slugs == ["other", "roboco-api"]
def test_projects_one_per_repo_normalizes_and_skips_repoless() -> None:
projects = [
_proj("a", "https://github.com/rennf93/roboco"), # no .git
_proj("b", "https://github.com/rennf93/roboco.git/"), # .git + slash
_proj("coordination", None), # product/coordination project, no repo
]
out = AgentOrchestrator._projects_one_per_repo(projects)
# a & b are the same repo; coordination (no git_url) is skipped.
assert [p.slug for p in out] == ["a"]
@pytest.mark.parametrize(
("pr", "expected"),
[