feat: restructure Open SWE Review tab + wire create_prs (#1319)

* feat(dashboard): restructure Open SWE Review tab + wire create_prs

Restructures the dashboard around two related changes the reviewer settings
have been asking for:

- Wire profile.create_prs. Defaults to true (opt-out); when off the system
  prompt gets a `Pull Request Policy Override` section telling the agent
  to push the branch and notify with the branch URL instead of opening a
  PR. Removes the noop Slack Notifications / Allow Artifacts / First Name
  / Last Name controls and their schema fields.
- Repositories opt-in for Open SWE Review. New per-team enabled list
  stored in the LangGraph Store (`["enabled_review_repos"]`). Every
  reviewer webhook chokepoint now goes through `_is_repo_enabled_for_review`
  which AND-combines the existing env allowlist with the dashboard list.
  Default is empty (opt-in) — admins enable repos per-installation from
  the new Repositories page nested under Open SWE Review.
- Open SWE Review tab now mirrors the Cursor "rules" pattern: main page
  shows installation rows + a Rules entry; both drill into nested pages
  (/review/repositories/$owner and /review/styles) with a back link.
- Adds the new logo/favicon assets shipped from sidebar + html head.

Tests pass with a new autouse fixture (`tests/conftest.py`) that defaults
`is_review_repo_enabled` to True for existing allowlist tests.

* fix(dashboard): make main content scroll independently of the sidebar

Outer flex container was min-h-svh, so it grew with main's content and the
whole page scrolled — sidebar moved with it. Pin to h-svh + overflow-hidden
so the sidebar stays put and only <main> scrolls.

* fix(dashboard): make disabled repo toggles obviously disabled

Switch's disabled state used opacity-50 against a muted background, so
the not-admin state looked nearly identical to the off state. Bump to
opacity-40 + grayscale, and wrap each repo toggle in a span carrying a
native hover tooltip explaining why it's disabled.

* fix(switch): handle base-ui's data-disabled state

base-ui's Switch.Root sets data-disabled (not the HTML disabled attribute)
when disabled, so Tailwind's disabled: variant never matches and the
button keeps its cursor-pointer + clickable look. Mirror the styling
under the data-[disabled] variant and add pointer-events-none so the
disabled state is both visible and actually unclickable.

* feat(dashboard): paginate per-installation repository list

20 repos per page with Prev / page X of Y / Next controls at the bottom.
Pager only renders when there are more than 20 repos. Page resets to 0
when navigating between installations.

* feat(dashboard): global default model selectors for Agent + Reviewer

Adds team-wide default model + reasoning effort for both agents in the
Admin tab so operators can switch models without redeploying.

Resolution chain:
  Agent:    hardcoded -> LLM_MODEL_ID env -> team default -> user profile
  Reviewer: hardcoded -> LLM_MODEL_ID env -> team default -> per-call configurable

Team defaults live in team_settings and are validated against the
SUPPORTED_MODELS allowlist + the model's supported reasoning efforts.
'Inherit from env' clears the override and falls back to LLM_MODEL_ID.

* refactor(models): drop LLM_MODEL_ID env in favour of the team default

The team default is now the single source of truth for the runtime model
choice; per-user (agent) and per-call configurable (reviewer) selections
still win on top. When no admin has touched the team default, it surfaces
the hardcoded fallback (DEFAULT_MODEL_ID + its default effort), so the
admin UI's dropdown is always pre-populated with a sensible value.

The Admin UI loses the 'Inherit from env' option since there is no longer
an env layer to inherit from.

* chore(models): set hardcoded fallback to gpt-5.5 medium

Decouple the team-default boot value (gpt-5.5 / medium) from each model's
ProfileForm-suggested default_effort so we can change one without nudging
the other. The Opus xhigh default for new user profiles is unchanged.

* feat(dashboard): trigger-mode copy, Coming Soon badges, logout in My Settings

- Rename trigger mode 'ready_for_review' -> 'once_per_pr' with new
  description copy that matches the screenshot. Legacy stored values
  fall back to 'every_push' on read so the UI never shows an unknown
  selection.
- Add a 'Coming soon' badge + greyed-out + disabled state on the
  controls that don't have runtime consumers yet: Trigger Mode,
  Autofix Mode, Autofix Severity Threshold, and Automatically fix CI
  failures. SettingsRow grew a comingSoon prop to keep this consistent.
- My Settings drops the noop PR Preferences section and adds a Sign
  Out button. preferred_pr_destination is removed from the profile
  schema; old records get the field popped on next write.

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-05-21 09:17:07 -07:00 • committed by GitHub
parent 1ea9c0d880
commit 32ec9b485f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 950 additions and 242 deletions

View file

@ -72,6 +72,16 @@ async def load_profile(login: str) -> dict[str, Any] | None:
return value if isinstance(value, dict) else 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]: 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)``.""" """Return ``(model_id, reasoning_effort)`` if both are valid, else ``(None, None)``."""
model_id = profile.get("default_model") model_id = profile.get("default_model")

View file

@ -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)

View file

@ -29,9 +29,22 @@ SUPPORTED_MODELS: list[ModelOption] = [
SUPPORTED_MODEL_IDS: frozenset[str] = frozenset(m["id"] for m in SUPPORTED_MODELS) 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: def model_supports_effort(model_id: str, effort: str) -> bool:
for m in SUPPORTED_MODELS: for m in SUPPORTED_MODELS:
if m["id"] == model_id: if m["id"] == model_id:
return effort in m["efforts"] return effort in m["efforts"]
return False 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"]

View file

@ -33,15 +33,10 @@ class ProfileUpdate(BaseModel):
default_model: str default_model: str
reasoning_effort: str reasoning_effort: str
default_repo: str | None = None default_repo: str | None = None
first_name: str | None = None
last_name: str | None = None
base_branch: str | None = None base_branch: str | None = None
branch_prefix: str | None = None branch_prefix: str | None = None
auto_fix_ci: bool = True auto_fix_ci: bool = True
create_prs: bool = True create_prs: bool = True
allow_artifacts: bool = False
slack_notifications: bool = True
preferred_pr_destination: str | None = None
@field_validator("default_model") @field_validator("default_model")
@classmethod @classmethod
@ -93,17 +88,20 @@ async def upsert_profile(login: str, email: str, update: ProfileUpdate) -> dict[
"default_model": update.default_model, "default_model": update.default_model,
"reasoning_effort": update.reasoning_effort, "reasoning_effort": update.reasoning_effort,
"default_repo": update.default_repo, "default_repo": update.default_repo,
"first_name": update.first_name,
"last_name": update.last_name,
"base_branch": update.base_branch, "base_branch": update.base_branch,
"branch_prefix": update.branch_prefix, "branch_prefix": update.branch_prefix,
"auto_fix_ci": update.auto_fix_ci, "auto_fix_ci": update.auto_fix_ci,
"create_prs": update.create_prs, "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(), "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) await _client().store.put_item(PROFILES_NAMESPACE, login, value)
return value return value

View file

@ -10,8 +10,13 @@ from typing import Any
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse, Response from fastapi.responses import RedirectResponse, Response
from pydantic import BaseModel
from .admin import is_admin from .admin import is_admin
from .enabled_repos import (
list_enabled_review_repos,
set_review_repo_enabled,
)
from .oauth import ( from .oauth import (
COOKIE_NAME, COOKIE_NAME,
SESSION_TTL_SECONDS, SESSION_TTL_SECONDS,
@ -257,6 +262,27 @@ async def api_put_team_settings(
return await upsert_team_settings(update) 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: def _next_link_url(link_header: str | None) -> str | None:
if not link_header: if not link_header:
return None return None

View file

@ -12,14 +12,16 @@ from datetime import UTC, datetime
from typing import Any, Literal from typing import Any, Literal
from langgraph_sdk import get_client 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__) logger = logging.getLogger(__name__)
TEAM_SETTINGS_NAMESPACE: list[str] = ["team_settings"] TEAM_SETTINGS_NAMESPACE: list[str] = ["team_settings"]
TEAM_SETTINGS_KEY = "default" 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"] AutofixMode = Literal["off", "low", "medium", "high"]
@ -29,6 +31,31 @@ class TeamSettingsUpdate(BaseModel):
pr_summaries: bool = True pr_summaries: bool = True
autofix_mode: AutofixMode = "off" autofix_mode: AutofixMode = "off"
autofix_severity_threshold: AutofixMode = "medium" 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(): def _client():
@ -36,28 +63,42 @@ def _client():
def _default_settings() -> dict[str, Any]: def _default_settings() -> dict[str, Any]:
fallback_model, fallback_effort = default_model_pair()
return { return {
"trigger_mode": "every_push", "trigger_mode": "every_push",
"review_draft_prs": False, "review_draft_prs": False,
"pr_summaries": True, "pr_summaries": True,
"autofix_mode": "off", "autofix_mode": "off",
"autofix_severity_threshold": "medium", "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, "updated_at": None,
} }
async def get_team_settings() -> dict[str, Any]: async def get_team_settings() -> dict[str, Any]:
defaults = _default_settings()
try: try:
item = await _client().store.get_item(TEAM_SETTINGS_NAMESPACE, TEAM_SETTINGS_KEY) item = await _client().store.get_item(TEAM_SETTINGS_NAMESPACE, TEAM_SETTINGS_KEY)
except Exception as e: except Exception as e:
logger.debug("team settings lookup failed: %s", e) logger.debug("team settings lookup failed: %s", e)
return _default_settings() return defaults
if item is None: if item is None:
return _default_settings() return defaults
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None) value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
if not isinstance(value, dict): if not isinstance(value, dict):
return _default_settings() return defaults
return {**_default_settings(), **value} # 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]: 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, "pr_summaries": update.pr_summaries,
"autofix_mode": update.autofix_mode, "autofix_mode": update.autofix_mode,
"autofix_severity_threshold": update.autofix_severity_threshold, "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(), "updated_at": datetime.now(UTC).isoformat(),
} }
await _client().store.put_item(TEAM_SETTINGS_NAMESPACE, TEAM_SETTINGS_KEY, value) await _client().store.put_item(TEAM_SETTINGS_NAMESPACE, TEAM_SETTINGS_KEY, value)
return 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()

View file

@ -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/<owner>/<repo>/tree/<branch>`) 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 = ( SYSTEM_PROMPT_TEMPLATE = (
WORKING_ENV_SECTION WORKING_ENV_SECTION
+ TASK_OVERVIEW_SECTION + TASK_OVERVIEW_SECTION
@ -394,6 +401,7 @@ SYSTEM_PROMPT_TEMPLATE = (
+ COMMUNICATION_SECTION + COMMUNICATION_SECTION
+ EXTERNAL_UNTRUSTED_COMMENTS_SECTION + EXTERNAL_UNTRUSTED_COMMENTS_SECTION
+ COMMIT_PR_SECTION + COMMIT_PR_SECTION
+ "{pr_policy_override_section}"
+ "{collaboration_section}" + "{collaboration_section}"
) )
@ -403,6 +411,7 @@ def construct_system_prompt(
linear_project_id: str = "", linear_project_id: str = "",
linear_issue_number: str = "", linear_issue_number: str = "",
triggering_user_identity: CollaboratorIdentity | None = None, triggering_user_identity: CollaboratorIdentity | None = None,
create_prs: bool = True,
) -> str: ) -> str:
default_prompt_section = _load_default_prompt() default_prompt_section = _load_default_prompt()
return SYSTEM_PROMPT_TEMPLATE.format( return SYSTEM_PROMPT_TEMPLATE.format(
@ -410,5 +419,6 @@ def construct_system_prompt(
linear_project_id=linear_project_id or "<PROJECT_ID>", linear_project_id=linear_project_id or "<PROJECT_ID>",
linear_issue_number=linear_issue_number or "<ISSUE_NUMBER>", linear_issue_number=linear_issue_number or "<ISSUE_NUMBER>",
default_prompt_section=default_prompt_section, 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), collaboration_section=_render_collaboration_section(triggering_user_identity),
) )

View file

@ -16,7 +16,6 @@ agent for code review only:
# ruff: noqa: E402 # ruff: noqa: E402
import logging import logging
import os
import warnings import warnings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -40,7 +39,6 @@ from .reviewer_findings import (
) )
from .server import ( from .server import (
DEFAULT_LLM_MAX_TOKENS, DEFAULT_LLM_MAX_TOKENS,
DEFAULT_LLM_MODEL_ID,
DEFAULT_RECURSION_LIMIT, DEFAULT_RECURSION_LIMIT,
MODEL_CALL_RECURSION_LIMIT, MODEL_CALL_RECURSION_LIMIT,
ensure_sandbox_for_thread, ensure_sandbox_for_thread,
@ -494,14 +492,20 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
head_sha=head_sha, head_sha=head_sha,
) )
from .dashboard.team_settings import get_team_default_model
configured_model_id = config["configurable"].get("reviewer_model_id") 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") 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_kwargs = provider_model_kwargs(
model_id, model_id,
reasoning_effort, reasoning_effort,

View file

@ -31,8 +31,11 @@ from langsmith.sandbox import SandboxClientError
from .dashboard.agent_overrides import ( from .dashboard.agent_overrides import (
load_profile, load_profile,
normalize_profile_overrides, normalize_profile_overrides,
profile_create_prs,
resolve_github_login, 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 .integrations.langsmith import _configure_github_proxy
from .middleware import ( from .middleware import (
ModelFallbackMiddleware, ModelFallbackMiddleware,
@ -331,7 +334,7 @@ async def ensure_sandbox_for_thread(thread_id: str) -> SandboxBackendProtocol:
return sandbox_backend 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_LLM_MAX_TOKENS = 64_000
DEFAULT_RECURSION_LIMIT = 9_999 DEFAULT_RECURSION_LIMIT = 9_999
MODEL_CALL_RECURSION_LIMIT = 5_000 # ~half the recursion limit to account for tool calls 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: def backend_factory(_runtime: object, _thread_id: str = thread_id) -> SandboxBackendProtocol:
return _get_cached_sandbox_backend(_thread_id) return _get_cached_sandbox_backend(_thread_id)
model_id = os.environ.get("LLM_MODEL_ID", DEFAULT_LLM_MODEL_ID) model_id, profile_effort = await get_team_default_model("agent")
profile_effort: str | None = None 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) profile_login = resolve_github_login(config)
if profile_login: if profile_login:
profile = await load_profile(profile_login) profile = await load_profile(profile_login)
@ -393,6 +398,10 @@ async def get_agent(config: RunnableConfig) -> Pregel:
model_id = overridden_model model_id = overridden_model
profile_effort = overridden_effort 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_kwargs = provider_model_kwargs(
model_id, model_id,
profile_effort, profile_effort,
@ -418,6 +427,7 @@ async def get_agent(config: RunnableConfig) -> Pregel:
linear_project_id=linear_project_id, linear_project_id=linear_project_id,
linear_issue_number=linear_issue_number, linear_issue_number=linear_issue_number,
triggering_user_identity=triggering_user_identity, triggering_user_identity=triggering_user_identity,
create_prs=create_prs,
), ),
tools=[ tools=[
http_request, http_request,

View file

@ -22,6 +22,7 @@ from .dashboard.agent_overrides import (
get_profile_default_repo, get_profile_default_repo,
resolve_login_from_email, resolve_login_from_email,
) )
from .dashboard.enabled_repos import is_review_repo_enabled
from .reviewer_findings import ( from .reviewer_findings import (
REVIEWER_THREAD_KIND, REVIEWER_THREAD_KIND,
ReviewerPRMeta, ReviewerPRMeta,
@ -408,6 +409,18 @@ def _is_repo_allowed_for_reviewer(repo_config: dict[str, str]) -> bool:
return owner in ALLOWED_REVIEWER_GITHUB_ORGS 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 = { _PUBLIC_REPO_GATE_REJECTION = {
"status": "ignored", "status": "ignored",
"reason": "Sender is not a member of the allowed organization for public-repo triggers", "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) clean_text = strip_bot_mention(text, bot_user_id, bot_username=SLACK_BOT_USERNAME)
pr_ref = parse_slack_review_command(clean_text) pr_ref = parse_slack_review_command(clean_text)
if pr_ref: if pr_ref:
if not _is_repo_allowed_for_reviewer({"owner": pr_ref.owner, "name": pr_ref.repo}): if not await _is_repo_enabled_for_review({"owner": pr_ref.owner, "name": pr_ref.repo}):
return {"status": "ignored", "reason": "Repository not in reviewer allowlist"} return {"status": "ignored", "reason": "Repository not enabled for review"}
background_tasks.add_task(process_slack_pr_review_request, pr_ref, channel_id, thread_ts) background_tasks.add_task(process_slack_pr_review_request, pr_ref, channel_id, thread_ts)
return {"status": "accepted", "message": "Slack PR review request queued"} return {"status": "accepted", "message": "Slack PR review request queued"}
@ -1568,8 +1581,8 @@ async def trigger_pr_review_from_ref(
slack_thread_ts: str = "", slack_thread_ts: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
repo_config = {"owner": pr_ref.owner, "name": pr_ref.repo} repo_config = {"owner": pr_ref.owner, "name": pr_ref.repo}
if not _is_repo_allowed_for_reviewer(repo_config): if not await _is_repo_enabled_for_review(repo_config):
return {"success": False, "error": "Repository not allowed for reviewer"} return {"success": False, "error": "Repository not enabled for review"}
app_token, app_token_expires_at = await get_github_app_installation_token_with_expiry() app_token, app_token_expires_at = await get_github_app_installation_token_with_expiry()
if not app_token: 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") pr_number = pull_request.get("number")
if not pr_number or not isinstance(pr_number, int): if not pr_number or not isinstance(pr_number, int):
return return
if not _is_repo_allowed_for_reviewer(repo_config): if not await _is_repo_enabled_for_review(repo_config):
return return
thread_id = generate_reviewer_thread_id( 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"]: if not repo_config["owner"] or not repo_config["name"]:
logger.warning("Push to %s ignored: repository owner/name missing from payload", head_ref) logger.warning("Push to %s ignored: repository owner/name missing from payload", head_ref)
return return
if not _is_repo_allowed_for_reviewer(repo_config): if not await _is_repo_enabled_for_review(repo_config):
logger.info( 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["owner"],
repo_config["name"], repo_config["name"],
head_ref, head_ref,
@ -2411,25 +2424,21 @@ async def github_webhook(request: Request, background_tasks: BackgroundTasks) ->
"reason": f"Unsupported GitHub pull_request action: {action}", "reason": f"Unsupported GitHub pull_request action: {action}",
} }
if action in {"closed", "reopened"}: if action in {"closed", "reopened"}:
if not _is_repo_allowed_for_reviewer(webhook_repo_config): if not await _is_repo_enabled_for_review(webhook_repo_config):
return {"status": "ignored", "reason": "Repository not in reviewer allowlist"} return {"status": "ignored", "reason": "Repository not enabled for review"}
logger.info("Accepted GitHub PR %s webhook, scheduling reviewer watch update", action) logger.info("Accepted GitHub PR %s webhook, scheduling reviewer watch update", action)
background_tasks.add_task(process_github_pr_close, payload) background_tasks.add_task(process_github_pr_close, payload)
return {"status": "accepted", "message": f"Processing PR {action} for reviewer watch"} return {"status": "accepted", "message": f"Processing PR {action} for reviewer watch"}
if not _is_open_swe_reviewer_request(payload): if not _is_open_swe_reviewer_request(payload):
logger.info("Ignoring PR review request for a different reviewer") logger.info("Ignoring PR review request for a different reviewer")
return {"status": "ignored", "reason": "Review request is not for open-swe bot"} 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( 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("owner"),
webhook_repo_config.get("name"), webhook_repo_config.get("name"),
) )
if ALLOWED_REVIEWER_GITHUB_REPOS: return {"status": "ignored", "reason": "Repository not enabled for review"}
reason = "Repository not in allowlist"
else:
reason = "Repository org not in allowlist"
return {"status": "ignored", "reason": reason}
gate_rejection = await _enforce_public_repo_org_gate(payload, "pull_request") gate_rejection = await _enforce_public_repo_org_gate(payload, "pull_request")
if gate_rejection is not None: 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"} return {"status": "accepted", "message": "Processing GitHub PR review request"}
if event_type == "push": if event_type == "push":
if not _is_repo_allowed_for_reviewer(webhook_repo_config): if not await _is_repo_enabled_for_review(webhook_repo_config):
return {"status": "ignored", "reason": "Repository not in reviewer allowlist"} return {"status": "ignored", "reason": "Repository not enabled for review"}
logger.info("Accepted GitHub push webhook, scheduling reviewer watch evaluation") logger.info("Accepted GitHub push webhook, scheduling reviewer watch evaluation")
background_tasks.add_task(process_github_push_event, payload) background_tasks.add_task(process_github_push_event, payload)
return {"status": "accepted", "message": "Processing GitHub push for reviewer watch"} 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) is_review_command, pr_url_override = parse_github_review_command(comment_body)
if is_review_command: if is_review_command:
if not _is_repo_allowed_for_reviewer(webhook_repo_config): if not await _is_repo_enabled_for_review(webhook_repo_config):
return {"status": "ignored", "reason": "Repository not in reviewer allowlist"} return {"status": "ignored", "reason": "Repository not enabled for review"}
background_tasks.add_task( background_tasks.add_task(
process_github_pr_review_command, payload, event_type, pr_url_override process_github_pr_review_command, payload, event_type, pr_url_override
) )

26
tests/conftest.py Normal file
View file

@ -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)

View file

@ -321,7 +321,7 @@ def test_github_webhook_blocks_reviewer_repo_not_in_reviewer_repo_allowlist(monk
) )
assert response.status_code == 200 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 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 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.status_code == 200
assert response.json() == { assert response.json() == {
"status": "ignored", "status": "ignored",
"reason": "Repository not in reviewer allowlist", "reason": "Repository not enabled for review",
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

BIN
ui/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
ui/public/logo-mark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -1,3 +1,5 @@
import { Link } from "@tanstack/react-router";
import { ArrowLeftIcon } from "@phosphor-icons/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { SessionUser } from "@/lib/api"; import type { SessionUser } from "@/lib/api";
@ -7,15 +9,25 @@ interface AppShellProps {
user: SessionUser; user: SessionUser;
title: string; title: string;
description?: string; description?: string;
backTo?: { to: string; label: string };
children: ReactNode; children: ReactNode;
} }
export function AppShell({ user, title, description, children }: AppShellProps) { export function AppShell({ user, title, description, backTo, children }: AppShellProps) {
return ( return (
<div className="flex min-h-svh bg-background text-foreground"> <div className="flex h-svh overflow-hidden bg-background text-foreground">
<AppSidebar user={user} /> <AppSidebar user={user} />
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl px-8 py-10"> <div className="mx-auto max-w-3xl px-8 py-10">
{backTo && (
<Link
to={backTo.to}
className="mb-4 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeftIcon className="size-3.5" />
{backTo.label}
</Link>
)}
<header className="mb-8"> <header className="mb-8">
<h1 className="font-heading text-lg font-medium">{title}</h1> <h1 className="font-heading text-lg font-medium">{title}</h1>
{description && ( {description && (
@ -60,18 +72,38 @@ interface SettingsRowProps {
description?: string; description?: string;
control: ReactNode; control: ReactNode;
htmlFor?: string; htmlFor?: string;
comingSoon?: boolean;
} }
export function SettingsRow({ label, description, control, htmlFor }: SettingsRowProps) { export function SettingsRow({
label,
description,
control,
htmlFor,
comingSoon,
}: SettingsRowProps) {
return ( return (
<div className="flex items-center justify-between gap-6 border-b border-border px-4 py-3 last:border-b-0"> <div className="flex items-center justify-between gap-6 border-b border-border px-4 py-3 last:border-b-0">
<label className="flex flex-col gap-0.5" htmlFor={htmlFor}> <label className="flex flex-col gap-0.5" htmlFor={htmlFor}>
<span className="text-xs font-medium text-foreground">{label}</span> <span className="flex items-center gap-2">
<span
className={`text-xs font-medium ${
comingSoon ? "text-muted-foreground" : "text-foreground"
}`}
>
{label}
</span>
{comingSoon && (
<span className="rounded-sm border border-border bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground">
Coming soon
</span>
)}
</span>
{description && ( {description && (
<span className="text-xs text-muted-foreground">{description}</span> <span className="text-xs text-muted-foreground">{description}</span>
)} )}
</label> </label>
<div className="shrink-0">{control}</div> <div className={`shrink-0 ${comingSoon ? "opacity-50" : ""}`}>{control}</div>
</div> </div>
); );
} }

View file

@ -69,7 +69,11 @@ export function AppSidebar({ user }: { user: SessionUser }) {
return ( return (
<aside className="flex h-svh w-60 shrink-0 flex-col border-r border-border bg-sidebar text-sidebar-foreground"> <aside className="flex h-svh w-60 shrink-0 flex-col border-r border-border bg-sidebar text-sidebar-foreground">
<div className="px-4 pt-5 pb-4"> <div className="px-4 pt-5 pb-4">
<Link to="/my-settings" className="font-heading text-sm font-medium tracking-tight"> <Link
to="/my-settings"
className="flex items-center gap-2 font-heading text-sm font-medium tracking-tight"
>
<img src="/logo-mark.png" alt="" className="size-5" />
open-swe open-swe
</Link> </Link>
</div> </div>

View file

@ -11,7 +11,8 @@ function Switch({ className, ...props }: SwitchPrimitive.Root.Props) {
className={cn( className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors outline-none", "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring/30", "focus-visible:ring-2 focus-visible:ring-ring/30",
"disabled:cursor-not-allowed disabled:opacity-50", "disabled:cursor-not-allowed disabled:opacity-40 disabled:grayscale",
"data-[disabled]:pointer-events-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-40 data-[disabled]:grayscale",
"bg-input data-[checked]:bg-primary", "bg-input data-[checked]:bg-primary",
className, className,
)} )}

View file

@ -61,15 +61,10 @@ export interface Profile {
default_model?: string; default_model?: string;
reasoning_effort?: string; reasoning_effort?: string;
default_repo?: string | null; default_repo?: string | null;
first_name?: string | null;
last_name?: string | null;
base_branch?: string | null; base_branch?: string | null;
branch_prefix?: string | null; branch_prefix?: string | null;
auto_fix_ci?: boolean; auto_fix_ci?: boolean;
create_prs?: boolean; create_prs?: boolean;
allow_artifacts?: boolean;
slack_notifications?: boolean;
preferred_pr_destination?: string | null;
updated_at?: string; updated_at?: string;
} }
@ -77,18 +72,13 @@ export interface ProfileUpdate {
default_model: string; default_model: string;
reasoning_effort: string; reasoning_effort: string;
default_repo?: string | null; default_repo?: string | null;
first_name?: string | null;
last_name?: string | null;
base_branch?: string | null; base_branch?: string | null;
branch_prefix?: string | null; branch_prefix?: string | null;
auto_fix_ci?: boolean; auto_fix_ci?: boolean;
create_prs?: boolean; create_prs?: boolean;
allow_artifacts?: boolean;
slack_notifications?: boolean;
preferred_pr_destination?: string | null;
} }
export type TriggerMode = "every_push" | "ready_for_review" | "manual"; export type TriggerMode = "every_push" | "once_per_pr" | "manual";
export type AutofixMode = "off" | "low" | "medium" | "high"; export type AutofixMode = "off" | "low" | "medium" | "high";
export interface TeamSettings { export interface TeamSettings {
@ -97,6 +87,10 @@ export interface TeamSettings {
pr_summaries: boolean; pr_summaries: boolean;
autofix_mode: AutofixMode; autofix_mode: AutofixMode;
autofix_severity_threshold: AutofixMode; autofix_severity_threshold: AutofixMode;
default_agent_model?: string | null;
default_agent_reasoning_effort?: string | null;
default_reviewer_model?: string | null;
default_reviewer_reasoning_effort?: string | null;
updated_at?: string | null; updated_at?: string | null;
} }
@ -163,6 +157,13 @@ export const api = {
getTeamSettings: () => request<TeamSettings>("/team-settings"), getTeamSettings: () => request<TeamSettings>("/team-settings"),
saveTeamSettings: (body: TeamSettings) => saveTeamSettings: (body: TeamSettings) =>
request<TeamSettings>("/team-settings", { method: "PUT", body: JSON.stringify(body) }), request<TeamSettings>("/team-settings", { method: "PUT", body: JSON.stringify(body) }),
listEnabledReviewRepos: () =>
request<{ repos: Array<string> }>("/enabled-review-repos"),
setEnabledReviewRepo: (full_name: string, enabled: boolean) =>
request<{ repos: Array<string> }>("/enabled-review-repos", {
method: "PUT",
body: JSON.stringify({ full_name, enabled }),
}),
adminListProfiles: () => request<Array<Profile>>("/admin/profiles"), adminListProfiles: () => request<Array<Profile>>("/admin/profiles"),
adminSaveProfile: (login: string, body: ProfileUpdate & { email?: string }) => adminSaveProfile: (login: string, body: ProfileUpdate & { email?: string }) =>
request<Profile>(`/admin/profiles/${encodeURIComponent(login)}`, { request<Profile>(`/admin/profiles/${encodeURIComponent(login)}`, {

View file

@ -64,15 +64,10 @@ export function buildProfileUpdate(
default_model: current?.default_model ?? fallbackModel, default_model: current?.default_model ?? fallbackModel,
reasoning_effort: current?.reasoning_effort ?? fallbackEffort, reasoning_effort: current?.reasoning_effort ?? fallbackEffort,
default_repo: current?.default_repo ?? null, default_repo: current?.default_repo ?? null,
first_name: current?.first_name ?? null,
last_name: current?.last_name ?? null,
base_branch: current?.base_branch ?? null, base_branch: current?.base_branch ?? null,
branch_prefix: current?.branch_prefix ?? null, branch_prefix: current?.branch_prefix ?? null,
auto_fix_ci: current?.auto_fix_ci ?? true, auto_fix_ci: current?.auto_fix_ci ?? true,
create_prs: current?.create_prs ?? true, create_prs: current?.create_prs ?? true,
allow_artifacts: current?.allow_artifacts ?? false,
slack_notifications: current?.slack_notifications ?? true,
preferred_pr_destination: current?.preferred_pr_destination ?? null,
...patch, ...patch,
}; };
} }

View file

@ -16,6 +16,8 @@ import { Route as IntegrationsRouteImport } from './routes/integrations'
import { Route as CloudAgentsRouteImport } from './routes/cloud-agents' import { Route as CloudAgentsRouteImport } from './routes/cloud-agents'
import { Route as AdminRouteImport } from './routes/admin' import { Route as AdminRouteImport } from './routes/admin'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as ReviewStylesRouteImport } from './routes/review_.styles'
import { Route as ReviewRepositoriesOwnerRouteImport } from './routes/review_.repositories.$owner'
const ReviewRoute = ReviewRouteImport.update({ const ReviewRoute = ReviewRouteImport.update({
id: '/review', id: '/review',
@ -52,6 +54,16 @@ const IndexRoute = IndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ReviewStylesRoute = ReviewStylesRouteImport.update({
id: '/review_/styles',
path: '/review/styles',
getParentRoute: () => rootRouteImport,
} as any)
const ReviewRepositoriesOwnerRoute = ReviewRepositoriesOwnerRouteImport.update({
id: '/review_/repositories/$owner',
path: '/review/repositories/$owner',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
@ -61,6 +73,8 @@ export interface FileRoutesByFullPath {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/my-settings': typeof MySettingsRoute '/my-settings': typeof MySettingsRoute
'/review': typeof ReviewRoute '/review': typeof ReviewRoute
'/review/styles': typeof ReviewStylesRoute
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
@ -70,6 +84,8 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/my-settings': typeof MySettingsRoute '/my-settings': typeof MySettingsRoute
'/review': typeof ReviewRoute '/review': typeof ReviewRoute
'/review/styles': typeof ReviewStylesRoute
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@ -80,6 +96,8 @@ export interface FileRoutesById {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/my-settings': typeof MySettingsRoute '/my-settings': typeof MySettingsRoute
'/review': typeof ReviewRoute '/review': typeof ReviewRoute
'/review_/styles': typeof ReviewStylesRoute
'/review_/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@ -91,6 +109,8 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/my-settings' | '/my-settings'
| '/review' | '/review'
| '/review/styles'
| '/review/repositories/$owner'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@ -100,6 +120,8 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/my-settings' | '/my-settings'
| '/review' | '/review'
| '/review/styles'
| '/review/repositories/$owner'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@ -109,6 +131,8 @@ export interface FileRouteTypes {
| '/login' | '/login'
| '/my-settings' | '/my-settings'
| '/review' | '/review'
| '/review_/styles'
| '/review_/repositories/$owner'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@ -119,6 +143,8 @@ export interface RootRouteChildren {
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
MySettingsRoute: typeof MySettingsRoute MySettingsRoute: typeof MySettingsRoute
ReviewRoute: typeof ReviewRoute ReviewRoute: typeof ReviewRoute
ReviewStylesRoute: typeof ReviewStylesRoute
ReviewRepositoriesOwnerRoute: typeof ReviewRepositoriesOwnerRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@ -172,6 +198,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/review_/styles': {
id: '/review_/styles'
path: '/review/styles'
fullPath: '/review/styles'
preLoaderRoute: typeof ReviewStylesRouteImport
parentRoute: typeof rootRouteImport
}
'/review_/repositories/$owner': {
id: '/review_/repositories/$owner'
path: '/review/repositories/$owner'
fullPath: '/review/repositories/$owner'
preLoaderRoute: typeof ReviewRepositoriesOwnerRouteImport
parentRoute: typeof rootRouteImport
}
} }
} }
@ -183,6 +223,8 @@ const rootRouteChildren: RootRouteChildren = {
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
MySettingsRoute: MySettingsRoute, MySettingsRoute: MySettingsRoute,
ReviewRoute: ReviewRoute, ReviewRoute: ReviewRoute,
ReviewStylesRoute: ReviewStylesRoute,
ReviewRepositoriesOwnerRoute: ReviewRepositoriesOwnerRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View file

@ -15,7 +15,11 @@ export const Route = createRootRoute({
{ name: "viewport", content: "width=device-width, initial-scale=1" }, { name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: "open-swe" }, { title: "open-swe" },
], ],
links: [{ rel: "stylesheet", href: appCss }], links: [
{ rel: "stylesheet", href: appCss },
{ rel: "icon", type: "image/png", href: "/favicon.png" },
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png" },
],
}), }),
notFoundComponent: () => ( notFoundComponent: () => (
<main className="container mx-auto p-4 pt-16"> <main className="container mx-auto p-4 pt-16">

View file

@ -1,11 +1,18 @@
import { Navigate, createFileRoute } from "@tanstack/react-router"; import { Navigate, createFileRoute } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useEffect, useState } from "react";
import type { Profile, ProfileUpdate } from "@/lib/api"; import type { ModelOption, Profile, ProfileUpdate, TeamSettings } from "@/lib/api";
import { AppShell, SettingsSection } from "@/components/AppShell"; import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
import { ProfileForm } from "@/components/ProfileForm"; import { ProfileForm } from "@/components/ProfileForm";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useSession } from "@/lib/session"; import { useSession } from "@/lib/session";
@ -57,11 +64,13 @@ function AdminPage() {
<AppShell <AppShell
user={session.data} user={session.data}
title="Admin" title="Admin"
description="Edit any user's profile defaults." description="Workspace-wide defaults and per-user profile edits."
> >
<div className="grid grid-cols-1 gap-4 md:grid-cols-[260px_1fr]"> <GlobalDefaultsSection models={options.data?.models ?? []} />
<SettingsSection title={`Users · ${profiles.data?.length ?? 0}`}>
<div className="flex flex-col gap-0.5 p-2"> <SettingsSection title="Per-user profiles">
<div className="grid grid-cols-1 gap-0 md:grid-cols-[260px_1fr]">
<div className="flex flex-col gap-0.5 border-b border-border p-2 md:border-b-0 md:border-r">
{profiles.isLoading ? ( {profiles.isLoading ? (
<Skeleton className="h-32" /> <Skeleton className="h-32" />
) : ( ) : (
@ -77,16 +86,10 @@ function AdminPage() {
)) ))
)} )}
</div> </div>
</SettingsSection>
<SettingsSection
title={activeProfile?.login ?? "Select a user"}
description={activeProfile?.email ?? undefined}
>
<div className="p-4"> <div className="p-4">
{!activeProfile ? ( {!activeProfile ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Pick a user on the left to edit. Pick a user on the left to edit their profile.
</p> </p>
) : options.isLoading ? ( ) : options.isLoading ? (
<Skeleton className="h-48" /> <Skeleton className="h-48" />
@ -103,8 +106,157 @@ function AdminPage() {
/> />
)} )}
</div> </div>
</SettingsSection> </div>
</div> </SettingsSection>
</AppShell> </AppShell>
); );
} }
function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
const qc = useQueryClient();
const settings = useQuery({
queryKey: ["teamSettings"],
queryFn: api.getTeamSettings,
});
const [error, setError] = useState<string | null>(null);
const save = useMutation({
mutationFn: (body: TeamSettings) => api.saveTeamSettings(body),
onSuccess: (saved) => {
qc.setQueryData(["teamSettings"], saved);
setError(null);
},
onError: (e: Error) => setError(e.message),
});
return (
<SettingsSection
title="Global defaults"
description="Workspace-wide model defaults. Per-user Cloud Agent selections override these for the agent."
>
<div className="divide-y divide-border">
<RolePicker
label="Open SWE Agent"
description="Model used for code-writing runs triggered from Slack, Linear, GitHub, and Cloud Agents."
models={models}
model={settings.data?.default_agent_model ?? null}
effort={settings.data?.default_agent_reasoning_effort ?? null}
onChange={(model, effort) =>
settings.data &&
save.mutate({
...settings.data,
default_agent_model: model,
default_agent_reasoning_effort: effort,
})
}
disabled={!settings.data || save.isPending}
/>
<RolePicker
label="Open SWE Reviewer"
description="Model used for PR review runs."
models={models}
model={settings.data?.default_reviewer_model ?? null}
effort={settings.data?.default_reviewer_reasoning_effort ?? null}
onChange={(model, effort) =>
settings.data &&
save.mutate({
...settings.data,
default_reviewer_model: model,
default_reviewer_reasoning_effort: effort,
})
}
disabled={!settings.data || save.isPending}
/>
</div>
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
</SettingsSection>
);
}
interface RolePickerProps {
label: string;
description: string;
models: Array<ModelOption>;
model: string | null;
effort: string | null;
onChange: (model: string, effort: string) => void;
disabled: boolean;
}
function RolePicker({
label,
description,
models,
model,
effort,
onChange,
disabled,
}: RolePickerProps) {
const [localModel, setLocalModel] = useState<string>(model ?? "");
const [localEffort, setLocalEffort] = useState<string>(effort ?? "");
useEffect(() => {
setLocalModel(model ?? "");
setLocalEffort(effort ?? "");
}, [model, effort]);
const selectedModel = models.find((m) => m.id === localModel);
const availableEfforts = selectedModel?.efforts ?? [];
const handleModelChange = (value: string | null) => {
if (!value) return;
const nextModel = models.find((m) => m.id === value);
if (!nextModel) return;
const nextEffort = nextModel.efforts.includes(localEffort)
? localEffort
: nextModel.default_effort;
setLocalModel(value);
setLocalEffort(nextEffort);
onChange(value, nextEffort);
};
const handleEffortChange = (value: string | null) => {
if (!value || !localModel) return;
setLocalEffort(value);
onChange(localModel, value);
};
return (
<SettingsRow
label={label}
description={description}
control={
<div className="flex items-center gap-2">
<Select value={localModel} onValueChange={handleModelChange} disabled={disabled}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={localEffort}
onValueChange={handleEffortChange}
disabled={disabled || !localModel}
>
<SelectTrigger className="w-28">
<SelectValue placeholder="effort" />
</SelectTrigger>
<SelectContent>
{availableEfforts.map((e) => (
<SelectItem key={e} value={e}>
{e}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
}
/>
);
}

View file

@ -217,16 +217,18 @@ function CloudAgentsPage() {
<SettingsRow <SettingsRow
label="Automatically fix CI failures" label="Automatically fix CI failures"
description="Agent will attempt to fix failing CI checks on PRs it opens." description="Agent will attempt to fix failing CI checks on PRs it opens."
comingSoon
control={ control={
<Switch <Switch
checked={profile.data?.auto_fix_ci ?? true} checked={profile.data?.auto_fix_ci ?? true}
onCheckedChange={(v) => persist({ auto_fix_ci: v })} onCheckedChange={(v) => persist({ auto_fix_ci: v })}
disabled
/> />
} }
/> />
<SettingsRow <SettingsRow
label="Create PRs" label="Create PRs"
description="Automatically create a pull request when a Cloud Agent completes." description="Automatically create a pull request when a Cloud Agent completes. When disabled, the branch is still pushed."
control={ control={
<Switch <Switch
checked={profile.data?.create_prs ?? true} checked={profile.data?.create_prs ?? true}
@ -234,32 +236,9 @@ function CloudAgentsPage() {
/> />
} }
/> />
<SettingsRow
label="Allow posting artifacts to GitHub"
description="Allow cloud agents to embed images directly in PR descriptions using hard-to-guess public URLs."
control={
<Switch
checked={profile.data?.allow_artifacts ?? false}
onCheckedChange={(v) => persist({ allow_artifacts: v })}
/>
}
/>
</div> </div>
</SettingsSection> </SettingsSection>
<SettingsSection title="Notifications">
<SettingsRow
label="Slack Notifications"
description="Get notified in Slack when a Cloud Agent completes a task."
control={
<Switch
checked={profile.data?.slack_notifications ?? true}
onCheckedChange={(v) => persist({ slack_notifications: v })}
/>
}
/>
</SettingsSection>
{error && <p className="text-xs text-destructive">{error}</p>} {error && <p className="text-xs text-destructive">{error}</p>}
</AppShell> </AppShell>
); );

View file

@ -1,41 +1,18 @@
import { Navigate, createFileRoute } from "@tanstack/react-router"; import { Navigate, createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query";
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell"; import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
import { Button } from "@/components/ui/button"; 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 { Skeleton } from "@/components/ui/skeleton";
import { buildProfileUpdate, useOptions, useProfile, useSaveProfile } from "@/lib/profile"; import { api } from "@/lib/api";
import { useSession } from "@/lib/session"; import { useSession } from "@/lib/session";
export const Route = createFileRoute("/my-settings")({ component: MySettingsPage }); export const Route = createFileRoute("/my-settings")({ component: MySettingsPage });
function MySettingsPage() { function MySettingsPage() {
const session = useSession(); const session = useSession();
const profile = useProfile(); const qc = useQueryClient();
const options = useOptions(); const navigate = useNavigate();
const save = useSaveProfile();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [prDestination, setPrDestination] = useState<string>("team_default");
const [error, setError] = useState<string | null>(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]);
if (session.isLoading) { if (session.isLoading) {
return ( return (
@ -46,97 +23,36 @@ function MySettingsPage() {
} }
if (!session.data) return <Navigate to="/login" />; if (!session.data) return <Navigate to="/login" />;
const firstModel = options.data?.models[0]; const handleLogout = async () => {
const handleSaveProfile = () => { await api.logout();
setError(null); qc.setQueryData(["session"], null);
const body = buildProfileUpdate( void navigate({ to: "/login" });
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));
}; };
return ( return (
<AppShell user={session.data} title="My Settings"> <AppShell user={session.data} title="My Settings">
<SettingsSection title="Profile"> <SettingsSection title="Profile">
<div className="divide-y divide-border">
<SettingsRow
label="Email"
control={
<span className="text-xs text-muted-foreground">
{session.data.email ?? "—"}
</span>
}
/>
<SettingsRow
label="First Name"
htmlFor="first-name"
control={
<Input
id="first-name"
className="w-56"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
}
/>
<SettingsRow
label="Last Name"
htmlFor="last-name"
control={
<Input
id="last-name"
className="w-56"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
}
/>
<div className="flex justify-end px-4 py-3">
<Button size="sm" onClick={handleSaveProfile} disabled={save.isPending}>
{save.isPending ? "Saving…" : "Save"}
</Button>
</div>
</div>
</SettingsSection>
<SettingsSection title="PR Preferences">
<SettingsRow <SettingsRow
label="Preferred PR destination" label="Email"
description="Choose where PR links open across web, the desktop app and IDE."
control={ control={
<Select value={prDestination} onValueChange={handleSavePrDestination}> <span className="text-xs text-muted-foreground">
<SelectTrigger className="w-40"> {session.data.email ?? "—"}
<SelectValue /> </span>
</SelectTrigger>
<SelectContent>
<SelectItem value="team_default">Team default</SelectItem>
<SelectItem value="web">Web</SelectItem>
<SelectItem value="desktop">Desktop App</SelectItem>
<SelectItem value="ide">IDE</SelectItem>
</SelectContent>
</Select>
} }
/> />
</SettingsSection> </SettingsSection>
{error && <p className="text-xs text-destructive">{error}</p>} <SettingsSection title="Account">
<SettingsRow
label="Sign out"
description="End your dashboard session."
control={
<Button size="sm" variant="outline" onClick={() => void handleLogout()}>
Sign out
</Button>
}
/>
</SettingsSection>
</AppShell> </AppShell>
); );
} }

View file

@ -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 { 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 { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
import { ReviewStylesPanel } from "@/components/ReviewStylesPanel";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -14,19 +14,27 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { api } from "@/lib/api"; import { ApiError, api } from "@/lib/api";
import { useSession } from "@/lib/session"; import { useSession } from "@/lib/session";
export const Route = createFileRoute("/review")({ component: ReviewPage }); export const Route = createFileRoute("/review")({ component: ReviewPage });
const TRIGGER_MODES: Array<{ value: TriggerMode; label: string; description: string }> = [ 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", value: "every_push",
label: "Ready For Review", label: "Every Push",
description: "Only when the PR is marked ready for review", 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 }> = [ const AUTOFIX_MODES: Array<{ value: AutofixMode; label: string }> = [
@ -42,6 +50,10 @@ const DEFAULT_SETTINGS: TeamSettings = {
pr_summaries: true, pr_summaries: true,
autofix_mode: "off", autofix_mode: "off",
autofix_severity_threshold: "medium", autofix_severity_threshold: "medium",
default_agent_model: null,
default_agent_reasoning_effort: null,
default_reviewer_model: null,
default_reviewer_reasoning_effort: null,
}; };
function ReviewPage() { function ReviewPage() {
@ -96,16 +108,34 @@ function ReviewPage() {
title="Open SWE Review" title="Open SWE Review"
description="Automatically review pull requests for bugs and issues. Runs are billed based on underlying agent usage." description="Automatically review pull requests for bugs and issues. Runs are billed based on underlying agent usage."
> >
<RepositoriesSection canEdit={canEdit} />
<SettingsSection title="Rules">
<Link
to="/review/styles"
className="flex items-center justify-between gap-6 px-4 py-3 hover:bg-muted/40"
>
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-foreground">Review Style Prompts</span>
<span className="text-xs text-muted-foreground">
Per-repo style guides learned from past PR review feedback.
</span>
</div>
<CaretRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
</Link>
</SettingsSection>
<SettingsSection title="Configuration"> <SettingsSection title="Configuration">
<div className="divide-y divide-border"> <div className="divide-y divide-border">
<SettingsRow <SettingsRow
label="Trigger Mode" label="Trigger Mode"
description={triggerDescription} description={triggerDescription}
comingSoon
control={ control={
<Select <Select
value={current.trigger_mode} value={current.trigger_mode}
onValueChange={(v) => persist({ trigger_mode: v as TriggerMode })} onValueChange={(v) => persist({ trigger_mode: v as TriggerMode })}
disabled={!canEdit} disabled
> >
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue /> <SelectValue />
@ -145,11 +175,12 @@ function ReviewPage() {
<SettingsRow <SettingsRow
label="Autofix Mode" label="Autofix Mode"
description="When enabled, the reviewer will propose fixes. Billed at plan rates." description="When enabled, the reviewer will propose fixes. Billed at plan rates."
comingSoon
control={ control={
<Select <Select
value={current.autofix_mode} value={current.autofix_mode}
onValueChange={(v) => persist({ autofix_mode: v as AutofixMode })} onValueChange={(v) => persist({ autofix_mode: v as AutofixMode })}
disabled={!canEdit} disabled
> >
<SelectTrigger className="w-32"> <SelectTrigger className="w-32">
<SelectValue /> <SelectValue />
@ -167,13 +198,14 @@ function ReviewPage() {
<SettingsRow <SettingsRow
label="Autofix Severity Threshold" label="Autofix Severity Threshold"
description="Findings at this severity or higher are auto-fixed" description="Findings at this severity or higher are auto-fixed"
comingSoon
control={ control={
<Select <Select
value={current.autofix_severity_threshold} value={current.autofix_severity_threshold}
onValueChange={(v) => onValueChange={(v) =>
persist({ autofix_severity_threshold: v as AutofixMode }) persist({ autofix_severity_threshold: v as AutofixMode })
} }
disabled={!canEdit} disabled
> >
<SelectTrigger className="w-32"> <SelectTrigger className="w-32">
<SelectValue /> <SelectValue />
@ -197,14 +229,95 @@ function ReviewPage() {
</p> </p>
)} )}
<SettingsSection
title="Review Style Prompts"
description="An agent browses recent merged PR review feedback on GitHub, then writes a per-repo style guide for the reviewer."
>
<ReviewStylesPanel />
</SettingsSection>
{error && <p className="text-xs text-destructive">{error}</p>} {error && <p className="text-xs text-destructive">{error}</p>}
</AppShell> </AppShell>
); );
} }
function RepositoriesSection({ canEdit: _canEdit }: { canEdit: boolean }) {
const repos = useQuery<ReposPayload>({
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<string, Array<{ full_name: string; private: boolean }>>();
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 (
<SettingsSection
title="Repositories"
description="Source-control installations. Click into one to enable repos for automatic review."
>
<div className="divide-y divide-border">
{loading && (
<div className="p-4">
<Skeleton className="h-16 w-full" />
</div>
)}
{!loading && grouped.length === 0 && (
<p className="px-4 py-3 text-xs text-muted-foreground">
No GitHub App installations found. Install the open-swe GitHub App on an
account or org to manage repos here.
</p>
)}
{grouped.map(([owner, list]) => {
const enabledCount = list.filter((r) => enabledSet.has(r.full_name)).length;
return (
<Link
key={owner}
to="/review/repositories/$owner"
params={{ owner }}
className="flex items-center justify-between gap-4 px-4 py-3 hover:bg-muted/40"
>
<div className="flex items-center gap-3">
<GithubLogoIcon className="size-5 shrink-0 text-muted-foreground" />
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2 text-xs">
<span className="font-medium text-foreground">{owner}</span>
</div>
<span className="text-xs text-muted-foreground">GitHub</span>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{enabledCount}/{list.length} Repositories Enabled
</span>
<CaretRightIcon className="size-3.5" />
</div>
</Link>
);
})}
</div>
</SettingsSection>
);
}

View file

@ -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<ReposPayload>({
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 (
<main className="p-6">
<Skeleton className="h-64 w-full" />
</main>
);
}
if (!session.data) return <Navigate to="/login" />;
const canEdit = session.data.is_admin;
const enabledCount = ownerRepos.filter((r) => enabledSet.has(r.full_name)).length;
const loading = repos.isLoading || enabled.isLoading;
return (
<AppShell
user={session.data}
title={owner}
description={
canEdit
? "Toggle a repository to opt it into automatic Open SWE Review."
: "Only team admins can modify enabled repositories."
}
backTo={{ to: "/review", label: "Back to Open SWE Review" }}
>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Repositories
</h2>
<span className="text-xs text-muted-foreground">
{enabledCount}/{ownerRepos.length} enabled
</span>
</div>
<div className="rounded-lg border border-border bg-card">
{loading && (
<div className="p-4">
<Skeleton className="h-32 w-full" />
</div>
)}
{!loading && ownerRepos.length === 0 && (
<p className="px-4 py-3 text-xs text-muted-foreground">
No repositories found for this installation.
</p>
)}
<ul className="divide-y divide-border">
{pageRepos.map((r) => {
const isEnabled = enabledSet.has(r.full_name);
return (
<li
key={r.full_name}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div className="flex min-w-0 items-center gap-2 text-xs">
<span className="truncate">
<span className="text-muted-foreground">{owner}/</span>
<span className="font-medium text-foreground">
{r.full_name.slice(owner.length + 1)}
</span>
</span>
{r.private && (
<span className="text-[10px] text-muted-foreground">private</span>
)}
</div>
<span
title={
!canEdit
? "Only team admins can modify enabled repositories"
: undefined
}
className={!canEdit ? "cursor-not-allowed" : undefined}
>
<Switch
checked={isEnabled}
disabled={!canEdit || toggle.isPending}
onCheckedChange={(v) =>
toggle.mutate({ full_name: r.full_name, on: v })
}
/>
</span>
</li>
);
})}
</ul>
{ownerRepos.length > PAGE_SIZE && (
<div className="flex items-center justify-between gap-4 border-t border-border px-4 py-2 text-xs">
<span className="text-muted-foreground">
Showing {pageStart + 1}-{pageEnd} of {ownerRepos.length}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={safePage === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
Prev
</Button>
<span className="text-muted-foreground">
{safePage + 1} / {totalPages}
</span>
<Button
size="sm"
variant="outline"
disabled={safePage >= totalPages - 1}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
Next
</Button>
</div>
</div>
)}
</div>
</section>
</AppShell>
);
}

View file

@ -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 (
<main className="p-6">
<Skeleton className="h-64 w-full" />
</main>
);
}
if (!session.data) return <Navigate to="/login" />;
return (
<AppShell
user={session.data}
title="Review Style Prompts"
description="An agent browses recent merged PR review feedback on GitHub, then writes a per-repo style guide for the reviewer."
backTo={{ to: "/review", label: "Back to Open SWE Review" }}
>
<div className="rounded-lg border border-border bg-card">
<ReviewStylesPanel />
</div>
</AppShell>
);
}