feat: add PR review link route (#1698)

* feat: add PR review link route

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: avoid duplicate review shortcut runs

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
(cherry picked from commit 52fe29168814d7936ee6612b9c81f33339bb05b0)
This commit is contained in:
Johannes du Plessis 2026-07-07 13:11:38 -07:00 • committed by seahaven-openswe[bot]
parent 5964a9f0d4
commit a6fb7fb16b
2 changed files with 232 additions and 0 deletions

View file

@ -31,6 +31,7 @@ import { Route as ReviewRepositoriesOwnerRouteImport } from './routes/review_.re
import { Route as AgentsAutomationsNewRouteImport } from './routes/agents/automations/new' import { Route as AgentsAutomationsNewRouteImport } from './routes/agents/automations/new'
import { Route as AgentsAutomationsScheduleIdRouteImport } from './routes/agents/automations/$scheduleId' import { Route as AgentsAutomationsScheduleIdRouteImport } from './routes/agents/automations/$scheduleId'
import { Route as AgentsThreadIdPlanRouteImport } from './routes/agents/$threadId_.plan' 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' import { Route as AgentsReviewsOwnerRepoNumberRouteImport } from './routes/agents/reviews/$owner.$repo.$number'
const UsageRoute = UsageRouteImport.update({ const UsageRoute = UsageRouteImport.update({
@ -144,6 +145,11 @@ const AgentsThreadIdPlanRoute = AgentsThreadIdPlanRouteImport.update({
path: '/$threadId/plan', path: '/$threadId/plan',
getParentRoute: () => AgentsRoute, getParentRoute: () => AgentsRoute,
} as any) } as any)
const OwnerRepoPullNumberRoute = OwnerRepoPullNumberRouteImport.update({
id: '/$owner/$repo/pull/$number',
path: '/$owner/$repo/pull/$number',
getParentRoute: () => rootRouteImport,
} as any)
const AgentsReviewsOwnerRepoNumberRoute = const AgentsReviewsOwnerRepoNumberRoute =
AgentsReviewsOwnerRepoNumberRouteImport.update({ AgentsReviewsOwnerRepoNumberRouteImport.update({
id: '/reviews/$owner/$repo/$number', id: '/reviews/$owner/$repo/$number',
@ -174,6 +180,7 @@ export interface FileRoutesByFullPath {
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute '/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
'/agents/automations/': typeof AgentsAutomationsIndexRoute '/agents/automations/': typeof AgentsAutomationsIndexRoute
'/agents/reviews/': typeof AgentsReviewsIndexRoute '/agents/reviews/': typeof AgentsReviewsIndexRoute
'/$owner/$repo/pull/$number': typeof OwnerRepoPullNumberRoute
'/agents/reviews/$owner/$repo/$number': typeof AgentsReviewsOwnerRepoNumberRoute '/agents/reviews/$owner/$repo/$number': typeof AgentsReviewsOwnerRepoNumberRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
@ -198,6 +205,7 @@ export interface FileRoutesByTo {
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute '/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
'/agents/automations': typeof AgentsAutomationsIndexRoute '/agents/automations': typeof AgentsAutomationsIndexRoute
'/agents/reviews': typeof AgentsReviewsIndexRoute '/agents/reviews': typeof AgentsReviewsIndexRoute
'/$owner/$repo/pull/$number': typeof OwnerRepoPullNumberRoute
'/agents/reviews/$owner/$repo/$number': typeof AgentsReviewsOwnerRepoNumberRoute '/agents/reviews/$owner/$repo/$number': typeof AgentsReviewsOwnerRepoNumberRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
@ -224,6 +232,7 @@ export interface FileRoutesById {
'/review_/repositories/$owner': typeof ReviewRepositoriesOwnerRoute '/review_/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
'/agents/automations/': typeof AgentsAutomationsIndexRoute '/agents/automations/': typeof AgentsAutomationsIndexRoute
'/agents/reviews/': typeof AgentsReviewsIndexRoute '/agents/reviews/': typeof AgentsReviewsIndexRoute
'/$owner/$repo/pull/$number': typeof OwnerRepoPullNumberRoute
'/agents/reviews/$owner/$repo/$number': typeof AgentsReviewsOwnerRepoNumberRoute '/agents/reviews/$owner/$repo/$number': typeof AgentsReviewsOwnerRepoNumberRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
@ -251,6 +260,7 @@ export interface FileRouteTypes {
| '/review/repositories/$owner' | '/review/repositories/$owner'
| '/agents/automations/' | '/agents/automations/'
| '/agents/reviews/' | '/agents/reviews/'
| '/$owner/$repo/pull/$number'
| '/agents/reviews/$owner/$repo/$number' | '/agents/reviews/$owner/$repo/$number'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
@ -275,6 +285,7 @@ export interface FileRouteTypes {
| '/review/repositories/$owner' | '/review/repositories/$owner'
| '/agents/automations' | '/agents/automations'
| '/agents/reviews' | '/agents/reviews'
| '/$owner/$repo/pull/$number'
| '/agents/reviews/$owner/$repo/$number' | '/agents/reviews/$owner/$repo/$number'
id: id:
| '__root__' | '__root__'
@ -300,6 +311,7 @@ export interface FileRouteTypes {
| '/review_/repositories/$owner' | '/review_/repositories/$owner'
| '/agents/automations/' | '/agents/automations/'
| '/agents/reviews/' | '/agents/reviews/'
| '/$owner/$repo/pull/$number'
| '/agents/reviews/$owner/$repo/$number' | '/agents/reviews/$owner/$repo/$number'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
@ -318,6 +330,7 @@ export interface RootRouteChildren {
AgentsSnapshotsRoute: typeof AgentsSnapshotsRoute AgentsSnapshotsRoute: typeof AgentsSnapshotsRoute
ReviewStylesRoute: typeof ReviewStylesRoute ReviewStylesRoute: typeof ReviewStylesRoute
ReviewRepositoriesOwnerRoute: typeof ReviewRepositoriesOwnerRoute ReviewRepositoriesOwnerRoute: typeof ReviewRepositoriesOwnerRoute
OwnerRepoPullNumberRoute: typeof OwnerRepoPullNumberRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@ -476,6 +489,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AgentsThreadIdPlanRouteImport preLoaderRoute: typeof AgentsThreadIdPlanRouteImport
parentRoute: typeof AgentsRoute 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': { '/agents/reviews/$owner/$repo/$number': {
id: '/agents/reviews/$owner/$repo/$number' id: '/agents/reviews/$owner/$repo/$number'
path: '/reviews/$owner/$repo/$number' path: '/reviews/$owner/$repo/$number'
@ -528,6 +548,7 @@ const rootRouteChildren: RootRouteChildren = {
AgentsSnapshotsRoute: AgentsSnapshotsRoute, AgentsSnapshotsRoute: AgentsSnapshotsRoute,
ReviewStylesRoute: ReviewStylesRoute, ReviewStylesRoute: ReviewStylesRoute,
ReviewRepositoriesOwnerRoute: ReviewRepositoriesOwnerRoute, ReviewRepositoriesOwnerRoute: ReviewRepositoriesOwnerRoute,
OwnerRepoPullNumberRoute: OwnerRepoPullNumberRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View file

@ -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<string | null>(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 (
<main className="flex min-h-svh items-center justify-center p-6">
<Skeleton className="h-52 w-full max-w-lg" />
</main>
)
}
if (!session.data) return <RequireLogin />
if (!Number.isFinite(prNumber)) {
return (
<ReviewLinkCard
title="Invalid pull request link"
description="Expected a GitHub-style pull request path like /owner/repo/pull/123."
owner={owner}
repo={repo}
number={number}
githubPrUrl={githubPrUrl}
/>
)
}
if (existingReview.data?.status === "running" || triggerReview.isSuccess) {
return (
<Navigate
to="/agents/reviews/$owner/$repo/$number"
params={{ owner, repo, number: String(prNumber) }}
replace
/>
)
}
if (triggerReview.isError) {
return (
<ReviewLinkCard
title="Could not start review"
description={triggerReview.error.message}
owner={owner}
repo={repo}
number={number}
githubPrUrl={githubPrUrl}
stableReviewPath={stableReviewPath}
onRetry={() => triggerReview.mutate()}
/>
)
}
const isCheckingExistingReview = existingReview.isLoading
return (
<ReviewLinkCard
title={
isCheckingExistingReview
? "Checking review status"
: "Starting Open SWE review"
}
description={
isCheckingExistingReview
? "This PR link was recognized. Open SWE is checking whether a review is already running."
: "Open SWE is starting a review and will redirect you to the stable review page."
}
owner={owner}
repo={repo}
number={number}
githubPrUrl={githubPrUrl}
stableReviewPath={stableReviewPath}
loading
/>
)
}
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 (
<main className="flex min-h-svh items-center justify-center bg-background p-6 text-foreground">
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<GitPullRequestIcon className="size-5 text-muted-foreground" />
{title}
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border border-border bg-muted/40 p-3 text-sm">
<div className="font-medium">
{owner}/{repo} #{number}
</div>
<a
href={githubPrUrl}
className="mt-1 inline-flex items-center gap-1 text-muted-foreground hover:text-foreground"
>
View on GitHub
<ArrowSquareOutIcon className="size-3.5" />
</a>
</div>
{loading && <Skeleton className="mt-4 h-2 w-full" />}
</CardContent>
<CardFooter className="flex flex-wrap gap-2">
{onRetry && (
<button
type="button"
className={buttonVariants()}
onClick={onRetry}
>
Try again
</button>
)}
{stableReviewPath && (
<Link
to="/agents/reviews/$owner/$repo/$number"
params={{ owner, repo, number }}
className={cn(buttonVariants({ variant: "outline" }))}
>
Open stable review page
</Link>
)}
<a
href={githubPrUrl}
className={cn(buttonVariants({ variant: "ghost" }))}
>
Open GitHub PR
</a>
</CardFooter>
</Card>
</main>
)
}