mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-02 07: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>
492 lines
15 KiB
Python
492 lines
15 KiB
Python
"""FastAPI router for the dashboard backend."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import logging
|
|
import os
|
|
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,
|
|
STATE_COOKIE_NAME,
|
|
STATE_TTL_SECONDS,
|
|
decode_state,
|
|
exchange_code,
|
|
fetch_github_user,
|
|
hash_state_nonce,
|
|
issue_session,
|
|
issue_state,
|
|
new_state_nonce,
|
|
require_session,
|
|
sanitize_redirect_to,
|
|
)
|
|
from .options import SUPPORTED_MODELS
|
|
from .profiles import (
|
|
ProfileUpdate,
|
|
get_access_token,
|
|
get_profile,
|
|
list_profiles,
|
|
upsert_access_token,
|
|
upsert_profile,
|
|
)
|
|
from .review_style_jobs import start_review_style_analysis, sync_review_style_run_status
|
|
from .review_styles import (
|
|
ReviewStyleCreate,
|
|
ReviewStylePromptUpdate,
|
|
create_review_style,
|
|
get_review_style,
|
|
list_review_styles,
|
|
normalize_repo_full_name,
|
|
set_custom_prompt,
|
|
)
|
|
from .team_settings import (
|
|
TeamSettingsUpdate,
|
|
get_team_settings,
|
|
upsert_team_settings,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/dashboard/api", tags=["dashboard"])
|
|
|
|
|
|
def _require_admin(session: dict[str, Any]) -> dict[str, Any]:
|
|
if not is_admin(session.get("email")):
|
|
raise HTTPException(403, "admin only")
|
|
return session
|
|
|
|
|
|
_SESSION_DEP = Depends(require_session)
|
|
|
|
|
|
def _admin_session(session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]:
|
|
return _require_admin(session)
|
|
|
|
|
|
_ADMIN_DEP = Depends(_admin_session)
|
|
|
|
|
|
def _api_base_url() -> str:
|
|
v = os.environ.get("DASHBOARD_API_BASE_URL", "").rstrip("/")
|
|
if not v:
|
|
raise HTTPException(500, "DASHBOARD_API_BASE_URL not configured")
|
|
return v
|
|
|
|
|
|
def _frontend_base_url() -> str:
|
|
v = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/")
|
|
if not v:
|
|
raise HTTPException(500, "DASHBOARD_BASE_URL not configured")
|
|
return v
|
|
|
|
|
|
def _set_session_cookie(response: Response, jwt_token: str) -> None:
|
|
response.set_cookie(
|
|
key=COOKIE_NAME,
|
|
value=jwt_token,
|
|
max_age=SESSION_TTL_SECONDS,
|
|
httponly=True,
|
|
secure=True,
|
|
samesite="none",
|
|
path="/",
|
|
)
|
|
|
|
|
|
def _set_state_cookie(response: Response, nonce: str) -> None:
|
|
# SameSite=Lax so GitHub's top-level redirect back to /auth/callback
|
|
# still presents this cookie; the cookie is single-purpose and lives
|
|
# only for the duration of one OAuth round-trip.
|
|
response.set_cookie(
|
|
key=STATE_COOKIE_NAME,
|
|
value=nonce,
|
|
max_age=STATE_TTL_SECONDS,
|
|
httponly=True,
|
|
secure=True,
|
|
samesite="lax",
|
|
path="/dashboard/api/auth",
|
|
)
|
|
|
|
|
|
def _clear_state_cookie(response: Response) -> None:
|
|
response.delete_cookie(
|
|
STATE_COOKIE_NAME, path="/dashboard/api/auth", samesite="lax", secure=True
|
|
)
|
|
|
|
|
|
@router.get("/auth/login")
|
|
async def auth_login(request: Request, redirect_to: str | None = None) -> RedirectResponse:
|
|
client_id = os.environ.get("GITHUB_APP_CLIENT_ID", "")
|
|
if not client_id:
|
|
raise HTTPException(500, "GITHUB_APP_CLIENT_ID not configured")
|
|
safe_redirect = sanitize_redirect_to(redirect_to) or _frontend_base_url()
|
|
|
|
nonce = new_state_nonce()
|
|
state = issue_state(redirect_to=safe_redirect, nonce_hash=hash_state_nonce(nonce))
|
|
redirect_uri = f"{_api_base_url()}/dashboard/api/auth/callback"
|
|
url = (
|
|
"https://github.com/login/oauth/authorize"
|
|
f"?client_id={client_id}"
|
|
f"&redirect_uri={redirect_uri}"
|
|
f"&state={state}"
|
|
)
|
|
response = RedirectResponse(url, status_code=302)
|
|
_set_state_cookie(response, nonce)
|
|
return response
|
|
|
|
|
|
@router.get("/auth/callback")
|
|
async def auth_callback(request: Request, code: str, state: str) -> RedirectResponse:
|
|
state_payload = decode_state(state)
|
|
state_nonce_hash = state_payload.get("nonce_hash")
|
|
cookie_nonce = request.cookies.get(STATE_COOKIE_NAME)
|
|
if (
|
|
not isinstance(state_nonce_hash, str)
|
|
or not cookie_nonce
|
|
or not hmac.compare_digest(hash_state_nonce(cookie_nonce), state_nonce_hash)
|
|
):
|
|
# Either the cookie went missing (different browser, expired,
|
|
# cookies blocked) or the state was issued for a different session.
|
|
raise HTTPException(400, "oauth state mismatch — please retry login")
|
|
|
|
redirect_to = sanitize_redirect_to(state_payload.get("redirect_to")) or _frontend_base_url()
|
|
|
|
access_token = await exchange_code(code)
|
|
user, email = await fetch_github_user(access_token)
|
|
login = user.get("login")
|
|
if not login:
|
|
raise HTTPException(400, "could not resolve GitHub login")
|
|
|
|
await upsert_access_token(login, email or "", access_token)
|
|
|
|
session_jwt = issue_session(login=login, email=email, avatar_url=user.get("avatar_url"))
|
|
response = RedirectResponse(redirect_to, status_code=302)
|
|
_set_session_cookie(response, session_jwt)
|
|
_clear_state_cookie(response)
|
|
return response
|
|
|
|
|
|
@router.post("/auth/logout")
|
|
async def auth_logout() -> Response:
|
|
response = Response(status_code=204)
|
|
response.delete_cookie(COOKIE_NAME, path="/", samesite="none", secure=True)
|
|
return response
|
|
|
|
|
|
@router.get("/me")
|
|
async def me(session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]:
|
|
return {
|
|
"login": session["sub"],
|
|
"email": session.get("email"),
|
|
"avatar_url": session.get("avatar_url"),
|
|
"is_admin": is_admin(session.get("email")),
|
|
}
|
|
|
|
|
|
@router.get("/options")
|
|
async def options() -> dict[str, Any]:
|
|
return {"models": SUPPORTED_MODELS}
|
|
|
|
|
|
@router.get("/profile")
|
|
async def get_my_profile(
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
profile = await get_profile(session["sub"])
|
|
return profile or {}
|
|
|
|
|
|
@router.put("/profile")
|
|
async def put_my_profile(
|
|
update: ProfileUpdate,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
update.validate_pairing()
|
|
return await upsert_profile(session["sub"], session.get("email") or "", update)
|
|
|
|
|
|
@router.get("/admin/profiles")
|
|
async def admin_list_profiles(
|
|
_admin: dict[str, Any] = _ADMIN_DEP,
|
|
) -> list[dict[str, Any]]:
|
|
return await list_profiles()
|
|
|
|
|
|
class AdminProfileUpdate(ProfileUpdate):
|
|
email: str | None = None
|
|
|
|
|
|
@router.put("/admin/profiles/{login}")
|
|
async def admin_put_profile(
|
|
login: str,
|
|
update: AdminProfileUpdate,
|
|
_admin: dict[str, Any] = _ADMIN_DEP,
|
|
) -> dict[str, Any]:
|
|
update.validate_pairing()
|
|
existing = await get_profile(login) or {}
|
|
email = update.email or existing.get("email") or ""
|
|
# Overlay only fields that were explicitly sent so the admin form (which
|
|
# only sends model/effort/repo) can't reset other fields the target user
|
|
# configured via My Settings / Cloud Agents to ProfileUpdate's defaults.
|
|
incoming = update.model_dump(exclude={"email"}, exclude_unset=True)
|
|
merged = {**existing, **incoming}
|
|
base = ProfileUpdate(
|
|
**{k: v for k, v in merged.items() if k in ProfileUpdate.model_fields},
|
|
)
|
|
return await upsert_profile(login, email, base)
|
|
|
|
|
|
@router.get("/team-settings")
|
|
async def api_get_team_settings(
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
return await get_team_settings()
|
|
|
|
|
|
@router.put("/team-settings")
|
|
async def api_put_team_settings(
|
|
update: TeamSettingsUpdate,
|
|
_admin: dict[str, Any] = _ADMIN_DEP,
|
|
) -> dict[str, Any]:
|
|
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
|
|
# GitHub Link header is comma-separated: '<url>; rel="next", <url>; rel="last"'
|
|
for part in link_header.split(","):
|
|
segments = [s.strip() for s in part.split(";")]
|
|
if len(segments) >= 2 and 'rel="next"' in segments[1] and segments[0].startswith("<"):
|
|
return segments[0][1:-1]
|
|
return None
|
|
|
|
|
|
async def _paginate(
|
|
client: httpx.AsyncClient,
|
|
url: str,
|
|
*,
|
|
headers: dict[str, str],
|
|
items_key: str | None,
|
|
cap: int = 1000,
|
|
) -> list[dict[str, Any]]:
|
|
"""Follow ``Link: rel="next"`` until exhausted (or cap reached).
|
|
|
|
``items_key`` is the JSON key holding the list when the endpoint returns
|
|
a wrapper object (e.g. ``/user/installations`` returns
|
|
``{"total_count": N, "installations": [...]}``). When ``None`` the
|
|
response body itself is treated as the list.
|
|
"""
|
|
out: list[dict[str, Any]] = []
|
|
next_url: str | None = url
|
|
first = True
|
|
while next_url and len(out) < cap:
|
|
params = {"per_page": "100"} if first else None
|
|
r = await client.get(next_url, headers=headers, params=params)
|
|
if r.status_code == 401:
|
|
raise HTTPException(401, "github token expired, re-login required")
|
|
r.raise_for_status()
|
|
body = r.json()
|
|
page = body.get(items_key, []) if items_key else body
|
|
if isinstance(page, list):
|
|
out.extend(page)
|
|
next_url = _next_link_url(r.headers.get("Link"))
|
|
first = False
|
|
return out
|
|
|
|
|
|
@router.get("/repos")
|
|
async def list_repos(
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
"""List repos where open-swe is installed and the user has access.
|
|
|
|
Paginates both ``/user/installations`` and per-installation
|
|
``/user/installations/{id}/repositories`` so users with multiple
|
|
installations or >30 accessible repos get the complete set.
|
|
"""
|
|
token = await get_access_token(session["sub"])
|
|
if not token:
|
|
raise HTTPException(401, "github token unavailable, re-login required")
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
}
|
|
async with httpx.AsyncClient() as client:
|
|
installations = await _paginate(
|
|
client,
|
|
"https://api.github.com/user/installations",
|
|
headers=headers,
|
|
items_key="installations",
|
|
)
|
|
repositories: list[dict[str, Any]] = []
|
|
for inst in installations:
|
|
inst_id = inst.get("id")
|
|
if inst_id is None:
|
|
continue
|
|
try:
|
|
repos = await _paginate(
|
|
client,
|
|
f"https://api.github.com/user/installations/{inst_id}/repositories",
|
|
headers=headers,
|
|
items_key="repositories",
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except httpx.HTTPStatusError:
|
|
continue
|
|
repositories.extend(repos)
|
|
return {
|
|
"installations": [
|
|
{
|
|
"id": i.get("id"),
|
|
"account": (i.get("account") or {}).get("login"),
|
|
"account_type": (i.get("account") or {}).get("type"),
|
|
}
|
|
for i in installations
|
|
],
|
|
"repositories": [
|
|
{"full_name": r.get("full_name"), "private": r.get("private", False)}
|
|
for r in repositories
|
|
if r.get("full_name")
|
|
],
|
|
}
|
|
|
|
|
|
async def _assert_repo_available_for_style_analysis(full_name: str, token: str) -> None:
|
|
"""Ensure the repo exists and is readable for style learning.
|
|
|
|
Public repositories are allowed without the GitHub App installed on them.
|
|
Private repositories require the authenticated user to have read access.
|
|
"""
|
|
full_name = normalize_repo_full_name(full_name)
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
}
|
|
owner, name = full_name.split("/", 1)
|
|
async with httpx.AsyncClient() as client:
|
|
r = await client.get(
|
|
f"https://api.github.com/repos/{owner}/{name}",
|
|
headers=headers,
|
|
)
|
|
if r.status_code == 404:
|
|
raise HTTPException(404, "repository not found")
|
|
if r.status_code == 403:
|
|
raise HTTPException(403, "no access to this private repository")
|
|
if r.status_code != 200:
|
|
raise HTTPException(502, f"github API error ({r.status_code})")
|
|
body = r.json()
|
|
if body.get("private") is not True:
|
|
return
|
|
# Private repo: 200 from GitHub implies the user's token can read it.
|
|
|
|
|
|
@router.get("/review-styles")
|
|
async def api_list_review_styles(
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> list[dict[str, Any]]:
|
|
records = await list_review_styles()
|
|
out: list[dict[str, Any]] = []
|
|
for record in records:
|
|
if record.get("status") == "running":
|
|
synced = await sync_review_style_run_status(record["full_name"])
|
|
out.append(synced)
|
|
else:
|
|
out.append(record)
|
|
return out
|
|
|
|
|
|
@router.post("/review-styles")
|
|
async def api_create_review_style(
|
|
body: ReviewStyleCreate,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
token = await get_access_token(session["sub"])
|
|
if not token:
|
|
raise HTTPException(401, "github token unavailable, re-login required")
|
|
await _assert_repo_available_for_style_analysis(body.full_name, token)
|
|
return await create_review_style(body.full_name, session["sub"])
|
|
|
|
|
|
@router.get("/review-styles/{full_name:path}")
|
|
async def api_get_review_style(
|
|
full_name: str,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
full_name = normalize_repo_full_name(full_name)
|
|
record = await get_review_style(full_name)
|
|
if not record:
|
|
raise HTTPException(404, "review style not found")
|
|
if record.get("status") == "running":
|
|
record = await sync_review_style_run_status(full_name)
|
|
return record
|
|
|
|
|
|
@router.put("/review-styles/{full_name:path}")
|
|
async def api_update_review_style_prompt(
|
|
full_name: str,
|
|
body: ReviewStylePromptUpdate,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
full_name = normalize_repo_full_name(full_name)
|
|
record = await get_review_style(full_name)
|
|
if not record:
|
|
raise HTTPException(404, "review style not found")
|
|
return await set_custom_prompt(full_name, body.custom_prompt)
|
|
|
|
|
|
@router.post("/review-styles/{full_name:path}/analyze")
|
|
async def api_analyze_review_style(
|
|
full_name: str,
|
|
session: dict[str, Any] = _SESSION_DEP,
|
|
) -> dict[str, Any]:
|
|
full_name = normalize_repo_full_name(full_name)
|
|
token = await get_access_token(session["sub"])
|
|
if not token:
|
|
raise HTTPException(401, "github token unavailable, re-login required")
|
|
await _assert_repo_available_for_style_analysis(full_name, token)
|
|
record = await get_review_style(full_name)
|
|
if not record:
|
|
record = await create_review_style(full_name, session["sub"])
|
|
if record.get("status") == "running":
|
|
raise HTTPException(409, "analysis already running")
|
|
return await start_review_style_analysis(
|
|
full_name,
|
|
github_token=token,
|
|
created_by=session["sub"],
|
|
)
|