diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index faf04480..3ad4806b 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -24,6 +24,7 @@ const IssueDetailView = lazy(() => import("./views/IssueDetailView")); const CursorView = lazy(() => import("./views/CursorView")); const DevinView = lazy(() => import("./views/DevinView")); const ChatGPTView = lazy(() => import("./views/ChatGPTView")); +const HighScoresView = lazy(() => import("./views/HighScoresView")); PerfLogger.mark("App.tsx module loaded"); @@ -185,6 +186,7 @@ function App() { path="/issues/:owner/:repo/:number" element={} /> + } /> } /> } /> } /> diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index fc84bd9a..df79130a 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -1,7 +1,7 @@ import { useState, useMemo, useEffect, useRef } from "react"; import type { MouseEvent as ReactMouseEvent } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import { GitPullRequest, GitBranch, Settings, AlertCircle, SatelliteDish } from "lucide-react"; +import { GitPullRequest, GitBranch, Settings, AlertCircle, SatelliteDish, Trophy } from "lucide-react"; import { CursorIcon } from "./icons/CursorIcon"; import { DevinIcon } from "./icons/DevinIcon"; import { ChatGPTIcon } from "./icons/ChatGPTIcon"; @@ -24,6 +24,7 @@ interface SidebarProps { const NAV_ITEMS: SidebarNavItem[] = [ { path: "/pulls", icon: GitPullRequest, label: "Pull Requests" }, + { path: "/high-scores", icon: Trophy, label: "High Scores" }, { path: "/issues", icon: AlertCircle, label: "Issues" }, { path: "/branches", icon: GitBranch, label: "Branches" }, { diff --git a/src/renderer/views/HighScoresView.tsx b/src/renderer/views/HighScoresView.tsx new file mode 100644 index 00000000..cf1910bd --- /dev/null +++ b/src/renderer/views/HighScoresView.tsx @@ -0,0 +1,281 @@ +import { useMemo } from "react"; +import { Trophy, GitMerge, Clock, FileDiff, Users } from "lucide-react"; +import { usePRStore } from "../stores/prStore"; +import { AgentIcon } from "../components/AgentIcon"; +import { detectAgentName } from "../utils/agentIcons"; +import type { PullRequest } from "../services/github"; + +type AuthorType = "agent" | "human"; + +interface PartitionedPRs { + byType: Record; + byAgent: Map; // agentKey -> PRs +} + +function getAuthorType(pr: PullRequest): { type: AuthorType; agentKey?: string } { + const agentKey = detectAgentName( + pr.user?.login, + pr.head?.ref, + pr.title, + ...(pr.labels || []).map((l) => l.name), + ); + if (agentKey) return { type: "agent", agentKey }; + return { type: "human" }; +} + +function formatDuration(ms: number): string { + if (!isFinite(ms) || ms < 0) return "-"; + const totalSeconds = Math.floor(ms / 1000); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +function median(values: number[]): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; +} + +export default function HighScoresView() { + const { pullRequests, selectedRepo } = usePRStore(); + + const repoPRs = useMemo(() => { + if (!selectedRepo) return [] as PullRequest[]; + return Array.from(pullRequests.values()).filter((pr) => { + const owner = pr.base?.repo?.owner?.login; + const name = pr.base?.repo?.name; + return owner === selectedRepo.owner && name === selectedRepo.name; + }); + }, [pullRequests, selectedRepo]); + + const partitions: PartitionedPRs = useMemo(() => { + const byType: Record = { agent: [], human: [] }; + const byAgent = new Map(); + repoPRs.forEach((pr) => { + const { type, agentKey } = getAuthorType(pr); + byType[type].push(pr); + if (type === "agent" && agentKey) { + const list = byAgent.get(agentKey) ?? []; + list.push(pr); + byAgent.set(agentKey, list); + } + }); + return { byType, byAgent }; + }, [repoPRs]); + + const stats = useMemo(() => { + const compute = (prs: PullRequest[]) => { + const total = prs.length; + const merged = prs.filter((p) => p.merged || p.state === "closed").length; + const mergeRate = total > 0 ? Math.round((merged / total) * 100) : 0; + + const mergedDurations = prs + .filter((p) => p.merged_at && p.created_at) + .map((p) => new Date(p.merged_at!).getTime() - new Date(p.created_at).getTime()) + .filter((ms) => isFinite(ms) && ms >= 0); + const medianToMergeMs = median(mergedDurations); + + const sizes = prs + .map((p) => (p.additions ?? 0) + (p.deletions ?? 0)) + .filter((n) => n > 0); + const avgSize = sizes.length > 0 + ? Math.round(sizes.reduce((a, b) => a + b, 0) / sizes.length) + : 0; + + return { total, merged, mergeRate, medianToMergeMs, avgSize }; + }; + + const human = compute(partitions.byType.human); + const agent = compute(partitions.byType.agent); + + // Largest PR overall + let largest: { pr?: PullRequest; size: number; authorType: AuthorType } = { + pr: undefined, + size: 0, + authorType: "human", + }; + for (const pr of repoPRs) { + const size = (pr.additions ?? 0) + (pr.deletions ?? 0); + if (size > largest.size) { + largest = { pr, size, authorType: getAuthorType(pr).type }; + } + } + + // Most active contributor (human) and agent by PR count + const humanCounts = new Map(); + partitions.byType.human.forEach((p) => { + humanCounts.set(p.user.login, (humanCounts.get(p.user.login) ?? 0) + 1); + }); + const topHuman = Array.from(humanCounts.entries()).sort((a, b) => b[1] - a[1])[0]; + + const topAgent = Array.from(partitions.byAgent.entries()) + .map(([agentKey, list]) => [agentKey, list.length] as const) + .sort((a, b) => b[1] - a[1])[0]; + + return { human, agent, largest, topHuman, topAgent }; + }, [partitions, repoPRs]); + + const agentTable = useMemo(() => { + const rows = Array.from(partitions.byAgent.entries()).map(([agentKey, list]) => { + const merged = list.filter((p) => p.merged || p.state === "closed").length; + return { agentKey, raised: list.length, merged }; + }); + // Stable order: by raised desc + rows.sort((a, b) => b.raised - a.raised); + return rows; + }, [partitions]); + + return ( +
+
+
+ +

High Scores

+
+

+ {selectedRepo + ? `Insights for ${selectedRepo.full_name}` + : "Select a repository to see stats."} +

+
+ +
+ {/* Overview cards: Humans vs Agents */} +
+
+
+ Humans PRs +
+
{stats.human.total}
+
Merge rate: {stats.human.mergeRate}%
+
+ +
+
+ Agents PRs +
+
{stats.agent.total}
+
Merge rate: {stats.agent.mergeRate}%
+
+ +
+
+ Median time to merge +
+
+ Humans: {stats.human.medianToMergeMs != null ? formatDuration(stats.human.medianToMergeMs) : "-"} +
+
Agents: {stats.agent.medianToMergeMs != null ? formatDuration(stats.agent.medianToMergeMs) : "-"}
+
+ +
+
+ Avg changes per PR +
+
Humans: {stats.human.avgSize.toLocaleString()} LOC
+
Agents: {stats.agent.avgSize.toLocaleString()} LOC
+
+
+ + {/* High score callouts */} +
+
+
+ Largest PR by changes +
+ {stats.largest.pr ? ( +
+
+ #{stats.largest.pr.number} · {(stats.largest.pr.additions ?? 0) + (stats.largest.pr.deletions ?? 0)} LOC +
+
+ Author: {stats.largest.pr.user.login} ({stats.largest.authorType}) +
+
+ ) : ( +
No PR data available.
+ )} +
+ +
+
+ Most active contributors +
+
+
+
Human
+
+ {stats.topHuman ? `${stats.topHuman[0]} · ${stats.topHuman[1]} PRs` : "-"} +
+
+
+
Agent
+
+ {stats.topAgent ? ( + <> + + {stats.topAgent[0]} + · {stats.topAgent[1]} PRs + + ) : ( + - + )} +
+
+
+
+
+ + {/* Agents section */} +
+
+ +

Agents · PRs Raised : Merged

+
+
+ + + + + + + + + + {agentTable.length === 0 && ( + + + + )} + {agentTable.map((row) => { + const pct = row.raised > 0 ? Math.round((row.merged / row.raised) * 100) : 0; + return ( + + + + + + ); + })} + +
AgentRaised : MergedMerged %
+ No agent PRs found. +
+
+ + {row.agentKey} +
+
{row.raised} : {row.merged}{pct}%
+
+
+
+
+ ); +}