diff --git a/agent/dashboard/profiles.py b/agent/dashboard/profiles.py index 011183c2..0b4bfedf 100644 --- a/agent/dashboard/profiles.py +++ b/agent/dashboard/profiles.py @@ -33,6 +33,15 @@ 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 @@ -84,6 +93,15 @@ 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(), } await _client().store.put_item(PROFILES_NAMESPACE, login, value) diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py index 1aab24bc..b52884a4 100644 --- a/agent/dashboard/routes.py +++ b/agent/dashboard/routes.py @@ -46,6 +46,11 @@ from .review_styles import ( normalize_repo_full_name, set_custom_prompt, ) +from .team_settings import ( + TeamSettingsUpdate, + get_team_settings, + upsert_team_settings, +) logger = logging.getLogger(__name__) @@ -226,14 +231,32 @@ async def admin_put_profile( 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( - default_model=update.default_model, - reasoning_effort=update.reasoning_effort, - default_repo=update.default_repo, + **{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) + + def _next_link_url(link_header: str | None) -> str | None: if not link_header: return None diff --git a/agent/dashboard/team_settings.py b/agent/dashboard/team_settings.py new file mode 100644 index 00000000..39da1c99 --- /dev/null +++ b/agent/dashboard/team_settings.py @@ -0,0 +1,73 @@ +"""Team-wide Open SWE Review (Bugbot) settings stored in LangGraph Store. + +A single record keyed ``"default"`` keeps all instance-wide reviewer +configuration in one place. Per-repo style prompts live in +:mod:`agent.dashboard.review_styles`. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import Any, Literal + +from langgraph_sdk import get_client +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +TEAM_SETTINGS_NAMESPACE: list[str] = ["team_settings"] +TEAM_SETTINGS_KEY = "default" + +TriggerMode = Literal["every_push", "ready_for_review", "manual"] +AutofixMode = Literal["off", "low", "medium", "high"] + + +class TeamSettingsUpdate(BaseModel): + trigger_mode: TriggerMode = "every_push" + review_draft_prs: bool = False + pr_summaries: bool = True + autofix_mode: AutofixMode = "off" + autofix_severity_threshold: AutofixMode = "medium" + + +def _client(): + return get_client() + + +def _default_settings() -> dict[str, Any]: + return { + "trigger_mode": "every_push", + "review_draft_prs": False, + "pr_summaries": True, + "autofix_mode": "off", + "autofix_severity_threshold": "medium", + "updated_at": None, + } + + +async def get_team_settings() -> dict[str, Any]: + 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() + if item is None: + return _default_settings() + 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} + + +async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]: + value: dict[str, Any] = { + "trigger_mode": update.trigger_mode, + "review_draft_prs": update.review_draft_prs, + "pr_summaries": update.pr_summaries, + "autofix_mode": update.autofix_mode, + "autofix_severity_threshold": update.autofix_severity_threshold, + "updated_at": datetime.now(UTC).isoformat(), + } + await _client().store.put_item(TEAM_SETTINGS_NAMESPACE, TEAM_SETTINGS_KEY, value) + return value diff --git a/ui/src/components/AppHeader.tsx b/ui/src/components/AppHeader.tsx deleted file mode 100644 index a3980c72..00000000 --- a/ui/src/components/AppHeader.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Link, useNavigate } from "@tanstack/react-router"; -import { useQueryClient } from "@tanstack/react-query"; - -import type {SessionUser} from "@/lib/api"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; -import { api } from "@/lib/api"; - -export function AppHeader({ user }: { user: SessionUser }) { - const qc = useQueryClient(); - const navigate = useNavigate(); - - const onLogout = async () => { - await api.logout(); - qc.setQueryData(["session"], null); - navigate({ to: "/login" }); - }; - - return ( -
-
- - open-swe - - -
- {user.is_admin && admin} - {user.login} - - {user.avatar_url && } - {user.login.slice(0, 2).toUpperCase()} - - - -
-
-
- ); -} diff --git a/ui/src/components/AppShell.tsx b/ui/src/components/AppShell.tsx new file mode 100644 index 00000000..48996062 --- /dev/null +++ b/ui/src/components/AppShell.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from "react"; + +import type { SessionUser } from "@/lib/api"; +import { AppSidebar } from "@/components/AppSidebar"; + +interface AppShellProps { + user: SessionUser; + title: string; + description?: string; + children: ReactNode; +} + +export function AppShell({ user, title, description, children }: AppShellProps) { + return ( +
+ +
+
+
+

{title}

+ {description && ( +

{description}

+ )} +
+
{children}
+
+
+
+ ); +} + +interface SettingsSectionProps { + title: string; + description?: string; + action?: ReactNode; + children: ReactNode; +} + +export function SettingsSection({ title, description, action, children }: SettingsSectionProps) { + return ( +
+
+
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ {action} +
+
{children}
+
+ ); +} + +interface SettingsRowProps { + label: string; + description?: string; + control: ReactNode; + htmlFor?: string; +} + +export function SettingsRow({ label, description, control, htmlFor }: SettingsRowProps) { + return ( +
+ +
{control}
+
+ ); +} diff --git a/ui/src/components/AppSidebar.tsx b/ui/src/components/AppSidebar.tsx new file mode 100644 index 00000000..2acdc8e1 --- /dev/null +++ b/ui/src/components/AppSidebar.tsx @@ -0,0 +1,137 @@ +import { Link, useNavigate } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; +import { + CloudIcon, + GearSixIcon, + PlugsIcon, + ShieldCheckIcon, + SignOutIcon, + SlidersHorizontalIcon, +} from "@phosphor-icons/react"; +import { useEffect, useRef, useState } from "react"; +import type {ComponentType} from "react"; + +import type { SessionUser } from "@/lib/api"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { api } from "@/lib/api"; +import { cn } from "@/lib/utils"; + +type IconType = ComponentType<{ className?: string; weight?: "regular" | "fill" | "duotone" }>; + +interface NavItem { + to: string; + label: string; + icon: IconType; + adminOnly?: boolean; +} + +const NAV: Array = [ + { to: "/my-settings", label: "My Settings", icon: SlidersHorizontalIcon }, + { to: "/cloud-agents", label: "Cloud Agents", icon: CloudIcon }, + { to: "/review", label: "Open SWE Review", icon: ShieldCheckIcon }, + { to: "/integrations", label: "Integrations", icon: PlugsIcon }, + { to: "/admin", label: "Admin", icon: GearSixIcon, adminOnly: true }, +]; + +export function AppSidebar({ user }: { user: SessionUser }) { + const qc = useQueryClient(); + const navigate = useNavigate(); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + if (!menuOpen) return; + const onClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setMenuOpen(false); + }; + document.addEventListener("mousedown", onClickOutside); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onClickOutside); + document.removeEventListener("keydown", onKey); + }; + }, [menuOpen]); + + const onLogout = async () => { + setMenuOpen(false); + await api.logout(); + qc.setQueryData(["session"], null); + navigate({ to: "/login" }); + }; + + const initials = (user.login || "?").slice(0, 2).toUpperCase(); + + return ( + + ); +} diff --git a/ui/src/components/ReviewStylesPanel.tsx b/ui/src/components/ReviewStylesPanel.tsx new file mode 100644 index 00000000..a97a62a7 --- /dev/null +++ b/ui/src/components/ReviewStylesPanel.tsx @@ -0,0 +1,267 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; + +import type {ReviewStyle} from "@/lib/api"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Textarea } from "@/components/ui/textarea"; +import { ApiError, api } from "@/lib/api"; +import { normalizeRepoFullName } from "@/lib/repo"; + +function statusVariant(status: ReviewStyle["status"]) { + switch (status) { + case "completed": + return "default" as const; + case "running": + return "secondary" as const; + case "failed": + return "destructive" as const; + default: + return "outline" as const; + } +} + +export function ReviewStylesPanel() { + const qc = useQueryClient(); + const [error, setError] = useState(null); + const [addRepo, setAddRepo] = useState(""); + const [selected, setSelected] = useState(null); + const [draftPrompt, setDraftPrompt] = useState(""); + + const styles = useQuery({ + queryKey: ["reviewStyles"], + queryFn: api.listReviewStyles, + refetchInterval: (q) => { + const hasRunning = (q.state.data ?? []).some((r) => r.status === "running"); + return hasRunning ? 4000 : false; + }, + }); + + const repos = useQuery({ + queryKey: ["repos"], + queryFn: async () => { + try { + return await api.repos(); + } catch (e) { + if (e instanceof ApiError && e.status === 401) + return { installations: [], repositories: [] }; + throw e; + } + }, + }); + + const detail = useQuery({ + queryKey: ["reviewStyle", selected], + queryFn: () => api.getReviewStyle(selected!), + enabled: !!selected, + refetchInterval: (q) => (q.state.data?.status === "running" ? 4000 : false), + }); + + useEffect(() => { + if (detail.data?.custom_prompt != null) { + setDraftPrompt(detail.data.custom_prompt); + } else if (detail.data) { + setDraftPrompt(""); + } + }, [detail.data?.custom_prompt, detail.data?.full_name]); + + const createStyle = useMutation({ + mutationFn: (full_name: string) => api.createReviewStyle(full_name), + onSuccess: (record) => { + void qc.invalidateQueries({ queryKey: ["reviewStyles"] }); + setSelected(record.full_name); + setError(null); + }, + onError: (e: Error) => setError(e.message), + }); + + const analyze = useMutation({ + mutationFn: (full_name: string) => api.analyzeReviewStyle(full_name), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["reviewStyles"] }); + void qc.invalidateQueries({ queryKey: ["reviewStyle", selected] }); + setError(null); + }, + onError: (e: Error) => setError(e.message), + }); + + const savePrompt = useMutation({ + mutationFn: ({ full_name, custom_prompt }: { full_name: string; custom_prompt: string }) => + api.saveReviewStylePrompt(full_name, custom_prompt), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["reviewStyles"] }); + void qc.invalidateQueries({ queryKey: ["reviewStyle", selected] }); + setError(null); + }, + onError: (e: Error) => setError(e.message), + }); + + if (styles.isLoading) { + return ; + } + + const configured = new Set((styles.data ?? []).map((s) => s.full_name)); + const suggestedRepos = (repos.data?.repositories ?? []).filter( + (r) => !configured.has(r.full_name), + ); + const normalizedAddRepo = normalizeRepoFullName(addRepo); + const canAdd = normalizedAddRepo !== null && !configured.has(normalizedAddRepo); + const active = detail.data ?? styles.data?.find((s) => s.full_name === selected) ?? null; + + const handleAdd = () => { + if (!normalizedAddRepo || !canAdd) return; + void createStyle.mutateAsync(normalizedAddRepo).then(() => setAddRepo("")); + }; + + return ( +
+
+
+ + setAddRepo(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAdd(); + } + }} + /> + {suggestedRepos.length > 0 && ( + r.full_name)} + value={addRepo} + onValueChange={(v) => setAddRepo(typeof v === "string" ? v : "")} + > + + + + No matches + {suggestedRepos.map((r) => ( + + {r.full_name} + {r.private && ( + + private + + )} + + ))} + + + + )} + +
+
    + {(styles.data ?? []).map((s) => ( +
  • + +
  • + ))} + {(styles.data ?? []).length === 0 && ( +
  • No repositories yet.
  • + )} +
+
+ +
+ {!selected || !active ? ( +

+ Select a repository on the left to view or edit its review style prompt. +

+ ) : ( + <> +
+ {active.status} + {active.top_reviewers.length > 0 && ( + + Reviewers: {active.top_reviewers.join(", ")} + + )} + {active.prs_sampled > 0 && ( + + {active.prs_sampled} PRs · {active.reviews_sampled} reviews sampled + + )} +
+ {active.analysis_summary && ( +

{active.analysis_summary}

+ )} + {active.error &&

{active.error}

} +
+ + +
+