diff --git a/src/api/MaintainerActivityApi.ts b/src/api/MaintainerActivityApi.ts new file mode 100644 index 00000000..620bfa20 --- /dev/null +++ b/src/api/MaintainerActivityApi.ts @@ -0,0 +1,151 @@ +// Maintainer activity — merged PRs authored by a repo's own maintainers. +// +// The scoring pipeline deliberately excludes maintainer-authored PRs from +// /prs (maintainers are paid through the repo's maintainer_cut carve-out, +// not per-PR scores), so activity digests built from /prs alone undercount +// repos where the maintainer ships their own work. This hook recovers those +// PRs from the mirror, restricted to accounts that are BOTH listed as a +// maintainer of a repo with maintainerCut > 0 AND registered as a miner +// UID — the same conditions under which the carve-out actually pays them. +import { useMemo } from 'react'; +import { useQueries } from '@tanstack/react-query'; +import axios from 'axios'; +import { parseNumber } from '../utils'; +import { type MinerEvaluation, type Repository } from './models/Dashboard'; + +const MIRROR_BASE_URL = import.meta.env.VITE_REACT_APP_MIRROR_BASE_URL; + +interface MirrorRepoMaintainersResponse { + repo_full_name: string; + maintainers: Array<{ + github_id: string | number; + login: string; + association: string; + }>; +} + +interface MirrorMinerPullsResponse { + github_id: string | number; + pull_requests: Array<{ + repo_full_name: string; + pr_number: number; + state: string; + merged_at: string | null; + }>; +} + +export interface MaintainerMergedPr { + pullRequestNumber: number; + mergedAt: string; + author: string; +} + +const mirrorQuery = (url: string) => ({ + queryKey: ['mirror', 'maintainerActivity', url] as const, + queryFn: async () => { + const { data } = await axios.get(`${MIRROR_BASE_URL}${url}`); + return data as TResponse; + }, + retry: false, + staleTime: 5 * 60 * 1000, + enabled: Boolean(MIRROR_BASE_URL), +}); + +/** + * Merged PRs authored by registered maintainers of cut-bearing repos, + * keyed by lowercased repo full name. A maintainer's merges only count + * toward repos they maintain — their contributions elsewhere already flow + * through /prs as ordinary miner PRs. + * + * Returns undefined until both inputs and the maintainer lookups resolve; + * individual mirror failures degrade to "no maintainer activity" for that + * repo rather than blocking the digest. + */ +export const useMaintainerMergedPrs = ( + repos: Repository[] | undefined, + miners: MinerEvaluation[] | undefined, +): Map | undefined => { + const cutRepos = useMemo( + () => + (repos ?? []).filter( + (repo) => parseNumber(repo.config?.maintainerCut ?? 0) > 0, + ), + [repos], + ); + + const maintainersData = useQueries({ + queries: cutRepos.map((repo) => + mirrorQuery( + `/repos/${repo.fullName}/maintainers`, + ), + ), + combine: (results) => + results + .map((result) => result.data) + .filter((data): data is MirrorRepoMaintainersResponse => Boolean(data)), + }); + + // github_id -> { login, repos } for maintainers holding a live UID. + const registeredMaintainers = useMemo(() => { + if (!miners) return undefined; + const registeredIds = new Set( + miners.map((miner) => String(miner.githubId)), + ); + const byId = new Map }>(); + for (const response of maintainersData) { + const repoKey = response.repo_full_name?.toLowerCase(); + if (!repoKey) continue; + for (const maintainer of response.maintainers ?? []) { + const githubId = String(maintainer.github_id); + if (!registeredIds.has(githubId)) continue; + let entry = byId.get(githubId); + if (!entry) { + entry = { login: maintainer.login, repos: new Set() }; + byId.set(githubId, entry); + } + entry.repos.add(repoKey); + } + } + return byId; + }, [miners, maintainersData]); + + const maintainerIds = useMemo( + () => Array.from(registeredMaintainers?.keys() ?? []), + [registeredMaintainers], + ); + + const pullsData = useQueries({ + queries: maintainerIds.map((githubId) => + mirrorQuery(`/miners/${githubId}/pulls`), + ), + combine: (results) => + results + .map((result) => result.data) + .filter((data): data is MirrorMinerPullsResponse => Boolean(data)), + }); + + return useMemo(() => { + if (!registeredMaintainers) return undefined; + const byRepo = new Map(); + for (const response of pullsData) { + const maintainer = registeredMaintainers.get(String(response.github_id)); + if (!maintainer) continue; + for (const pr of response.pull_requests ?? []) { + const repoKey = pr.repo_full_name?.toLowerCase(); + if (!repoKey || !maintainer.repos.has(repoKey)) continue; + if (pr.state !== 'MERGED' || !pr.merged_at) continue; + let entries = byRepo.get(repoKey); + if (!entries) { + entries = []; + byRepo.set(repoKey, entries); + } + entries.push({ + pullRequestNumber: pr.pr_number, + mergedAt: pr.merged_at, + author: maintainer.login, + }); + } + } + return byRepo; + }, [registeredMaintainers, pullsData]); +}; diff --git a/src/api/index.ts b/src/api/index.ts index f521f980..f855a309 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -2,6 +2,7 @@ export * from './ApiUtils'; export * from './ConfigurationsApi'; export * from './DashboardApi'; export * from './IssuesApi'; +export * from './MaintainerActivityApi'; export * from './MinerApi'; export * from './MirrorApi'; export * from './MirrorDashboardApi'; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index b6e4fc17..46a02914 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -4,7 +4,12 @@ import { alpha } from '@mui/material/styles'; import { Page } from '../components/layout'; import { SEO } from '../components'; import { LinkBox } from '../components/common/linkBehavior'; -import { useAllMiners, useAllPrs, useReposAndWeights } from '../api'; +import { + useAllMiners, + useAllPrs, + useMaintainerMergedPrs, + useReposAndWeights, +} from '../api'; import { type Repository } from '../api/models/Dashboard'; import { formatUsdEstimate, minerRepositoryPath, parseNumber } from '../utils'; import repoWebsitesSnapshot from '../generated/repoWebsites.json'; @@ -902,6 +907,14 @@ const HomePage: React.FC = () => { // the network-wide PR list in one pass. PR records may carry a lowercased // repository name, so matching is case-insensitive. const prsQuery = useAllPrs(); + const minersQuery = useAllMiners(); + // Maintainer-authored merged PRs are excluded from /prs by the scoring + // pipeline (maintainers are paid via maintainer_cut, not per-PR scores), + // so they're recovered separately and folded into the digest below. + const maintainerPrsByRepo = useMaintainerMergedPrs( + reposQuery.data, + minersQuery.data, + ); const activityByRepo = useMemo(() => { if (!prsQuery.data) return undefined; const now = Date.now(); @@ -909,9 +922,7 @@ const HomePage: React.FC = () => { string, { mergedThisWeek: number; miners: Set; mergedDaily: number[] } >(); - for (const pr of prsQuery.data) { - const key = pr.repository?.toLowerCase(); - if (!key) continue; + const getEntry = (key: string) => { let entry = map.get(key); if (!entry) { entry = { @@ -921,21 +932,48 @@ const HomePage: React.FC = () => { }; map.set(key, entry); } - const mergedAtMs = pr.mergedAt ? new Date(pr.mergedAt).getTime() : null; - if (mergedAtMs !== null) { - if (now - mergedAtMs < WEEK_MS) entry.mergedThisWeek += 1; - const daysAgo = Math.floor((now - mergedAtMs) / DAY_MS); - if (daysAgo >= 0 && daysAgo < SPARK_DAYS) { - entry.mergedDaily[SPARK_DAYS - 1 - daysAgo] += 1; - } + return entry; + }; + const countMerge = ( + entry: { mergedThisWeek: number; mergedDaily: number[] }, + mergedAtMs: number, + ) => { + if (now - mergedAtMs < WEEK_MS) entry.mergedThisWeek += 1; + const daysAgo = Math.floor((now - mergedAtMs) / DAY_MS); + if (daysAgo >= 0 && daysAgo < SPARK_DAYS) { + entry.mergedDaily[SPARK_DAYS - 1 - daysAgo] += 1; } + }; + const seen = new Set(); + for (const pr of prsQuery.data) { + const key = pr.repository?.toLowerCase(); + if (!key) continue; + seen.add(`${key}#${pr.pullRequestNumber}`); + const entry = getEntry(key); + const mergedAtMs = pr.mergedAt ? new Date(pr.mergedAt).getTime() : null; + if (mergedAtMs !== null) countMerge(entry, mergedAtMs); const activeAtMs = new Date(pr.mergedAt ?? pr.prCreatedAt).getTime(); if (pr.author && now - activeAtMs < ACTIVE_MINER_WINDOW_MS) { entry.miners.add(pr.author); } } + // Registered maintainers of cut-bearing repos are paid UIDs too — count + // their merges. Dedup by PR number in case the feed ever includes one. + if (maintainerPrsByRepo) { + for (const [key, maintainerPrs] of maintainerPrsByRepo) { + const entry = getEntry(key); + for (const pr of maintainerPrs) { + if (seen.has(`${key}#${pr.pullRequestNumber}`)) continue; + const mergedAtMs = new Date(pr.mergedAt).getTime(); + countMerge(entry, mergedAtMs); + if (now - mergedAtMs < ACTIVE_MINER_WINDOW_MS) { + entry.miners.add(pr.author); + } + } + } + } return map; - }, [prsQuery.data]); + }, [prsQuery.data, maintainerPrsByRepo]); // Live description refresh: one GitHub metadata request per repo, at most // once per session (cached in sessionStorage), fired a few seconds after @@ -989,7 +1027,6 @@ const HomePage: React.FC = () => { // A repo's estimated payout: its emission share of the total daily USD // currently flowing to miners across the network. - const minersQuery = useAllMiners(); const networkUsdPerDay = useMemo( () => minersQuery.data?.reduce(