diff --git a/package-lock.json b/package-lock.json index 0b1ebfef..643fc824 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bottleneck", - "version": "0.1.8", + "version": "0.1.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bottleneck", - "version": "0.1.8", + "version": "0.1.10", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index faf04480..241421c5 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"); @@ -186,6 +187,7 @@ function App() { element={} /> } /> + } /> } /> } /> } /> diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index fc84bd9a..8b37f4fe 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"; @@ -26,6 +26,7 @@ const NAV_ITEMS: SidebarNavItem[] = [ { path: "/pulls", icon: GitPullRequest, label: "Pull Requests" }, { path: "/issues", icon: AlertCircle, label: "Issues" }, { path: "/branches", icon: GitBranch, label: "Branches" }, + { path: "/high-scores", icon: Trophy, label: "High Scores" }, { icon: SatelliteDish, label: "Async Agents", diff --git a/src/renderer/views/HighScoresView.tsx b/src/renderer/views/HighScoresView.tsx new file mode 100644 index 00000000..de7072b6 --- /dev/null +++ b/src/renderer/views/HighScoresView.tsx @@ -0,0 +1,736 @@ +import { useMemo } from "react"; +import { Trophy, TrendingUp, Clock, GitMerge, Users, Bot } from "lucide-react"; +import { useUIStore } from "../stores/uiStore"; +import { usePRStore } from "../stores/prStore"; +import { cn } from "../utils/cn"; +import type { PullRequest } from "../services/github"; + +interface ContributorStats { + author: string; + totalPRs: number; + mergedPRs: number; + openPRs: number; + avgMergeTimeHours: number; + linesAdded: number; + linesRemoved: number; + isAgent: boolean; + agentType?: string; +} + +interface AgentStats { + agentType: string; + raised: number; + merged: number; + mergeRate: number; +} + +// Detect if a PR is from an agent and what type +function detectAgent(pr: PullRequest): { isAgent: boolean; agentType?: string } { + const branchName = pr.head?.ref?.toLowerCase() || ""; + const title = pr.title?.toLowerCase() || ""; + const author = pr.user?.login?.toLowerCase() || ""; + + // Check for cursor + if (branchName.includes("cursor/") || title.includes("cursor:") || author.includes("cursor")) { + return { isAgent: true, agentType: "cursor" }; + } + + // Check for devin + if (branchName.includes("devin/") || title.includes("devin:") || author.includes("devin")) { + return { isAgent: true, agentType: "devin" }; + } + + // Check for chatgpt/codex + if ( + branchName.includes("chatgpt/") || + branchName.includes("codex/") || + title.includes("chatgpt:") || + title.includes("codex:") || + author.includes("chatgpt") || + author.includes("codex") + ) { + return { isAgent: true, agentType: "chatgpt" }; + } + + // Check for github copilot + if (branchName.includes("copilot/") || title.includes("copilot:") || author.includes("copilot")) { + return { isAgent: true, agentType: "copilot" }; + } + + return { isAgent: false }; +} + +export default function HighScoresView() { + const { theme } = useUIStore(); + const { pullRequests } = usePRStore(); + + const { contributorStats, agentStats, globalStats } = useMemo(() => { + const prs = Array.from(pullRequests.values()); + const contributorMap = new Map(); + const agentMap = new Map(); + + let totalMergeTimeHours = 0; + let mergedCount = 0; + let fastestMergeTimeHours = Infinity; + let fastestMergePR = ""; + + prs.forEach((pr) => { + const author = pr.user?.login || "unknown"; + const { isAgent, agentType } = detectAgent(pr); + + // Update contributor stats + if (!contributorMap.has(author)) { + contributorMap.set(author, { + author, + totalPRs: 0, + mergedPRs: 0, + openPRs: 0, + avgMergeTimeHours: 0, + linesAdded: 0, + linesRemoved: 0, + isAgent, + agentType, + }); + } + + const stats = contributorMap.get(author)!; + stats.totalPRs++; + + if (pr.state === "open") { + stats.openPRs++; + } + + if (pr.merged && pr.merged_at && pr.created_at) { + stats.mergedPRs++; + const mergeTime = new Date(pr.merged_at).getTime() - new Date(pr.created_at).getTime(); + const mergeTimeHours = mergeTime / (1000 * 60 * 60); + totalMergeTimeHours += mergeTimeHours; + mergedCount++; + + if (mergeTimeHours < fastestMergeTimeHours) { + fastestMergeTimeHours = mergeTimeHours; + fastestMergePR = `#${pr.number} by ${author}`; + } + } + + if (pr.additions) { + stats.linesAdded += pr.additions; + } + if (pr.deletions) { + stats.linesRemoved += pr.deletions; + } + + // Update agent stats + if (isAgent && agentType) { + if (!agentMap.has(agentType)) { + agentMap.set(agentType, { + agentType, + raised: 0, + merged: 0, + mergeRate: 0, + }); + } + + const agentStat = agentMap.get(agentType)!; + agentStat.raised++; + if (pr.merged) { + agentStat.merged++; + } + } + }); + + // Calculate average merge time for each contributor + contributorMap.forEach((stats) => { + if (stats.mergedPRs > 0) { + const contributorMergeTime = prs + .filter((pr) => pr.user?.login === stats.author && pr.merged && pr.merged_at && pr.created_at) + .reduce((sum, pr) => { + const mergeTime = new Date(pr.merged_at!).getTime() - new Date(pr.created_at).getTime(); + return sum + mergeTime / (1000 * 60 * 60); + }, 0); + stats.avgMergeTimeHours = contributorMergeTime / stats.mergedPRs; + } + }); + + // Calculate merge rates for agents + agentMap.forEach((agentStat) => { + agentStat.mergeRate = agentStat.raised > 0 ? (agentStat.merged / agentStat.raised) * 100 : 0; + }); + + const sortedContributors = Array.from(contributorMap.values()).sort( + (a, b) => b.totalPRs - a.totalPRs + ); + + const sortedAgents = Array.from(agentMap.values()).sort((a, b) => b.raised - a.raised); + + const avgMergeTimeHours = mergedCount > 0 ? totalMergeTimeHours / mergedCount : 0; + + return { + contributorStats: sortedContributors, + agentStats: sortedAgents, + globalStats: { + totalPRs: prs.length, + mergedPRs: prs.filter((pr) => pr.merged).length, + openPRs: prs.filter((pr) => pr.state === "open").length, + avgMergeTimeHours, + fastestMergeTimeHours: fastestMergeTimeHours === Infinity ? 0 : fastestMergeTimeHours, + fastestMergePR, + totalContributors: contributorMap.size, + humanContributors: Array.from(contributorMap.values()).filter((s) => !s.isAgent).length, + agentContributors: Array.from(contributorMap.values()).filter((s) => s.isAgent).length, + }, + }; + }, [pullRequests]); + + const topContributors = contributorStats.slice(0, 5); + const fastestMergers = contributorStats + .filter((s) => s.mergedPRs > 0) + .sort((a, b) => a.avgMergeTimeHours - b.avgMergeTimeHours) + .slice(0, 5); + + const mostProductiveContributors = contributorStats + .filter((s) => !s.isAgent) + .sort((a, b) => b.linesAdded + b.linesRemoved - (a.linesAdded + a.linesRemoved)) + .slice(0, 5); + + const formatTime = (hours: number) => { + if (hours < 1) { + return `${Math.round(hours * 60)}m`; + } else if (hours < 24) { + return `${hours.toFixed(1)}h`; + } else { + return `${(hours / 24).toFixed(1)}d`; + } + }; + + const StatCard = ({ + title, + icon: Icon, + children, + }: { + title: string; + icon: any; + children: React.ReactNode; + }) => ( +
+
+ +

