Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
213 changes: 167 additions & 46 deletions src/app/api/gt/repos/[owner]/[name]/miners/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,58 @@ import { backfillPrIssueLinksIfNeeded } from '@/lib/refresh';

export const dynamic = 'force-dynamic';

const PRS_URL = 'https://api.gittensor.io/prs';
const MINERS_URL = 'https://api.gittensor.io/miners';
const REPOS_URL = 'https://api.gittensor.io/dash/repos';
const REPO_EVALS_URL_BASE = 'https://api.gittensor.io/repos';
const TTL_MS = 30_000;
const TOP_MINERS_LIMIT = 5;

interface UpstreamPr {
repository: string;
author?: string | null;
githubId?: string | null;
mergedAt: string | null;
score?: string | number | null;
}
const TOP_ISSUE_DISCOVERY_LIMIT = 5;

interface UpstreamMiner {
id: string;
githubUsername: string;
githubId?: string | null;
totalScore?: string | number | null;
uid?: string | number | null;
}

interface UpstreamRepoMiner {
id?: string | number | null;
uid?: string | number | null;
repositoryFullName?: string | null;
repository_full_name?: string | null;
githubUsername?: string | null;
github_username?: string | null;
githubId?: string | number | null;
github_id?: string | number | null;
credibility?: string | number | null;
repoCredibility?: string | number | null;
repo_credibility?: string | number | null;
prCredibility?: string | number | null;
pr_credibility?: string | number | null;
baseTotalScore?: string | number | null;
base_total_score?: string | number | null;
totalScore?: string | number | null;
total_score?: string | number | null;
totalCollateralScore?: string | number | null;
total_collateral_score?: string | number | null;
totalOpenPrs?: string | number | null;
total_open_prs?: string | number | null;
totalClosedPrs?: string | number | null;
total_closed_prs?: string | number | null;
totalMergedPrs?: string | number | null;
total_merged_prs?: string | number | null;
totalPrs?: string | number | null;
total_prs?: string | number | null;
isEligible?: boolean | null;
is_eligible?: boolean | null;
failedReason?: string | null;
failed_reason?: string | null;
alphaPerDay?: string | number | null;
alpha_per_day?: string | number | null;
taoPerDay?: string | number | null;
tao_per_day?: string | number | null;
usdPerDay?: string | number | null;
usd_per_day?: string | number | null;
}

