From 14bc9fd5e68ec0f8c719900794e2ace102b7b43a Mon Sep 17 00:00:00 2001 From: Khaostica <256858950+Khaostica@users.noreply.github.com> Date: Thu, 28 May 2026 13:52:53 -0400 Subject: [PATCH] feat(repo): add median time-to-review stat to Repository Stats tile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Median Time to Review" row to the Repository Stats tile that measures how quickly maintainers engage with outside contributions. It prefers the true signal — the median time from PR open to the first maintainer comment over recent non-maintainer PRs (bounded fan-out via a new parallel comments hook) — and falls back to median time-to-merge for repos where review-comment data isn't available, so the stat stays useful across repos. --- src/api/PrsApi.ts | 42 +++++ .../repositories/RepositoryStats.tsx | 176 ++++++++++++++++++ 2 files changed, 218 insertions(+) diff --git a/src/api/PrsApi.ts b/src/api/PrsApi.ts index 553db2ca..1049d2f5 100644 --- a/src/api/PrsApi.ts +++ b/src/api/PrsApi.ts @@ -1,4 +1,6 @@ // Pull Request API hooks - uses /prs endpoints +import { useQueries } from '@tanstack/react-query'; +import axios from 'axios'; import { useApiQuery } from './ApiUtils'; import { type CommitLog, @@ -67,3 +69,43 @@ export const usePullRequestComments = (repo: string, number: number) => number, }, ); + +/** + * Fetch comments for several pull requests of one repo in parallel. + * + * Each PR is a separate `/prs/comments` request (there's no bulk endpoint), + * so callers must bound `numbers` to a sane size. Query keys mirror + * `usePullRequestComments` exactly so results are shared with — and reused by + * — the single-PR hook's cache. + * + * @param repo - Full repository name (e.g., "opentensor/btcli") + * @param numbers - PR numbers to fetch comments for + * @param enabled - Gate the whole batch (e.g. until the PR list is loaded) + */ +export const usePullRequestCommentsBatch = ( + repo: string, + numbers: number[], + enabled = true, +) => { + const baseUrl = import.meta.env.VITE_REACT_APP_BASE_URL; + return useQueries({ + queries: numbers.map((number) => ({ + queryKey: [ + 'usePullRequestComments', + '/prs/comments', + { repo, number }, + ] as const, + queryFn: async () => { + const requestUrl = baseUrl + ? `${baseUrl}/prs/comments` + : '/prs/comments'; + const { data } = await axios.get(requestUrl, { + params: { repo, number }, + }); + return data; + }, + retry: false, + enabled, + })), + }); +}; diff --git a/src/components/repositories/RepositoryStats.tsx b/src/components/repositories/RepositoryStats.tsx index b9fe89af..e9592b7b 100644 --- a/src/components/repositories/RepositoryStats.tsx +++ b/src/components/repositories/RepositoryStats.tsx @@ -13,6 +13,8 @@ import { useRepositoryIssues, useRepoBountySummary, useRepositoryConfig, + useRepositoryMaintainers, + usePullRequestCommentsBatch, } from '../../api'; import { RANK_COLORS, STATUS_COLORS } from '../../theme'; import { formatTokenAmount, formatWeight } from '../../utils/format'; @@ -22,6 +24,32 @@ interface RepositoryStatsProps { repositoryFullName: string; } +// Cap how many recent non-maintainer PRs we fetch comments for. Each PR is a +// separate /prs/comments request, so this bounds the request fan-out per repo +// while keeping the median representative of recent maintainer responsiveness. +const REVIEW_SAMPLE_LIMIT = 30; + +// GitHub author_association values that indicate the commenter has write/triage +// access — i.e. acts as a maintainer — even if they aren't a listed assignee. +const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + +// Human-readable duration for the median time-to-review stat. Picks the +// coarsest unit that keeps the number readable (min < hr < day). +const formatDuration = (ms: number): string => { + const minutes = ms / 60_000; + if (minutes < 60) { + const m = Math.max(1, Math.round(minutes)); + return `${m} min`; + } + const hours = minutes / 60; + if (hours < 24) { + const h = Math.round(hours); + return `${h} hr${h === 1 ? '' : 's'}`; + } + const days = hours / 24; + return `${days.toFixed(days < 10 ? 1 : 0)} days`; +}; + const RepositoryStats: React.FC = ({ repositoryFullName, }) => { @@ -32,6 +60,7 @@ const RepositoryStats: React.FC = ({ useRepositoryIssues(repositoryFullName); const { data: bountySummary } = useRepoBountySummary(repositoryFullName); const { data: repoConfig } = useRepositoryConfig(repositoryFullName); + const { data: maintainers } = useRepositoryMaintainers(repositoryFullName); const repository = useMemo( () => @@ -60,6 +89,124 @@ const RepositoryStats: React.FC = ({ }; }, [allPRs, repositoryFullName]); + const maintainerLogins = useMemo( + () => new Set((maintainers ?? []).map((m) => m.login.toLowerCase())), + [maintainers], + ); + + // The most recent non-maintainer PRs in this repo, capped at + // REVIEW_SAMPLE_LIMIT. These are the PRs whose comments we'll fetch to find + // the first maintainer response. Keep PR number + open time together so we + // can measure each one without re-deriving it from the comment results. + const reviewSample = useMemo(() => { + if (!allPRs) return [] as { number: number; createdAt: string }[]; + + return allPRs + .filter( + (pr) => + pr.repository.toLowerCase() === repositoryFullName.toLowerCase() && + !!pr.prCreatedAt && + !maintainerLogins.has((pr.author ?? '').toLowerCase()), + ) + .sort( + (a, b) => + new Date(b.prCreatedAt).getTime() - new Date(a.prCreatedAt).getTime(), + ) + .slice(0, REVIEW_SAMPLE_LIMIT) + .map((pr) => ({ + number: pr.pullRequestNumber, + createdAt: pr.prCreatedAt, + })); + }, [allPRs, maintainerLogins, repositoryFullName]); + + const reviewSampleNumbers = useMemo( + () => reviewSample.map((pr) => pr.number), + [reviewSample], + ); + + const commentResults = usePullRequestCommentsBatch( + repositoryFullName, + reviewSampleNumbers, + reviewSampleNumbers.length > 0, + ); + + // Median time from PR open to the first maintainer comment, over the recent + // non-maintainer PRs sampled above — a proxy for how quickly maintainers + // engage with outside contributions (regardless of whether they merge). + const review = useMemo<{ loading: boolean; medianMs: number | null }>(() => { + if (reviewSample.length === 0) return { loading: false, medianMs: null }; + + const loading = commentResults.some((r) => r.isPending); + + const durations: number[] = []; + reviewSample.forEach((pr, i) => { + const comments = commentResults[i]?.data; + if (!comments || comments.length === 0) return; + + const openedMs = new Date(pr.createdAt).getTime(); + const firstMaintainerMs = comments + .filter((c) => { + const login = (c.user?.login ?? '').toLowerCase(); + const association = (c.authorAssociation ?? '').toUpperCase(); + const isMaintainer = + maintainerLogins.has(login) || + MAINTAINER_ASSOCIATIONS.has(association); + return isMaintainer && !!c.createdAt; + }) + .map((c) => new Date(c.createdAt).getTime()) + .filter((ms) => Number.isFinite(ms) && ms >= openedMs) + .sort((a, b) => a - b)[0]; + + if (firstMaintainerMs != null) + durations.push(firstMaintainerMs - openedMs); + }); + + durations.sort((a, b) => a - b); + if (durations.length === 0) return { loading, medianMs: null }; + + const mid = Math.floor(durations.length / 2); + const medianMs = + durations.length % 2 === 0 + ? (durations[mid - 1] + durations[mid]) / 2 + : durations[mid]; + return { loading, medianMs }; + }, [commentResults, reviewSample, maintainerLogins]); + + // Fallback when no maintainer-review data is available: median time from PR + // open to merge, over merged non-maintainer PRs. Comment data is sparse for + // many repos, but merge timestamps are dense, so this keeps the stat useful. + const mergeMedianMs = useMemo(() => { + if (!allPRs) return null; + + const durations = allPRs + .filter( + (pr) => + pr.repository.toLowerCase() === repositoryFullName.toLowerCase() && + isMergedPr(pr) && + !!pr.mergedAt && + !!pr.prCreatedAt && + !maintainerLogins.has((pr.author ?? '').toLowerCase()), + ) + .map( + (pr) => + new Date(pr.mergedAt as string).getTime() - + new Date(pr.prCreatedAt).getTime(), + ) + .filter((ms) => Number.isFinite(ms) && ms > 0) + .sort((a, b) => a - b); + + if (durations.length === 0) return null; + + const mid = Math.floor(durations.length / 2); + return durations.length % 2 === 0 + ? (durations[mid - 1] + durations[mid]) / 2 + : durations[mid]; + }, [allPRs, maintainerLogins, repositoryFullName]); + + // Hybrid display value: prefer the true first-maintainer-review time; fall + // back to time-to-merge where review data is unavailable. + const timeToReviewMs = review.medianMs ?? mergeMedianMs; + const issueStats = useMemo(() => { if (!issues) return { totalIssues: 0, closedIssues: 0 }; @@ -185,6 +332,35 @@ const RepositoryStats: React.FC = ({ + {/* Median Time to Merge (non-maintainer PRs) */} + + + Median Time to Review + + {review.loading ? ( + + ) : ( + + {timeToReviewMs != null ? formatDuration(timeToReviewMs) : '—'} + + )} + + {/* Closed Issues */}