feat: PR status (#31)

<img width="1528" height="1792" alt="CleanShot 2026-02-24 at 10 13
21@2x"
src="https://github.com/user-attachments/assets/200cfca4-9174-4136-aab9-bb9f20412db3"
/>
This commit is contained in:
Maxwell
2026-02-24 14:32:05 -08:00
committed by GitHub
parent f85cf177fc
commit d491f136f4
3 changed files with 219 additions and 8 deletions
@@ -22,6 +22,7 @@ import {
type TimelineEntry,
type ReviewCommentEntry,
type CommitEntry,
type StateChangeEntry,
} from "@/components/pr/pr-conversation";
import { PRMergePanel } from "@/components/pr/pr-merge-panel";
import { PRCommentForm } from "@/components/pr/pr-comment-form";
@@ -104,6 +105,7 @@ export default async function PRDetailPage({
reviews,
reviewThreads: threads,
commits,
stateEvents,
} = bundle;
const comments = { issueComments, reviewComments };
@@ -294,6 +296,18 @@ export default async function PRDetailPage({
timeline.push(entry);
}
for (const s of stateEvents) {
const entry: StateChangeEntry = {
type: "state_change",
id: s.id,
event: s.event,
user: s.actor,
created_at: s.created_at,
merge_ref_name: s.merge_ref_name,
};
timeline.push(entry);
}
timeline.sort((a, b) => {
if (a.type === "description") return -1;
if (b.type === "description") return 1;
+151 -7
View File
@@ -1,6 +1,13 @@
import Link from "next/link";
import Image from "next/image";
import { GitCommitHorizontal } from "lucide-react";
import {
GitCommitHorizontal,
CircleDot,
CircleX,
CircleDashed,
GitMerge,
FileCheck,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { TimeAgo } from "@/components/ui/time-ago";
import { parseCoAuthors, getInitials } from "@/lib/commit-utils";
@@ -68,12 +75,27 @@ export interface CommitEntry {
created_at: string;
}
export type TimelineEntry = DescriptionEntry | CommentEntry | ReviewEntry | CommitEntry;
export interface StateChangeEntry {
type: "state_change";
id: string;
event: "closed" | "reopened" | "merged" | "ready_for_review" | "convert_to_draft";
user: BaseUser | null;
created_at: string;
merge_ref_name?: string;
}
export type TimelineEntry =
| DescriptionEntry
| CommentEntry
| ReviewEntry
| CommitEntry
| StateChangeEntry;
function isBot(entry: TimelineEntry): boolean {
if (!entry.user) return false;
if (entry.type === "description") return false;
if (entry.type === "commit") return false;
if (entry.type === "state_change") return false;
return (
entry.user.type === "Bot" ||
entry.user.login.endsWith("[bot]") ||
@@ -191,6 +213,19 @@ export async function PRConversation({
/>
);
}
if (
entry.type ===
"state_change"
) {
return (
<StateChangeEvent
key={`state-${entry.id}`}
entry={
entry
}
/>
);
}
return (
<ChatMessage
key={
@@ -245,6 +280,14 @@ export async function PRConversation({
/>
);
}
if (entry.type === "state_change") {
return (
<StateChangeEvent
key={`state-${entry.id}`}
entry={entry}
/>
);
}
return (
<ChatMessage
key={
@@ -458,10 +501,19 @@ function CommitGroup({ commits }: { commits: CommitEntry[] }) {
<GitCommitHorizontal className="w-3.5 h-3.5 text-muted-foreground/30 shrink-0" />
<div className="flex items-center -space-x-1 shrink-0">
{commit.user ? (
<Link href={`/users/${commit.user.login}`} className="relative z-10">
<Link
href={`/users/${commit.user.login}`}
className="relative z-10"
>
<Image
src={commit.user.avatar_url}
alt={commit.user.login}
src={
commit.user
.avatar_url
}
alt={
commit.user
.login
}
width={16}
height={16}
className="rounded-full border border-background"
@@ -474,11 +526,17 @@ function CommitGroup({ commits }: { commits: CommitEntry[] }) {
<div
key={ca.email}
className="rounded-full bg-muted border border-background flex items-center justify-center shrink-0 relative"
style={{ width: 16, height: 16, zIndex: 9 - ci }}
style={{
width: 16,
height: 16,
zIndex: 9 - ci,
}}
title={`${ca.name} <${ca.email}>`}
>
<span className="text-[7px] font-medium text-muted-foreground leading-none">
{getInitials(ca.name)}
{getInitials(
ca.name,
)}
</span>
</div>
))}
@@ -506,3 +564,89 @@ function CommitGroup({ commits }: { commits: CommitEntry[] }) {
</CommitActivityGroup>
);
}
function StateChangeEvent({ entry }: { entry: StateChangeEntry }) {
const eventConfig = {
closed: {
icon: CircleX,
label: "closed this",
color: "text-red-500",
bgColor: "bg-red-500/10",
borderColor: "border-red-500/20",
},
reopened: {
icon: CircleDot,
label: "reopened this",
color: "text-green-500",
bgColor: "bg-green-500/10",
borderColor: "border-green-500/20",
},
merged: {
icon: GitMerge,
label: entry.merge_ref_name
? `merged into ${entry.merge_ref_name}`
: "merged this",
color: "text-purple-500",
bgColor: "bg-purple-500/10",
borderColor: "border-purple-500/20",
},
ready_for_review: {
icon: FileCheck,
label: "marked this as ready for review",
color: "text-green-500",
bgColor: "bg-green-500/10",
borderColor: "border-green-500/20",
},
convert_to_draft: {
icon: CircleDashed,
label: "converted this to draft",
color: "text-muted-foreground",
bgColor: "bg-muted/50",
borderColor: "border-border/60",
},
};
const config = eventConfig[entry.event];
const Icon = config.icon;
return (
<div
className={cn(
"flex items-center gap-2.5 px-3 py-2 rounded-lg border",
config.bgColor,
config.borderColor,
)}
>
<Icon className={cn("w-4 h-4 shrink-0", config.color)} />
<div className="flex items-center gap-2 min-w-0 flex-1">
{entry.user ? (
<Link
href={`/users/${entry.user.login}`}
className="flex items-center gap-1.5 hover:opacity-80 transition-opacity shrink-0"
>
<Image
src={entry.user.avatar_url}
alt={entry.user.login}
width={16}
height={16}
className="rounded-full"
/>
<span className="text-xs font-medium text-foreground/80">
{entry.user.login}
</span>
</Link>
) : (
<span className="text-xs font-medium text-foreground/80">
Someone
</span>
)}
<span className="text-xs text-muted-foreground">
{config.label}
</span>
</div>
<span className="text-[10px] text-muted-foreground/40 shrink-0">
<TimeAgo date={entry.created_at} />
</span>
</div>
);
}
+54 -1
View File
@@ -2339,6 +2339,13 @@ 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 = `
@@ -2440,6 +2447,32 @@ 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 }
}
}
}
}
}
}
@@ -2607,9 +2640,29 @@ 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 };
return { pr, issueComments, reviewComments, reviews, reviewThreads, commits, stateEvents };
}
async function fetchPRBundleFromGitHub(