feat: CI status in PR (#33)

<img width="1658" height="1852" alt="CleanShot 2026-02-24 at 11 33
18@2x"
src="https://github.com/user-attachments/assets/55cd4db5-7063-458d-806f-1c853dd51f50"
/>

---------

Co-authored-by: Bereket Engida <bekacru@gmail.com>
This commit is contained in:
Maxwell
2026-02-24 14:40:27 -08:00
committed by GitHub
co-authored by Bereket Engida
parent a24def37dc
commit 154d6be5d3
17 changed files with 404 additions and 296 deletions
+3
View File
@@ -30,6 +30,9 @@ const nextConfig: NextConfig = {
},
},
images: {
...(process.env.NODE_ENV === "development" && {
dangerouslyAllowLocalIP: true,
}),
remotePatterns: [
{
protocol: "https",
@@ -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
}
/>
</>
}
@@ -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 };
}
-61
View File
@@ -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 });
}
}
-41
View File
@@ -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;
}
}
+1 -1
View File
@@ -234,7 +234,7 @@ export function AppNavbar({ session, notifications }: AppNavbarProps) {
<Search className="w-3.5 h-3.5" />
Search repos
<DropdownMenuShortcut className="flex items-center gap-0.5 text-[10px] font-mono">
<Command className="size-[5px]" />/
<Command className="w-2.5 h-2.5" />K
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
@@ -30,22 +30,24 @@ export function CommitActivityGroup({ count, avatars, children }: CommitActivity
expanded && "rotate-90",
)}
/>
<div className="flex items-center -space-x-1.5">
{avatars.slice(0, 3).map((url, i) => (
<Image
key={i}
src={url}
alt=""
width={16}
height={16}
className="rounded-full shrink-0 ring-1 ring-background"
/>
))}
</div>
<GitCommitHorizontal className="w-3.5 h-3.5 text-muted-foreground/30 shrink-0" />
<span className="text-[11px] text-muted-foreground/50">
{count} {count === 1 ? "commit" : "commits"}
</span>
{avatars.length > 0 && (
<div className="flex items-center -space-x-1.5 ml-auto">
{avatars.slice(0, 3).map((url, i) => (
<Image
key={i}
src={url}
alt=""
width={16}
height={16}
className="rounded-full shrink-0 ring-1 ring-background"
/>
))}
</div>
)}
</button>
{expanded && <div className="p-2">{children}</div>}
@@ -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 <CheckCircle2 className={cn("text-success", className)} />;
case "failure":
case "error":
return <XCircle className={cn("text-destructive", className)} />;
case "pending":
return <Clock className={cn("text-warning", className)} />;
case "neutral":
return (
<MinusCircle
className={cn("text-muted-foreground/60", className)}
/>
);
case "skipped":
return (
<SkipForward
className={cn("text-muted-foreground/40", className)}
/>
);
}
}
interface ProviderInfo {
name: string;
icon: React.ReactNode;
}
const VERCEL_SVG = (
<svg viewBox="0 0 76 65" fill="currentColor" className="w-3 h-3">
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" />
</svg>
);
const GITHUB_SVG = (
<svg viewBox="0 0 16 16" fill="currentColor" className="w-3 h-3">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
);
const NETLIFY_SVG = (
<svg viewBox="0 0 256 256" fill="currentColor" className="w-3 h-3">
<path d="M170.3 132.5h-19.2l34.5-34.5a86 86 0 0 1 3 11.3l-18.3 23.2zm-48.3 24h47.4l-24.7 24.7c-5.3-1.2-11-4.5-15.2-8.8-3.6-3.7-6.1-8.7-7.5-15.9zm79.6-24h-14l18-22.8a87 87 0 0 1 5.6 22.8h-9.6zm-99.8-6.4 14.2-14.2c4 2 7 5.7 8.8 10.4l-23 3.8zm70.5-70.5L128 100l-10.6-3.6a38 38 0 0 0-5-10l60-60a85 85 0 0 1-.1 29.2z" />
</svg>
);
const CIRCLECI_SVG = (
<svg viewBox="0 0 24 24" fill="currentColor" className="w-3 h-3">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-14c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z" />
</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<string, GroupedChecks>();
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 (
<div className="border-b border-border/40 last:border-b-0">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-muted/30 transition-colors cursor-pointer"
>
<ChevronRight
className={cn(
"w-3 h-3 text-muted-foreground/50 transition-transform duration-150 shrink-0",
expanded && "rotate-90",
)}
/>
<span className="text-muted-foreground/70 shrink-0">
{group.provider.icon}
</span>
<span className="text-xs font-medium text-foreground/90 flex-1 text-left truncate">
{group.provider.name}
</span>
{group.failed > 0 && (
<span className="text-[10px] font-mono text-destructive tabular-nums shrink-0">
{group.failed} failed
</span>
)}
{group.failed === 0 && group.pending > 0 && (
<span className="text-[10px] font-mono text-warning tabular-nums shrink-0 flex items-center gap-1">
<Loader2 className="w-2.5 h-2.5 animate-spin" />
{group.pending} running
</span>
)}
{group.failed === 0 && group.pending === 0 && (
<CheckCircle2 className="w-3 h-3 text-success shrink-0" />
)}
<span className="text-[10px] font-mono text-muted-foreground/50 tabular-nums shrink-0 ml-1">
{group.checks.length}
</span>
</button>
{expanded && (
<div className="pb-1">
{sortedChecks.map((check, i) => (
<div
key={`${check.name}-${i}`}
className="flex items-center gap-2 pl-8 pr-3 py-1 hover:bg-muted/20 transition-colors group/check"
>
<CheckIcon
state={check.state}
className="w-3 h-3 shrink-0"
/>
<span className="text-[11px] font-mono truncate flex-1 text-foreground/80">
{check.name}
</span>
{check.runId && owner && repo ? (
<Link
href={`/${owner}/${repo}/actions/${check.runId}`}
className="shrink-0 text-muted-foreground/40 hover:text-foreground transition-colors opacity-0 group-hover/check:opacity-100"
>
<ArrowRight className="w-3 h-3" />
</Link>
) : check.url ? (
<a
href={check.url}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-muted-foreground/40 hover:text-foreground transition-colors opacity-0 group-hover/check:opacity-100"
>
<ExternalLink className="w-3 h-3" />
</a>
) : null}
</div>
))}
</div>
)}
</div>
);
}
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 (
<div
className={cn(
"rounded-lg border border-dashed overflow-hidden",
borderColor,
bgColor,
)}
>
<button
onClick={() => setExpanded(!expanded)}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 hover:bg-muted/30 transition-colors cursor-pointer",
expanded && "border-b border-dashed border-border/40",
)}
>
<ChevronRight
className={cn(
"w-3 h-3 text-muted-foreground/40 transition-transform duration-150 shrink-0",
expanded && "rotate-90",
)}
/>
<div className={cn("shrink-0", statusColor)}>
{checkStatus.state === "pending" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : checkStatus.state === "success" ? (
<CheckCircle2 className="w-3.5 h-3.5" />
) : (
<XCircle className="w-3.5 h-3.5" />
)}
</div>
<span className={cn("text-[11px]", statusColor)}>{statusText}</span>
<span className="text-[11px] text-muted-foreground/50 font-mono">
{checkStatus.success}/{checkStatus.total}
</span>
</button>
{expanded && (
<div>
{grouped.map((group) => (
<ProviderSection
key={group.provider.name}
group={group}
owner={owner}
repo={repo}
defaultOpen={
group.failed > 0 ||
group.pending > 0
}
/>
))}
</div>
)}
</div>
);
}
+24 -5
View File
@@ -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 (
<div key={entry.id} className="space-y-3">
<ChatMessage
entry={entry}
owner={owner}
repo={repo}
pullNumber={pullNumber}
/>
{checkStatus && (
<PRChecksPanel
checkStatus={checkStatus}
owner={owner}
repo={repo}
/>
)}
</div>
);
}
return (
<ChatMessage
key={
entry.type === "description"
? entry.id
: `comment-${entry.id}`
}
key={`comment-${entry.id}`}
entry={entry}
owner={owner}
repo={repo}
+11 -11
View File
@@ -4816,18 +4816,18 @@ function SidebarReviews({
const handleResolve = (threadId: string, resolve: boolean) => {
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();
});
};
@@ -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 (
<button
@@ -1,7 +1,6 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import {
@@ -315,18 +314,16 @@ function ThreadCard({
repo: string;
pullNumber: number;
}) {
const router = useRouter();
const [expanded, setExpanded] = useState(false);
const [isPending, startTransition] = useTransition();
const handleToggleResolve = (e: React.MouseEvent) => {
e.stopPropagation();
startTransition(async () => {
const res = thread.isResolved
? await unresolveReviewThread(thread.id, owner, repo, pullNumber)
: await resolveReviewThread(thread.id, owner, repo, pullNumber);
if (!res.error) {
router.refresh();
if (thread.isResolved) {
await unresolveReviewThread(thread.id, owner, repo, pullNumber);
} else {
await resolveReviewThread(thread.id, owner, repo, pullNumber);
}
});
};
@@ -19,31 +19,6 @@ function flashCheck(btn: HTMLButtonElement) {
}, 1500);
}
/** Create and show a fullscreen lightbox overlay for the given image src */
function openLightbox(src: string, alt: string) {
const overlay = document.createElement("div");
overlay.className = "ghmd-lightbox";
overlay.innerHTML = `<img src="${src}" alt="${alt}" class="ghmd-lightbox-img" />`;
function close() {
overlay.classList.add("ghmd-lightbox-closing");
overlay.addEventListener("animationend", () => overlay.remove(), { once: true });
document.removeEventListener("keydown", handleKey);
}
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
overlay.addEventListener("click", (e) => {
// Close when clicking the backdrop, not the image itself
if ((e.target as HTMLElement) === overlay) close();
});
document.addEventListener("keydown", handleKey);
document.body.appendChild(overlay);
}
export function MarkdownCopyHandler({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
@@ -60,22 +35,9 @@ export function MarkdownCopyHandler({ children }: { children: ReactNode }) {
// Inject copy buttons on every <pre> that doesn't already have one
injectCopyButtons(container);
// Make images in markdown look clickable
const images = container.querySelectorAll<HTMLImageElement>(".ghmd img");
for (const img of images) {
img.style.cursor = "zoom-in";
}
function handleClick(e: MouseEvent) {
const target = e.target as HTMLElement;
// Image lightbox
if (target.tagName === "IMG" && target.closest(".ghmd")) {
const img = target as HTMLImageElement;
openLightbox(img.src, img.alt || "");
return;
}
// Package-manager install copy
const pkgBtn = target.closest<HTMLButtonElement>(".ghmd-pkg-copy[data-copy]");
if (pkgBtn) {
@@ -77,19 +77,6 @@ function resolveUrls(html: string, ctx: RepoContext): string {
return html;
}
/**
* Rewrite github.com/user-attachments image/video URLs to go through our proxy.
* These URLs require GitHub auth cookies to resolve the S3 redirect.
*/
function proxyGitHubAssets(html: string): string {
return html.replace(
/((?:src|poster|srcset)=")([^"]*github\.com\/user-attachments\/assets\/[^"]+)(")/gi,
(_match, before, url, after) => {
return `${before}/api/image-proxy?url=${encodeURIComponent(url)}${after}`;
},
);
}
// Convert GitHub alert syntax: > [!NOTE] / [!TIP] / [!IMPORTANT] / [!WARNING] / [!CAUTION]
function processAlerts(html: string): string {
const alertTypes: Record<string, { icon: string; className: string; label: string }> = {
@@ -385,9 +372,6 @@ export async function renderMarkdownToHtml(
html = resolveUrls(html, repoContext);
}
// Proxy GitHub user-attachment images/videos through our API to handle auth redirects
html = proxyGitHubAssets(html);
// Convert github.com links to internal app paths
html = html.replace(/<a\s+href="(https:\/\/github\.com\/[^"]+)"/gi, (_match, href) => {
const internal = toInternalUrl(href);
+3 -24
View File
@@ -1,29 +1,8 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useColorTheme } from "@/components/theme/theme-provider";
function SunIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" className={className}>
<path
fill="currentColor"
d="M8 1a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 1m2.5 7a2.5 2.5 0 1 1-5 0a2.5 2.5 0 0 1 5 0m2.45-3.89a.75.75 0 1 0-1.06-1.06l-1.062 1.06a.75.75 0 0 0 1.061 1.062zM15 8a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 15 8m-3.11 4.95a.75.75 0 0 0 1.06-1.06l-1.06-1.062a.75.75 0 0 0-1.062 1.061zM8 12a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 12m-2.828-.11a.75.75 0 0 0-1.061-1.062L3.05 11.89a.75.75 0 1 0 1.06 1.06zM4 8a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 4 8m.11-2.828A.75.75 0 0 0 5.173 4.11L4.11 3.05a.75.75 0 1 0-1.06 1.06z"
/>
</svg>
);
}
function MoonIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" className={className}>
<path
fill="currentColor"
d="M14.438 10.148c.19-.425-.321-.787-.748-.601A5.5 5.5 0 0 1 6.453 2.31c.186-.427-.176-.938-.6-.748a6.501 6.501 0 1 0 8.585 8.586"
/>
</svg>
);
}
export function ThemeToggle() {
const { mode, toggleMode } = useColorTheme();
const isDark = mode === "dark";
@@ -39,9 +18,9 @@ export function ThemeToggle() {
style={{ left: isDark ? "2px" : "16px" }}
>
{isDark ? (
<MoonIcon className="size-2 text-background" />
<Moon className="size-2 text-background" />
) : (
<SunIcon className="size-2 text-background" />
<Sun className="size-2 text-background" />
)}
</span>
<span className="sr-only">Toggle theme</span>
-10
View File
@@ -156,13 +156,6 @@ const GITHUB_NON_USER_PATHS = new Set([
"watching",
]);
/** GitHub top-level paths that look like owner/repo but aren't real repositories */
const GITHUB_NON_REPO_OWNERS = new Set([
"user-attachments",
"assets",
"github",
]);
type ParsedGitHubUrl =
| {
owner: string;
@@ -193,9 +186,6 @@ export function parseGitHubUrl(htmlUrl: string): ParsedGitHubUrl | null {
const [owner, repo, ...rest] = parts;
// Skip GitHub system paths that are not real repos (e.g. user-attachments/assets)
if (GITHUB_NON_REPO_OWNERS.has(owner)) return null;
if (rest.length === 0) return { owner, repo, type: "repo" };
if (rest[0] === "pull" && rest[1])
return { owner, repo, type: "pull", number: parseInt(rest[1], 10) };
+1 -54
View File
@@ -2339,13 +2339,6 @@ export interface PRBundleData {
};
author: { login: string; avatar_url: string } | null;
}[];
stateEvents: {
id: string;
event: "closed" | "reopened" | "merged" | "ready_for_review" | "convert_to_draft";
created_at: string;
actor: { login: string; avatar_url: string } | null;
merge_ref_name?: string;
}[];
}
const PR_BUNDLE_QUERY = `
@@ -2447,32 +2440,6 @@ const PR_BUNDLE_QUERY = `
resourcePath
}
}
timelineItems(first: 100, itemTypes: [CLOSED_EVENT, REOPENED_EVENT, MERGED_EVENT, READY_FOR_REVIEW_EVENT, CONVERT_TO_DRAFT_EVENT]) {
nodes {
__typename
... on ClosedEvent {
createdAt
actor { login avatarUrl }
}
... on ReopenedEvent {
createdAt
actor { login avatarUrl }
}
... on MergedEvent {
createdAt
actor { login avatarUrl }
mergeRefName
}
... on ReadyForReviewEvent {
createdAt
actor { login avatarUrl }
}
... on ConvertToDraftEvent {
createdAt
actor { login avatarUrl }
}
}
}
}
}
}
@@ -2640,29 +2607,9 @@ function transformGraphQLPRBundle(node: Record<string, any>): PRBundleData {
author: null,
};
});
const eventTypeMap: Record<string, PRBundleData["stateEvents"][0]["event"]> = {
ClosedEvent: "closed",
ReopenedEvent: "reopened",
MergedEvent: "merged",
ReadyForReviewEvent: "ready_for_review",
ConvertToDraftEvent: "convert_to_draft",
};
const stateEvents: PRBundleData["stateEvents"] = (node.timelineItems?.nodes ?? [])
.filter((e: Record<string, any>) => e?.__typename && eventTypeMap[e.__typename])
.map((e: Record<string, any>, index: number) => ({
id: `${e.__typename}-${e.createdAt}-${index}`,
event: eventTypeMap[e.__typename],
created_at: e.createdAt,
actor: e.actor
? { login: e.actor.login, avatar_url: e.actor.avatarUrl }
: null,
merge_ref_name: e.mergeRefName ?? undefined,
}));
/* eslint-enable @typescript-eslint/no-explicit-any */
return { pr, issueComments, reviewComments, reviews, reviewThreads, commits, stateEvents };
return { pr, issueComments, reviewComments, reviews, reviewThreads, commits };
}
async function fetchPRBundleFromGitHub(