From 154d6be5d3f54418ef23ab209ef8530308e051f4 Mon Sep 17 00:00:00 2001 From: Maxwell <145994855+ping-maxwell@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:40:27 -0800 Subject: [PATCH] feat: CI status in PR (#33) CleanShot 2026-02-24 at 11 33
18@2x --------- Co-authored-by: Bereket Engida --- apps/web/next.config.ts | 3 + .../[owner]/[repo]/pulls/[number]/page.tsx | 10 +- .../repos/[owner]/[repo]/pulls/pr-actions.ts | 6 - apps/web/src/app/api/image-proxy/route.ts | 61 ---- apps/web/src/app/globals.css | 41 --- apps/web/src/components/layout/navbar.tsx | 2 +- .../components/pr/commit-activity-group.tsx | 26 +- .../web/src/components/pr/pr-checks-panel.tsx | 333 ++++++++++++++++++ .../web/src/components/pr/pr-conversation.tsx | 29 +- apps/web/src/components/pr/pr-diff-viewer.tsx | 22 +- apps/web/src/components/pr/pr-review-form.tsx | 10 +- .../src/components/pr/pr-reviews-panel.tsx | 11 +- .../shared/markdown-copy-handler.tsx | 38 -- .../components/shared/markdown-renderer.tsx | 16 - apps/web/src/components/theme-toggle.tsx | 27 +- apps/web/src/lib/github-utils.ts | 10 - apps/web/src/lib/github.ts | 55 +-- 17 files changed, 404 insertions(+), 296 deletions(-) delete mode 100644 apps/web/src/app/api/image-proxy/route.ts create mode 100644 apps/web/src/components/pr/pr-checks-panel.tsx diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index f96e30b..27fc746 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -30,6 +30,9 @@ const nextConfig: NextConfig = { }, }, images: { + ...(process.env.NODE_ENV === "development" && { + dangerouslyAllowLocalIP: true, + }), remotePatterns: [ { protocol: "https", diff --git a/apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/[number]/page.tsx b/apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/[number]/page.tsx index 69f5ab4..64ad483 100644 --- a/apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/[number]/page.tsx +++ b/apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/[number]/page.tsx @@ -29,6 +29,7 @@ import { PRCommentForm } from "@/components/pr/pr-comment-form"; import { PRReviewForm } from "@/components/pr/pr-review-form"; import { PRConflictResolver } from "@/components/pr/pr-conflict-resolver"; import { PRAuthorDossier } from "@/components/pr/pr-author-dossier"; +import { PRChecksPanel } from "@/components/pr/pr-checks-panel"; import { ChatPageActivator } from "@/components/shared/chat-page-activator"; import { TrackView } from "@/components/shared/track-view"; import { auth } from "@/lib/auth"; @@ -432,10 +433,6 @@ export default async function PRDetailPage({ pullNumber={ pr.number } - isOwnPR={ - pr.user?.login === - currentUser?.login - } participants={ participants } @@ -564,6 +561,11 @@ export default async function PRDetailPage({ owner={owner} repo={repo} pullNumber={pullNumber} + checkStatus={ + checkStatus && checkStatus.total > 0 + ? checkStatus + : undefined + } /> } diff --git a/apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/pr-actions.ts b/apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/pr-actions.ts index bdd38ed..a257f3b 100644 --- a/apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/pr-actions.ts +++ b/apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/pr-actions.ts @@ -388,9 +388,6 @@ export async function resolveReviewThread( }), }); const json = await response.json(); - if (!response.ok) { - return { error: json.message || `GitHub API error (${response.status})` }; - } if (json.errors?.length) { return { error: json.errors[0].message }; } @@ -427,9 +424,6 @@ export async function unresolveReviewThread( }), }); const json = await response.json(); - if (!response.ok) { - return { error: json.message || `GitHub API error (${response.status})` }; - } if (json.errors?.length) { return { error: json.errors[0].message }; } diff --git a/apps/web/src/app/api/image-proxy/route.ts b/apps/web/src/app/api/image-proxy/route.ts deleted file mode 100644 index fa8938b..0000000 --- a/apps/web/src/app/api/image-proxy/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getGitHubToken } from "@/lib/github"; - -const ALLOWED_HOSTS = new Set([ - "github.com", - "raw.githubusercontent.com", - "private-user-images.githubusercontent.com", - "github-production-user-asset-6210df.s3.amazonaws.com", -]); - -export async function GET(request: NextRequest) { - const url = request.nextUrl.searchParams.get("url"); - if (!url) { - return NextResponse.json({ error: "Missing url parameter" }, { status: 400 }); - } - - let parsed: URL; - try { - parsed = new URL(url); - } catch { - return NextResponse.json({ error: "Invalid URL" }, { status: 400 }); - } - - if (!ALLOWED_HOSTS.has(parsed.hostname)) { - return NextResponse.json({ error: "Host not allowed" }, { status: 403 }); - } - - const token = await getGitHubToken(); - if (!token) { - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - } - - try { - const res = await fetch(url, { - headers: { - Authorization: `token ${token}`, - Accept: "image/*,*/*", - }, - redirect: "follow", - }); - - if (!res.ok) { - return NextResponse.json( - { error: `Upstream error (${res.status})` }, - { status: res.status }, - ); - } - - const contentType = res.headers.get("content-type") || "application/octet-stream"; - const body = await res.arrayBuffer(); - - return new NextResponse(body, { - headers: { - "Content-Type": contentType, - "Cache-Control": "public, max-age=3600, immutable", - }, - }); - } catch { - return NextResponse.json({ error: "Failed to fetch image" }, { status: 502 }); - } -} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 9caede6..c6f76c5 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1362,44 +1362,3 @@ tr.diff-search-match-active > td { transform: translate(-1%, 2%); } } - -/* Image lightbox */ -.ghmd-lightbox { - position: fixed; - inset: 0; - z-index: 9999; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.85); - backdrop-filter: blur(4px); - cursor: zoom-out; - animation: ghmd-lightbox-in 0.2s ease-out; -} -.ghmd-lightbox-closing { - animation: ghmd-lightbox-out 0.15s ease-in forwards; -} -.ghmd-lightbox-img { - max-width: 90vw; - max-height: 90vh; - object-fit: contain; - border-radius: 6px; - box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5); - cursor: default; -} -@keyframes ghmd-lightbox-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} -@keyframes ghmd-lightbox-out { - from { - opacity: 1; - } - to { - opacity: 0; - } -} diff --git a/apps/web/src/components/layout/navbar.tsx b/apps/web/src/components/layout/navbar.tsx index 2ce15eb..c99071f 100644 --- a/apps/web/src/components/layout/navbar.tsx +++ b/apps/web/src/components/layout/navbar.tsx @@ -234,7 +234,7 @@ export function AppNavbar({ session, notifications }: AppNavbarProps) { Search repos - / + K diff --git a/apps/web/src/components/pr/commit-activity-group.tsx b/apps/web/src/components/pr/commit-activity-group.tsx index 2108cb4..cdbed37 100644 --- a/apps/web/src/components/pr/commit-activity-group.tsx +++ b/apps/web/src/components/pr/commit-activity-group.tsx @@ -30,22 +30,24 @@ export function CommitActivityGroup({ count, avatars, children }: CommitActivity expanded && "rotate-90", )} /> -
- {avatars.slice(0, 3).map((url, i) => ( - - ))} -
{count} {count === 1 ? "commit" : "commits"} + {avatars.length > 0 && ( +
+ {avatars.slice(0, 3).map((url, i) => ( + + ))} +
+ )} {expanded &&
{children}
} diff --git a/apps/web/src/components/pr/pr-checks-panel.tsx b/apps/web/src/components/pr/pr-checks-panel.tsx new file mode 100644 index 0000000..59b0c83 --- /dev/null +++ b/apps/web/src/components/pr/pr-checks-panel.tsx @@ -0,0 +1,333 @@ +"use client"; + +import { useState, useMemo } from "react"; +import Link from "next/link"; +import { + CheckCircle2, + XCircle, + Clock, + ExternalLink, + ArrowRight, + MinusCircle, + SkipForward, + ChevronRight, + Loader2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { CheckStatus, CheckRun } from "@/lib/github"; + +function CheckIcon({ state, className }: { state: CheckRun["state"]; className?: string }) { + switch (state) { + case "success": + return ; + case "failure": + case "error": + return ; + case "pending": + return ; + case "neutral": + return ( + + ); + case "skipped": + return ( + + ); + } +} + +interface ProviderInfo { + name: string; + icon: React.ReactNode; +} + +const VERCEL_SVG = ( + + + +); + +const GITHUB_SVG = ( + + + +); + +const NETLIFY_SVG = ( + + + +); + +const CIRCLECI_SVG = ( + + + +); + +function getProvider(check: CheckRun): ProviderInfo { + const name = check.name.toLowerCase(); + const url = (check.url ?? "").toLowerCase(); + + if (name.startsWith("vercel") || url.includes("vercel.com")) { + return { name: "Vercel", icon: VERCEL_SVG }; + } + if (name.startsWith("netlify") || url.includes("netlify.com")) { + return { name: "Netlify", icon: NETLIFY_SVG }; + } + if (name.includes("circleci") || url.includes("circleci.com")) { + return { name: "CircleCI", icon: CIRCLECI_SVG }; + } + if (check.runId != null) { + return { name: "GitHub Actions", icon: GITHUB_SVG }; + } + + return { name: "CI", icon: GITHUB_SVG }; +} + +interface GroupedChecks { + provider: ProviderInfo; + checks: CheckRun[]; + failed: number; + passed: number; + pending: number; + skipped: number; +} + +function groupChecksByProvider(checks: CheckRun[]): GroupedChecks[] { + const map = new Map(); + + for (const check of checks) { + const provider = getProvider(check); + let group = map.get(provider.name); + if (!group) { + group = { + provider, + checks: [], + failed: 0, + passed: 0, + pending: 0, + skipped: 0, + }; + map.set(provider.name, group); + } + group.checks.push(check); + if (check.state === "failure" || check.state === "error") group.failed++; + else if (check.state === "success") group.passed++; + else if (check.state === "pending") group.pending++; + else if (check.state === "skipped" || check.state === "neutral") group.skipped++; + } + + return [...map.values()].sort((a, b) => { + if (a.failed !== b.failed) return b.failed - a.failed; + if (a.pending !== b.pending) return b.pending - a.pending; + return a.provider.name.localeCompare(b.provider.name); + }); +} + +function ProviderSection({ + group, + owner, + repo, + defaultOpen, +}: { + group: GroupedChecks; + owner: string; + repo: string; + defaultOpen: boolean; +}) { + const [expanded, setExpanded] = useState(defaultOpen); + + const sortedChecks = useMemo(() => { + return [...group.checks].sort((a, b) => { + const order = { + failure: 0, + error: 0, + pending: 1, + success: 2, + neutral: 3, + skipped: 3, + }; + return (order[a.state] ?? 4) - (order[b.state] ?? 4); + }); + }, [group.checks]); + + return ( +
+ + + {expanded && ( +
+ {sortedChecks.map((check, i) => ( +
+ + + {check.name} + + {check.runId && owner && repo ? ( + + + + ) : check.url ? ( + + + + ) : null} +
+ ))} +
+ )} +
+ ); +} + +export function PRChecksPanel({ + checkStatus, + owner, + repo, +}: { + checkStatus: CheckStatus; + owner: string; + repo: string; +}) { + const [expanded, setExpanded] = useState(true); + + const grouped = useMemo( + () => groupChecksByProvider(checkStatus.checks), + [checkStatus.checks], + ); + + const statusColor = + checkStatus.state === "success" + ? "text-success" + : checkStatus.state === "pending" + ? "text-warning" + : "text-destructive"; + + const borderColor = + checkStatus.state === "success" + ? "border-success/20" + : checkStatus.state === "pending" + ? "border-warning/20" + : "border-destructive/30"; + + const bgColor = + checkStatus.state === "success" + ? "bg-success/[0.03]" + : checkStatus.state === "pending" + ? "bg-warning/[0.03]" + : "bg-destructive/[0.03]"; + + const statusText = + checkStatus.state === "success" + ? "All checks have passed" + : checkStatus.state === "pending" + ? `${checkStatus.pending} check${checkStatus.pending !== 1 ? "s" : ""} in progress` + : `${checkStatus.failure} check${checkStatus.failure !== 1 ? "s" : ""} failed`; + + return ( +
+ + + {expanded && ( +
+ {grouped.map((group) => ( + 0 || + group.pending > 0 + } + /> + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/pr/pr-conversation.tsx b/apps/web/src/components/pr/pr-conversation.tsx index 303ec9d..fe19dd5 100644 --- a/apps/web/src/components/pr/pr-conversation.tsx +++ b/apps/web/src/components/pr/pr-conversation.tsx @@ -18,6 +18,8 @@ import { CommitActivityGroup } from "./commit-activity-group"; import { ReactionDisplay, type Reactions } from "@/components/shared/reaction-display"; import { CollapsibleDescription } from "./collapsible-description"; import { ChatMessageWrapper } from "./chat-message-wrapper"; +import { PRChecksPanel } from "./pr-checks-panel"; +import type { CheckStatus } from "@/lib/github"; interface BaseUser { login: string; @@ -152,11 +154,13 @@ export async function PRConversation({ owner, repo, pullNumber, + checkStatus, }: { entries: TimelineEntry[]; owner: string; repo: string; pullNumber: number; + checkStatus?: CheckStatus; }) { const grouped = groupEntries(entries); @@ -288,13 +292,28 @@ export async function PRConversation({ /> ); } + if (entry.type === "description") { + return ( +
+ + {checkStatus && ( + + )} +
+ ); + } return ( { if (!owner || !repo || !pullNumber) return; startTransition(async () => { - const res = resolve - ? await resolveReviewThread(threadId, owner, repo, pullNumber) - : await unresolveReviewThread(threadId, owner, repo, pullNumber); - if (!res.error) { - emit({ - type: resolve ? "pr:thread-resolved" : "pr:thread-unresolved", - owner, - repo, - number: pullNumber, - }); - router.refresh(); + if (resolve) { + await resolveReviewThread(threadId, owner, repo, pullNumber); + } else { + await unresolveReviewThread(threadId, owner, repo, pullNumber); } + emit({ + type: resolve ? "pr:thread-resolved" : "pr:thread-unresolved", + owner, + repo, + number: pullNumber, + }); + router.refresh(); }); }; diff --git a/apps/web/src/components/pr/pr-review-form.tsx b/apps/web/src/components/pr/pr-review-form.tsx index 925ce8c..1dd822d 100644 --- a/apps/web/src/components/pr/pr-review-form.tsx +++ b/apps/web/src/components/pr/pr-review-form.tsx @@ -16,7 +16,6 @@ interface PRReviewFormProps { owner: string; repo: string; pullNumber: number; - isOwnPR?: boolean; participants?: Array<{ login: string; avatar_url: string }>; } @@ -50,7 +49,7 @@ const reviewOptions: { }, ]; -export function PRReviewForm({ owner, repo, pullNumber, isOwnPR, participants }: PRReviewFormProps) { +export function PRReviewForm({ owner, repo, pullNumber, participants }: PRReviewFormProps) { const router = useRouter(); const { emit } = useMutationEvents(); const [open, setOpen] = useState(false); @@ -149,10 +148,9 @@ export function PRReviewForm({ owner, repo, pullNumber, isOwnPR, participants }: }) => { const isSelected = selected === key; const isDisabled = - (isOwnPR && key !== "COMMENT") || - (key === "REQUEST_CHANGES" && - !body.trim() && - !isSelected); + key === "REQUEST_CHANGES" && + !body.trim() && + !isSelected; return (