Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/api/PrsApi.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<PullRequestComment[]>(requestUrl, {
params: { repo, number },
});
return data;
},
retry: false,
enabled,
})),
});
};
176 changes: 176 additions & 0 deletions src/components/repositories/RepositoryStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<RepositoryStatsProps> = ({
repositoryFullName,
}) => {
Expand All @@ -32,6 +60,7 @@ const RepositoryStats: React.FC<RepositoryStatsProps> = ({
useRepositoryIssues(repositoryFullName);
const { data: bountySummary } = useRepoBountySummary(repositoryFullName);
const { data: repoConfig } = useRepositoryConfig(repositoryFullName);
const { data: maintainers } = useRepositoryMaintainers(repositoryFullName);

const repository = useMemo(
() =>
Expand Down Expand Up @@ -60,6 +89,124 @@ const RepositoryStats: React.FC<RepositoryStatsProps> = ({
};
}, [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 };

Expand Down Expand Up @@ -185,6 +332,35 @@ const RepositoryStats: React.FC<RepositoryStatsProps> = ({
</Typography>
</Box>

{/* Median Time to Merge (non-maintainer PRs) */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography
variant="body2"
sx={{ fontSize: '13px', color: STATUS_COLORS.open }}
>
Median Time to Review
</Typography>
{review.loading ? (
<Skeleton variant="text" width={44} sx={{ fontSize: '13px' }} />
) : (
<Typography
variant="body2"
sx={{
color: 'text.primary',
fontSize: '13px',
}}
>
{timeToReviewMs != null ? formatDuration(timeToReviewMs) : '—'}
</Typography>
)}
</Box>

{/* Closed Issues */}
<Box
sx={{
Expand Down
Loading