diff --git a/agent/dashboard/agent_overrides.py b/agent/dashboard/agent_overrides.py index c2672468..526dd0fa 100644 --- a/agent/dashboard/agent_overrides.py +++ b/agent/dashboard/agent_overrides.py @@ -72,6 +72,16 @@ async def load_profile(login: str) -> dict[str, Any] | None: return value if isinstance(value, dict) else None +def profile_create_prs(profile: dict[str, Any] | None) -> bool: + """Return whether the agent should automatically open a PR. Defaults to True.""" + if not isinstance(profile, dict): + return True + value = profile.get("create_prs") + if isinstance(value, bool): + return value + return True + + def normalize_profile_overrides(profile: dict[str, Any]) -> tuple[str | None, str | None]: """Return ``(model_id, reasoning_effort)`` if both are valid, else ``(None, None)``.""" model_id = profile.get("default_model") diff --git a/agent/dashboard/enabled_repos.py b/agent/dashboard/enabled_repos.py new file mode 100644 index 00000000..da50e988 --- /dev/null +++ b/agent/dashboard/enabled_repos.py @@ -0,0 +1,68 @@ +"""Team-wide opt-in list of repos that Open SWE Review may auto-review. + +A single record keyed ``"default"`` holds the list. Repos default to +**disabled** — webhooks for repos absent from the list are ignored, so an +operator who installs the GitHub App into a new org doesn't get surprise +review comments on every PR. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime + +from langgraph_sdk import get_client + +from .review_styles import normalize_repo_full_name + +logger = logging.getLogger(__name__) + +ENABLED_REVIEW_REPOS_NAMESPACE: list[str] = ["enabled_review_repos"] +ENABLED_REVIEW_REPOS_KEY = "default" + + +def _client(): + return get_client() + + +async def list_enabled_review_repos() -> list[str]: + try: + item = await _client().store.get_item( + ENABLED_REVIEW_REPOS_NAMESPACE, ENABLED_REVIEW_REPOS_KEY + ) + except Exception as e: + logger.debug("enabled review repos lookup failed: %s", e) + return [] + if item is None: + return [] + value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None) + if not isinstance(value, dict): + return [] + repos = value.get("repos") + if not isinstance(repos, list): + return [] + return [r for r in repos if isinstance(r, str)] + + +async def set_review_repo_enabled(full_name: str, enabled: bool) -> list[str]: + full_name = normalize_repo_full_name(full_name) + current = set(await list_enabled_review_repos()) + if enabled: + current.add(full_name) + else: + current.discard(full_name) + repos = sorted(current) + await _client().store.put_item( + ENABLED_REVIEW_REPOS_NAMESPACE, + ENABLED_REVIEW_REPOS_KEY, + {"repos": repos, "updated_at": datetime.now(UTC).isoformat()}, + ) + return repos + + +async def is_review_repo_enabled(owner: str, name: str) -> bool: + if not owner or not name: + return False + full_name = f"{owner.lower()}/{name.lower()}" + enabled = await list_enabled_review_repos() + return any(r.lower() == full_name for r in enabled) diff --git a/agent/dashboard/options.py b/agent/dashboard/options.py index 3b1c642a..6a31e6a4 100644 --- a/agent/dashboard/options.py +++ b/agent/dashboard/options.py @@ -29,9 +29,22 @@ SUPPORTED_MODELS: list[ModelOption] = [ SUPPORTED_MODEL_IDS: frozenset[str] = frozenset(m["id"] for m in SUPPORTED_MODELS) +DEFAULT_MODEL_ID: str = "openai:gpt-5.5" +DEFAULT_MODEL_EFFORT: str = "medium" + def model_supports_effort(model_id: str, effort: str) -> bool: for m in SUPPORTED_MODELS: if m["id"] == model_id: return effort in m["efforts"] return False + + +def default_model_pair() -> tuple[str, str]: + """Hardcoded fallback (model_id, reasoning_effort) used when no team default is set.""" + if DEFAULT_MODEL_ID in SUPPORTED_MODEL_IDS and model_supports_effort( + DEFAULT_MODEL_ID, DEFAULT_MODEL_EFFORT + ): + return DEFAULT_MODEL_ID, DEFAULT_MODEL_EFFORT + first = SUPPORTED_MODELS[0] + return first["id"], first["default_effort"] diff --git a/agent/dashboard/profiles.py b/agent/dashboard/profiles.py index 0b4bfedf..0f33db0d 100644 --- a/agent/dashboard/profiles.py +++ b/agent/dashboard/profiles.py @@ -33,15 +33,10 @@ class ProfileUpdate(BaseModel): default_model: str reasoning_effort: str default_repo: str | None = None - first_name: str | None = None - last_name: str | None = None base_branch: str | None = None branch_prefix: str | None = None auto_fix_ci: bool = True create_prs: bool = True - allow_artifacts: bool = False - slack_notifications: bool = True - preferred_pr_destination: str | None = None @field_validator("default_model") @classmethod @@ -93,17 +88,20 @@ async def upsert_profile(login: str, email: str, update: ProfileUpdate) -> dict[ "default_model": update.default_model, "reasoning_effort": update.reasoning_effort, "default_repo": update.default_repo, - "first_name": update.first_name, - "last_name": update.last_name, "base_branch": update.base_branch, "branch_prefix": update.branch_prefix, "auto_fix_ci": update.auto_fix_ci, "create_prs": update.create_prs, - "allow_artifacts": update.allow_artifacts, - "slack_notifications": update.slack_notifications, - "preferred_pr_destination": update.preferred_pr_destination, "updated_at": datetime.now(UTC).isoformat(), } + for stale_field in ( + "first_name", + "last_name", + "allow_artifacts", + "slack_notifications", + "preferred_pr_destination", + ): + value.pop(stale_field, None) await _client().store.put_item(PROFILES_NAMESPACE, login, value) return value diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py index b52884a4..205c7fb2 100644 --- a/agent/dashboard/routes.py +++ b/agent/dashboard/routes.py @@ -10,8 +10,13 @@ from typing import Any import httpx from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import RedirectResponse, Response +from pydantic import BaseModel from .admin import is_admin +from .enabled_repos import ( + list_enabled_review_repos, + set_review_repo_enabled, +) from .oauth import ( COOKIE_NAME, SESSION_TTL_SECONDS, @@ -257,6 +262,27 @@ async def api_put_team_settings( return await upsert_team_settings(update) +class EnabledReviewRepoUpdate(BaseModel): + full_name: str + enabled: bool + + +@router.get("/enabled-review-repos") +async def api_list_enabled_review_repos( + _session: dict[str, Any] = _SESSION_DEP, +) -> dict[str, list[str]]: + return {"repos": await list_enabled_review_repos()} + + +@router.put("/enabled-review-repos") +async def api_set_enabled_review_repo( + update: EnabledReviewRepoUpdate, + _admin: dict[str, Any] = _ADMIN_DEP, +) -> dict[str, list[str]]: + repos = await set_review_repo_enabled(update.full_name, update.enabled) + return {"repos": repos} + + def _next_link_url(link_header: str | None) -> str | None: if not link_header: return None diff --git a/agent/dashboard/team_settings.py b/agent/dashboard/team_settings.py index 39da1c99..e4b0570e 100644 --- a/agent/dashboard/team_settings.py +++ b/agent/dashboard/team_settings.py @@ -12,14 +12,16 @@ from datetime import UTC, datetime from typing import Any, Literal from langgraph_sdk import get_client -from pydantic import BaseModel +from pydantic import BaseModel, model_validator + +from .options import SUPPORTED_MODEL_IDS, default_model_pair, model_supports_effort logger = logging.getLogger(__name__) TEAM_SETTINGS_NAMESPACE: list[str] = ["team_settings"] TEAM_SETTINGS_KEY = "default" -TriggerMode = Literal["every_push", "ready_for_review", "manual"] +TriggerMode = Literal["every_push", "once_per_pr", "manual"] AutofixMode = Literal["off", "low", "medium", "high"] @@ -29,6 +31,31 @@ class TeamSettingsUpdate(BaseModel): pr_summaries: bool = True autofix_mode: AutofixMode = "off" autofix_severity_threshold: AutofixMode = "medium" + default_agent_model: str | None = None + default_agent_reasoning_effort: str | None = None + default_reviewer_model: str | None = None + default_reviewer_reasoning_effort: str | None = None + + @model_validator(mode="after") + def _validate_model_pairs(self) -> TeamSettingsUpdate: + _validate_model_effort_pair( + self.default_agent_model, self.default_agent_reasoning_effort, "agent" + ) + _validate_model_effort_pair( + self.default_reviewer_model, self.default_reviewer_reasoning_effort, "reviewer" + ) + return self + + +def _validate_model_effort_pair(model: str | None, effort: str | None, role: str) -> None: + if model is None and effort is None: + return + if model is None: + raise ValueError(f"{role} reasoning effort set without a model") + if model not in SUPPORTED_MODEL_IDS: + raise ValueError(f"unsupported {role} model: {model}") + if effort is None or not model_supports_effort(model, effort): + raise ValueError(f"effort {effort!r} not supported by {role} model {model!r}") def _client(): @@ -36,28 +63,42 @@ def _client(): def _default_settings() -> dict[str, Any]: + fallback_model, fallback_effort = default_model_pair() return { "trigger_mode": "every_push", "review_draft_prs": False, "pr_summaries": True, "autofix_mode": "off", "autofix_severity_threshold": "medium", + "default_agent_model": fallback_model, + "default_agent_reasoning_effort": fallback_effort, + "default_reviewer_model": fallback_model, + "default_reviewer_reasoning_effort": fallback_effort, "updated_at": None, } async def get_team_settings() -> dict[str, Any]: + defaults = _default_settings() try: item = await _client().store.get_item(TEAM_SETTINGS_NAMESPACE, TEAM_SETTINGS_KEY) except Exception as e: logger.debug("team settings lookup failed: %s", e) - return _default_settings() + return defaults if item is None: - return _default_settings() + return defaults value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None) if not isinstance(value, dict): - return _default_settings() - return {**_default_settings(), **value} + return defaults + # Skip None-valued model fields so legacy records (or PUTs that cleared the + # selection) still surface the hardcoded default instead of a null. + overlay = {k: v for k, v in value.items() if v is not None} + merged = {**defaults, **overlay} + # Drop obsolete trigger mode values so a legacy record doesn't surface a + # value the new TriggerMode literal would reject on the next PUT. + if merged.get("trigger_mode") not in {"every_push", "once_per_pr", "manual"}: + merged["trigger_mode"] = defaults["trigger_mode"] + return merged async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]: @@ -67,7 +108,38 @@ async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]: "pr_summaries": update.pr_summaries, "autofix_mode": update.autofix_mode, "autofix_severity_threshold": update.autofix_severity_threshold, + "default_agent_model": update.default_agent_model, + "default_agent_reasoning_effort": update.default_agent_reasoning_effort, + "default_reviewer_model": update.default_reviewer_model, + "default_reviewer_reasoning_effort": update.default_reviewer_reasoning_effort, "updated_at": datetime.now(UTC).isoformat(), } await _client().store.put_item(TEAM_SETTINGS_NAMESPACE, TEAM_SETTINGS_KEY, value) return value + + +async def get_team_default_model( + role: Literal["agent", "reviewer"], +) -> tuple[str, str]: + """Return the team-wide default ``(model_id, reasoning_effort)`` for ``role``. + + Always returns a valid pair: the admin-configured pair if set, otherwise the + hardcoded fallback from :func:`agent.dashboard.options.default_model_pair`. + Invalid stored pairs (unsupported model or mismatched effort) fall back to + the hardcoded default rather than propagating bad data. + """ + settings = await get_team_settings() + if role == "agent": + model = settings.get("default_agent_model") + effort = settings.get("default_agent_reasoning_effort") + else: + model = settings.get("default_reviewer_model") + effort = settings.get("default_reviewer_reasoning_effort") + if ( + isinstance(model, str) + and model in SUPPORTED_MODEL_IDS + and isinstance(effort, str) + and model_supports_effort(model, effort) + ): + return model, effort + return default_model_pair() diff --git a/agent/prompt.py b/agent/prompt.py index 246e6833..f8e0148a 100644 --- a/agent/prompt.py +++ b/agent/prompt.py @@ -377,6 +377,13 @@ def _render_collaboration_section(identity: CollaboratorIdentity | None) -> str: ) +NO_PR_OVERRIDE_SECTION = """--- + +### Pull Request Policy Override + +The user has disabled automatic PR creation. After implementation, **commit and push your branch** so the work is preserved, then notify the source channel with the branch URL (e.g. `https://github.com///tree/`) and a summary. **Do not** run `gh pr create` or `gh pr edit`. Ignore any instructions elsewhere in this prompt that tell you to open or update a draft pull request.""" + + SYSTEM_PROMPT_TEMPLATE = ( WORKING_ENV_SECTION + TASK_OVERVIEW_SECTION @@ -394,6 +401,7 @@ SYSTEM_PROMPT_TEMPLATE = ( + COMMUNICATION_SECTION + EXTERNAL_UNTRUSTED_COMMENTS_SECTION + COMMIT_PR_SECTION + + "{pr_policy_override_section}" + "{collaboration_section}" ) @@ -403,6 +411,7 @@ def construct_system_prompt( linear_project_id: str = "", linear_issue_number: str = "", triggering_user_identity: CollaboratorIdentity | None = None, + create_prs: bool = True, ) -> str: default_prompt_section = _load_default_prompt() return SYSTEM_PROMPT_TEMPLATE.format( @@ -410,5 +419,6 @@ def construct_system_prompt( linear_project_id=linear_project_id or "", linear_issue_number=linear_issue_number or "", default_prompt_section=default_prompt_section, + pr_policy_override_section="" if create_prs else NO_PR_OVERRIDE_SECTION, collaboration_section=_render_collaboration_section(triggering_user_identity), ) diff --git a/agent/reviewer.py b/agent/reviewer.py index ae5dca48..9fe3a7b3 100644 --- a/agent/reviewer.py +++ b/agent/reviewer.py @@ -16,7 +16,6 @@ agent for code review only: # ruff: noqa: E402 import logging -import os import warnings logger = logging.getLogger(__name__) @@ -40,7 +39,6 @@ from .reviewer_findings import ( ) from .server import ( DEFAULT_LLM_MAX_TOKENS, - DEFAULT_LLM_MODEL_ID, DEFAULT_RECURSION_LIMIT, MODEL_CALL_RECURSION_LIMIT, ensure_sandbox_for_thread, @@ -494,14 +492,20 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel: head_sha=head_sha, ) + from .dashboard.team_settings import get_team_default_model + configured_model_id = config["configurable"].get("reviewer_model_id") - model_id = ( - configured_model_id - if isinstance(configured_model_id, str) and configured_model_id - else os.environ.get("LLM_MODEL_ID", DEFAULT_LLM_MODEL_ID) - ) configured_effort = config["configurable"].get("reviewer_reasoning_effort") - reasoning_effort = configured_effort if isinstance(configured_effort, str) else None + if isinstance(configured_model_id, str) and configured_model_id: + model_id = configured_model_id + reasoning_effort = configured_effort if isinstance(configured_effort, str) else None + else: + model_id, reasoning_effort = await get_team_default_model("reviewer") + logger.info( + "Using team default reviewer model: model=%s effort=%s", + model_id, + reasoning_effort, + ) model_kwargs = provider_model_kwargs( model_id, reasoning_effort, diff --git a/agent/server.py b/agent/server.py index 4fd6a34c..c6e59dce 100644 --- a/agent/server.py +++ b/agent/server.py @@ -31,8 +31,11 @@ from langsmith.sandbox import SandboxClientError from .dashboard.agent_overrides import ( load_profile, normalize_profile_overrides, + profile_create_prs, resolve_github_login, ) +from .dashboard.options import DEFAULT_MODEL_ID +from .dashboard.team_settings import get_team_default_model from .integrations.langsmith import _configure_github_proxy from .middleware import ( ModelFallbackMiddleware, @@ -331,7 +334,7 @@ async def ensure_sandbox_for_thread(thread_id: str) -> SandboxBackendProtocol: return sandbox_backend -DEFAULT_LLM_MODEL_ID = "openai:gpt-5.5" +DEFAULT_LLM_MODEL_ID = DEFAULT_MODEL_ID DEFAULT_LLM_MAX_TOKENS = 64_000 DEFAULT_RECURSION_LIMIT = 9_999 MODEL_CALL_RECURSION_LIMIT = 5_000 # ~half the recursion limit to account for tool calls @@ -376,8 +379,10 @@ 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 = os.environ.get("LLM_MODEL_ID", DEFAULT_LLM_MODEL_ID) - profile_effort: str | None = None + model_id, profile_effort = await get_team_default_model("agent") + logger.info("Using team default agent model: model=%s effort=%s", model_id, profile_effort) + + profile: dict[str, Any] | None = None profile_login = resolve_github_login(config) if profile_login: profile = await load_profile(profile_login) @@ -393,6 +398,10 @@ async def get_agent(config: RunnableConfig) -> Pregel: model_id = overridden_model profile_effort = overridden_effort + create_prs = profile_create_prs(profile) + if not create_prs: + logger.info("PR creation disabled by profile for %s", profile_login) + model_kwargs = provider_model_kwargs( model_id, profile_effort, @@ -418,6 +427,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: linear_project_id=linear_project_id, linear_issue_number=linear_issue_number, triggering_user_identity=triggering_user_identity, + create_prs=create_prs, ), tools=[ http_request, diff --git a/agent/webapp.py b/agent/webapp.py index eb97c517..69586f44 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -22,6 +22,7 @@ from .dashboard.agent_overrides import ( get_profile_default_repo, resolve_login_from_email, ) +from .dashboard.enabled_repos import is_review_repo_enabled from .reviewer_findings import ( REVIEWER_THREAD_KIND, ReviewerPRMeta, @@ -408,6 +409,18 @@ def _is_repo_allowed_for_reviewer(repo_config: dict[str, str]) -> bool: return owner in ALLOWED_REVIEWER_GITHUB_ORGS +async def _is_repo_enabled_for_review(repo_config: dict[str, str]) -> bool: + """Combined gate: operator allowlist + team opt-in list from the dashboard. + + Both checks must pass. The opt-in list is empty by default, so repos + are off until an admin enables them in the dashboard's Open SWE Review + tab. + """ + if not _is_repo_allowed_for_reviewer(repo_config): + return False + return await is_review_repo_enabled(repo_config.get("owner", ""), repo_config.get("name", "")) + + _PUBLIC_REPO_GATE_REJECTION = { "status": "ignored", "reason": "Sender is not a member of the allowed organization for public-repo triggers", @@ -1347,8 +1360,8 @@ async def slack_webhook(request: Request, background_tasks: BackgroundTasks) -> clean_text = strip_bot_mention(text, bot_user_id, bot_username=SLACK_BOT_USERNAME) pr_ref = parse_slack_review_command(clean_text) if pr_ref: - if not _is_repo_allowed_for_reviewer({"owner": pr_ref.owner, "name": pr_ref.repo}): - return {"status": "ignored", "reason": "Repository not in reviewer allowlist"} + if not await _is_repo_enabled_for_review({"owner": pr_ref.owner, "name": pr_ref.repo}): + return {"status": "ignored", "reason": "Repository not enabled for review"} background_tasks.add_task(process_slack_pr_review_request, pr_ref, channel_id, thread_ts) return {"status": "accepted", "message": "Slack PR review request queued"} @@ -1568,8 +1581,8 @@ async def trigger_pr_review_from_ref( slack_thread_ts: str = "", ) -> dict[str, Any]: repo_config = {"owner": pr_ref.owner, "name": pr_ref.repo} - if not _is_repo_allowed_for_reviewer(repo_config): - return {"success": False, "error": "Repository not allowed for reviewer"} + if not await _is_repo_enabled_for_review(repo_config): + return {"success": False, "error": "Repository not enabled for review"} app_token, app_token_expires_at = await get_github_app_installation_token_with_expiry() if not app_token: @@ -1900,7 +1913,7 @@ async def process_github_pr_close(payload: dict[str, Any]) -> None: pr_number = pull_request.get("number") if not pr_number or not isinstance(pr_number, int): return - if not _is_repo_allowed_for_reviewer(repo_config): + if not await _is_repo_enabled_for_review(repo_config): return thread_id = generate_reviewer_thread_id( @@ -1944,9 +1957,9 @@ async def process_github_push_event(payload: dict[str, Any]) -> None: if not repo_config["owner"] or not repo_config["name"]: logger.warning("Push to %s ignored: repository owner/name missing from payload", head_ref) return - if not _is_repo_allowed_for_reviewer(repo_config): + if not await _is_repo_enabled_for_review(repo_config): logger.info( - "Push to %s/%s head=%s ignored: repo not in reviewer allowlist", + "Push to %s/%s head=%s ignored: repo not enabled for review", repo_config["owner"], repo_config["name"], head_ref, @@ -2411,25 +2424,21 @@ async def github_webhook(request: Request, background_tasks: BackgroundTasks) -> "reason": f"Unsupported GitHub pull_request action: {action}", } if action in {"closed", "reopened"}: - if not _is_repo_allowed_for_reviewer(webhook_repo_config): - return {"status": "ignored", "reason": "Repository not in reviewer allowlist"} + if not await _is_repo_enabled_for_review(webhook_repo_config): + return {"status": "ignored", "reason": "Repository not enabled for review"} logger.info("Accepted GitHub PR %s webhook, scheduling reviewer watch update", action) background_tasks.add_task(process_github_pr_close, payload) return {"status": "accepted", "message": f"Processing PR {action} for reviewer watch"} if not _is_open_swe_reviewer_request(payload): logger.info("Ignoring PR review request for a different reviewer") return {"status": "ignored", "reason": "Review request is not for open-swe bot"} - if not _is_repo_allowed_for_reviewer(webhook_repo_config): + if not await _is_repo_enabled_for_review(webhook_repo_config): logger.warning( - "Rejecting GitHub reviewer webhook: repo '%s/%s' failed reviewer allowlist", + "Rejecting GitHub reviewer webhook: repo '%s/%s' not enabled for review", webhook_repo_config.get("owner"), webhook_repo_config.get("name"), ) - if ALLOWED_REVIEWER_GITHUB_REPOS: - reason = "Repository not in allowlist" - else: - reason = "Repository org not in allowlist" - return {"status": "ignored", "reason": reason} + return {"status": "ignored", "reason": "Repository not enabled for review"} gate_rejection = await _enforce_public_repo_org_gate(payload, "pull_request") if gate_rejection is not None: @@ -2440,8 +2449,8 @@ async def github_webhook(request: Request, background_tasks: BackgroundTasks) -> return {"status": "accepted", "message": "Processing GitHub PR review request"} if event_type == "push": - if not _is_repo_allowed_for_reviewer(webhook_repo_config): - return {"status": "ignored", "reason": "Repository not in reviewer allowlist"} + if not await _is_repo_enabled_for_review(webhook_repo_config): + return {"status": "ignored", "reason": "Repository not enabled for review"} logger.info("Accepted GitHub push webhook, scheduling reviewer watch evaluation") background_tasks.add_task(process_github_push_event, payload) return {"status": "accepted", "message": "Processing GitHub push for reviewer watch"} @@ -2508,8 +2517,8 @@ async def github_webhook(request: Request, background_tasks: BackgroundTasks) -> }: is_review_command, pr_url_override = parse_github_review_command(comment_body) if is_review_command: - if not _is_repo_allowed_for_reviewer(webhook_repo_config): - return {"status": "ignored", "reason": "Repository not in reviewer allowlist"} + if not await _is_repo_enabled_for_review(webhook_repo_config): + return {"status": "ignored", "reason": "Repository not enabled for review"} background_tasks.add_task( process_github_pr_review_command, payload, event_type, pr_url_override ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..77d2133a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,26 @@ +"""Shared pytest fixtures.""" + +from __future__ import annotations + +import pytest + +from agent import webapp + + +@pytest.fixture(autouse=True) +def _default_enable_review_repos(monkeypatch: pytest.MonkeyPatch) -> None: + """Treat every repo as enabled for review by default. + + The dashboard's opt-in list (loaded by :func:`agent.dashboard.enabled_repos.is_review_repo_enabled`) + is empty in the test environment because there is no live LangGraph Store. Tests that + exercise the env-based allowlists (``ALLOWED_REVIEWER_GITHUB_*``) would otherwise be + blocked by the opt-in gate that lives on top of them. + + Tests targeting the opt-in gate itself should override this fixture or set + ``monkeypatch.setattr(webapp, "is_review_repo_enabled", ...)`` to a stricter stub. + """ + + async def _enabled(_owner: str, _name: str) -> bool: + return True + + monkeypatch.setattr(webapp, "is_review_repo_enabled", _enabled) diff --git a/tests/test_github_issue_webhook.py b/tests/test_github_issue_webhook.py index 0ed01a95..59d16abc 100644 --- a/tests/test_github_issue_webhook.py +++ b/tests/test_github_issue_webhook.py @@ -321,7 +321,7 @@ def test_github_webhook_blocks_reviewer_repo_not_in_reviewer_repo_allowlist(monk ) assert response.status_code == 200 - assert response.json() == {"status": "ignored", "reason": "Repository not in allowlist"} + assert response.json() == {"status": "ignored", "reason": "Repository not enabled for review"} assert called is False @@ -873,7 +873,7 @@ def test_trigger_pr_review_from_ref_respects_reviewer_allowlist(monkeypatch) -> ) ) - assert result == {"success": False, "error": "Repository not allowed for reviewer"} + assert result == {"success": False, "error": "Repository not enabled for review"} assert called is False @@ -1185,7 +1185,7 @@ def test_github_webhook_blocks_pr_review_command_outside_reviewer_allowlist(monk assert response.status_code == 200 assert response.json() == { "status": "ignored", - "reason": "Repository not in reviewer allowlist", + "reason": "Repository not enabled for review", } diff --git a/ui/assets/LangChain_Symbol_LightBlue.png b/ui/assets/LangChain_Symbol_LightBlue.png new file mode 100644 index 00000000..63c36b45 Binary files /dev/null and b/ui/assets/LangChain_Symbol_LightBlue.png differ diff --git a/ui/assets/ic_launcher_round.png b/ui/assets/ic_launcher_round.png new file mode 100644 index 00000000..e3ac9b26 Binary files /dev/null and b/ui/assets/ic_launcher_round.png differ diff --git a/ui/assets/playstore-icon.png b/ui/assets/playstore-icon.png new file mode 100644 index 00000000..beecd6c0 Binary files /dev/null and b/ui/assets/playstore-icon.png differ diff --git a/ui/public/apple-touch-icon.png b/ui/public/apple-touch-icon.png new file mode 100644 index 00000000..e3ac9b26 Binary files /dev/null and b/ui/public/apple-touch-icon.png differ diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico deleted file mode 100644 index a11777cc..00000000 Binary files a/ui/public/favicon.ico and /dev/null differ diff --git a/ui/public/favicon.png b/ui/public/favicon.png new file mode 100644 index 00000000..e3ac9b26 Binary files /dev/null and b/ui/public/favicon.png differ diff --git a/ui/public/logo-mark.png b/ui/public/logo-mark.png new file mode 100644 index 00000000..63c36b45 Binary files /dev/null and b/ui/public/logo-mark.png differ diff --git a/ui/src/components/AppShell.tsx b/ui/src/components/AppShell.tsx index 48996062..d7b1634f 100644 --- a/ui/src/components/AppShell.tsx +++ b/ui/src/components/AppShell.tsx @@ -1,3 +1,5 @@ +import { Link } from "@tanstack/react-router"; +import { ArrowLeftIcon } from "@phosphor-icons/react"; import type { ReactNode } from "react"; import type { SessionUser } from "@/lib/api"; @@ -7,15 +9,25 @@ interface AppShellProps { user: SessionUser; title: string; description?: string; + backTo?: { to: string; label: string }; children: ReactNode; } -export function AppShell({ user, title, description, children }: AppShellProps) { +export function AppShell({ user, title, description, backTo, children }: AppShellProps) { return ( -
+
+ {backTo && ( + + + {backTo.label} + + )}

