From e0f5c68cda74c225021a7f3d0dbd1ca3369954c9 Mon Sep 17 00:00:00 2001 From: e35ventura Date: Mon, 20 Jul 2026 17:17:06 -0500 Subject: [PATCH] feat(landing): count registered maintainers' merged PRs in repo activity The scoring pipeline excludes maintainer-authored PRs from /prs (maintainers are paid via the repo's maintainer_cut carve-out, not per-PR scores), so repos where the maintainer ships their own work showed '0 prs merged this week' on the landing grid despite real, paid activity. Recover those PRs from the mirror and fold them into the activity digest, 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. Merges only count toward repos the account maintains; contributions elsewhere already flow through /prs. Deduped by PR number. --- src/api/MaintainerActivityApi.ts | 151 +++++++++++++++++++++++++++++++ src/api/index.ts | 1 + src/pages/HomePage.tsx | 63 ++++++++++--- 3 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 src/api/MaintainerActivityApi.ts 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(