interface UpstreamRepo {
Expand All @@ -34,7 +67,6 @@ interface UpstreamRepo {

interface CachedShared {
fetched_at: number;
prs: UpstreamPr[];
miners: UpstreamMiner[];
issueDiscoveryShareByRepo: Map<string, number>;
ossRankByGithubId: Map<string, number>;
Expand Down Expand Up @@ -73,8 +105,7 @@ function issueDiscoveryReason(row: {
}

async function refresh(): Promise<CachedShared> {
const [prs, miners, repos] = await Promise.all([
fetchJson<UpstreamPr[]>(PRS_URL),
const [miners, repos] = await Promise.all([
fetchJson<UpstreamMiner[]>(MINERS_URL),
fetchJson<UpstreamRepo[]>(REPOS_URL),
]);
Expand All @@ -88,7 +119,7 @@ async function refresh(): Promise<CachedShared> {
const ossRanked = [...miners].sort((a, b) => num(b.totalScore) - num(a.totalScore));
const ossRankByGithubId = new Map<string, number>();
ossRanked.forEach((m, i) => { if (m.githubId) ossRankByGithubId.set(m.githubId, i + 1); });
const next: CachedShared = { fetched_at: Date.now(), prs, miners, issueDiscoveryShareByRepo, ossRankByGithubId };
const next: CachedShared = { fetched_at: Date.now(), miners, issueDiscoveryShareByRepo, ossRankByGithubId };
cache = next;
return next;
}
Expand All @@ -100,12 +131,87 @@ async function getShared(): Promise<CachedShared> {
return inFlight;
}

const repoMinersCache = new Map<string, { fetched_at: number; rows: UpstreamRepoMiner[] }>();
const repoMinersInFlight = new Map<string, Promise<UpstreamRepoMiner[]>>();

async function fetchRepoMiners(fullName: string): Promise<UpstreamRepoMiner[]> {
const key = fullName.toLowerCase();
const now = Date.now();
const cached = repoMinersCache.get(key);
if (cached && now - cached.fetched_at < TTL_MS) return cached.rows;
const existing = repoMinersInFlight.get(key);
if (existing) return existing;

const promise = (async () => {
const r = await fetch(`${REPO_EVALS_URL_BASE}/${encodeURIComponent(fullName)}/miners`, {
cache: 'no-store',
signal: AbortSignal.timeout(8_000),
});
if (!r.ok) return [];
const raw = (await r.json()) as unknown;
const rows = Array.isArray(raw)
? raw
: (raw && typeof raw === 'object' && Array.isArray((raw as { miners?: unknown }).miners))
? ((raw as { miners: unknown[] }).miners)
: [];
const typedRows = rows
.filter((row): row is UpstreamRepoMiner => Boolean(row) && typeof row === 'object')
.filter((row) => {
const rowRepo = repoNameFromRow(row);
return !rowRepo || rowRepo === key;
});
repoMinersCache.set(key, { fetched_at: Date.now(), rows: typedRows });
return typedRows;
})().finally(() => {
repoMinersInFlight.delete(key);
});

repoMinersInFlight.set(key, promise);
return promise;
}

function stringValue(v: unknown): string {
if (typeof v === 'string') return v;
if (typeof v === 'number' && Number.isFinite(v)) return String(v);
return '';
}

function repoNameFromRow(row: UpstreamRepoMiner): string {
return stringValue(row.repositoryFullName ?? row.repository_full_name).toLowerCase();
}

function repoScopedCredibility(row: UpstreamRepoMiner): number {
return num(row.credibility ?? row.repoCredibility ?? row.repo_credibility ?? row.prCredibility ?? row.pr_credibility);
}

function meaningfulRepoMiner(row: {
isEligible: boolean;
score: number;
baseScore: number;
collateralScore: number;
prCount: number;
openPrCount: number;
closedPrCount: number;
totalPrCount: number;
}): boolean {
return (
row.isEligible ||
row.score > 0 ||
row.baseScore > 0 ||
row.collateralScore > 0 ||
row.prCount > 0 ||
row.openPrCount > 0 ||
row.closedPrCount > 0 ||
row.totalPrCount > 0
);
}

export async function GET(_req: Request, ctx: { params: Promise<{ owner: string; name: string }> }) {
const params = await ctx.params;
const fullName = `${params.owner}/${params.name}`;
const fullNameKey = fullName.toLowerCase();
try {
const shared = await getShared();
const [shared, repoMinerRows] = await Promise.all([getShared(), fetchRepoMiners(fullName)]);
const issueDiscoveryEnabled = (shared.issueDiscoveryShareByRepo.get(fullNameKey) ?? 0) > 0;
const minersByGithubId = new Map<string, UpstreamMiner>();
const minersByLogin = new Map<string, UpstreamMiner>();
Expand All @@ -114,40 +220,55 @@ export async function GET(_req: Request, ctx: { params: Promise<{ owner: string;
minersByLogin.set(m.githubUsername.toLowerCase(), m);
}

// OSS Contributions: sum of merged PR scores per author for this repo.
interface OssAgg { githubId: string; githubUsername: string; prCount: number; score: number }
const ossMap = new Map<string, OssAgg>();
for (const p of shared.prs) {
if (p.repository.toLowerCase() !== fullNameKey) continue;
const id = p.githubId || p.author;
if (!id) continue;
let row = ossMap.get(id);
if (!row) {
row = { githubId: p.githubId || '', githubUsername: p.author || id, prCount: 0, score: 0 };
ossMap.set(id, row);
}
// Count only merged PRs and their official PR scores.
if (p.mergedAt) {
row.prCount += 1;
row.score += num(p.score);
}
}
const ossContributions = [...ossMap.values()]
.filter((r) => r.prCount > 0 || r.score > 0)
.sort((a, b) => b.score - a.score || b.prCount - a.prCount)
.slice(0, TOP_MINERS_LIMIT)
// OSS Contributions: per-repo validator rows. This endpoint already
// includes the repo-scoped score and eligibility gate, so do not rebuild
// the panel from global PR data or global miner score.
const ossContributions = repoMinerRows
.map((r) => {
const m = r.githubId ? minersByGithubId.get(r.githubId) : undefined;
const username = m?.githubUsername || r.githubUsername;
const githubId = stringValue(r.githubId ?? r.github_id);
const username = r.githubUsername ?? r.github_username ?? '';
const m = githubId ? minersByGithubId.get(githubId) : minersByLogin.get(username.toLowerCase());
const rawUid = r.uid ?? m?.uid;
const uidNum =
typeof rawUid === 'number'
? rawUid
: typeof rawUid === 'string'
? Number.parseInt(rawUid, 10)
: NaN;
const score = num(r.totalScore ?? r.total_score);
const baseScore = num(r.baseTotalScore ?? r.base_total_score);
const collateralScore = num(r.totalCollateralScore ?? r.total_collateral_score);
const prCount = num(r.totalMergedPrs ?? r.total_merged_prs);
const openPrCount = num(r.totalOpenPrs ?? r.total_open_prs);
const closedPrCount = num(r.totalClosedPrs ?? r.total_closed_prs);
const totalPrCount = num(r.totalPrs ?? r.total_prs);
const isEligible = (r.isEligible ?? r.is_eligible) === true;
return {
githubId: r.githubId,
githubUsername: username,
prCount: r.prCount,
score: Number(r.score.toFixed(2)),
ossRank: r.githubId ? shared.ossRankByGithubId.get(r.githubId) ?? null : null,
githubId,
githubUsername: username || m?.githubUsername || githubId,
prCount,
score: Number(score.toFixed(2)),
baseScore: Number(baseScore.toFixed(2)),
collateralScore: Number(collateralScore.toFixed(2)),
openPrCount,
closedPrCount,
totalPrCount,
credibility: repoScopedCredibility(r),
ossRank: githubId ? shared.ossRankByGithubId.get(githubId) ?? null : null,
globalScore: m ? Number(num(m.totalScore).toFixed(2)) : null,
avatarUrl: `https://github.com/${username}.png?size=48`,
uid: Number.isFinite(uidNum) ? uidNum : null,
avatarUrl: `https://github.com/${encodeURIComponent(username || m?.githubUsername || githubId)}.png?size=48`,
isEligible,
failedReason: r.failedReason ?? r.failed_reason ?? null,
alphaPerDay: num(r.alphaPerDay ?? r.alpha_per_day),
taoPerDay: num(r.taoPerDay ?? r.tao_per_day),
usdPerDay: num(r.usdPerDay ?? r.usd_per_day),
};
})
.filter(meaningfulRepoMiner)
.sort((a, b) => {
if ((a.isEligible ? 1 : 0) !== (b.isEligible ? 1 : 0)) return a.isEligible ? -1 : 1;
return b.score - a.score || b.baseScore - a.baseScore || b.collateralScore - a.collateralScore || b.prCount - a.prCount;
});

// Issue Discoveries: repo-specific candidates only. Gittensor scores a
Expand Down Expand Up @@ -263,7 +384,7 @@ export async function GET(_req: Request, ctx: { params: Promise<{ owner: string;
avatarUrl: string;
} => Boolean(row))
.sort((a, b) => b.issueCount - a.issueCount || b.candidateIssueCount - a.candidateIssueCount || b.solvedIssueCount - a.solvedIssueCount)
.slice(0, TOP_MINERS_LIMIT);
.slice(0, TOP_ISSUE_DISCOVERY_LIMIT);

return NextResponse.json({
fullName,
Expand Down
56 changes: 56 additions & 0 deletions src/app/api/gt/repositories/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ async function refresh(): Promise<Cached> {
const weekAgo = now - WEEK_MS;
const twoWeeksAgo = now - 2 * WEEK_MS;

// Day-bucket for the per-repo daily sparklines. `dayStartMs` is the UTC
// midnight epoch of "today"; bin index = floor((dayStart - prDayStart) / 86_400_000),
// valid when in [0, N-1] → most recent N days. Output arrays are
// oldest-first (index 0 = oldest, index N-1 = today) so the UI doesn't
// have to reverse. We compute both a 14-day spark (kept for back-compat)
// and a 30-day spark + 30-day filtered counts (merged, closed, total,
// active contributors) for the new 30-day activity panel.
const DAY_MS = 24 * 60 * 60 * 1000;
const todayStart = Math.floor(now / DAY_MS) * DAY_MS;
const THIRTY_DAYS_MS = 30 * DAY_MS;
const thirtyDaysAgo = now - THIRTY_DAYS_MS;

interface Agg {
totalScore: number;
totalPrCount: number;
Expand All @@ -135,6 +147,13 @@ async function refresh(): Promise<Cached> {
prsLastWeek: number;
contributors: Set<string>;
lastPrAt: number;
dailyPrs14d: number[];
// 30-day window — all use `prCreatedAt` to define the window
prsLast30d: number;
mergedLast30d: number;
closedLast30d: number;
contributors30d: Set<string>;
dailyPrs30d: number[];
}
const aggMap = new Map<string, Agg>();
const ensure = (k: string): Agg => {
Expand All @@ -150,6 +169,12 @@ async function refresh(): Promise<Cached> {
prsLastWeek: 0,
contributors: new Set<string>(),
lastPrAt: 0,
dailyPrs14d: new Array(14).fill(0),
prsLast30d: 0,
mergedLast30d: 0,
closedLast30d: 0,
contributors30d: new Set<string>(),
dailyPrs30d: new Array(30).fill(0),
};
aggMap.set(key, a);
}
Expand All @@ -170,6 +195,31 @@ async function refresh(): Promise<Cached> {
if (t > a.lastPrAt) a.lastPrAt = t;
if (t >= weekAgo) a.prsThisWeek += 1;
else if (t >= twoWeeksAgo) a.prsLastWeek += 1;
// 30-day window — filters by `prCreatedAt`, then classifies by current state.
// PRs created in the last 30d whose state is currently OPEN don't count
// toward merged or closed — they're still pending and will show up in
// the live open-PR count from /api/repos/metadata.
if (t > 0 && t >= thirtyDaysAgo) {
a.prsLast30d += 1;
if (p.mergedAt) {
a.mergedLast30d += 1;
const author = p.author || p.githubId;
if (author) a.contributors30d.add(author);
} else if (p.prState && p.prState !== 'OPEN' && p.prState !== 'open') {
a.closedLast30d += 1;
}
}
// Per-day binning for the 14-day + 30-day sparklines
if (t > 0) {
const prDayStart = Math.floor(t / DAY_MS) * DAY_MS;
const daysAgo = Math.floor((todayStart - prDayStart) / DAY_MS);
if (daysAgo >= 0 && daysAgo < 14) {
a.dailyPrs14d[13 - daysAgo] += 1;
}
if (daysAgo >= 0 && daysAgo < 30) {
a.dailyPrs30d[29 - daysAgo] += 1;
}
}
}

const repos: GtRepo[] = reposRaw.map((r) => {
Expand Down Expand Up @@ -199,6 +249,12 @@ async function refresh(): Promise<Cached> {
prsLastWeek,
trendingPct,
lastPrAt: a?.lastPrAt ? new Date(a.lastPrAt).toISOString() : null,
dailyPrs14d: a?.dailyPrs14d ?? new Array(14).fill(0),
prsLast30d: a?.prsLast30d ?? 0,
mergedLast30d: a?.mergedLast30d ?? 0,
closedLast30d: a?.closedLast30d ?? 0,
contributorsLast30d: a?.contributors30d.size ?? 0,
dailyPrs30d: a?.dailyPrs30d ?? new Array(30).fill(0),
};
});

Expand Down
Loading