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>
|
|
@ -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")
|
||||
|
|
|
|||
68
agent/dashboard/enabled_repos.py
Normal 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)
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
BIN
ui/assets/LangChain_Symbol_LightBlue.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
ui/assets/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
ui/assets/playstore-icon.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
ui/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
BIN
ui/public/favicon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
ui/public/logo-mark.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)}`, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
189
ui/src/routes/review_.repositories.$owner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
ui/src/routes/review_.styles.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||