From 55bab235cb6959c973ecbc04b8c5a7b254dd3f4c Mon Sep 17 00:00:00 2001 From: statxc Date: Sat, 9 May 2026 02:35:01 +0000 Subject: [PATCH] fix(dashboard): dedupe issues fan-out --- src/api/MinerApi.ts | 18 ++- src/pages/dashboard/dashboardData.ts | 36 ++++- src/pages/dashboard/useDashboardData.ts | 56 ++++++- src/tests/dashboardIssuesTrend.test.ts | 204 ++++++++++++++++++++++++ 4 files changed, 303 insertions(+), 11 deletions(-) create mode 100644 src/tests/dashboardIssuesTrend.test.ts diff --git a/src/api/MinerApi.ts b/src/api/MinerApi.ts index e150ab35..2cabd160 100644 --- a/src/api/MinerApi.ts +++ b/src/api/MinerApi.ts @@ -96,12 +96,24 @@ export const useMinerIssues = (githubId: string, enabled?: boolean) => ); /** - * Fan-out variant: one mirror-API call per miner, useful for the watchlist. + * Fan-out variant: one mirror-API call per miner, useful for the watchlist + * and the dashboard issues-trend aggregation. + * + * `since` (ISO timestamp) is forwarded to the mirror as a query param. Omit + * for the mirror's default 35-day window — this also keeps the cache key + * stable across callers that don't need a custom range. */ -export const useMinersIssues = (githubIds: string[], enabled?: boolean) => +export const useMinersIssues = ( + githubIds: string[], + enabled?: boolean, + since?: string, +) => useMirrorApiQueries( 'useMinerIssues', - githubIds.map((id) => `/miners/${id}/issues`), + githubIds.map((id) => { + const path = `/miners/${id}/issues`; + return since ? `${path}?since=${encodeURIComponent(since)}` : path; + }), { enabled, select: (data) => data?.issues ?? [], diff --git a/src/pages/dashboard/dashboardData.ts b/src/pages/dashboard/dashboardData.ts index 187ab4f4..160b19c5 100644 --- a/src/pages/dashboard/dashboardData.ts +++ b/src/pages/dashboard/dashboardData.ts @@ -10,6 +10,7 @@ import { type CommitLog, type MinerEvaluation, + type MinerIssue, type Repository, } from '../../api'; import { type IssueBounty } from '../../api/models/Issues'; @@ -170,6 +171,33 @@ export const getPreviousWindowBounds = ( }; }; +// Omitting `since` uses the mirror's default 35-day window and keeps the +// cache key stable across 1d/7d/35d ranges. +export const getMirrorSinceParam = ( + range: TrendTimeRange, +): string | undefined => + range === 'all' ? new Date(GITTENSOR_START_MS).toISOString() : undefined; + +// Dedupe by (repo, number) so an issue surfaced under multiple miners is counted once. +export const flattenMinerIssues = ( + responses: ReadonlyArray>, +): MinerIssue[] => { + const seen = new Set(); + const flattened: MinerIssue[] = []; + responses.forEach((batch) => { + batch.forEach((issue) => { + const key = `${issue.repo_full_name}#${issue.issue_number}`; + if (seen.has(key)) return; + seen.add(key); + flattened.push(issue); + }); + }); + return flattened; +}; + +export const isResolvedMinerIssue = (issue: MinerIssue): boolean => + issue.state === 'CLOSED' && issue.state_reason === 'COMPLETED'; + const getUtcWeekStart = (timestamp: number) => { const date = new Date(timestamp); const dayOfWeek = date.getUTCDay(); @@ -278,18 +306,18 @@ const formatDelta = ( export const buildDashboardTrendData = ( prs: CommitLog[], - issues: IssueBounty[], + issues: MinerIssue[], range: TrendTimeRange, now = new Date(), ): { labels: string[]; series: DashboardTrendSeries[] } => { const mergedPrTimestamps = prs.map((pr) => toTimestamp(pr.mergedAt)); const openedPrTimestamps = prs.map((pr) => toTimestamp(pr.prCreatedAt)); const openedIssueTimestamps = issues.map((issue) => - toTimestamp(issue.createdAt), + toTimestamp(issue.created_at), ); const resolvedIssueTimestamps = issues - .filter((issue) => issue.status === 'completed') - .map((issue) => toTimestamp(issue.completedAt)); + .filter(isResolvedMinerIssue) + .map((issue) => toTimestamp(issue.closed_at)); const buckets = buildTrendBuckets( [ ...mergedPrTimestamps, diff --git a/src/pages/dashboard/useDashboardData.ts b/src/pages/dashboard/useDashboardData.ts index 315f8deb..581faf80 100644 --- a/src/pages/dashboard/useDashboardData.ts +++ b/src/pages/dashboard/useDashboardData.ts @@ -12,6 +12,7 @@ import { useAllMiners, useAllPrs, useIssues, + useMinersIssues, useReposAndWeights, } from '../../api'; import { @@ -19,6 +20,7 @@ import { type DatasetState, type IssueBounty, type MinerEvaluation, + type MinerIssue, type Repository, } from '../../api/models'; import { @@ -28,6 +30,8 @@ import { buildFeaturedContributors, buildFeaturedWork, buildFeaturedDiscoveryContributors, + flattenMinerIssues, + getMirrorSinceParam, type TrendTimeRange, } from './dashboardData'; @@ -36,14 +40,47 @@ type DashboardDatasets = { miners: DatasetState; issues: DatasetState; repos: DatasetState; + minerIssues: DatasetState; }; +const hasIssueActivity = (miner: MinerEvaluation): boolean => + (miner.totalSolvedIssues ?? 0) + + (miner.totalOpenIssues ?? 0) + + (miner.totalClosedIssues ?? 0) > + 0; + export const useDashboardData = (range: TrendTimeRange) => { const prsQuery = useAllPrs(); const minersQuery = useAllMiners(); const issuesQuery = useIssues(); const reposQuery = useReposAndWeights(); + // Fan-out per-miner mirror calls; gate on issue activity to bound parallel requests. + const activeMinerGithubIds = useMemo( + () => + (minersQuery.data ?? []) + .filter(hasIssueActivity) + .map((m) => m.githubId) + .filter((id): id is string => Boolean(id)), + [minersQuery.data], + ); + + const minerIssuesSince = getMirrorSinceParam(range); + const minerIssuesQueries = useMinersIssues( + activeMinerGithubIds, + activeMinerGithubIds.length > 0, + minerIssuesSince, + ); + + const minerIssuesData = useMemo( + () => flattenMinerIssues(minerIssuesQueries.map((q) => q.data ?? [])), + [minerIssuesQueries], + ); + const isMinerIssuesLoading = + activeMinerGithubIds.length > 0 && + minerIssuesQueries.some((q) => q.isLoading); + const isMinerIssuesError = minerIssuesQueries.some((q) => q.isError); + const datasets: DashboardDatasets = { prs: { data: prsQuery.data ?? [], @@ -65,6 +102,11 @@ export const useDashboardData = (range: TrendTimeRange) => { isLoading: reposQuery.isLoading, isError: reposQuery.isError, }, + minerIssues: { + data: minerIssuesData, + isLoading: isMinerIssuesLoading, + isError: isMinerIssuesError, + }, }; const overview = useMemo( @@ -75,8 +117,12 @@ export const useDashboardData = (range: TrendTimeRange) => { const trendData = useMemo( () => - buildDashboardTrendData(datasets.prs.data, datasets.issues.data, range), - [datasets.issues.data, datasets.prs.data, range], + buildDashboardTrendData( + datasets.prs.data, + datasets.minerIssues.data, + range, + ), + [datasets.minerIssues.data, datasets.prs.data, range], ); const featuredContributors = useMemo( @@ -119,11 +165,13 @@ export const useDashboardData = (range: TrendTimeRange) => { isLoading: datasets.prs.isLoading || datasets.miners.isLoading || - datasets.issues.isLoading, + datasets.issues.isLoading || + datasets.minerIssues.isLoading, isError: datasets.prs.isError || datasets.miners.isError || - datasets.issues.isError, + datasets.issues.isError || + datasets.minerIssues.isError, }; }; diff --git a/src/tests/dashboardIssuesTrend.test.ts b/src/tests/dashboardIssuesTrend.test.ts new file mode 100644 index 00000000..6d3182b4 --- /dev/null +++ b/src/tests/dashboardIssuesTrend.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDashboardTrendData, + flattenMinerIssues, + getMirrorSinceParam, + isResolvedMinerIssue, +} from '../pages/dashboard/dashboardData'; +import type { MinerIssue } from '../api/models'; + +const NOW = new Date('2026-05-09T12:00:00Z'); + +const makeIssue = (overrides: Partial): MinerIssue => ({ + repo_full_name: 'foo/bar', + issue_number: 1, + title: 't', + state: 'OPEN', + ...overrides, +}); + +describe('getMirrorSinceParam', () => { + it('omits since for ranges within the mirror default window', () => { + expect(getMirrorSinceParam('1d')).toBeUndefined(); + expect(getMirrorSinceParam('7d')).toBeUndefined(); + expect(getMirrorSinceParam('35d')).toBeUndefined(); + }); + + it('emits an explicit far-back since for "all"', () => { + const since = getMirrorSinceParam('all'); + expect(since).toBeTypeOf('string'); + expect(new Date(since!).getTime()).toBe(Date.UTC(2025, 11, 1, 0, 0, 0)); + }); +}); + +describe('buildDashboardTrendData (mirror MinerIssue source)', () => { + it('counts opened issues by created_at and resolved by closed_at + COMPLETED', () => { + // Within 7d window relative to NOW. + const issues: MinerIssue[] = [ + makeIssue({ + issue_number: 1, + state: 'CLOSED', + state_reason: 'COMPLETED', + created_at: '2026-05-06T10:00:00Z', + closed_at: '2026-05-08T10:00:00Z', + }), + makeIssue({ + issue_number: 2, + state: 'CLOSED', + state_reason: 'NOT_PLANNED', + created_at: '2026-05-05T10:00:00Z', + closed_at: '2026-05-07T10:00:00Z', + }), + makeIssue({ + issue_number: 3, + state: 'OPEN', + created_at: '2026-05-07T10:00:00Z', + }), + ]; + + const { series } = buildDashboardTrendData([], issues, '7d', NOW); + const byKey = Object.fromEntries(series.map((s) => [s.key, s.values])); + + // Three issues opened total within window; sums must equal counts. + expect(byKey.issuesOpened.reduce((a, b) => a + b, 0)).toBe(3); + // Only the COMPLETED close counts as resolved. + expect(byKey.issuesResolved.reduce((a, b) => a + b, 0)).toBe(1); + }); + + it('excludes NOT_PLANNED and TRANSFERRED closes from resolved', () => { + const issues: MinerIssue[] = [ + makeIssue({ + issue_number: 1, + state: 'CLOSED', + state_reason: 'NOT_PLANNED', + created_at: '2026-05-06T10:00:00Z', + closed_at: '2026-05-08T10:00:00Z', + }), + makeIssue({ + issue_number: 2, + state: 'CLOSED', + state_reason: 'TRANSFERRED', + created_at: '2026-05-06T10:00:00Z', + closed_at: '2026-05-08T10:00:00Z', + }), + ]; + + const { series } = buildDashboardTrendData([], issues, '7d', NOW); + const resolved = series.find((s) => s.key === 'issuesResolved')!; + expect(resolved.values.reduce((a, b) => a + b, 0)).toBe(0); + }); + + it('drops issues whose timestamps fall outside the requested window', () => { + const issues: MinerIssue[] = [ + makeIssue({ + issue_number: 1, + state: 'CLOSED', + state_reason: 'COMPLETED', + created_at: '2025-01-01T00:00:00Z', + closed_at: '2025-01-02T00:00:00Z', + }), + ]; + + const { series } = buildDashboardTrendData([], issues, '7d', NOW); + expect(series.every((s) => s.values.every((v) => v === 0))).toBe(true); + }); + + it('returns the four canonical series in stable order', () => { + const { series } = buildDashboardTrendData([], [], '35d', NOW); + expect(series.map((s) => s.key)).toEqual([ + 'mergedPrs', + 'issuesResolved', + 'prsOpened', + 'issuesOpened', + ]); + }); + + it('does not double-count when the same issue arrives via fan-out twice', () => { + // Two miner responses, identical (repo, number) — must collapse to one. + const issue = makeIssue({ + issue_number: 42, + state: 'CLOSED', + state_reason: 'COMPLETED', + created_at: '2026-05-06T10:00:00Z', + closed_at: '2026-05-08T10:00:00Z', + }); + const flattened = flattenMinerIssues([[issue], [issue]]); + + const { series } = buildDashboardTrendData([], flattened, '7d', NOW); + const byKey = Object.fromEntries(series.map((s) => [s.key, s.values])); + + expect(byKey.issuesOpened.reduce((a, b) => a + b, 0)).toBe(1); + expect(byKey.issuesResolved.reduce((a, b) => a + b, 0)).toBe(1); + }); +}); + +describe('flattenMinerIssues', () => { + it('dedupes by (repo, number) across responses', () => { + const a = makeIssue({ repo_full_name: 'foo/bar', issue_number: 1 }); + const b = makeIssue({ repo_full_name: 'foo/bar', issue_number: 2 }); + const c = makeIssue({ repo_full_name: 'baz/qux', issue_number: 1 }); + + const result = flattenMinerIssues([[a, b], [a, c], [b]]); + + expect(result).toHaveLength(3); + expect(result.map((i) => `${i.repo_full_name}#${i.issue_number}`)).toEqual([ + 'foo/bar#1', + 'foo/bar#2', + 'baz/qux#1', + ]); + }); + + it('returns an empty array for no responses', () => { + expect(flattenMinerIssues([])).toEqual([]); + expect(flattenMinerIssues([[], []])).toEqual([]); + }); + + it('preserves the first occurrence in input order', () => { + const first = makeIssue({ + repo_full_name: 'foo/bar', + issue_number: 1, + title: 'first', + }); + const second = makeIssue({ + repo_full_name: 'foo/bar', + issue_number: 1, + title: 'second', + }); + + const [only] = flattenMinerIssues([[first], [second]]); + expect(only.title).toBe('first'); + }); +}); + +describe('isResolvedMinerIssue', () => { + it('accepts CLOSED + COMPLETED only', () => { + expect( + isResolvedMinerIssue( + makeIssue({ state: 'CLOSED', state_reason: 'COMPLETED' }), + ), + ).toBe(true); + }); + + it('rejects open issues regardless of state_reason', () => { + expect( + isResolvedMinerIssue( + makeIssue({ state: 'OPEN', state_reason: 'COMPLETED' }), + ), + ).toBe(false); + expect(isResolvedMinerIssue(makeIssue({ state: 'OPEN' }))).toBe(false); + }); + + it('rejects CLOSED issues with non-COMPLETED state_reason', () => { + expect( + isResolvedMinerIssue( + makeIssue({ state: 'CLOSED', state_reason: 'NOT_PLANNED' }), + ), + ).toBe(false); + expect( + isResolvedMinerIssue( + makeIssue({ state: 'CLOSED', state_reason: 'TRANSFERRED' }), + ), + ).toBe(false); + expect(isResolvedMinerIssue(makeIssue({ state: 'CLOSED' }))).toBe(false); + }); +});