diff --git a/src/app/api/gt/repos/[owner]/[name]/miners/route.ts b/src/app/api/gt/repos/[owner]/[name]/miners/route.ts index 5ac9010..cd3a8f9 100644 --- a/src/app/api/gt/repos/[owner]/[name]/miners/route.ts +++ b/src/app/api/gt/repos/[owner]/[name]/miners/route.ts @@ -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 { @@ -34,7 +67,6 @@ interface UpstreamRepo { interface CachedShared { fetched_at: number; - prs: UpstreamPr[]; miners: UpstreamMiner[]; issueDiscoveryShareByRepo: Map; ossRankByGithubId: Map; @@ -73,8 +105,7 @@ function issueDiscoveryReason(row: { } async function refresh(): Promise { - const [prs, miners, repos] = await Promise.all([ - fetchJson(PRS_URL), + const [miners, repos] = await Promise.all([ fetchJson(MINERS_URL), fetchJson(REPOS_URL), ]); @@ -88,7 +119,7 @@ async function refresh(): Promise { const ossRanked = [...miners].sort((a, b) => num(b.totalScore) - num(a.totalScore)); const ossRankByGithubId = new Map(); 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; } @@ -100,12 +131,87 @@ async function getShared(): Promise { return inFlight; } +const repoMinersCache = new Map(); +const repoMinersInFlight = new Map>(); + +async function fetchRepoMiners(fullName: string): Promise { + 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) throw new Error(`upstream repo miners ${fullName} ${r.status}`); + 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(); const minersByLogin = new Map(); @@ -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(); - 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 @@ -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, diff --git a/src/app/api/gt/repositories/route.ts b/src/app/api/gt/repositories/route.ts index d5357b5..a83db67 100644 --- a/src/app/api/gt/repositories/route.ts +++ b/src/app/api/gt/repositories/route.ts @@ -126,6 +126,18 @@ async function refresh(): Promise { 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; @@ -135,6 +147,13 @@ async function refresh(): Promise { prsLastWeek: number; contributors: Set; lastPrAt: number; + dailyPrs14d: number[]; + // 30-day window — all use `prCreatedAt` to define the window + prsLast30d: number; + mergedLast30d: number; + closedLast30d: number; + contributors30d: Set; + dailyPrs30d: number[]; } const aggMap = new Map(); const ensure = (k: string): Agg => { @@ -150,6 +169,12 @@ async function refresh(): Promise { prsLastWeek: 0, contributors: new Set(), lastPrAt: 0, + dailyPrs14d: new Array(14).fill(0), + prsLast30d: 0, + mergedLast30d: 0, + closedLast30d: 0, + contributors30d: new Set(), + dailyPrs30d: new Array(30).fill(0), }; aggMap.set(key, a); } @@ -170,6 +195,31 @@ async function refresh(): Promise { 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) => { @@ -199,6 +249,12 @@ async function refresh(): Promise { 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), }; }); diff --git a/src/app/api/repos/metadata/route.ts b/src/app/api/repos/metadata/route.ts new file mode 100644 index 0000000..ba50ef1 --- /dev/null +++ b/src/app/api/repos/metadata/route.ts @@ -0,0 +1,446 @@ +/* Bulk repo metadata fetched from GitHub. + * + * The `/api/sn74-repos` mirror only carries the SN74 *policy* (emission + * share, label multipliers, eligibility, etc.) — it has no description, + * topics, or language breakdown. The `/repositories` page needs both for + * its card / list / drawer surfaces, so this route fans out to the GitHub + * REST API once for every SN74 repo and caches the result in-memory for an + * hour (descriptions and language ratios change rarely). + * + * Output shape: `{ [fullName]: { description, langs: [[name, pct], …] } }`. */ + +import { NextResponse } from 'next/server'; +import { withRotation } from '@/lib/github'; +import { getLiveReposAsyncServer } from '@/lib/repos-server'; + +export const dynamic = 'force-dynamic'; + +const CACHE_TTL_MS = 60 * 60 * 1000; // 1h — metadata churn is slow +const PER_REPO_TIMEOUT_MS = 30_000; // per-repo cap; covers up to ~10 sequential search pages + one rate-limit cooldown without falling back to stale cache +const CONCURRENCY = 4; // GitHub secondary rate limits punish bursts; throttle ourselves +/** How long to wait before re-trying a repo that came back without langs. + * Independent of CACHE_TTL_MS — a transient 5xx shouldn't sentence a repo + * to an hour of `—` in the UI. */ +const EMPTY_LANGS_RETRY_MS = 60_000; +/** Per-call retry budget for transient errors (5xx / network) inside a + * single refresh. Rate-limit handling already lives in withRotation. */ +const SUBCALL_RETRIES = 2; +const SUBCALL_BACKOFF_MS = 600; + +export interface RepoMeta { + description: string; + /** Languages sorted descending by byte share, expressed as percentages. */ + langs: Array<[string, number]>; + /** Live open pull request count from GitHub. -1 when the fetch failed. */ + openPrCount: number; + /** Daily count of GitHub issues opened on the repo over the last 30 + * days (oldest first, length 30). Index 0 = 29 days ago, 29 = today. + * Powers the Contributions chart's lower (issue) half. Empty array + * when the issues fetch failed. */ + dailyIssues30d: number[]; +} + +interface CacheEntry { + fetchedAt: number; + data: Record; +} + +let cache: CacheEntry | null = null; +let inflight: Promise | null = null; + +/** Run an async task list with bounded concurrency. */ +async function mapPool(items: T[], limit: number, fn: (item: T) => Promise): Promise>> { + const results: Array> = new Array(items.length); + let cursor = 0; + async function worker() { + while (true) { + const i = cursor++; + if (i >= items.length) return; + try { + results[i] = { status: 'fulfilled', value: await fn(items[i]) }; + } catch (err) { + results[i] = { status: 'rejected', reason: err }; + } + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return results; +} + +function withTimeout(promise: Promise, ms: number, label: string): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + promise.then( + (v) => { clearTimeout(t); resolve(v); }, + (e) => { clearTimeout(t); reject(e); }, + ); + }); +} + +/** Count open PRs via Link-header pagination — call with per_page=1 and the + * "last" page number IS the total count. One API call, no full pagination. */ +function parseLastPage(linkHeader: string | undefined): number | null { + if (!linkHeader) return null; + const match = linkHeader.match(/<[^>]*[?&]page=(\d+)[^>]*>;\s*rel="last"/); + return match ? parseInt(match[1], 10) : null; +} + +/** Search-API fetch of issues *created* in the last 30 days. Uses + * `is:issue created:>=DATE` so PRs are filtered server-side — the older + * `issues.listForRepo` path returned issues+PRs combined and the + * per_page=100 cap was being exhausted by PRs on active repos + * (e.g. on ragflow we'd see ~29 of 240 real issues). Search is capped + * at 100/page and 1000 total — paginate up to 10 pages. Uses the + * search-quota lane in withRotation (30/min per PAT). */ +async function fetchIssueCreates30d( + owner: string, + name: string, + thirtyDaysAgoIso: string, +): Promise<{ items: Array<{ created_at?: string }>; totalCount: number; hardCapped: boolean; incomplete: boolean }> { + const q = `repo:${owner}/${name} is:issue created:>=${thirtyDaysAgoIso}`; + const items: Array<{ created_at?: string }> = []; + let totalCount = 0; + let hardCapped = false; + let incomplete = false; + for (let page = 1; page <= 10; page++) { + const resp = await withRotation( + (o) => + o.search.issuesAndPullRequests({ + q, + sort: 'created', + order: 'desc', + per_page: 100, + page, + }), + { kind: 'search' }, + ); + totalCount = resp.data.total_count; + if (resp.data.incomplete_results) incomplete = true; + const batch = resp.data.items as Array<{ created_at?: string }>; + items.push(...batch); + if (batch.length < 100) break; + if (items.length >= totalCount) break; + if (page === 10 && items.length < totalCount) hardCapped = true; + } + return { items, totalCount, hardCapped, incomplete }; +} + +/** Retry a sub-call on transient errors (5xx / network). 404s and auth + * errors bubble immediately — no point retrying those. Rate-limit + * rotation already happens inside withRotation. */ +async function retrySubcall(fn: () => Promise): Promise { + let lastErr: unknown; + for (let attempt = 0; attempt <= SUBCALL_RETRIES; attempt++) { + try { + return await fn(); + } catch (err) { + lastErr = err; + const status = (err as { status?: number })?.status; + // 404, 401, 403 (after rotation exhaustion) — terminal, don't retry. + if (status && status >= 400 && status < 500 && status !== 408 && status !== 429) { + throw err; + } + if (attempt < SUBCALL_RETRIES) { + await new Promise((r) => setTimeout(r, SUBCALL_BACKOFF_MS * (attempt + 1))); + } + } + } + throw lastErr; +} + +/** Track per-repo when we last attempted a fill. Stops the empty-langs + * retry from hammering GitHub when a repo genuinely has no langs (e.g. + * docs-only repo) — we wait EMPTY_LANGS_RETRY_MS between retries. */ +const lastAttemptAt = new Map(); + +/** Per-repo "search issues succeeded since process start". Lets the + * partial-refresh helper distinguish "fetched and genuinely empty" + * (legit quiet repo) from "fetch failed → seeded with zeros" (needs + * retry). Without this, the empty-array sentinel is indistinguishable + * from a real empty result and we can't safely re-fetch. */ +const issuesFetched = new Set(); + +async function refresh(): Promise { + const { repos } = await getLiveReposAsyncServer(); + // Seed from the previous cache so a repo that fails *this* refresh keeps + // its last-known langs/description/openPrCount instead of disappearing + // for an hour. Per-field fallbacks below also merge with this so partial + // failures (e.g. pulls.list 403 but listLanguages 200) don't wipe out + // good fields that were just refreshed. + const map: Record = { ...(cache?.data ?? {}) }; + console.warn(`[repos/metadata] refresh begin — ${repos.length} repos`); + const t0 = Date.now(); + + // Per-field counters so the log line tells us *which* call is failing — + // helpful when GitHub rate-limits one endpoint but not others. + let okRepo = 0, okLang = 0, okPulls = 0, okIssues = 0; + let failRepo = 0, failLang = 0, failPulls = 0, failIssues = 0; + + // Day-bin helpers shared by every per-repo issues binning below. + const DAY_MS = 24 * 60 * 60 * 1000; + const now = Date.now(); + const todayStart = Math.floor(now / DAY_MS) * DAY_MS; + const thirtyDaysAgoIso = new Date(now - 30 * DAY_MS).toISOString(); + + const results = await mapPool(repos, CONCURRENCY, async (r) => { + const [owner, name] = r.fullName.split('/'); + if (!owner || !name) return null; + const key = r.fullName.toLowerCase(); + const prior = cache?.data[key]; + + // allSettled per sub-call so one rate-limit doesn't drop the whole + // repo. Each field independently falls back to the prior cached value + // when its specific call fails. Each call also has a small retry + // budget for transient 5xx / network hiccups (rate-limit rotation + // already happens inside withRotation). + lastAttemptAt.set(key, Date.now()); + const [repoResult, langResult, pullsResult, issuesResult] = await withTimeout( + Promise.allSettled([ + retrySubcall(() => withRotation((o) => o.repos.get({ owner, repo: name }))), + retrySubcall(() => withRotation((o) => o.repos.listLanguages({ owner, repo: name }))), + retrySubcall(() => + withRotation((o) => o.pulls.list({ owner, repo: name, state: 'open', per_page: 1 })), + ), + // 30-day issue history via the search API. `is:issue` filters + // PRs server-side so the per-page budget isn't burned on PRs + // (the older `issues.listForRepo` path returned both combined + // and the 100-row cap was exhausted by PRs on any active repo). + // See `fetchIssueCreates30d` for the pagination + 1000-row + // hard cap handling. + retrySubcall(() => fetchIssueCreates30d(owner, name, thirtyDaysAgoIso)), + ]), + PER_REPO_TIMEOUT_MS, + `repos/metadata ${r.fullName}`, + ); + + let description = prior?.description ?? ''; + if (repoResult.status === 'fulfilled') { + description = repoResult.value.data.description ?? ''; + okRepo++; + } else { + failRepo++; + console.warn(`[repos/metadata] ${r.fullName} repos.get failed:`, errMsg(repoResult.reason)); + } + + let langs: Array<[string, number]> = prior?.langs ?? []; + if (langResult.status === 'fulfilled') { + const langEntries = Object.entries(langResult.value.data) as Array<[string, number]>; + const total = langEntries.reduce((s, [, v]) => s + (v || 0), 0) || 1; + langs = langEntries + .map(([n, bytes]) => [n, (bytes / total) * 100] as [string, number]) + .sort((a, b) => b[1] - a[1]); + okLang++; + } else { + failLang++; + console.warn(`[repos/metadata] ${r.fullName} listLanguages failed:`, errMsg(langResult.reason)); + } + + let openPrCount = prior?.openPrCount ?? -1; + if (pullsResult.status === 'fulfilled') { + const linkHeader = pullsResult.value.headers?.link as string | undefined; + const lastPage = parseLastPage(linkHeader); + openPrCount = lastPage ?? pullsResult.value.data.length; + okPulls++; + } else { + failPulls++; + console.warn(`[repos/metadata] ${r.fullName} pulls.list failed:`, errMsg(pullsResult.reason)); + } + + // Issue-creation sparkline. Search API has already filtered PRs and + // already filtered by created_at server-side; bin by date into a + // 30-day oldest-first array. The bin filter still drops anything + // outside the window as a safety net against day-boundary drift. + let dailyIssues30d: number[] = prior?.dailyIssues30d ?? new Array(30).fill(0); + if (issuesResult.status === 'fulfilled') { + const { items, totalCount, hardCapped, incomplete } = issuesResult.value; + if (hardCapped) { + console.warn(`[repos/metadata] ${r.fullName} search hit 1000-row hard cap (total_count=${totalCount}) — extreme outlier`); + } + if (incomplete) { + console.warn(`[repos/metadata] ${r.fullName} search returned incomplete_results — bins may undercount until GitHub re-indexes`); + } + const bins = new Array(30).fill(0); + for (const it of items) { + if (!it.created_at) continue; + const t = Date.parse(it.created_at); + if (!Number.isFinite(t) || t <= 0) continue; + const dayStart = Math.floor(t / DAY_MS) * DAY_MS; + const daysAgo = Math.floor((todayStart - dayStart) / DAY_MS); + if (daysAgo < 0 || daysAgo >= 30) continue; + bins[29 - daysAgo] += 1; + } + dailyIssues30d = bins; + issuesFetched.add(key); + okIssues++; + } else { + failIssues++; + console.warn(`[repos/metadata] ${r.fullName} search issues failed:`, errMsg(issuesResult.reason)); + } + + return [r.fullName, { description, langs, openPrCount, dailyIssues30d }] as const; + }); + + for (const result of results) { + if (result.status === 'fulfilled' && result.value) { + const [fullName, meta] = result.value; + map[fullName.toLowerCase()] = meta; + } else if (result.status === 'rejected') { + // Only path here is withTimeout firing (the per-sub-call rejections + // are already swallowed by allSettled). Prior cache entry, if any, + // is preserved via the initial map spread. + console.warn('[repos/metadata] per-repo timeout:', errMsg(result.reason)); + } + } + console.warn( + `[repos/metadata] refresh done in ${Date.now() - t0}ms — ` + + `repo ${okRepo}/${okRepo + failRepo} · langs ${okLang}/${okLang + failLang} · ` + + `pulls ${okPulls}/${okPulls + failPulls} · issues ${okIssues}/${okIssues + failIssues}`, + ); + + const entry: CacheEntry = { fetchedAt: Date.now(), data: map }; + cache = entry; + return entry; +} + +function errMsg(e: unknown): string { + if (e instanceof Error) return e.message; + return String(e); +} + +/** Re-fetch just the per-repo sub-calls that came back empty / failed + * on the last attempt — covers transient 5xx / rate-limit cases + * without forcing a full hourly refresh. Throttled by `lastAttemptAt` + * per repo. Now handles both `langs` (empty array sentinel) and + * `issues` (never-fetched-since-startup sentinel via `issuesFetched`). */ +let partialInflight: Promise | null = null; +async function refreshMissing(): Promise { + if (partialInflight) return partialInflight; + if (!cache) return; + const { repos } = await getLiveReposAsyncServer(); + const now = Date.now(); + const stale = repos + .map((r) => { + const key = r.fullName.toLowerCase(); + const entry = cache?.data[key]; + const needsLangs = !entry || entry.langs.length === 0; + const needsIssues = !issuesFetched.has(key); + if (!needsLangs && !needsIssues) return null; + const last = lastAttemptAt.get(key) ?? 0; + if (now - last < EMPTY_LANGS_RETRY_MS) return null; + return { r, key, needsLangs, needsIssues }; + }) + .filter((x): x is { r: typeof repos[number]; key: string; needsLangs: boolean; needsIssues: boolean } => x !== null); + if (stale.length === 0) return; + partialInflight = (async () => { + console.warn(`[repos/metadata] partial refresh — ${stale.length} repo(s) (langs/issues backfill)`); + const results = await mapPool(stale, CONCURRENCY, async ({ r, key, needsLangs, needsIssues }) => { + const [owner, name] = r.fullName.split('/'); + if (!owner || !name) return null; + lastAttemptAt.set(key, Date.now()); + + // Run only the sub-calls this repo actually needs. allSettled so a + // langs failure doesn't block issues backfill (or vice-versa). + const tasks: Array> = []; + if (needsLangs) { + tasks.push( + retrySubcall(() => withRotation((o) => o.repos.listLanguages({ owner, repo: name }))), + ); + } else { + tasks.push(Promise.resolve(null)); + } + if (needsIssues) { + const thirtyDaysAgoIso = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + tasks.push(retrySubcall(() => fetchIssueCreates30d(owner, name, thirtyDaysAgoIso))); + } else { + tasks.push(Promise.resolve(null)); + } + const [langRes, issuesRes] = await withTimeout(Promise.allSettled(tasks), PER_REPO_TIMEOUT_MS, `repos/metadata partial ${r.fullName}`); + + let langs: Array<[string, number]> | null = null; + if (needsLangs && langRes.status === 'fulfilled' && langRes.value) { + // Same shape as the main-refresh listLanguages handler. + const data = (langRes.value as { data: Record }).data; + const langEntries = Object.entries(data) as Array<[string, number]>; + const total = langEntries.reduce((s, [, v]) => s + (v || 0), 0) || 1; + langs = langEntries + .map(([n, bytes]) => [n, (bytes / total) * 100] as [string, number]) + .sort((a, b) => b[1] - a[1]); + } else if (needsLangs) { + console.warn(`[repos/metadata] partial ${r.fullName} langs failed:`, errMsg(langRes.status === 'rejected' ? langRes.reason : 'unexpected')); + } + + let issueBins: number[] | null = null; + if (needsIssues && issuesRes.status === 'fulfilled' && issuesRes.value) { + const DAY_MS = 24 * 60 * 60 * 1000; + const todayStart = Math.floor(Date.now() / DAY_MS) * DAY_MS; + const bins = new Array(30).fill(0); + const { items } = issuesRes.value as { items: Array<{ created_at?: string }> }; + for (const it of items) { + if (!it.created_at) continue; + const t = Date.parse(it.created_at); + if (!Number.isFinite(t) || t <= 0) continue; + const dayStart = Math.floor(t / DAY_MS) * DAY_MS; + const daysAgo = Math.floor((todayStart - dayStart) / DAY_MS); + if (daysAgo < 0 || daysAgo >= 30) continue; + bins[29 - daysAgo] += 1; + } + issueBins = bins; + } else if (needsIssues) { + console.warn(`[repos/metadata] partial ${r.fullName} issues failed:`, errMsg(issuesRes.status === 'rejected' ? issuesRes.reason : 'unexpected')); + } + + return { key, fullName: r.fullName, langs, issueBins }; + }); + + let langsRecovered = 0; + let issuesRecovered = 0; + for (const result of results) { + if (result.status !== 'fulfilled' || !result.value) continue; + const { key, langs, issueBins } = result.value; + const entry = cache?.data[key]; + if (!entry) continue; + if (langs && langs.length > 0) { + entry.langs = langs; + langsRecovered++; + } + if (issueBins) { + entry.dailyIssues30d = issueBins; + issuesFetched.add(key); + issuesRecovered++; + } + } + console.warn(`[repos/metadata] partial refresh recovered langs ${langsRecovered}, issues ${issuesRecovered} / ${stale.length}`); + })().finally(() => { + partialInflight = null; + }); + return partialInflight; +} + +async function getCached(): Promise { + const now = Date.now(); + if (cache && now - cache.fetchedAt < CACHE_TTL_MS) { + // Fire-and-forget: opportunistically backfill any repos still missing + // langs. Doesn't block this response — next refetch picks up the + // recovered entries. Throttled per-repo so it can't burst-call. + void refreshMissing(); + return cache; + } + if (inflight) return inflight; + inflight = refresh().finally(() => { + inflight = null; + }); + return inflight; +} + +export async function GET() { + try { + const entry = await getCached(); + return NextResponse.json({ + fetched_at: new Date(entry.fetchedAt).toISOString(), + count: Object.keys(entry.data).length, + repos: entry.data, + }); + } catch (err) { + return NextResponse.json({ error: String(err), repos: {} }, { status: 502 }); + } +} diff --git a/src/app/api/sn74-emission/route.ts b/src/app/api/sn74-emission/route.ts new file mode 100644 index 0000000..2f5ca49 --- /dev/null +++ b/src/app/api/sn74-emission/route.ts @@ -0,0 +1,244 @@ +/* SN74 daily TAO emission, proxied from TaoMarketCap. + * + * Combines two upstream calls: + * 1. /internal/v1/subnets/74/ → alpha price + miners_tao_per_day + * 2. /internal/v1/subnets/neurons/74/ → per-UID alpha_per_day for all 256 UIDs + * + * From those we derive: + * alpha_price = latest_snapshot.subnet_moving_price (TAO/alpha) + * per-UID TAO/day = neuron.alpha_per_day × alpha_price + * recycleTaoPerDay = UID 0 (Gittensor's recycle sink) + * treasuryTaoPerDay = UID 111 (Gittensor's issues treasury) + * activeTaoPerDay = sum of all other UIDs + * totalSubnetTaoPerDay = recycle + treasury + active = the true on-chain + * daily emission to SN74 + * + * Cached in-memory for 60s with in-flight dedup so concurrent client + * refreshes never burst the upstream. + */ + +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const SUBNET_URL = 'https://api.taomarketcap.com/internal/v1/subnets/74/'; +const NEURONS_URL = 'https://api.taomarketcap.com/internal/v1/subnets/neurons/74/'; +const CACHE_TTL_MS = 60_000; +const FETCH_TIMEOUT_MS = 10_000; +const RECYCLE_UID = 0; +const TREASURY_UID = 111; +/** Bittensor block cadence: 12s/block → 7200 blocks/day. */ +const BLOCKS_PER_DAY = 7200; +/** rao → alpha (1 alpha = 1e9 rao). */ +const RAO_PER_ALPHA = 1e9; + +interface UpstreamSnapshot { + latest_snapshot?: { + miners_tao_per_day?: number | string; + /** Current instantaneous sqrt price (DEX-style). Squaring gives the + * alpha→TAO conversion that TaoMarketCap displays on neurons rows. */ + alpha_sqrt_price?: number | string; + /** EMA price — older/smoothed. Used as fallback. */ + subnet_moving_price?: number | string; + dtao?: { + daily_alpha_emission?: number | string; + daily_burn?: number | string; + effective_daily_emission?: number | string; + /** Subnet owner's alpha cut per block, in rao. Taken off the top + * before per-UID distribution, so it doesn't appear in the neurons + * endpoint. Daily owner alpha = owner_cut_per_block × 7200 / 1e9. */ + owner_cut_per_block?: number | string; + }; + }; +} + +interface UpstreamNeuron { + uid?: number; + alpha_per_day?: number | string | null; + is_validator?: boolean | null; + is_miner?: boolean | null; + hotkey?: string | null; +} + +export interface Sn74EmissionSnapshot { + /** Headline emission — matches TaoMarketCap's "Emissions/Day": + * daily_alpha_emission × price = full subnet emission per day. */ + totalTaoPerDay: number; + /** Miner-side daily slice (= (total − owner) / 2). Includes recycle + * (UID 0) and treasury (UID 111) as sub-components since both are + * on-chain miner UIDs. Matches TaoMarketCap's "Miner/Day". */ + minerTaoPerDay: number; + /** Validator-side daily slice (= (total − owner) / 2). The owner cut + * flows to the owner_hotkey (a validator) on-chain but is shown + * separately as `ownerTaoPerDay`. Matches TaoMarketCap's "Validator/Day". */ + validatorTaoPerDay: number; + /** Per-UID recycle emission to UID 0 (Gittensor recycle sink). + * Sub-component of `minerTaoPerDay`. */ + recycleTaoPerDay: number; + /** Per-UID treasury emission to UID 111 (issues treasury). + * Sub-component of `minerTaoPerDay`. */ + treasuryTaoPerDay: number; + /** Per-UID sum of active (non-recycle, non-treasury) miner alpha. + * Used for per-repo TAO math — the "claimable for contributors" + * slice. NOT shown in the headline cards (those use the + * TaoMarketCap-style 50/50 split via `minerTaoPerDay`). */ + activeMinerTaoPerDay: number; + /** Subnet owner's daily TAO cut, derived from + * `dtao.owner_cut_per_block × 7200 blocks/day`. Paid as elevated + * dividends to the owner_hotkey UID on-chain but surfaced separately + * here to match TaoMarketCap's "Owner/Day" card. */ + ownerTaoPerDay: number; + /** Count of UIDs in each category, for context on the cards. */ + minerCount: number; + validatorCount: number; + /** Alpha → TAO price used for the per-UID conversion. */ + alphaPriceInTao: number; + /** TaoMarketCap's `miners_tao_per_day` for cross-reference (a narrower + * metric — not the per-UID miner sum we compute). */ + minersTaoPerDayUpstream: number | null; + /** Back-compat alias (was `taoPerDay`); same as `totalTaoPerDay`. */ + taoPerDay: number; + alphaPerDay: number | null; + effectiveAlphaPerDay: number | null; + alphaBurnPerDay: number | null; + fetched_at: number; +} + +// Renamed on each schema change so Next.js HMR drops any stale +// pre-refactor cache that lacked newer fields — those would otherwise +// read as undefined → 0 on the client. +let cacheV4: Sn74EmissionSnapshot | null = null; +let inflightV4: Promise | null = null; + +function num(v: unknown): number | null { + if (v == null) return null; + const n = typeof v === 'number' ? v : Number.parseFloat(String(v)); + return Number.isFinite(n) ? n : null; +} + +async function fetchJson(url: string): Promise { + const r = await fetch(url, { + cache: 'no-store', + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: { accept: 'application/json' }, + }); + if (!r.ok) throw new Error(`upstream ${url} ${r.status}`); + return (await r.json()) as T; +} + +async function refresh(): Promise { + const [subnetData, neurons] = await Promise.all([ + fetchJson(SUBNET_URL), + fetchJson(NEURONS_URL), + ]); + const snap = subnetData.latest_snapshot ?? {}; + // TaoMarketCap renders the per-UID τ/Day column as + // alpha_per_day × (alpha_sqrt_price)² + // — the instantaneous DEX price, not the EMA `subnet_moving_price`. + // Match that so our numbers align pixel-for-pixel with their site. + // Fall back to `subnet_moving_price` if the sqrt-price field is missing. + const sqrtPrice = num(snap.alpha_sqrt_price); + const movingPrice = num(snap.subnet_moving_price); + const alphaPrice = sqrtPrice != null ? sqrtPrice * sqrtPrice : movingPrice; + if (alphaPrice == null) throw new Error('upstream missing alpha_sqrt_price and subnet_moving_price'); + + // Classify each UID and accumulate alpha: + // UID 0 → recycle sink + // UID 111 → issues treasury + // is_validator → validator (Bittensor bundles subnet-owner cut into + // validator dividends, so this also covers the owner) + // is_miner → miner (PR contributor — what we surface as "claimable") + let recycleAlpha = 0; + let treasuryAlpha = 0; + let minerAlpha = 0; + let validatorCount = 0; + let minerCount = 0; + for (const n of neurons) { + const a = num(n.alpha_per_day) ?? 0; + if (n.uid === RECYCLE_UID) { + recycleAlpha += a; + } else if (n.uid === TREASURY_UID) { + treasuryAlpha += a; + } else if (n.is_validator) { + // validator UID alpha is not summed — the headline validator + // figure is derived from `(gross − owner) / 2`. We still count + // UIDs for the card sub-text. + validatorCount++; + } else if (n.is_miner) { + minerAlpha += a; + minerCount++; + } + } + + // Headline emission math — match TaoMarketCap's authoritative figures: + // Emissions/Day = daily_alpha_emission × price (gross, 7200α) + // Owner/Day = owner_cut_per_block × 7200 × price + // Miner/Day = (gross − owner) / 2 + // Validator/Day = (gross − owner) / 2 + // TMC uses the 50/50 chain split between miner-side and validator-side + // emission, post-owner-cut. Owner is shown as a separate card even + // though on-chain it's paid to the owner_hotkey UID (which is also a + // validator). The recycle (UID 0) and treasury (UID 111) per-UID sums + // are kept around for the granular sub-breakdown but they're + // sub-components of the miner-side slice — they shouldn't be added + // on top of `minerTaoPerDay`. + const ownerCutPerBlock = num(snap.dtao?.owner_cut_per_block); + const ownerAlphaPerDay = ownerCutPerBlock != null + ? (ownerCutPerBlock * BLOCKS_PER_DAY) / RAO_PER_ALPHA + : 0; + const dailyAlphaEmission = num(snap.dtao?.daily_alpha_emission); + const grossAlphaPerDay = dailyAlphaEmission != null + ? dailyAlphaEmission / RAO_PER_ALPHA + : 0; + const totalTaoPerDay = grossAlphaPerDay * alphaPrice; + const ownerTaoPerDay = ownerAlphaPerDay * alphaPrice; + const participantTaoPerDay = Math.max(0, totalTaoPerDay - ownerTaoPerDay); + const minerTaoPerDay = participantTaoPerDay / 2; + const validatorTaoPerDay = participantTaoPerDay / 2; + // Per-UID granular values — used only for the optional sub-breakdown + // and for per-repo TAO math (which needs the active-miner slice). + const recycleTaoPerDay = recycleAlpha * alphaPrice; + const treasuryTaoPerDay = treasuryAlpha * alphaPrice; + const activeMinerTaoPerDay = minerAlpha * alphaPrice; + + const next: Sn74EmissionSnapshot = { + totalTaoPerDay, + minerTaoPerDay, + validatorTaoPerDay, + activeMinerTaoPerDay, + recycleTaoPerDay, + treasuryTaoPerDay, + ownerTaoPerDay, + minerCount, + validatorCount, + alphaPriceInTao: alphaPrice, + minersTaoPerDayUpstream: num(snap.miners_tao_per_day), + taoPerDay: totalTaoPerDay, + alphaPerDay: num(snap.dtao?.daily_alpha_emission), + effectiveAlphaPerDay: num(snap.dtao?.effective_daily_emission), + alphaBurnPerDay: num(snap.dtao?.daily_burn), + fetched_at: Date.now(), + }; + cacheV4 = next; + return next; +} + +async function getCached(): Promise { + const now = Date.now(); + if (cacheV4 && now - cacheV4.fetched_at < CACHE_TTL_MS) return cacheV4; + if (inflightV4) return inflightV4; + inflightV4 = refresh().finally(() => { + inflightV4 = null; + }); + return inflightV4; +} + +export async function GET() { + try { + const fresh = await getCached(); + return NextResponse.json({ ...fresh, source: 'live' }); + } catch (err) { + if (cacheV4) return NextResponse.json({ ...cacheV4, source: 'stale', error: String(err) }); + return NextResponse.json({ error: String(err) }, { status: 502 }); + } +} diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx index cb61c21..3869b34 100644 --- a/src/app/docs/page.tsx +++ b/src/app/docs/page.tsx @@ -283,22 +283,25 @@ reward share = PR share x effective repo PR reward pool`}

- /repositories — the full catalog of every repo the dashboard knows about, with - per-repository statistics: + /repositories — a strategy-oriented SN74 repository catalog. It combines the live + whitelist policy with Gittensor PR aggregates, GitHub metadata, and subnet emission estimates so + miners can compare where work is likely to be valuable.

    -
  • Weight / emission share: the repo's configured SN74 reward allocation share (0-1)
  • -
  • Band: Flagship (≥0.5), High (0.3–0.5), Mid-high (0.15–0.3), Standard (0.05–0.15), Low
  • -
  • Issues / Open: total cached issues + currently open
  • -
  • PRs / PR Open / Merged: total / open / merged pulls
  • -
  • Activity: last update timestamp across issues + PRs
  • +
  • Market view: emission, miner / validator split, recycling, treasury, and owner-cut context for SN74
  • +
  • Strategy filters: show all repos or bias the list toward bugs, enhancements, features, refactors, or issue discovery
  • +
  • Card / list views: TAO/day estimates, repo stream split, label multipliers, language metadata, 30-day PR activity, and issue submissions
  • +
  • Compare: add up to four repositories and inspect relative reward, competition, stream, label, and eligibility signals
  • +
  • Drawer: open a repository for GitHub links, description, emission breakdown, eligibility rules, labels, and language details
  • +
  • Tracking: star repositories from the cards or list rows to keep them in your tracked repo set for issue and PR filtering

The SN74 whitelist auto-syncs from{' '} master_repositories.json {' '} - every hour. Custom repos added via Manage Repositories appear with a blue CUSTOM pill. + every few minutes. GitHub descriptions, language ratios, open PR counts, and issue-submission + sparklines are cached separately because that metadata changes more slowly.

diff --git a/src/app/repositories/_components/Avatar.tsx b/src/app/repositories/_components/Avatar.tsx new file mode 100644 index 0000000..bd17f38 --- /dev/null +++ b/src/app/repositories/_components/Avatar.tsx @@ -0,0 +1,46 @@ +'use client'; + +import React from 'react'; +import styles from '../page.module.css'; + +interface AvatarProps { + fullName: string; + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + pxSize?: number; + className?: string; +} + +const sizeClass: Record, string> = { + xs: styles.avatarXs, + sm: styles.avatarSm, + md: styles.avatarMd, + lg: styles.avatarLg, + xl: styles.avatarXl, +}; + +const defaultPx: Record, number> = { + xs: 32, + sm: 48, + md: 64, + lg: 96, + xl: 128, +}; + +export default function Avatar({ fullName, size = 'md', pxSize, className }: AvatarProps) { + const owner = fullName.split('/')[0] ?? fullName; + const px = pxSize ?? defaultPx[size]; + const src = `https://github.com/${encodeURIComponent(owner)}.png?size=${px}`; + const cls = [styles.avatar, sizeClass[size], className].filter(Boolean).join(' '); + // Native — next/image needs predeclared remote patterns and the rest + // of the app (AppHeader, UserMenu, etc.) uses the same direct-fetch pattern. + return ( + // eslint-disable-next-line @next/next/no-img-element + {owner} + ); +} diff --git a/src/app/repositories/_components/CompareModal.tsx b/src/app/repositories/_components/CompareModal.tsx new file mode 100644 index 0000000..349cdb7 --- /dev/null +++ b/src/app/repositories/_components/CompareModal.tsx @@ -0,0 +1,948 @@ +'use client'; + +import React, { useEffect } from 'react'; +import styles from '../page.module.css'; +import Avatar from './Avatar'; +import { LABEL_COLORS, LABEL_KEYS, LANG_COLORS, formatLangPct } from '../_lib/colors'; +import { + competitionLevel, + decisionScore, + effectiveLabelMult, + eligibilityRisk, + expectedTAOPerPR, + formatTAO, + mergeSpeedLevel, + openSlotPressure, + repoDailyTAO, + repoIssueTAO, + repoMaintainerTAO, + repoPerMaintainerTAO, + repoPRTAO, + rewardSignal, + type RepoRow, + type StrategyKey, +} from '../_lib/incentives'; + +interface CompareModalProps { + open: boolean; + repos: RepoRow[]; + subnetTAO: number; + strategy: StrategyKey; + onClose: () => void; + onRemove: (full: string) => void; +} + +export default function CompareModal({ open, repos, subnetTAO, strategy, onClose, onRemove }: CompareModalProps) { + // Lock body scroll while open + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = prev; + }; + }, [open]); + + // ESC closes — matches HTML's global keydown handler that closed drawer + + // palette + compare panel together. + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + + const cols = Math.max(1, repos.length); + // Side-by-side columns: left label rail + N panel columns + const colStyle: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: `minmax(120px, 160px) repeat(${cols}, minmax(170px, 1fr))`, + gap: 12, + marginBottom: 12, + }; + + const allLabels = new Set(); + repos.forEach((r) => r.labels && Object.keys(r.labels).forEach((l) => allLabels.add(l))); + // Show canonical labels first (in protocol-declared order), then any extras. + const canonicalKeys: string[] = (LABEL_KEYS as readonly string[]).filter((l) => allLabels.has(l)); + const extraKeys: string[] = Array.from(allLabels).filter((l) => !(LABEL_KEYS as readonly string[]).includes(l)); + const labelList: string[] = [...canonicalKeys, ...extraKeys]; + + return ( + <> +
+
+
+
+
+ Side-by-side comparison +
+

Where should I send my next PR?

+
+ +
+ +
+ {/* Mobile stacked layout — sticky per-repo tabs + vertical sections */} +
+ +
+ + {/* Desktop side-by-side grid (≥768px) */} +
+ {/* Header row: repo cards */} +
+
+ {repos.map((r) => ( +
+
+
+ +
+
+ {r.owner}/ +
+
+ {r.name} +
+
+
+ +
+
+ {r.isSelf ? your repo : null} + {r.trusted ? trusted : null} + {r.share === 0 ? benchmark : null} +
+
+ ))} +
+ + ( + <> +
+ {formatTAO(repoDailyTAO(r, subnetTAO))} + τ/day +
+
{(r.share * 100).toFixed(3)}% × 90% OSS
+ + )} /> + + ( + (r.maintCut || 0) === 0 ? ( +
none
+ ) : ( + <> +
{formatTAO(repoMaintainerTAO(r, subnetTAO))}
+
+ {(r.maintCut * 100).toFixed(0)}% off the top · {r.maintainerCount} maint ·{' '} + {formatTAO(repoPerMaintainerTAO(r, subnetTAO))} ea +
+ {r.demoMaint ? demo : null} + + ) + )} /> + + ( + r.share === 0 ? ( +
+ ) : ( + <> +
{formatTAO(repoPRTAO(r, subnetTAO))}
+
+ {(r.maintCut || 0) > 0 + ? `${((1 - r.maintCut) * (1 - r.issue) * 100).toFixed(0)}% (after the cut)` + : `${((1 - r.issue) * 100).toFixed(0)}% of slice`} + {' · TAO/day'} +
+ + ) + )} /> + + ( + r.share === 0 || r.issue === 0 ? ( +
+ ) : ( + <> +
{formatTAO(repoIssueTAO(r, subnetTAO))}
+
+ {(r.maintCut || 0) > 0 + ? `${((1 - r.maintCut) * r.issue * 100).toFixed(0)}% (after the cut)` + : `${(r.issue * 100).toFixed(0)}% of slice`} + {' · TAO/day'} +
+ + ) + )} /> + + ( + <> +
+ {r.activity.merged30d} + merged +
+
+ {r.activity.openPRs} open · {r.activity.contribs} contribs +
+ + )} /> + + { + const cred = r.activity.merged30d + r.activity.closed30d > 0 + ? r.activity.merged30d / (r.activity.merged30d + r.activity.closed30d) + : 0; + const color = + cred >= 0.85 ? 'var(--color-moss-400)' : + cred >= 0.7 ? 'var(--color-enh)' : + 'var(--color-refact)'; + return ( +
{(cred * 100).toFixed(0)}%
+ ); + }} /> + + {/* Practical decision factors heading */} +
+
+ Practical factors +
+ {repos.map((r) =>
)} +
+ + { + if (r.share === 0) return
; + const v = expectedTAOPerPR(r, strategy, subnetTAO); + return ( + <> +
+ {formatTAO(v)} τ +
+
+ ~ pool ÷ merge rate ·{' '} + {strategy !== 'none' && strategy !== 'issue' + ? `×${effectiveLabelMult(r, strategy).toFixed(2)} ${strategy}` + : 'no strategy applied'} +
+ + ); + }} /> + + { + const c = competitionLevel(r); + return ( + <> +
+ + {c.label} +
+
{c.desc}
+ + ); + }} /> + + { + const s = mergeSpeedLevel(r); + return ( + <> +
{s.label}
+
+ {s.desc} demo +
+ + ); + }} /> + + { + const e = eligibilityRisk(r); + return ( + <> +
{e.level}
+
{e.text}
+ + ); + }} /> + + { + const p = openSlotPressure(r); + return ( + <> +
+ + {p.label} + demo +
+
{p.text}
+ + ); + }} /> + + ( +
+ {r.langs.slice(0, 3).map(([n, p]) => ( + + + {n} {formatLangPct(p)} + + ))} + {r.langs.length === 0 ? : null} +
+ )} /> + + {/* Label multipliers section */} +
+
+ Label multipliers +
+ {repos.map((r) =>
)} +
+ + {labelList.length === 0 ? ( +
+
+ {repos.map((r) => ( +
+ No multipliers configured. +
+ ))} +
+ ) : labelList.map((label) => { + const color = LABEL_COLORS[label] ?? { fg: 'var(--fg-muted)', soft: 'rgba(146,152,163,0.10)' }; + const highlight = strategy === label; + return ( +
+
+ + {label} +
+ {repos.map((r) => { + const m = r.labels?.[label] ?? r.defaultLabel; + const configured = r.labels?.[label] !== undefined; + const isPenalty = m < 1; + const isHigh = m >= 1.3; + const barPct = Math.min(100, (m / 2.0) * 100); + return ( +
+
+ + ×{m.toFixed(2)} + + {configured ? null : ( + + default + + )} +
+
+ +
+
+ ); + })} +
+ ); + })} + + { + if (r.eligibility) { + return ( + <> + {Object.entries(r.eligibility).map(([k, v]) => ( +
+ {k} + {v} +
+ ))} + + ); + } + return ( +
+ Defaults: 3 valid PRs · cred ≥ 0.80 +
+ ); + }} /> + + ( + r.trusted ? ( +
+ + + + Yes +
+ ) : ( +
No
+ ) + )} /> + + {/* Verdict row */} +
+
+ {strategy === 'none' ? 'For miners' : 'For your strategy'} +
+ {repos.map((r) => ( +
+ +
+ ))} +
+
{/* /cmpDesktop */} + + {/* Decision summary sits below both layouts */} + +
+
+ + ); +} + +function CompareRow({ + label, + repos, + colStyle, + render, +}: { + label: string; + repos: RepoRow[]; + colStyle: React.CSSProperties; + render: (r: RepoRow) => React.ReactNode; +}) { + return ( +
+
+ {label} +
+ {repos.map((r) => ( +
+ {render(r)} +
+ ))} +
+ ); +} + +function Verdict({ r, strategy, subnetTAO }: { r: RepoRow; strategy: StrategyKey; subnetTAO: number }) { + if (r.share === 0) { + return <>Benchmark repo — PRs don't pay TAO. Calibration target only.; + } + if (strategy === 'issue') { + if (r.issue === 0) return <>No issue-discovery stream. Skip for issue strategy.; + return ( + <> + {(r.issue * 100).toFixed(0)}% of slice for issues:{' '} + {formatTAO(repoIssueTAO(r, subnetTAO))} TAO/day in the issue pool. + + ); + } + if (strategy !== 'none') { + const m = effectiveLabelMult(r, strategy); + const sig = subnetTAO * rewardSignal(r, strategy); + if (m >= 1.3) { + return ( + <> + ×{m.toFixed(2)} for {strategy} — premium. + Max signal: {formatTAO(sig)} TAO/day if you dominate the repo's PR scoring. + + ); + } + if (m >= 1.0) { + return ( + <> + ×{m.toFixed(2)} for {strategy} — neutral. + Max signal: {formatTAO(sig)} TAO/day. + + ); + } + if (m >= 0.5) { + return ( + <> + ×{m.toFixed(2)} for {strategy} — partial. + Cap: {formatTAO(sig)} TAO/day. + + ); + } + return ( + <> + ×{m.toFixed(2)} for {strategy} — heavily penalized. + Cap: {formatTAO(sig)} TAO/day. Avoid for {strategy} work. + + ); + } + if (r.share >= 0.05) { + return ( + <> + Top share. {formatTAO(repoDailyTAO(r, subnetTAO))} TAO/day in the slice, but expect competition. + + ); + } + if (r.share >= 0.02) { + return ( + <> + Mid-tier. {formatTAO(repoDailyTAO(r, subnetTAO))} TAO/day — quieter than the top. + + ); + } + return ( + <> + Tail share — {formatTAO(repoDailyTAO(r, subnetTAO))} TAO/day. Good for credibility-building (3 valid PRs at cred ≥ 0.80). + + ); +} + +function DecisionSummary({ repos, strategy, subnetTAO }: { repos: RepoRow[]; strategy: StrategyKey; subnetTAO: number }) { + const eligible = repos.filter((r) => r.share > 0); + if (eligible.length < 2) return null; + const scored = eligible + .map((r) => ({ repo: r, score: decisionScore(r, strategy, subnetTAO) })) + .sort((a, b) => b.score - a.score); + const top = scored[0]; + const others = scored.slice(1); + const r = top.repo; + + const reasons: React.ReactNode[] = []; + const exp = expectedTAOPerPR(r, strategy, subnetTAO); + if (exp > 0) { + reasons.push( + + yields ~{formatTAO(exp)} τ per merged PR + , + ); + } + if (r.activity.medianMergeHours != null && r.activity.medianMergeHours <= 24) { + reasons.push( + + merges in ~{r.activity.medianMergeHours}h + , + ); + } + const cred = (r.activity.merged30d || 0) / Math.max(1, (r.activity.merged30d || 0) + (r.activity.closed30d || 0)); + if (cred >= 0.85) { + reasons.push( + + {(cred * 100).toFixed(0)}% repo merge rate + , + ); + } + if ((r.activity.userOpenPRs || 0) === 0) reasons.push(no open-PR pressure for you); + if (strategy !== 'none' && strategy !== 'issue' && effectiveLabelMult(r, strategy) >= 1.25) { + reasons.push( + + ×{effectiveLabelMult(r, strategy).toFixed(2)} {strategy} multiplier + , + ); + } + + return ( +
+
+
+ + + +
+
+
+ Suggested pick +
+
+ + + {top.repo.owner}/ + {top.repo.name} + +
+ {reasons.length > 0 ? ( +
+ {reasons.map((r2, i) => ( + {i > 0 ? ' · ' : ''}{r2} + ))} +
+ ) : null} + {others.length > 0 ? ( +
+ Runners-up: + {others.slice(0, 3).map((o, i) => ( + + {o.repo.owner}/ + {o.repo.name} ({o.score.toFixed(3)}) + {i < Math.min(others.length, 3) - 1 ? ' ·' : ''} + + ))} +
+ ) : null} +
+ Composite score: expected per-PR × merge speed × credibility × open-slot pressure. A heuristic, not a guarantee. +
+
+
+
+ ); +} + +/* ────────────────────────────────────────────────────────────────────── + * Mobile stacked variant (< 768px) + * + * Instead of fighting horizontal scroll, give each repo its own vertical + * panel with every metric inside. A sticky tab strip at the top jumps + * between repos via anchor scroll. Ported from `renderCompareMobile` in + * the HTML prototype. + * ────────────────────────────────────────────────────────────────────── */ +function CompareMobile({ + repos, + labelList, + strategy, + subnetTAO, + onRemove, +}: { + repos: RepoRow[]; + labelList: string[]; + strategy: StrategyKey; + subnetTAO: number; + onRemove: (full: string) => void; +}) { + return ( + <> +
+ {repos.map((r, i) => ( + + + {r.name} + + ))} +
+ +
+ {repos.map((r, i) => { + const cred = + r.activity.merged30d + r.activity.closed30d > 0 + ? r.activity.merged30d / (r.activity.merged30d + r.activity.closed30d) + : 0; + const credColor = + cred >= 0.85 ? 'var(--color-moss-400)' : + cred >= 0.7 ? 'var(--color-enh)' : + 'var(--color-refact)'; + const comp = competitionLevel(r); + const merge = mergeSpeedLevel(r); + const pressure = openSlotPressure(r); + const risk = eligibilityRisk(r); + return ( +
+ {/* Repo header */} +
+
+ +
+
{r.owner}/
+
+ {r.name} +
+
+ {r.isSelf ? your repo : null} + {r.trusted ? trusted : null} + {r.share === 0 ? benchmark : null} + {r.issue === 1 ? issues only : null} + {r.issue > 0 && r.issue < 1 ? mixed : null} +
+
+
+ +
+ + {/* Headline TAO */} +
+
Daily TAO emission
+
+ {formatTAO(repoDailyTAO(r, subnetTAO))} + τ/day +
+
+ {(r.share * 100).toFixed(3)}% × 90% OSS +
+
+ + {/* Pool grid: maint / activity / PR / issue / credibility */} +
+ {(r.maintCut || 0) > 0 ? ( +
+
Maintainer cut
+
+ {formatTAO(repoMaintainerTAO(r, subnetTAO))} +
+
+ {(r.maintCut * 100).toFixed(0)}% · {r.maintainerCount} maint +
+
+ {formatTAO(repoPerMaintainerTAO(r, subnetTAO))} ea + {r.demoMaint ? demo : null} +
+
+ ) : ( +
+
Maintainer cut
+
none
+
+ )} +
+
PR activity · 30d
+
+ {r.activity.merged30d} + merged +
+
+ {r.activity.openPRs} open · {r.activity.contribs} contrib +
+
+ {r.share > 0 ? ( +
+
PR slice
+
+ {formatTAO(repoPRTAO(r, subnetTAO))} +
+
+ {(r.maintCut || 0) > 0 + ? `${((1 - r.maintCut) * (1 - r.issue) * 100).toFixed(0)}% (after cut)` + : `${((1 - r.issue) * 100).toFixed(0)}% of slice`} +
+
+ ) : null} + {r.share > 0 && r.issue > 0 ? ( +
+
Issue discovery slice
+
+ {formatTAO(repoIssueTAO(r, subnetTAO))} +
+
+ {(r.maintCut || 0) > 0 + ? `${((1 - r.maintCut) * r.issue * 100).toFixed(0)}% (after cut)` + : `${(r.issue * 100).toFixed(0)}% of slice`} +
+
+ ) : null} +
+
Merge rate · 30d
+
+ {(cred * 100).toFixed(0)}% +
+
+
+ + {/* Practical factors */} + {r.share > 0 ? ( + <> +
+ Practical factors +
+
+
+
Per merged PR
+
+ {formatTAO(expectedTAOPerPR(r, strategy, subnetTAO))} + τ +
+
expected yield
+
+
+
Competition
+
+ + {comp.label} +
+
{comp.desc}
+
+
+
Time to merge
+
{merge.label}
+
{merge.desc}
+
+
+
Open-PR pressure
+
+ + {pressure.label} +
+
+ {r.activity.userOpenPRs || 0} of your PRs open +
+
+
+
+
Eligibility risk
+
{risk.level}
+
{risk.text}
+
+ + ) : null} + + {/* Languages */} + {r.langs.length > 0 ? ( +
+
Primary languages
+
+ {r.langs.slice(0, 4).map(([n, p]) => ( + + + {n} {formatLangPct(p)} + + ))} +
+
+ ) : null} + + {/* Label multipliers */} + {labelList.length > 0 ? ( +
+
Label multipliers
+
+ {labelList.map((label) => { + const color = LABEL_COLORS[label] ?? { fg: 'var(--fg-subtle)', soft: '' }; + const m = r.labels?.[label] ?? r.defaultLabel; + const configured = r.labels?.[label] !== undefined; + const isPenalty = m < 1; + const isHigh = m >= 1.3; + const barPct = Math.min(100, (m / 2.0) * 100); + const highlight = strategy === label; + return ( +
+ + {label} + +
+ +
+ + ×{m.toFixed(2)} + +
+ ); + })} +
+
+ ) : ( +
+ No label multipliers configured. +
+ )} + + {/* Verdict */} +
+
+ {strategy === 'none' ? 'For miners' : 'For your strategy'} +
+
+ +
+
+
+ ); + })} +
+ + ); +} diff --git a/src/app/repositories/_components/CompareTray.tsx b/src/app/repositories/_components/CompareTray.tsx new file mode 100644 index 0000000..e3231fa --- /dev/null +++ b/src/app/repositories/_components/CompareTray.tsx @@ -0,0 +1,107 @@ +'use client'; + +import React from 'react'; +import styles from '../page.module.css'; +import Avatar from './Avatar'; +import { formatTAO, repoDailyTAO, type RepoRow } from '../_lib/incentives'; + +interface CompareTrayProps { + rows: RepoRow[]; + subnetTAO: number; + onRemove: (fullName: string) => void; + onClear: () => void; + onOpen: () => void; +} + +const MAX = 4; + +export default function CompareTray({ rows, subnetTAO, onRemove, onClear, onOpen }: CompareTrayProps) { + const n = rows.length; + if (n === 0) return null; + + return ( +
+
+
+ Comparing +
+
+ {rows.map((r) => ( +
+ + + {r.owner}/ + {r.name} + + + {formatTAO(repoDailyTAO(r, subnetTAO))} τ/d + + +
+ ))} + {n < MAX ? ( + + {MAX - n} more slot{n === MAX - 1 ? '' : 's'} + + ) : null} +
+ + +
+
+ ); +} diff --git a/src/app/repositories/_components/Drawer.tsx b/src/app/repositories/_components/Drawer.tsx new file mode 100644 index 0000000..026c26b --- /dev/null +++ b/src/app/repositories/_components/Drawer.tsx @@ -0,0 +1,1048 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import styles from '../page.module.css'; +import Avatar from './Avatar'; +import { LABEL_COLORS, LANG_COLORS, LANG_NAME_ICONS, formatLangPct } from '../_lib/colors'; +import LangIcon from './LangIcon'; +import { + formatTAO, + repoDailyTAO, + repoIssueTAO, + repoMaintainerTAO, + repoPerMaintainerTAO, + repoPRTAO, + type RepoRow, +} from '../_lib/incentives'; +import { squarify } from '../_lib/squarify'; +import type { RepoMiner, RepoMinersResponse } from '@/types/entities'; + +interface DrawerProps { + open: boolean; + row: RepoRow | null; + subnetTAO: number; + isInCompare: boolean; + /** Whether /api/repos/metadata has resolved (regardless of whether this + * specific repo has a description / languages on GitHub). Lets the drawer + * distinguish "still loading" from "loaded but empty". */ + metadataLoaded: boolean; + onClose: () => void; + onToggleCompare: (full: string) => void; +} + +export default function Drawer({ + open, + row, + subnetTAO, + isInCompare, + metadataLoaded, + onClose, + onToggleCompare, +}: DrawerProps) { + // Esc closes + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + + if (!row) { + return ( + <> +
+