fix: Reduce graph load and dashboard repo failures (#1412)

* fix: reduce graph load and dashboard repo failures

* fix: surface repo listing timeouts
This commit is contained in:
Johannes du Plessis 2026-06-04 13:54:06 -07:00 • committed by GitHub
parent 9df5883d1f
commit 8b18e7e95d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 368 additions and 154 deletions

View file

@ -91,6 +91,8 @@ from .user_mappings import (
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/dashboard/api", tags=["dashboard"])
_GITHUB_API_TIMEOUT = httpx.Timeout(10.0, connect=3.0)
_SKIPPABLE_INSTALLATION_REPO_STATUS_CODES = frozenset({403, 404})
def _require_admin(session: dict[str, Any]) -> dict[str, Any]:
@ -446,6 +448,16 @@ def _next_link_url(link_header: str | None) -> str | None:
return None
def _github_api_http_exception(status_code: int) -> HTTPException:
if status_code == 401:
return HTTPException(401, "github token expired, re-login required")
if status_code == 403:
return HTTPException(403, "github API forbidden")
if status_code == 404:
return HTTPException(404, "github API resource not found")
return HTTPException(502, f"github API error ({status_code})")
async def _paginate(
client: httpx.AsyncClient,
url: str,
@ -466,10 +478,23 @@ async def _paginate(
first = True
while next_url and len(out) < cap:
params = {"per_page": "100"} if first else None
r = await client.get(next_url, headers=headers, params=params)
if r.status_code == 401:
raise HTTPException(401, "github token expired, re-login required")
r.raise_for_status()
try:
r = await client.get(next_url, headers=headers, params=params)
except httpx.TimeoutException as exc:
logger.warning("GitHub API timed out while paginating %s", next_url)
raise HTTPException(503, "github API request timed out") from exc
except httpx.RequestError as exc:
logger.warning("GitHub API request failed while paginating %s: %s", next_url, exc)
raise HTTPException(502, "github API request failed") from exc
try:
r.raise_for_status()
except httpx.HTTPStatusError as exc:
logger.warning(
"GitHub API returned %s while paginating %s",
r.status_code,
next_url,
)
raise _github_api_http_exception(r.status_code) from exc
body = r.json()
page = body.get(items_key, []) if items_key else body
if isinstance(page, list):
@ -498,7 +523,7 @@ async def list_repos(
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(timeout=_GITHUB_API_TIMEOUT) as client:
try:
installations = await _paginate(
client,
@ -531,10 +556,13 @@ async def list_repos(
headers=headers,
items_key="repositories",
)
except HTTPException:
except HTTPException as exc:
if exc.status_code in _SKIPPABLE_INSTALLATION_REPO_STATUS_CODES:
logger.warning(
"Skipping installation %s repository list: %s", inst_id, exc.detail
)
continue
raise
except httpx.HTTPStatusError:
continue
repositories.extend(repos)
return {
"installations": [

View file

@ -169,6 +169,32 @@ async def get_team_default_model(
return _resolve_default_pair(model, effort)
async def get_team_default_model_pair(
role: Literal["agent", "reviewer"],
) -> tuple[tuple[str, str], tuple[str, str]]:
"""Return default ``(main, subagent)`` model pairs for ``role`` from one store read."""
settings = await get_team_settings()
if role == "agent":
main = _resolve_default_pair(
settings.get("default_agent_model"),
settings.get("default_agent_reasoning_effort"),
)
subagent = _resolve_default_pair(
settings.get("default_agent_subagent_model"),
settings.get("default_agent_subagent_reasoning_effort"),
)
else:
main = _resolve_default_pair(
settings.get("default_reviewer_model"),
settings.get("default_reviewer_reasoning_effort"),
)
subagent = _resolve_default_pair(
settings.get("default_reviewer_subagent_model"),
settings.get("default_reviewer_subagent_reasoning_effort"),
)
return main, subagent
async def get_team_review_trace_links_enabled() -> bool:
"""Return whether GitHub review bodies should include a LangSmith trace link."""
settings = await get_team_settings()

View file

@ -15,6 +15,7 @@ agent for code review only:
"""
# ruff: noqa: E402
import asyncio
import logging
import re
import warnings
@ -30,6 +31,7 @@ warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarnin
from deepagents import create_deep_agent
from langchain.agents.middleware import ModelCallLimitMiddleware
from .dashboard.team_settings import get_team_default_model_pair
from .middleware import (
SanitizeThinkingBlocksMiddleware,
SanitizeToolInputsMiddleware,
@ -640,8 +642,8 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
# Cache in-process so reviewer tools and the sandbox proxy can read it this run.
cache_github_token_for_thread(thread_id, github_token, expires_at=expires_at)
repo_private = config["configurable"].get("repo_private")
github_proxy_token = github_token if repo_private is False else None
github_proxy_token = github_token
github_api_token = github_token
sandbox_backend = await ensure_sandbox_for_thread(
thread_id,
github_proxy_token=github_proxy_token,
@ -659,74 +661,51 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
is_re_review = bool(config["configurable"].get("re_review"))
reviewer_event = str(config["configurable"].get("reviewer_event", "") or "")
# Fetch the PR's unified diff from the GitHub API and populate
# diff_text + diff_line_set so add_finding can reject bad anchors at
# creation time (instead of letting them fail at publish_review with a
# 422 the agent then has to clean up). The API path is reliable — the
# previous sandbox-based prep was sometimes producing empty diffs,
# which is what forced the earlier hotfix. If the fetch fails, leave
# the validation disabled so the run isn't blocked entirely.
pr_diff_text = ""
pr_diff_line_set: dict[str, set[int]] | None = None
if (
can_fetch_pr = (
pr_number is not None
and isinstance(pr_number, int)
and repo_owner
and repo_name
and github_token
):
and bool(repo_owner)
and bool(repo_name)
and bool(github_api_token)
)
async def _fetch_diff_context() -> tuple[str, dict[str, dict[str, set[int]]] | None]:
if not can_fetch_pr or github_api_token is None or not isinstance(pr_number, int):
return "", None
fetched_diff = await fetch_pr_diff(
owner=repo_owner,
repo=repo_name,
pr_number=pr_number,
token=github_token,
token=github_api_token,
)
if fetched_diff is not None:
pr_diff_text = fetched_diff
pr_diff_line_set = compute_diff_line_set(fetched_diff)
config["configurable"]["diff_text"] = pr_diff_text
config["configurable"]["diff_line_set"] = pr_diff_line_set
if fetched_diff is None:
return "", None
return fetched_diff, compute_diff_line_set(fetched_diff)
# Fetch the PR title and body fresh every run (never cached) so an edited
# title/description is reflected on re-reviews. Injected into the review
# context so the agent knows the original intent of the PR. On failure we
# leave both blank and the overview block is simply omitted.
pr_title = ""
pr_body = ""
if (
pr_number is not None
and isinstance(pr_number, int)
and repo_owner
and repo_name
and github_token
):
async def _fetch_pr_overview() -> tuple[str, str]:
if not can_fetch_pr or github_api_token is None or not isinstance(pr_number, int):
return "", ""
metadata = await fetch_pr_metadata(
owner=repo_owner,
repo=repo_name,
pr_number=pr_number,
token=github_token,
token=github_api_token,
)
if metadata is not None:
pr_title, pr_body = metadata
return metadata if metadata is not None else ("", "")
existing_threads_block = ""
if (
pr_number is not None
and isinstance(pr_number, int)
and repo_owner
and repo_name
and github_token
):
async def _fetch_existing_threads_block() -> str:
if not can_fetch_pr or github_api_token is None or not isinstance(pr_number, int):
return ""
try:
threads = await fetch_pr_review_threads(
owner=repo_owner,
repo=repo_name,
pr_number=pr_number,
token=github_token,
token=github_api_token,
)
await reconcile_findings_with_review_threads(thread_id, threads)
existing_threads_block = _format_pr_review_threads(threads)
if existing_threads_block:
block = _format_pr_review_threads(threads)
if block:
logger.info(
"Loaded %d existing PR review thread(s) into reviewer context for %s/%s#%s",
len(threads),
@ -734,6 +713,7 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
repo_name,
pr_number,
)
return block
except Exception: # noqa: BLE001
logger.exception(
"Failed to load existing PR review threads for %s/%s#%s; "
@ -742,6 +722,51 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
repo_name,
pr_number,
)
return ""
async def _fetch_repo_style_prompt() -> str | None:
if not repo_owner or not repo_name:
return None
from .dashboard.review_styles import get_repo_custom_prompt
return await get_repo_custom_prompt(repo_owner, repo_name)
async def _fetch_agents_md_context() -> str | None:
if not repo_owner or not repo_name or not base_sha:
return None
content = await fetch_agents_md(
repo_owner,
repo_name,
base_sha,
token=github_api_token,
)
if content:
logger.info(
"Loaded AGENTS.md (%d chars) from %s/%s@%s into reviewer prompt",
len(content),
repo_owner,
repo_name,
base_sha,
)
return content
(
diff_context,
pr_overview,
existing_threads_block,
repo_style_prompt,
agents_md_content,
) = await asyncio.gather(
_fetch_diff_context(),
_fetch_pr_overview(),
_fetch_existing_threads_block(),
_fetch_repo_style_prompt(),
_fetch_agents_md_context(),
)
pr_diff_text, pr_diff_line_set = diff_context
pr_title, pr_body = pr_overview
config["configurable"]["diff_text"] = pr_diff_text
config["configurable"]["diff_line_set"] = pr_diff_line_set
review_context = ""
if pr_number is not None and isinstance(pr_number, int):
@ -787,8 +812,6 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
existing_threads_block=existing_threads_block,
)
from .dashboard.team_settings import get_team_default_model, get_team_default_subagent_model
configured_model_id = config["configurable"].get("reviewer_model_id")
configured_effort = config["configurable"].get("reviewer_reasoning_effort")
if isinstance(configured_model_id, str) and configured_model_id:
@ -797,13 +820,15 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
subagent_model_id = model_id
subagent_effort = reasoning_effort
else:
model_id, reasoning_effort = await get_team_default_model("reviewer")
(
(model_id, reasoning_effort),
(subagent_model_id, subagent_effort),
) = await get_team_default_model_pair("reviewer")
logger.info(
"Using team default reviewer model: model=%s effort=%s",
model_id,
reasoning_effort,
)
subagent_model_id, subagent_effort = await get_team_default_subagent_model("reviewer")
logger.info(
"Using team default reviewer subagent model: model=%s effort=%s",
subagent_model_id,
@ -833,34 +858,8 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
config["configurable"].get("reviewer_eval") is True
or config["configurable"].get("eval") is True
)
repo_style_prompt: str | None = None
if repo_owner and repo_name:
from .dashboard.review_styles import get_repo_custom_prompt
repo_style_prompt = await get_repo_custom_prompt(repo_owner, repo_name)
# Fetch AGENTS.md from base_sha (the target branch's state before this
# PR's changes), not head_sha. The contents are inlined into the system
# prompt, so reading from head would let a PR author smuggle reviewer
# instructions ("ignore all bugs", "publish no findings") into the
# review. base_sha is the trusted ref.
agents_md_content: str | None = None
if repo_owner and repo_name and base_sha:
agents_md_content = await fetch_agents_md(
repo_owner,
repo_name,
base_sha,
token=github_token,
)
if agents_md_content:
logger.info(
"Loaded AGENTS.md (%d chars) from %s/%s@%s into reviewer prompt",
len(agents_md_content),
repo_owner,
repo_name,
base_sha,
)
del github_token
github_api_token = None
github_token = None
system_prompt = _reviewer_system_prompt(
f"{work_dir}/{repo_name}" if repo_name else work_dir,

View file

@ -38,7 +38,7 @@ from .dashboard.agent_overrides import (
resolve_github_login,
)
from .dashboard.options import DEFAULT_MODEL_ID, SUPPORTED_MODEL_IDS, model_supports_effort
from .dashboard.team_settings import get_team_default_model, get_team_default_subagent_model
from .dashboard.team_settings import get_team_default_model_pair
from .integrations.langsmith import _configure_github_proxy
from .middleware import (
ModelFallbackMiddleware,
@ -411,13 +411,21 @@ async def get_agent(config: RunnableConfig) -> Pregel:
).with_config(config)
github_token, _expires_at = await resolve_github_token(config, thread_id)
triggering_user_identity = await asyncio.to_thread(
resolve_triggering_user_identity, config, github_token
profile_login = resolve_github_login(config)
triggering_user_identity_task = asyncio.create_task(
asyncio.to_thread(resolve_triggering_user_identity, config, github_token)
)
sandbox_task = asyncio.create_task(ensure_sandbox_for_thread(thread_id))
team_defaults_task = asyncio.create_task(get_team_default_model_pair("agent"))
profile_task = asyncio.create_task(load_profile(profile_login)) if profile_login else None
triggering_user_identity, sandbox_backend, team_defaults = await asyncio.gather(
triggering_user_identity_task,
sandbox_task,
team_defaults_task,
)
profile = await profile_task if profile_task is not None else None
del github_token
sandbox_backend = await ensure_sandbox_for_thread(thread_id)
linear_issue = config["configurable"].get("linear_issue", {})
linear_project_id = linear_issue.get("linear_project_id", "")
linear_issue_number = linear_issue.get("linear_issue_number", "")
@ -427,44 +435,39 @@ async def get_agent(config: RunnableConfig) -> Pregel:
def backend_factory(_runtime: object, _thread_id: str = thread_id) -> SandboxBackendProtocol:
return _get_cached_sandbox_backend(_thread_id)
model_id, profile_effort = await get_team_default_model("agent")
(model_id, profile_effort), (subagent_model_id, subagent_effort) = team_defaults
logger.info("Using team default agent model: model=%s effort=%s", model_id, profile_effort)
subagent_model_id, subagent_effort = await get_team_default_subagent_model("agent")
logger.info(
"Using team default agent subagent model: model=%s effort=%s",
subagent_model_id,
subagent_effort,
)
profile: dict[str, Any] | None = None
profile_login = resolve_github_login(config)
if profile_login:
profile = await load_profile(profile_login)
if profile:
overridden_model, overridden_effort = normalize_profile_overrides(profile)
if overridden_model:
logger.info(
"Applying dashboard profile override for %s: model=%s effort=%s",
profile_login,
overridden_model,
overridden_effort,
)
model_id = overridden_model
profile_effort = overridden_effort
subagent_model_id = overridden_model
subagent_effort = overridden_effort
overridden_subagent_model, overridden_subagent_effort = (
normalize_profile_subagent_overrides(profile)
if profile_login and profile:
overridden_model, overridden_effort = normalize_profile_overrides(profile)
if overridden_model:
logger.info(
"Applying dashboard profile override for %s: model=%s effort=%s",
profile_login,
overridden_model,
overridden_effort,
)
if overridden_subagent_model:
logger.info(
"Applying dashboard profile subagent override for %s: model=%s effort=%s",
profile_login,
overridden_subagent_model,
overridden_subagent_effort,
)
subagent_model_id = overridden_subagent_model
subagent_effort = overridden_subagent_effort
model_id = overridden_model
profile_effort = overridden_effort
subagent_model_id = overridden_model
subagent_effort = overridden_effort
overridden_subagent_model, overridden_subagent_effort = (
normalize_profile_subagent_overrides(profile)
)
if overridden_subagent_model:
logger.info(
"Applying dashboard profile subagent override for %s: model=%s effort=%s",
profile_login,
overridden_subagent_model,
overridden_subagent_effort,
)
subagent_model_id = overridden_subagent_model
subagent_effort = overridden_subagent_effort
configurable = (config or {}).get("configurable") or {}
per_thread_model = configurable.get("agent_model_id")

View file

@ -1,22 +1,32 @@
import os
from collections.abc import Callable
from importlib import import_module
from deepagents.backends.protocol import SandboxBackendProtocol
from agent.integrations.daytona import create_daytona_sandbox
from agent.integrations.langsmith import create_langsmith_sandbox
from agent.integrations.local import create_local_sandbox
from agent.integrations.modal import create_modal_sandbox
from agent.integrations.runloop import create_runloop_sandbox
SandboxFactory = Callable[[str | None], SandboxBackendProtocol]
SANDBOX_FACTORIES = {
"langsmith": create_langsmith_sandbox,
"daytona": create_daytona_sandbox,
"modal": create_modal_sandbox,
"runloop": create_runloop_sandbox,
"local": create_local_sandbox,
SANDBOX_FACTORIES: dict[str, tuple[str, str]] = {
"langsmith": ("agent.integrations.langsmith", "create_langsmith_sandbox"),
"daytona": ("agent.integrations.daytona", "create_daytona_sandbox"),
"modal": ("agent.integrations.modal", "create_modal_sandbox"),
"runloop": ("agent.integrations.runloop", "create_runloop_sandbox"),
"local": ("agent.integrations.local", "create_local_sandbox"),
}
def _load_sandbox_factory(sandbox_type: str) -> SandboxFactory:
factory_path = SANDBOX_FACTORIES.get(sandbox_type)
if factory_path is None:
supported = ", ".join(sorted(SANDBOX_FACTORIES))
raise ValueError(f"Invalid sandbox type: {sandbox_type}. Supported types: {supported}")
module_name, function_name = factory_path
factory = getattr(import_module(module_name), function_name)
if not callable(factory):
raise TypeError(f"Sandbox factory {module_name}.{function_name} is not callable")
return factory
def create_sandbox(sandbox_id: str | None = None) -> SandboxBackendProtocol:
"""Create or reconnect to a sandbox using the configured provider.
@ -30,10 +40,7 @@ def create_sandbox(sandbox_id: str | None = None) -> SandboxBackendProtocol:
A sandbox backend implementing SandboxBackendProtocol.
"""
sandbox_type = os.getenv("SANDBOX_TYPE", "langsmith")
factory = SANDBOX_FACTORIES.get(sandbox_type)
if not factory:
supported = ", ".join(sorted(SANDBOX_FACTORIES))
raise ValueError(f"Invalid sandbox type: {sandbox_type}. Supported types: {supported}")
factory = _load_sandbox_factory(sandbox_type)
return factory(sandbox_id)

View file

@ -82,7 +82,6 @@ from .utils.linear import post_linear_trace_comment
from .utils.linear_team_repo_map import LINEAR_TEAM_TO_REPO
from .utils.multimodal import dedupe_urls, extract_image_urls, fetch_image_block
from .utils.repo import extract_repo_from_text
from .utils.sandbox import validate_sandbox_startup_config
from .utils.slack import (
GitHubPrRef,
fetch_slack_thread_messages,
@ -110,6 +109,8 @@ logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
from .utils.sandbox import validate_sandbox_startup_config
validate_sandbox_startup_config()
yield

View file

@ -48,14 +48,9 @@ async def test_agent_uses_profile_subagent_model_override() -> None:
return_value="/workspace",
),
patch(
"agent.server.get_team_default_model",
"agent.server.get_team_default_model_pair",
new_callable=AsyncMock,
return_value=("openai:gpt-5.5", "medium"),
),
patch(
"agent.server.get_team_default_subagent_model",
new_callable=AsyncMock,
return_value=("openai:gpt-5.5", "low"),
return_value=(("openai:gpt-5.5", "medium"), ("openai:gpt-5.5", "low")),
),
patch(
"agent.server.load_profile",
@ -126,14 +121,9 @@ async def test_agent_subagent_inherits_profile_model_override_without_explicit_p
return_value="/workspace",
),
patch(
"agent.server.get_team_default_model",
"agent.server.get_team_default_model_pair",
new_callable=AsyncMock,
return_value=("openai:gpt-5.5", "medium"),
),
patch(
"agent.server.get_team_default_subagent_model",
new_callable=AsyncMock,
return_value=("openai:gpt-5.5", "low"),
return_value=(("openai:gpt-5.5", "medium"), ("openai:gpt-5.5", "low")),
),
patch(
"agent.server.load_profile",

View file

@ -0,0 +1,92 @@
from __future__ import annotations
from unittest.mock import AsyncMock
import httpx
import pytest
from fastapi import HTTPException
from agent.dashboard import routes
@pytest.mark.asyncio
async def test_paginate_converts_github_timeout_to_503() -> None:
request = httpx.Request("GET", "https://api.github.com/user/installations")
async def handler(_request: httpx.Request) -> httpx.Response:
raise httpx.ConnectTimeout("connect timed out", request=request)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
with pytest.raises(HTTPException) as exc:
await routes._paginate(
client,
"https://api.github.com/user/installations",
headers={},
items_key="installations",
)
assert exc.value.status_code == 503
assert exc.value.detail == "github API request timed out"
@pytest.mark.asyncio
async def test_paginate_converts_github_status_error_to_502() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, request=request, json={"message": "server error"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
with pytest.raises(HTTPException) as exc:
await routes._paginate(
client,
"https://api.github.com/user/installations",
headers={},
items_key="installations",
)
assert exc.value.status_code == 502
assert exc.value.detail == "github API error (500)"
@pytest.mark.asyncio
async def test_list_repos_propagates_repository_page_timeouts(monkeypatch) -> None:
monkeypatch.setattr(routes, "get_valid_access_token", AsyncMock(return_value="token"))
calls = 0
async def fake_paginate(*args: object, **kwargs: object) -> list[dict[str, object]]:
nonlocal calls
calls += 1
if calls == 1:
return [{"id": 123, "account": {"login": "acme", "type": "Organization"}}]
raise HTTPException(503, "github API request timed out")
monkeypatch.setattr(routes, "_paginate", fake_paginate)
with pytest.raises(HTTPException) as exc:
await routes.list_repos(session={"sub": "octocat"})
assert exc.value.status_code == 503
assert exc.value.detail == "github API request timed out"
@pytest.mark.asyncio
async def test_list_repos_skips_inaccessible_installations(monkeypatch) -> None:
monkeypatch.setattr(routes, "get_valid_access_token", AsyncMock(return_value="token"))
calls = 0
async def fake_paginate(*args: object, **kwargs: object) -> list[dict[str, object]]:
nonlocal calls
calls += 1
if calls == 1:
return [{"id": 123, "account": {"login": "acme", "type": "Organization"}}]
raise HTTPException(403, "github API forbidden")
monkeypatch.setattr(routes, "_paginate", fake_paginate)
result = await routes.list_repos(session={"sub": "octocat"})
assert result == {
"installations": [{"id": 123, "account": "acme", "account_type": "Organization"}],
"repositories": [],
}

View file

@ -11,6 +11,25 @@ import pytest
from agent.integrations.langsmith import _configure_github_proxy
class TestSandboxFactoryLoading:
def test_create_sandbox_loads_only_selected_provider(self) -> None:
with (
patch("agent.utils.sandbox.import_module") as mock_import_module,
patch.dict("os.environ", {"SANDBOX_TYPE": "local"}),
):
module = MagicMock()
module.create_local_sandbox.return_value = MagicMock(id="local")
mock_import_module.return_value = module
from agent.utils.sandbox import create_sandbox
sandbox = create_sandbox("existing")
assert sandbox.id == "local"
mock_import_module.assert_called_once_with("agent.integrations.local")
module.create_local_sandbox.assert_called_once_with("existing")
class TestConfigureGithubProxy:
"""Tests for _configure_github_proxy payload shape and error handling."""

View file

@ -105,6 +105,55 @@ async def test_reviewer_resolves_app_installation_token_at_run_start() -> None:
assert reviewer.check_message_queue_before_model in middleware
@pytest.mark.asyncio
async def test_reviewer_reuses_app_token_for_sandbox_proxy() -> None:
config: RunnableConfig = {
"configurable": {
"__is_for_execution__": True,
"thread_id": "reviewer-thread-id",
"repo": {"owner": "acme", "name": "repo"},
"source": "github",
"pr_number": 42,
"base_sha": "base",
},
"metadata": {},
}
with (
patch(
"agent.reviewer.get_github_app_installation_token_with_expiry",
new_callable=AsyncMock,
return_value=("app-token", "exp"),
),
patch(
"agent.reviewer.ensure_sandbox_for_thread",
new_callable=AsyncMock,
return_value=MagicMock(),
) as mock_sandbox,
patch(
"agent.reviewer.aresolve_sandbox_work_dir",
new_callable=AsyncMock,
return_value="/workspace",
),
patch("agent.reviewer.fetch_pr_diff", new_callable=AsyncMock, return_value=None),
patch("agent.reviewer.fetch_pr_metadata", new_callable=AsyncMock, return_value=None),
patch("agent.reviewer.fetch_pr_review_threads", new_callable=AsyncMock, return_value=[]),
patch(
"agent.reviewer.reconcile_findings_with_review_threads",
new_callable=AsyncMock,
),
patch("agent.reviewer.fetch_agents_md", new_callable=AsyncMock, return_value=None),
patch("agent.reviewer.make_model", return_value=MagicMock()),
patch("agent.reviewer.create_deep_agent", return_value=_DummyAgent()),
):
await reviewer.get_reviewer_agent(config)
mock_sandbox.assert_awaited_once_with(
"reviewer-thread-id",
github_proxy_token="app-token",
)
@pytest.mark.asyncio
async def test_reviewer_raises_when_app_installation_token_unavailable() -> None:
config: RunnableConfig = {