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
151 changes: 151 additions & 0 deletions src/api/MaintainerActivityApi.ts
Original file line number Diff line number Diff line change
@@ -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 = <TResponse>(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<string, MaintainerMergedPr[]> | undefined => {
const cutRepos = useMemo(
() =>
(repos ?? []).filter(
(repo) => parseNumber(repo.config?.maintainerCut ?? 0) > 0,
),
[repos],
);

const maintainersData = useQueries({
queries: cutRepos.map((repo) =>
mirrorQuery<MirrorRepoMaintainersResponse>(
`/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<string, { login: string; repos: Set<string> }>();
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<MirrorMinerPullsResponse>(`/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<string, MaintainerMergedPr[]>();
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]);
};
1 change: 1 addition & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
63 changes: 50 additions & 13 deletions src/pages/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -902,16 +907,22 @@ 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();
const map = new Map<
string,
{ mergedThisWeek: number; miners: Set<string>; 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 = {
Expand All @@ -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<string>();
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
Expand Down Expand Up @@ -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(
Expand Down
Loading