{title}

{description && ( @@ -60,18 +72,38 @@ interface SettingsRowProps { description?: string; control: ReactNode; htmlFor?: string; + comingSoon?: boolean; } -export function SettingsRow({ label, description, control, htmlFor }: SettingsRowProps) { +export function SettingsRow({ + label, + description, + control, + htmlFor, + comingSoon, +}: SettingsRowProps) { return (
-
{control}
+
{control}
); } diff --git a/ui/src/components/AppSidebar.tsx b/ui/src/components/AppSidebar.tsx index 2acdc8e1..287a9dcc 100644 --- a/ui/src/components/AppSidebar.tsx +++ b/ui/src/components/AppSidebar.tsx @@ -69,7 +69,11 @@ export function AppSidebar({ user }: { user: SessionUser }) { return (
- - persist({ slack_notifications: v })} - /> - } - /> - - {error &&

{error}

} ); diff --git a/ui/src/routes/my-settings.tsx b/ui/src/routes/my-settings.tsx index 389427d7..800f47eb 100644 --- a/ui/src/routes/my-settings.tsx +++ b/ui/src/routes/my-settings.tsx @@ -1,41 +1,18 @@ -import { Navigate, createFileRoute } from "@tanstack/react-router"; -import { useEffect, useRef, useState } from "react"; +import { Navigate, createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; -import { buildProfileUpdate, useOptions, useProfile, useSaveProfile } from "@/lib/profile"; +import { api } from "@/lib/api"; import { useSession } from "@/lib/session"; export const Route = createFileRoute("/my-settings")({ component: MySettingsPage }); function MySettingsPage() { const session = useSession(); - const profile = useProfile(); - const options = useOptions(); - const save = useSaveProfile(); - - const [firstName, setFirstName] = useState(""); - const [lastName, setLastName] = useState(""); - const [prDestination, setPrDestination] = useState("team_default"); - const [error, setError] = useState(null); - const initialized = useRef(false); - - useEffect(() => { - if (!profile.data || initialized.current) return; - initialized.current = true; - setFirstName(profile.data.first_name ?? ""); - setLastName(profile.data.last_name ?? ""); - setPrDestination(profile.data.preferred_pr_destination ?? "team_default"); - }, [profile.data]); + const qc = useQueryClient(); + const navigate = useNavigate(); if (session.isLoading) { return ( @@ -46,97 +23,36 @@ function MySettingsPage() { } if (!session.data) return ; - const firstModel = options.data?.models[0]; - const handleSaveProfile = () => { - setError(null); - const body = buildProfileUpdate( - profile.data, - { first_name: firstName || null, last_name: lastName || null }, - firstModel?.id ?? "", - firstModel?.default_effort ?? "", - ); - save - .mutateAsync(body) - .catch((e: Error) => setError(e.message)); - }; - - const handleSavePrDestination = (value: string | null) => { - if (!value) return; - setPrDestination(value); - const body = buildProfileUpdate( - profile.data, - { preferred_pr_destination: value === "team_default" ? null : value }, - firstModel?.id ?? "", - firstModel?.default_effort ?? "", - ); - save.mutateAsync(body).catch((e: Error) => setError(e.message)); + const handleLogout = async () => { + await api.logout(); + qc.setQueryData(["session"], null); + void navigate({ to: "/login" }); }; return ( -
- - {session.data.email ?? "—"} - - } - /> - setFirstName(e.target.value)} - /> - } - /> - setLastName(e.target.value)} - /> - } - /> -
- -
-
-
- - - - - - - Team default - Web - Desktop App - IDE - - + + {session.data.email ?? "—"} + } /> - {error &&

{error}

} + + void handleLogout()}> + Sign out + + } + /> +
); } diff --git a/ui/src/routes/review.tsx b/ui/src/routes/review.tsx index 9fe2a2ed..27b877e9 100644 --- a/ui/src/routes/review.tsx +++ b/ui/src/routes/review.tsx @@ -1,10 +1,10 @@ -import { Navigate, createFileRoute } from "@tanstack/react-router"; +import { Link, Navigate, createFileRoute } from "@tanstack/react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; +import { CaretRightIcon, GithubLogoIcon } from "@phosphor-icons/react"; +import { useEffect, useMemo, useState } from "react"; -import type { AutofixMode, TeamSettings, TriggerMode } from "@/lib/api"; +import type { AutofixMode, ReposPayload, TeamSettings, TriggerMode } from "@/lib/api"; import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell"; -import { ReviewStylesPanel } from "@/components/ReviewStylesPanel"; import { Select, SelectContent, @@ -14,19 +14,27 @@ import { } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; -import { api } from "@/lib/api"; +import { ApiError, api } from "@/lib/api"; import { useSession } from "@/lib/session"; export const Route = createFileRoute("/review")({ component: ReviewPage }); const TRIGGER_MODES: Array<{ value: TriggerMode; label: string; description: string }> = [ - { value: "every_push", label: "Every Push", description: "Review every push to a PR" }, { - value: "ready_for_review", - label: "Ready For Review", - description: "Only when the PR is marked ready for review", + value: "every_push", + label: "Every Push", + description: "Review on every push to the PR", + }, + { + value: "once_per_pr", + label: "Once Per PR", + description: "Review once when the PR is opened, skip subsequent pushes", + }, + { + value: "manual", + label: "Manual Only", + description: "Only review when '@open-swe review' is commented", }, - { value: "manual", label: "Manual", description: "Review only when explicitly invoked" }, ]; const AUTOFIX_MODES: Array<{ value: AutofixMode; label: string }> = [ @@ -42,6 +50,10 @@ const DEFAULT_SETTINGS: TeamSettings = { pr_summaries: true, autofix_mode: "off", autofix_severity_threshold: "medium", + default_agent_model: null, + default_agent_reasoning_effort: null, + default_reviewer_model: null, + default_reviewer_reasoning_effort: null, }; function ReviewPage() { @@ -96,16 +108,34 @@ function ReviewPage() { title="Open SWE Review" description="Automatically review pull requests for bugs and issues. Runs are billed based on underlying agent usage." > + + + + +
+ Review Style Prompts + + Per-repo style guides learned from past PR review feedback. + +
+ + +
+
persist({ trigger_mode: v as TriggerMode })} - disabled={!canEdit} + disabled > @@ -145,11 +175,12 @@ function ReviewPage() { persist({ autofix_mode: v as AutofixMode })} - disabled={!canEdit} + disabled > @@ -167,13 +198,14 @@ function ReviewPage() { persist({ autofix_severity_threshold: v as AutofixMode }) } - disabled={!canEdit} + disabled > @@ -197,14 +229,95 @@ function ReviewPage() {

)} - - - - {error &&

{error}

} ); } + +function RepositoriesSection({ canEdit: _canEdit }: { canEdit: boolean }) { + const repos = useQuery({ + queryKey: ["repos"], + queryFn: async () => { + try { + return await api.repos(); + } catch (e) { + if (e instanceof ApiError && e.status === 401) + return { installations: [], repositories: [] }; + throw e; + } + }, + }); + + const enabled = useQuery({ + queryKey: ["enabledReviewRepos"], + queryFn: api.listEnabledReviewRepos, + }); + + const enabledSet = useMemo( + () => new Set(enabled.data?.repos ?? []), + [enabled.data?.repos], + ); + + const grouped = useMemo(() => { + const byOwner = new Map>(); + for (const r of repos.data?.repositories ?? []) { + const [owner] = r.full_name.split("/"); + if (!owner) continue; + const arr = byOwner.get(owner) ?? []; + arr.push(r); + byOwner.set(owner, arr); + } + return Array.from(byOwner.entries()).sort(([a], [b]) => a.localeCompare(b)); + }, [repos.data?.repositories]); + + const loading = repos.isLoading || enabled.isLoading; + + return ( + +
+ {loading && ( +
+ +
+ )} + {!loading && grouped.length === 0 && ( +

+ No GitHub App installations found. Install the open-swe GitHub App on an + account or org to manage repos here. +

+ )} + {grouped.map(([owner, list]) => { + const enabledCount = list.filter((r) => enabledSet.has(r.full_name)).length; + return ( + +
+ +
+
+ {owner} +
+ GitHub +
+
+
+ + {enabledCount}/{list.length} Repositories Enabled + + +
+ + ); + })} +
+
+ ); +} + diff --git a/ui/src/routes/review_.repositories.$owner.tsx b/ui/src/routes/review_.repositories.$owner.tsx new file mode 100644 index 00000000..1cf611f8 --- /dev/null +++ b/ui/src/routes/review_.repositories.$owner.tsx @@ -0,0 +1,189 @@ +import { Navigate, createFileRoute } from "@tanstack/react-router"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; + +import type { ReposPayload } from "@/lib/api"; +import { AppShell } from "@/components/AppShell"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Switch } from "@/components/ui/switch"; +import { ApiError, api } from "@/lib/api"; +import { useSession } from "@/lib/session"; + +const PAGE_SIZE = 20; + +export const Route = createFileRoute("/review_/repositories/$owner")({ + component: RepositoriesOwnerPage, +}); + +function RepositoriesOwnerPage() { + const session = useSession(); + const { owner } = Route.useParams(); + const qc = useQueryClient(); + + const repos = useQuery({ + queryKey: ["repos"], + queryFn: async () => { + try { + return await api.repos(); + } catch (e) { + if (e instanceof ApiError && e.status === 401) + return { installations: [], repositories: [] }; + throw e; + } + }, + enabled: !!session.data, + }); + + const enabled = useQuery({ + queryKey: ["enabledReviewRepos"], + queryFn: api.listEnabledReviewRepos, + enabled: !!session.data, + }); + + const toggle = useMutation({ + mutationFn: ({ full_name, on }: { full_name: string; on: boolean }) => + api.setEnabledReviewRepo(full_name, on), + onSuccess: (data) => { + qc.setQueryData(["enabledReviewRepos"], data); + }, + }); + + const ownerRepos = useMemo( + () => + (repos.data?.repositories ?? []) + .filter((r) => r.full_name.split("/")[0] === owner) + .sort((a, b) => a.full_name.localeCompare(b.full_name)), + [repos.data?.repositories, owner], + ); + + const enabledSet = useMemo( + () => new Set(enabled.data?.repos ?? []), + [enabled.data?.repos], + ); + + const [page, setPage] = useState(0); + useEffect(() => setPage(0), [owner]); + + const totalPages = Math.max(1, Math.ceil(ownerRepos.length / PAGE_SIZE)); + const safePage = Math.min(page, totalPages - 1); + const pageStart = safePage * PAGE_SIZE; + const pageEnd = Math.min(pageStart + PAGE_SIZE, ownerRepos.length); + const pageRepos = ownerRepos.slice(pageStart, pageEnd); + + if (session.isLoading) { + return ( +
+ +
+ ); + } + if (!session.data) return ; + + const canEdit = session.data.is_admin; + const enabledCount = ownerRepos.filter((r) => enabledSet.has(r.full_name)).length; + const loading = repos.isLoading || enabled.isLoading; + + return ( + +
+
+

+ Repositories +

+ + {enabledCount}/{ownerRepos.length} enabled + +
+
+ {loading && ( +
+ +
+ )} + {!loading && ownerRepos.length === 0 && ( +

+ No repositories found for this installation. +

+ )} +
    + {pageRepos.map((r) => { + const isEnabled = enabledSet.has(r.full_name); + return ( +
  • +
    + + {owner}/ + + {r.full_name.slice(owner.length + 1)} + + + {r.private && ( + private + )} +
    + + + toggle.mutate({ full_name: r.full_name, on: v }) + } + /> + +
  • + ); + })} +
+ {ownerRepos.length > PAGE_SIZE && ( +
+ + Showing {pageStart + 1}-{pageEnd} of {ownerRepos.length} + +
+ + + {safePage + 1} / {totalPages} + + +
+
+ )} +
+
+
+ ); +} diff --git a/ui/src/routes/review_.styles.tsx b/ui/src/routes/review_.styles.tsx new file mode 100644 index 00000000..2d72f49a --- /dev/null +++ b/ui/src/routes/review_.styles.tsx @@ -0,0 +1,34 @@ +import { Navigate, createFileRoute } from "@tanstack/react-router"; + +import { AppShell } from "@/components/AppShell"; +import { ReviewStylesPanel } from "@/components/ReviewStylesPanel"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useSession } from "@/lib/session"; + +export const Route = createFileRoute("/review_/styles")({ component: ReviewStylesPage }); + +function ReviewStylesPage() { + const session = useSession(); + + if (session.isLoading) { + return ( +
+ +
+ ); + } + if (!session.data) return ; + + return ( + +
+ +
+
+ ); +}