+ {title} +

+
+ {children} +
+ ); + + return ( +
+
+
+ {/* Header */} +
+

+ + High Scores +

+

+ Top contributors, fastest mergers, and agent performance metrics +

+
+ + {/* Global Stats Overview */} +
+
+
+ Total PRs +
+
+ {globalStats.totalPRs} +
+
+
+
+ Avg Merge Time +
+
+ {formatTime(globalStats.avgMergeTimeHours)} +
+
+
+
+ Human Contributors +
+
+ {globalStats.humanContributors} +
+
+
+
+ Agent Contributors +
+
+ {globalStats.agentContributors} +
+
+
+ + {/* Main Stats Grid */} +
+ {/* Top Contributors by PR Count */} + +
+ {topContributors.length > 0 ? ( + topContributors.map((contributor, index) => ( +
+
+ + {index + 1} + +
+
+ + {contributor.author} + + {contributor.isAgent && ( + + )} +
+ + {contributor.mergedPRs} merged, {contributor.openPRs} open + +
+
+ + {contributor.totalPRs} + +
+ )) + ) : ( +

+ No PR data available +

+ )} +
+
+ + {/* Fastest Mergers */} + +
+ {fastestMergers.length > 0 ? ( + fastestMergers.map((contributor, index) => ( +
+
+ + {index + 1} + +
+
+ + {contributor.author} + + {contributor.isAgent && ( + + )} +
+ + {contributor.mergedPRs} PRs merged + +
+
+ + {formatTime(contributor.avgMergeTimeHours)} + +
+ )) + ) : ( +

+ No merged PRs available +

+ )} +
+
+ + {/* Most Productive (by lines changed) */} + +
+ {mostProductiveContributors.length > 0 ? ( + mostProductiveContributors.map((contributor, index) => ( +
+
+ + {index + 1} + +
+ + {contributor.author} + +
+ +{contributor.linesAdded} + {" / "} + -{contributor.linesRemoved} +
+
+
+ + {(contributor.linesAdded + contributor.linesRemoved).toLocaleString()} + +
+ )) + ) : ( +

+ No human contributor data available +

+ )} +
+
+ + {/* Merge Rate Leaders */} + +
+ {contributorStats + .filter((s) => s.totalPRs >= 3) // At least 3 PRs to be meaningful + .sort((a, b) => b.mergedPRs / b.totalPRs - a.mergedPRs / a.totalPRs) + .slice(0, 5) + .map((contributor, index) => { + const mergeRate = (contributor.mergedPRs / contributor.totalPRs) * 100; + return ( +
+
+ + {index + 1} + +
+
+ + {contributor.author} + + {contributor.isAgent && ( + + )} +
+ + {contributor.mergedPRs}/{contributor.totalPRs} merged + +
+
+ + {mergeRate.toFixed(0)}% + +
+ ); + })} +
+
+
+ + {/* Agent Stats Section */} +
+
+ +

+ Agent Performance +

+
+ +
+ {agentStats.length > 0 ? ( + agentStats.map((agent) => ( +
+
+

+ {agent.agentType} +

+ +
+ +
+
+ + PRs Raised + + + {agent.raised} + +
+ +
+ + PRs Merged + + + {agent.merged} + +
+ +
+
+ + Merge Rate + + = 75 + ? "text-green-500" + : agent.mergeRate >= 50 + ? "text-yellow-500" + : "text-orange-500" + )} + > + {agent.mergeRate.toFixed(0)}% + +
+ + {/* Progress bar */} +
+
= 75 + ? "bg-green-500" + : agent.mergeRate >= 50 + ? "bg-yellow-500" + : "bg-orange-500" + )} + style={{ width: `${agent.mergeRate}%` }} + /> +
+
+
+
+ )) + ) : ( +
+ +

+ No agent activity detected yet. Agents are identified by branch names or titles containing + cursor/, devin/, chatgpt/, codex/, or copilot/. +

+
+ )} +
+
+
+
+
+ ); +}