mirror of
https://github.com/better-auth/better-hub
synced 2026-08-09 12:26:04 +00:00
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:
co-authored by
Bereket Engida
parent
a24def37dc
commit
154d6be5d3
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user