-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Publish AI code reviews as shareable pages #6881
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5a2aa42
323adb6
c3acf16
b05a747
62d5595
df31d00
447afd0
9396751
1f2efa9
29228cc
68e8357
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| import { Button } from "@superset/ui/button"; | ||
| import { Label } from "@superset/ui/label"; | ||
| import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; | ||
| import { | ||
| Select, | ||
| SelectContent, | ||
| SelectItem, | ||
| SelectTrigger, | ||
| SelectValue, | ||
| } from "@superset/ui/select"; | ||
| import { Separator } from "@superset/ui/separator"; | ||
| import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; | ||
| import { useState } from "react"; | ||
| import { | ||
| LuBuilding2, | ||
| LuCheck, | ||
| LuLink2, | ||
| LuLock, | ||
| LuShare2, | ||
| } from "react-icons/lu"; | ||
| import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; | ||
| import { cloudTrpc } from "renderer/lib/cloud-trpc"; | ||
|
|
||
| interface ReviewSharePopoverProps { | ||
| prUrl: string; | ||
| } | ||
|
|
||
| /** | ||
| * Surfaces the AI code review already published for this PR (via | ||
| * `reviews_publish` — an external, agent-driven action, not something this | ||
| * view can trigger itself: it has no findings to publish). Reviews reuse the | ||
| * generic Pages table, so visibility is changed through `page.setVisibility` | ||
| * the same way the Pages share popover does. | ||
| */ | ||
| export function ReviewSharePopover({ prUrl }: ReviewSharePopoverProps) { | ||
| const [open, setOpen] = useState(false); | ||
| const { copyToClipboard, copied } = useCopyToClipboard(); | ||
|
|
||
| const review = cloudTrpc.review.getForPullRequest.useQuery( | ||
| { prUrl }, | ||
| { enabled: Boolean(prUrl) }, | ||
| ); | ||
| const setVisibility = cloudTrpc.page.setVisibility.useMutation({ | ||
| onSuccess: () => void review.refetch(), | ||
| }); | ||
|
|
||
| const page = review.data; | ||
| const iconButton = ( | ||
| <Button | ||
| variant="ghost" | ||
| size="icon-sm" | ||
| disabled={!page} | ||
| aria-label={page ? "Share AI review" : "No AI review shared yet"} | ||
| > | ||
| <LuShare2 className="size-4" /> | ||
| </Button> | ||
| ); | ||
|
|
||
| if (!page) { | ||
| return ( | ||
| <Tooltip delayDuration={300}> | ||
| <TooltipTrigger asChild> | ||
| {/* A disabled button still lets a wrapping span pick up the | ||
| hover that opens the tooltip. */} | ||
| <span>{iconButton}</span> | ||
| </TooltipTrigger> | ||
| <TooltipContent side="bottom"> | ||
| {review.isLoading | ||
| ? "Checking for a shared review…" | ||
| : "No AI review has been shared for this PR yet"} | ||
| </TooltipContent> | ||
| </Tooltip> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <Popover open={open} onOpenChange={setOpen}> | ||
| <PopoverTrigger asChild>{iconButton}</PopoverTrigger> | ||
| <PopoverContent align="end" className="w-80 p-0"> | ||
| <div className="flex items-center justify-between gap-2 px-3 py-2.5"> | ||
| <span className="min-w-0 truncate font-medium text-sm"> | ||
| {page.title} | ||
| </span> | ||
| <Button | ||
| size="xs" | ||
| variant="ghost" | ||
| onClick={() => void copyToClipboard(page.url)} | ||
| > | ||
| {copied ? ( | ||
| <LuCheck className="size-3.5 text-primary" /> | ||
| ) : ( | ||
| <LuLink2 className="size-3.5" /> | ||
| )} | ||
| {copied ? "Copied" : "Copy link"} | ||
| </Button> | ||
| </div> | ||
| <Separator /> | ||
| <div className="space-y-2 px-3 py-2.5"> | ||
| <div className="space-y-0.5"> | ||
| <Label className="font-medium text-sm">General access</Label> | ||
| <p className="text-muted-foreground text-xs"> | ||
| Who can open this review from its link | ||
| </p> | ||
| </div> | ||
| <Select | ||
| value={page.visibility} | ||
| disabled={setVisibility.isPending} | ||
| onValueChange={(value) => | ||
| setVisibility.mutate({ | ||
| id: page.id, | ||
| visibility: value as "just_me" | "org", | ||
| }) | ||
| } | ||
| > | ||
| <SelectTrigger size="sm" className="w-full"> | ||
| <SelectValue /> | ||
| </SelectTrigger> | ||
| <SelectContent> | ||
| <SelectItem value="just_me"> | ||
| <LuLock className="size-3.5 text-muted-foreground" /> | ||
| Only you | ||
| </SelectItem> | ||
| <SelectItem value="org"> | ||
| <LuBuilding2 className="size-3.5 text-muted-foreground" /> | ||
| Anyone in your organization | ||
| </SelectItem> | ||
| </SelectContent> | ||
| </Select> | ||
| </div> | ||
| </PopoverContent> | ||
| </Popover> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { ReviewSharePopover } from "./ReviewSharePopover"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| "use client"; | ||
|
|
||
| import { parseGithubPullRequestUrl } from "@superset/shared/github-pr-url"; | ||
| import { renderReviewReportHtml } from "@superset/shared/review-report"; | ||
| import { Button } from "@superset/ui/button"; | ||
| import { Input } from "@superset/ui/input"; | ||
| import { useQuery } from "@tanstack/react-query"; | ||
| import { TRPCClientError } from "@trpc/client"; | ||
| import Link from "next/link"; | ||
| import { useRouter, useSearchParams } from "next/navigation"; | ||
| import { type FormEvent, useMemo, useState } from "react"; | ||
| import { useTRPC } from "@/trpc/react"; | ||
|
|
||
| function errorInfo(error: unknown): { message: string; unauthorized: boolean } { | ||
| if (error instanceof TRPCClientError) { | ||
| return { | ||
| message: error.message, | ||
| unauthorized: error.data?.code === "UNAUTHORIZED", | ||
| }; | ||
| } | ||
| return { | ||
| message: "Something went wrong loading this pull request.", | ||
| unauthorized: false, | ||
| }; | ||
| } | ||
|
|
||
| export function PrViewer() { | ||
| const router = useRouter(); | ||
| const searchParams = useSearchParams(); | ||
| const trpc = useTRPC(); | ||
|
|
||
| const urlParam = searchParams.get("url") ?? ""; | ||
| const [inputValue, setInputValue] = useState(urlParam); | ||
| const isValidUrl = parseGithubPullRequestUrl(urlParam) !== null; | ||
|
|
||
| const query = useQuery({ | ||
| ...trpc.githubPr.fetchByUrl.queryOptions({ prUrl: urlParam }), | ||
| enabled: isValidUrl, | ||
| retry: false, | ||
| }); | ||
|
|
||
| const html = useMemo(() => { | ||
| const pr = query.data; | ||
| if (!pr) return null; | ||
| return renderReviewReportHtml({ | ||
| title: pr.title, | ||
| repo: `${pr.owner}/${pr.repo}`, | ||
| prNumber: pr.number, | ||
| prUrl: pr.htmlUrl, | ||
| branch: pr.headBranch, | ||
| generatedAt: pr.updatedAt, | ||
| // Same precedence as the app's normalizePRState: draft wins. | ||
| prState: pr.isDraft ? "draft" : pr.merged ? "merged" : pr.state, | ||
| authorLogin: pr.authorLogin, | ||
| authorAvatarUrl: pr.authorAvatarUrl, | ||
| createdAt: pr.createdAt, | ||
| // "" (not undefined) so a body-less PR still renders as a plain PR | ||
| // view — "No description provided." — never the findings empty state. | ||
| description: pr.description ?? "", | ||
| checks: pr.checks, | ||
| diff: pr.diff, | ||
| comments: pr.comments.map((comment) => ({ | ||
| authorLogin: comment.authorLogin, | ||
| authorAvatarUrl: comment.authorAvatarUrl, | ||
| body: comment.body, | ||
| createdAt: comment.createdAt, | ||
| htmlUrl: comment.htmlUrl, | ||
| })), | ||
| }); | ||
| }, [query.data]); | ||
|
|
||
| function navigateTo(value: string) { | ||
| const trimmed = value.trim(); | ||
| const params = new URLSearchParams(searchParams); | ||
| if (trimmed) params.set("url", trimmed); | ||
| else params.delete("url"); | ||
| router.push(`/pr${params.size > 0 ? `?${params.toString()}` : ""}`); | ||
| } | ||
|
|
||
| function handleSubmit(event: FormEvent) { | ||
| event.preventDefault(); | ||
| navigateTo(inputValue); | ||
| } | ||
|
|
||
| const searchForm = ( | ||
| <form onSubmit={handleSubmit} className="flex w-full gap-2"> | ||
| <Input | ||
| value={inputValue} | ||
| onChange={(event) => setInputValue(event.target.value)} | ||
| placeholder="https://github.com/owner/repo/pull/123" | ||
| aria-label="GitHub pull request URL" | ||
| /> | ||
| <Button type="submit">View</Button> | ||
| </form> | ||
| ); | ||
|
|
||
| if (!urlParam || !isValidUrl) { | ||
| return ( | ||
| <div className="flex h-dvh flex-col items-center justify-center gap-4 px-4"> | ||
| <div className="w-full max-w-md space-y-3"> | ||
| <h1 className="text-center font-semibold text-xl"> | ||
| View a pull request | ||
| </h1> | ||
| <p className="text-center text-muted-foreground text-sm"> | ||
| Paste a GitHub pull request link to view its description and diff. | ||
| </p> | ||
| {searchForm} | ||
| {urlParam && !isValidUrl ? ( | ||
| <p className="text-center text-destructive text-sm"> | ||
| That doesn't look like a github.com pull request link. | ||
| </p> | ||
| ) : null} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (query.isPending) { | ||
| return ( | ||
| <div className="flex h-dvh items-center justify-center text-muted-foreground text-sm"> | ||
| Loading pull request… | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (query.isError) { | ||
| const { message, unauthorized } = errorInfo(query.error); | ||
| return ( | ||
| <div className="flex h-dvh flex-col items-center justify-center gap-3 px-4 text-center"> | ||
| <p className="max-w-md text-sm">{message}</p> | ||
| {unauthorized ? ( | ||
| <Button asChild> | ||
| <Link | ||
| href={`/sign-in?redirect=${encodeURIComponent(`/pr?url=${urlParam}`)}`} | ||
| > | ||
| Sign in | ||
| </Link> | ||
| </Button> | ||
| ) : ( | ||
| <Button variant="outline" onClick={() => navigateTo("")}> | ||
| Try another link | ||
| </Button> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <div className="flex h-dvh flex-col"> | ||
| <div className="flex shrink-0 items-center gap-2 border-b px-3 py-2"> | ||
| {searchForm} | ||
| </div> | ||
| <main className="min-h-0 flex-1"> | ||
| {html ? ( | ||
| <iframe | ||
| srcDoc={html} | ||
| title={query.data?.title ?? "Pull request"} | ||
| className="h-full w-full border-0" | ||
| // No allow-scripts/allow-same-origin: the diff and description | ||
| // are someone else's PR content, not ours. Tabs and collapsible | ||
| // sections are pure CSS/native <details>, so they still work. | ||
| // allow-popups(-to-escape-sandbox) only lets the target="_blank" | ||
| // links open real, unsandboxed new tabs. | ||
| sandbox="allow-popups allow-popups-to-escape-sandbox" | ||
|
Comment on lines
+155
to
+164
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
echo '--- renderer symbols ---'
ast-grep outline packages/shared/src/review-report.ts --match 'renderMarkdown' --view expanded
ast-grep outline packages/shared/src/review-report.ts --match 'escapeHtml' --view expanded
ast-grep outline packages/shared/src/review-report.ts --match 'renderReviewReportHtml' --view expanded
echo '--- renderer implementation and call sites ---'
rg -n -A45 -B12 'function (renderMarkdown|escapeHtml|renderReviewReportHtml)|renderMarkdown\\(' packages/shared/src/review-report.tsRepository: superset-sh/superset Length of output: 669 🏁 Script executed: #!/bin/bash
set -eu
echo '--- escaping helper ---'
sed -n '300,345p' packages/shared/src/review-report.ts
echo '--- markdown renderer ---'
sed -n '700,790p' packages/shared/src/review-report.ts
echo '--- renderer output around interpolated content ---'
sed -n '850,940p' packages/shared/src/review-report.tsRepository: superset-sh/superset Length of output: 9519 🏁 Script executed: #!/bin/bash
set -eu
echo '--- markdown security definitions ---'
rg -n -A12 -B8 'ALLOWED_HTML_TAGS|renderInline|raw HTML|<img|<iframe|<object|stylesheet|href=|src=' packages/shared/src/review-report.ts
echo '--- inline renderer ---'
sed -n '600,740p' packages/shared/src/review-report.tsRepository: superset-sh/superset Length of output: 17418 Sensitive Data Exposure (CWE-200): Exposure of Sensitive Information to an Unauthorized Actor Reachability: External · Exploitability: Trivial Block external resource loads from PR descriptions.
🤖 Prompt for AI Agents |
||
| /> | ||
| ) : null} | ||
| </main> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { PrViewer } from "./PrViewer"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import type { Metadata } from "next"; | ||
| import { Suspense } from "react"; | ||
| import { PrViewer } from "./components/PrViewer"; | ||
|
|
||
| export const metadata: Metadata = { | ||
| title: "View a pull request", | ||
| }; | ||
|
|
||
| export default function PrPage() { | ||
| return ( | ||
| <Suspense fallback={null}> | ||
| <PrViewer /> | ||
| </Suspense> | ||
| ); | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| CREATE TABLE "review_pages" ( | ||
| "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | ||
| "organization_id" uuid NOT NULL, | ||
| "github_pull_request_id" uuid NOT NULL, | ||
| "page_id" uuid NOT NULL, | ||
| "created_at" timestamp with time zone DEFAULT now() NOT NULL | ||
| ); | ||
| --> statement-breakpoint | ||
| ALTER TABLE "review_pages" ADD CONSTRAINT "review_pages_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | ||
| ALTER TABLE "review_pages" ADD CONSTRAINT "review_pages_github_pull_request_id_github_pull_requests_id_fk" FOREIGN KEY ("github_pull_request_id") REFERENCES "public"."github_pull_requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | ||
| ALTER TABLE "review_pages" ADD CONSTRAINT "review_pages_page_id_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | ||
| CREATE UNIQUE INDEX "review_pages_organization_id_pr_id_unique" ON "review_pages" USING btree ("organization_id","github_pull_request_id");--> statement-breakpoint | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Make the first PR-link operation atomic. Two concurrent first publishes can both find no link, then both create a page before they collide on this unique index. One request can fail or leave an unlinked page, depending on 🤖 Prompt for AI Agents |
||
| CREATE INDEX "review_pages_page_id_idx" ON "review_pages" USING btree ("page_id"); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| ALTER TABLE "review_pages" DROP CONSTRAINT "review_pages_github_pull_request_id_github_pull_requests_id_fk"; | ||
| --> statement-breakpoint | ||
| DROP INDEX "review_pages_organization_id_pr_id_unique";--> statement-breakpoint | ||
| ALTER TABLE "review_pages" ADD COLUMN "repo_owner" text NOT NULL;--> statement-breakpoint | ||
| ALTER TABLE "review_pages" ADD COLUMN "repo_name" text NOT NULL;--> statement-breakpoint | ||
| ALTER TABLE "review_pages" ADD COLUMN "pr_number" integer NOT NULL;--> statement-breakpoint | ||
| CREATE UNIQUE INDEX "review_pages_org_repo_pr_unique" ON "review_pages" USING btree ("organization_id","repo_owner","repo_name","pr_number");--> statement-breakpoint | ||
| ALTER TABLE "review_pages" DROP COLUMN "github_pull_request_id"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Synchronize
inputValuewhenurlParamchanges.useState(urlParam)runs only on the first mount. Browser back/forward navigation andnavigateTo("")can changeurlParamwhile the input still shows the previous URL. The user can submit that stale URL again. Reset the state whenurlParamchanges.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents