diff --git a/agent/dashboard/team_settings.py b/agent/dashboard/team_settings.py index bca2ab85..e81ff4b8 100644 --- a/agent/dashboard/team_settings.py +++ b/agent/dashboard/team_settings.py @@ -31,7 +31,6 @@ TEAM_SETTINGS_KEY = "default" # prompt. Generous enough for a detailed policy, small enough to stay bounded. ORG_GUIDELINES_MAX_CHARS = 10_000 REVIEW_TRACING_PROJECT_MAX_CHARS = 256 - # Sea Haven review baseline seeded as the org-wide guidelines default. Surfaces # in the reviewer prompt for every repo until an admin overrides it with a # non-empty value via the dashboard (PUT /team-settings). Keep it stack-agnostic diff --git a/agent/webapp.py b/agent/webapp.py index da19a7af..c47fd231 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -131,6 +131,26 @@ from .utils.thread_ids import generate_thread_id_from_slack_thread logger = logging.getLogger(__name__) +# Opt-in leak diagnostics. Bursts of aiohttp "Unclosed client session" warnings +# (from a third-party SDK) leak fds + memory in prod, but the warning omits the +# allocation site. With tracemalloc running, aiohttp appends an "Object allocated +# at" traceback to each warning, naming the exact source. Inert unless the env +# var is set, so this is safe to ship and flip on for one diagnostic run. +if os.environ.get("DEBUG_TRACEMALLOC"): + import tracemalloc + + try: + _tracemalloc_frames = int(os.environ.get("DEBUG_TRACEMALLOC_FRAMES") or "25") + except ValueError: + _tracemalloc_frames = 25 + tracemalloc.start(_tracemalloc_frames) + logger.warning( + "DEBUG_TRACEMALLOC enabled: tracemalloc started (%d frames) to attribute " + "unclosed-session warnings", + _tracemalloc_frames, + ) + + @asynccontextmanager async def lifespan(_app: FastAPI) -> AsyncIterator[None]: from .utils.model import validate_local_dev_llm_config diff --git a/ui/src/routeTree.gen.ts b/ui/src/routeTree.gen.ts index 4a12a8e1..02540c57 100644 --- a/ui/src/routeTree.gen.ts +++ b/ui/src/routeTree.gen.ts @@ -31,6 +31,7 @@ import { Route as ReviewRepositoriesOwnerRouteImport } from './routes/review_.re import { Route as AgentsAutomationsNewRouteImport } from './routes/agents/automations/new' import { Route as AgentsAutomationsScheduleIdRouteImport } from './routes/agents/automations/$scheduleId' import { Route as AgentsThreadIdPlanRouteImport } from './routes/agents/$threadId_.plan' +import { Route as OwnerRepoPullNumberRouteImport } from './routes/$owner.$repo.pull.$number' import { Route as AgentsReviewsOwnerRepoNumberRouteImport } from './routes/agents/reviews/$owner.$repo.$number' const UsageRoute = UsageRouteImport.update({ @@ -144,6 +145,11 @@ const AgentsThreadIdPlanRoute = AgentsThreadIdPlanRouteImport.update({ path: '/$threadId/plan', getParentRoute: () => AgentsRoute, } as any) +const OwnerRepoPullNumberRoute = OwnerRepoPullNumberRouteImport.update({ + id: '/$owner/$repo/pull/$number', + path: '/$owner/$repo/pull/$number', + getParentRoute: () => rootRouteImport, +} as any) const AgentsReviewsOwnerRepoNumberRoute = AgentsReviewsOwnerRepoNumberRouteImport.update({ id: '/reviews/$owner/$repo/$number', @@ -174,6 +180,7 @@ export interface FileRoutesByFullPath { '/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute '/agents/automations/': typeof AgentsAutomationsIndexRoute '/agents/reviews/': typeof AgentsReviewsIndexRoute + '/$owner/$repo/pull/$number': typeof OwnerRepoPullNumberRoute '/agents/reviews/$owner/$repo/$number': typeof AgentsReviewsOwnerRepoNumberRoute } export interface FileRoutesByTo { @@ -198,6 +205,7 @@ export interface FileRoutesByTo { '/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute '/agents/automations': typeof AgentsAutomationsIndexRoute '/agents/reviews': typeof AgentsReviewsIndexRoute + '/$owner/$repo/pull/$number': typeof OwnerRepoPullNumberRoute '/agents/reviews/$owner/$repo/$number': typeof AgentsReviewsOwnerRepoNumberRoute } export interface FileRoutesById { @@ -224,6 +232,7 @@ export interface FileRoutesById { '/review_/repositories/$owner': typeof ReviewRepositoriesOwnerRoute '/agents/automations/': typeof AgentsAutomationsIndexRoute '/agents/reviews/': typeof AgentsReviewsIndexRoute + '/$owner/$repo/pull/$number': typeof OwnerRepoPullNumberRoute '/agents/reviews/$owner/$repo/$number': typeof AgentsReviewsOwnerRepoNumberRoute } export interface FileRouteTypes { @@ -251,6 +260,7 @@ export interface FileRouteTypes { | '/review/repositories/$owner' | '/agents/automations/' | '/agents/reviews/' + | '/$owner/$repo/pull/$number' | '/agents/reviews/$owner/$repo/$number' fileRoutesByTo: FileRoutesByTo to: @@ -275,6 +285,7 @@ export interface FileRouteTypes { | '/review/repositories/$owner' | '/agents/automations' | '/agents/reviews' + | '/$owner/$repo/pull/$number' | '/agents/reviews/$owner/$repo/$number' id: | '__root__' @@ -300,6 +311,7 @@ export interface FileRouteTypes { | '/review_/repositories/$owner' | '/agents/automations/' | '/agents/reviews/' + | '/$owner/$repo/pull/$number' | '/agents/reviews/$owner/$repo/$number' fileRoutesById: FileRoutesById } @@ -318,6 +330,7 @@ export interface RootRouteChildren { AgentsSnapshotsRoute: typeof AgentsSnapshotsRoute ReviewStylesRoute: typeof ReviewStylesRoute ReviewRepositoriesOwnerRoute: typeof ReviewRepositoriesOwnerRoute + OwnerRepoPullNumberRoute: typeof OwnerRepoPullNumberRoute } declare module '@tanstack/react-router' { @@ -476,6 +489,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AgentsThreadIdPlanRouteImport parentRoute: typeof AgentsRoute } + '/$owner/$repo/pull/$number': { + id: '/$owner/$repo/pull/$number' + path: '/$owner/$repo/pull/$number' + fullPath: '/$owner/$repo/pull/$number' + preLoaderRoute: typeof OwnerRepoPullNumberRouteImport + parentRoute: typeof rootRouteImport + } '/agents/reviews/$owner/$repo/$number': { id: '/agents/reviews/$owner/$repo/$number' path: '/reviews/$owner/$repo/$number' @@ -528,6 +548,7 @@ const rootRouteChildren: RootRouteChildren = { AgentsSnapshotsRoute: AgentsSnapshotsRoute, ReviewStylesRoute: ReviewStylesRoute, ReviewRepositoriesOwnerRoute: ReviewRepositoriesOwnerRoute, + OwnerRepoPullNumberRoute: OwnerRepoPullNumberRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/ui/src/routes/$owner.$repo.pull.$number.tsx b/ui/src/routes/$owner.$repo.pull.$number.tsx new file mode 100644 index 00000000..0c816a56 --- /dev/null +++ b/ui/src/routes/$owner.$repo.pull.$number.tsx @@ -0,0 +1,211 @@ +import { Link, Navigate, createFileRoute } from "@tanstack/react-router" +import { useEffect, useMemo, useRef } from "react" +import { ArrowSquareOutIcon, GitPullRequestIcon } from "@phosphor-icons/react" +import { useMutation, useQuery } from "@tanstack/react-query" + +import { buttonVariants } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { api } from "@/lib/api" +import { RequireLogin } from "@/lib/auth-redirect" +import { useSession } from "@/lib/session" +import { cn } from "@/lib/utils" + +export const Route = createFileRoute("/$owner/$repo/pull/$number")({ + component: PullRequestReviewLinkPage, +}) + +function PullRequestReviewLinkPage() { + const { owner, repo, number } = Route.useParams() + const prNumber = Number(number) + const session = useSession() + const stableReviewPath = `/agents/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${prNumber}` + const githubPrUrl = useMemo( + () => `https://github.com/${owner}/${repo}/pull/${number}`, + [owner, repo, number] + ) + const existingReview = useQuery({ + queryKey: ["review", owner, repo, prNumber], + queryFn: () => api.getReview(owner, repo, prNumber), + enabled: !!session.data && Number.isFinite(prNumber), + retry: false, + }) + const triggerRef = useRef(null) + const triggerReview = useMutation({ + mutationFn: () => api.reReview(owner, repo, prNumber), + }) + + useEffect(() => { + if (!session.data || !Number.isFinite(prNumber)) return + if (existingReview.isLoading || existingReview.data?.status === "running") { + return + } + const key = `${owner}/${repo}#${prNumber}` + if (triggerRef.current === key) return + triggerRef.current = key + triggerReview.mutate() + }, [ + existingReview.data?.status, + existingReview.isLoading, + owner, + repo, + prNumber, + session.data, + triggerReview, + ]) + + if (session.isLoading) { + return ( +
+ +
+ ) + } + + if (!session.data) return + + if (!Number.isFinite(prNumber)) { + return ( + + ) + } + + if (existingReview.data?.status === "running" || triggerReview.isSuccess) { + return ( + + ) + } + + if (triggerReview.isError) { + return ( + triggerReview.mutate()} + /> + ) + } + + const isCheckingExistingReview = existingReview.isLoading + + return ( + + ) +} + +function ReviewLinkCard({ + title, + description, + owner, + repo, + number, + githubPrUrl, + stableReviewPath, + loading = false, + onRetry, +}: { + title: string + description: string + owner: string + repo: string + number: string + githubPrUrl: string + stableReviewPath?: string + loading?: boolean + onRetry?: () => void +}) { + return ( +
+ + + + + {title} + + {description} + + +
+
+ {owner}/{repo} #{number} +
+ + View on GitHub + + +
+ {loading && } +
+ + {onRetry && ( + + )} + {stableReviewPath && ( + + Open stable review page + + )} + + Open GitHub PR + + +
+
+ ) +}