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

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)
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"]

View file

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

View file

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

View file

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

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 = (
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 "<PROJECT_ID>",
linear_issue_number=linear_issue_number or "<ISSUE_NUMBER>",
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),
)

View file

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

View file

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

View file

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

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.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",
}

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 { 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 (
<div className="flex min-h-svh bg-background text-foreground">
<div className="flex h-svh overflow-hidden bg-background text-foreground">
<AppSidebar user={user} />
<main className="flex-1 overflow-y-auto">
<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">
<h1 className="font-heading text-lg font-medium">{title}</h1>
{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 (
<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}>
<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 && (
<span className="text-xs text-muted-foreground">{description}</span>
)}
</label>
<div className="shrink-0">{control}</div>
<div className={`shrink-0 ${comingSoon ? "opacity-50" : ""}`}>{control}</div>
</div>
);
}

View file

@ -69,7 +69,11 @@ export function AppSidebar({ user }: { user: SessionUser }) {
return (
<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">
<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
</Link>
</div>

View file

@ -11,7 +11,8 @@ function Switch({ className, ...props }: SwitchPrimitive.Root.Props) {
className={cn(
"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",
"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",
className,
)}

View file

@ -61,15 +61,10 @@ export interface Profile {
default_model?: string;
reasoning_effort?: string;
default_repo?: string | null;
first_name?: string | null;
last_name?: string | null;
base_branch?: string | null;
branch_prefix?: string | null;
auto_fix_ci?: boolean;
create_prs?: boolean;
allow_artifacts?: boolean;
slack_notifications?: boolean;
preferred_pr_destination?: string | null;
updated_at?: string;
}
@ -77,18 +72,13 @@ export interface ProfileUpdate {
default_model: string;
reasoning_effort: string;
default_repo?: string | null;
first_name?: string | null;
last_name?: string | null;
base_branch?: string | null;
branch_prefix?: string | null;
auto_fix_ci?: 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 interface TeamSettings {
@ -97,6 +87,10 @@ export interface TeamSettings {
pr_summaries: boolean;
autofix_mode: 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;
}
@ -163,6 +157,13 @@ export const api = {
getTeamSettings: () => request<TeamSettings>("/team-settings"),
saveTeamSettings: (body: TeamSettings) =>
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"),
adminSaveProfile: (login: string, body: ProfileUpdate & { email?: string }) =>
request<Profile>(`/admin/profiles/${encodeURIComponent(login)}`, {

View file

@ -64,15 +64,10 @@ export function buildProfileUpdate(
default_model: current?.default_model ?? fallbackModel,
reasoning_effort: current?.reasoning_effort ?? fallbackEffort,
default_repo: current?.default_repo ?? null,
first_name: current?.first_name ?? null,
last_name: current?.last_name ?? null,
base_branch: current?.base_branch ?? null,
branch_prefix: current?.branch_prefix ?? null,
auto_fix_ci: current?.auto_fix_ci ?? 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,
};
}

View file

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

View file

@ -15,7 +15,11 @@ export const Route = createRootRoute({
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ 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: () => (
<main className="container mx-auto p-4 pt-16">

View file

@ -1,11 +1,18 @@
import { Navigate, createFileRoute } from "@tanstack/react-router";
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 { AppShell, SettingsSection } from "@/components/AppShell";
import type { ModelOption, Profile, ProfileUpdate, TeamSettings } from "@/lib/api";
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
import { ProfileForm } from "@/components/ProfileForm";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/lib/api";
import { useSession } from "@/lib/session";
@ -57,11 +64,13 @@ function AdminPage() {
<AppShell
user={session.data}
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]">
<SettingsSection title={`Users · ${profiles.data?.length ?? 0}`}>
<div className="flex flex-col gap-0.5 p-2">
<GlobalDefaultsSection models={options.data?.models ?? []} />
<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 ? (
<Skeleton className="h-32" />
) : (
@ -77,16 +86,10 @@ function AdminPage() {
))
)}
</div>
</SettingsSection>
<SettingsSection
title={activeProfile?.login ?? "Select a user"}
description={activeProfile?.email ?? undefined}
>
<div className="p-4">
{!activeProfile ? (
<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>
) : options.isLoading ? (
<Skeleton className="h-48" />
@ -103,8 +106,157 @@ function AdminPage() {
/>
)}
</div>
</SettingsSection>
</div>
</SettingsSection>
</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
label="Automatically fix CI failures"
description="Agent will attempt to fix failing CI checks on PRs it opens."
comingSoon
control={
<Switch
checked={profile.data?.auto_fix_ci ?? true}
onCheckedChange={(v) => persist({ auto_fix_ci: v })}
disabled
/>
}
/>
<SettingsRow
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={
<Switch
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>
</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>}
</AppShell>
);

View file

@ -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<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]);
const qc = useQueryClient();
const navigate = useNavigate();
if (session.isLoading) {
return (
@ -46,36 +23,15 @@ function MySettingsPage() {
}
if (!session.data) return <Navigate to="/login" />;
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 (
<AppShell user={session.data} title="My Settings">
<SettingsSection title="Profile">
<div className="divide-y divide-border">
<SettingsRow
label="Email"
control={
@ -84,59 +40,19 @@ function MySettingsPage() {
</span>
}
/>
</SettingsSection>
<SettingsSection title="Account">
<SettingsRow
label="First Name"
htmlFor="first-name"
label="Sign out"
description="End your dashboard session."
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 size="sm" variant="outline" onClick={() => void handleLogout()}>
Sign out
</Button>
</div>
</div>
</SettingsSection>
<SettingsSection title="PR Preferences">
<SettingsRow
label="Preferred PR destination"
description="Choose where PR links open across web, the desktop app and IDE."
control={
<Select value={prDestination} onValueChange={handleSavePrDestination}>
<SelectTrigger className="w-40">
<SelectValue />
</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>
{error && <p className="text-xs text-destructive">{error}</p>}
</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 { 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."
>
<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">
<div className="divide-y divide-border">
<SettingsRow
label="Trigger Mode"
description={triggerDescription}
comingSoon
control={
<Select
value={current.trigger_mode}
onValueChange={(v) => persist({ trigger_mode: v as TriggerMode })}
disabled={!canEdit}
disabled
>
<SelectTrigger className="w-40">
<SelectValue />
@ -145,11 +175,12 @@ function ReviewPage() {
<SettingsRow
label="Autofix Mode"
description="When enabled, the reviewer will propose fixes. Billed at plan rates."
comingSoon
control={
<Select
value={current.autofix_mode}
onValueChange={(v) => persist({ autofix_mode: v as AutofixMode })}
disabled={!canEdit}
disabled
>
<SelectTrigger className="w-32">
<SelectValue />
@ -167,13 +198,14 @@ function ReviewPage() {
<SettingsRow
label="Autofix Severity Threshold"
description="Findings at this severity or higher are auto-fixed"
comingSoon
control={
<Select
value={current.autofix_severity_threshold}
onValueChange={(v) =>
persist({ autofix_severity_threshold: v as AutofixMode })
}
disabled={!canEdit}
disabled
>
<SelectTrigger className="w-32">
<SelectValue />
@ -197,14 +229,95 @@ function ReviewPage() {
</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>}
</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>
);
}