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