mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 17:23:15 +00:00
* 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>
145 lines
5.5 KiB
Python
145 lines
5.5 KiB
Python
"""Team-wide Open SWE Review (Bugbot) settings stored in LangGraph Store.
|
|
|
|
A single record keyed ``"default"`` keeps all instance-wide reviewer
|
|
configuration in one place. Per-repo style prompts live in
|
|
:mod:`agent.dashboard.review_styles`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
from typing import Any, Literal
|
|
|
|
from langgraph_sdk import get_client
|
|
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", "once_per_pr", "manual"]
|
|
AutofixMode = Literal["off", "low", "medium", "high"]
|
|
|
|
|
|
class TeamSettingsUpdate(BaseModel):
|
|
trigger_mode: TriggerMode = "every_push"
|
|
review_draft_prs: bool = False
|
|
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():
|
|
return get_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 defaults
|
|
if item is None:
|
|
return defaults
|
|
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
|
|
if not isinstance(value, dict):
|
|
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]:
|
|
value: dict[str, Any] = {
|
|
"trigger_mode": update.trigger_mode,
|
|
"review_draft_prs": update.review_draft_prs,
|
|
"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()
|