diff --git a/packages/codev/dashboard/__tests__/analytics.test.tsx b/packages/codev/dashboard/__tests__/analytics.test.tsx index 140b97ae..9d80c4d0 100644 --- a/packages/codev/dashboard/__tests__/analytics.test.tsx +++ b/packages/codev/dashboard/__tests__/analytics.test.tsx @@ -1,5 +1,5 @@ /** - * Tests for the Analytics tab (Spec 456, Phase 3). + * Tests for the Analytics tab (Bugfix #531). * * Tests: useAnalytics hook behavior, AnalyticsView rendering, * null value formatting, error states, and range switching. @@ -25,18 +25,16 @@ vi.mock('../src/lib/api.js', () => ({ function makeStats(overrides: Partial = {}): AnalyticsResponse { return { timeRange: '7d', - github: { + activity: { prsMerged: 12, avgTimeToMergeHours: 3.5, - bugBacklog: 4, - nonBugBacklog: 8, issuesClosed: 6, avgTimeToCloseBugsHours: 1.2, - }, - builders: { projectsCompleted: 5, + bugsFixed: 3, throughputPerWeek: 2.5, activeBuilders: 2, + projectsByProtocol: { spir: 3, bugfix: 2, aspir: 1 }, }, consultation: { totalCount: 20, @@ -50,10 +48,6 @@ function makeStats(overrides: Partial = {}): AnalyticsRespons ], byReviewType: { spec: 5, plan: 5, pr: 10 }, byProtocol: { spir: 15, tick: 5 }, - costByProject: [ - { projectId: '456', totalCost: 0.75 }, - { projectId: '123', totalCost: 0.48 }, - ], }, ...overrides, }; @@ -84,7 +78,7 @@ describe('useAnalytics', () => { }); expect(mockFetchAnalytics).toHaveBeenCalledWith('7', false); - expect(result.current.data?.github.prsMerged).toBe(12); + expect(result.current.data?.activity.prsMerged).toBe(12); expect(result.current.loading).toBe(false); }); @@ -180,21 +174,22 @@ describe('AnalyticsView', () => { expect(screen.getByText('Loading analytics...')).toBeInTheDocument(); }); - it('renders all three section headers', async () => { + it('renders Activity and Consultation section headers (not GitHub/Builders)', async () => { mockFetchAnalytics.mockResolvedValue(makeStats()); const { AnalyticsView } = await import('../src/components/AnalyticsView.js'); render(); await waitFor(() => { - expect(screen.getByText('GitHub')).toBeInTheDocument(); + expect(screen.getByText('Activity')).toBeInTheDocument(); }); - expect(screen.getByText('Builders')).toBeInTheDocument(); expect(screen.getByText('Consultation')).toBeInTheDocument(); + expect(screen.queryByText('GitHub')).not.toBeInTheDocument(); + expect(screen.queryByText('Builders')).not.toBeInTheDocument(); }); - it('renders GitHub metric values', async () => { + it('renders Activity metric values', async () => { mockFetchAnalytics.mockResolvedValue(makeStats()); const { AnalyticsView } = await import('../src/components/AnalyticsView.js'); @@ -206,6 +201,23 @@ describe('AnalyticsView', () => { expect(screen.getByText('12')).toBeInTheDocument(); expect(screen.getByText('3.5h')).toBeInTheDocument(); + expect(screen.getByText('Projects Completed')).toBeInTheDocument(); + expect(screen.getByText('Bugs Fixed')).toBeInTheDocument(); + }); + + it('renders protocol breakdown metrics', async () => { + mockFetchAnalytics.mockResolvedValue(makeStats()); + + const { AnalyticsView } = await import('../src/components/AnalyticsView.js'); + render(); + + await waitFor(() => { + expect(screen.getByText('Projects by Protocol')).toBeInTheDocument(); + }); + + expect(screen.getByText('SPIR')).toBeInTheDocument(); + expect(screen.getByText('BUGFIX')).toBeInTheDocument(); + expect(screen.getByText('ASPIR')).toBeInTheDocument(); }); it('renders consultation total cost', async () => { @@ -223,13 +235,16 @@ describe('AnalyticsView', () => { it('displays null values as em-dash', async () => { const stats = makeStats({ - github: { + activity: { prsMerged: 3, avgTimeToMergeHours: null, - bugBacklog: 0, - nonBugBacklog: 0, issuesClosed: 0, avgTimeToCloseBugsHours: null, + projectsCompleted: 0, + bugsFixed: 0, + throughputPerWeek: 0, + activeBuilders: 0, + projectsByProtocol: {}, }, }); mockFetchAnalytics.mockResolvedValue(stats); @@ -238,7 +253,7 @@ describe('AnalyticsView', () => { render(); await waitFor(() => { - expect(screen.getByText('GitHub')).toBeInTheDocument(); + expect(screen.getByText('Activity')).toBeInTheDocument(); }); const dashes = screen.getAllByText('\u2014'); @@ -273,19 +288,30 @@ describe('AnalyticsView', () => { expect(screen.getByText('gpt-5.2-codex')).toBeInTheDocument(); }); - it('renders cost per project section', async () => { + it('does not render Cost per Project section', async () => { + mockFetchAnalytics.mockResolvedValue(makeStats()); + + const { AnalyticsView } = await import('../src/components/AnalyticsView.js'); + render(); + + await waitFor(() => { + expect(screen.getByText('Activity')).toBeInTheDocument(); + }); + + expect(screen.queryByText('Cost per Project')).not.toBeInTheDocument(); + }); + + it('does not render Open Issue Backlog', async () => { mockFetchAnalytics.mockResolvedValue(makeStats()); const { AnalyticsView } = await import('../src/components/AnalyticsView.js'); render(); await waitFor(() => { - expect(screen.getByText('Cost per Project')).toBeInTheDocument(); + expect(screen.getByText('Activity')).toBeInTheDocument(); }); - // Chart internals (#456, $0.75) render as SVG via Recharts and are - // not visible in jsdom's 0-width ResponsiveContainer. Verifying the - // section heading confirms the data path is wired correctly. + expect(screen.queryByText('Open Issue Backlog')).not.toBeInTheDocument(); }); it('calls fetchAnalytics with new range when range button is clicked', async () => { @@ -295,7 +321,7 @@ describe('AnalyticsView', () => { render(); await waitFor(() => { - expect(screen.getByText('GitHub')).toBeInTheDocument(); + expect(screen.getByText('Activity')).toBeInTheDocument(); }); mockFetchAnalytics.mockResolvedValue(makeStats({ timeRange: '30d' })); @@ -313,7 +339,7 @@ describe('AnalyticsView', () => { render(); await waitFor(() => { - expect(screen.getByText('GitHub')).toBeInTheDocument(); + expect(screen.getByText('Activity')).toBeInTheDocument(); }); const refreshBtn = screen.getByRole('button', { name: /Refresh/ }); diff --git a/packages/codev/dashboard/src/components/AnalyticsView.tsx b/packages/codev/dashboard/src/components/AnalyticsView.tsx index 8dea970a..aa8fc903 100644 --- a/packages/codev/dashboard/src/components/AnalyticsView.tsx +++ b/packages/codev/dashboard/src/components/AnalyticsView.tsx @@ -3,7 +3,6 @@ import { useAnalytics } from '../hooks/useAnalytics.js'; import type { AnalyticsResponse } from '../lib/api.js'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, - PieChart, Pie, } from 'recharts'; interface AnalyticsViewProps { @@ -99,72 +98,33 @@ function MiniBarChart({ data, dataKey, nameKey, color, formatter }: { ); } -function MiniPieChart({ data, dataKey, nameKey }: { - data: Array>; - dataKey: string; - nameKey: string; -}) { - if (data.length === 0) return null; - return ( - - - `${name} ${((percent ?? 0) * 100).toFixed(0)}%`} - labelLine={false} - style={{ fontSize: 10 }} - > - {data.map((_entry, idx) => ( - - ))} - - - - - ); -} - -function GitHubSection({ github, errors }: { github: AnalyticsResponse['github']; errors?: AnalyticsResponse['errors'] }) { - const backlogData = [ - { name: 'Bug', value: github.bugBacklog }, - { name: 'Non-Bug', value: github.nonBugBacklog }, - ].filter(d => d.value > 0); +function ActivitySection({ activity, errors }: { activity: AnalyticsResponse['activity']; errors?: AnalyticsResponse['errors'] }) { + const protocolData = Object.entries(activity.projectsByProtocol) + .map(([proto, count]) => ({ name: proto.toUpperCase(), value: count })) + .sort((a, b) => b.value - a.value); return ( -
- - - - - - - {backlogData.length > 0 && ( +
+ {protocolData.length > 0 && (
-

Open Issue Backlog

- +

Projects by Protocol

+ + {protocolData.map(d => ( + + ))} + +
)} -
- ); -} - -function BuildersSection({ builders }: { builders: AnalyticsResponse['builders'] }) { - return ( -
- - - + + + + + + + +
); @@ -189,11 +149,6 @@ function ConsultationSection({ consultation, errors }: { consultation: Analytics value: count, })); - const projectData = consultation.costByProject.map(p => ({ - name: `#${p.projectId}`, - cost: p.totalCost, - })); - return (
@@ -248,29 +203,17 @@ function ConsultationSection({ consultation, errors }: { consultation: Analytics {reviewTypeData.length > 0 && (

By Review Type

- +
)} {protocolData.length > 0 && (

By Protocol

- +
)} )} - - {projectData.length > 0 && ( -
-

Cost per Project

- `$${v.toFixed(2)}`} - /> -
- )}
); } @@ -312,8 +255,7 @@ export function AnalyticsView({ isActive }: AnalyticsViewProps) { {data && ( <> - - + )} diff --git a/packages/codev/dashboard/src/lib/api.ts b/packages/codev/dashboard/src/lib/api.ts index 39b03d5c..5d08647b 100644 --- a/packages/codev/dashboard/src/lib/api.ts +++ b/packages/codev/dashboard/src/lib/api.ts @@ -145,18 +145,16 @@ export interface OverviewData { export interface AnalyticsResponse { timeRange: '24h' | '7d' | '30d' | 'all'; - github: { + activity: { prsMerged: number; avgTimeToMergeHours: number | null; - bugBacklog: number; - nonBugBacklog: number; issuesClosed: number; avgTimeToCloseBugsHours: number | null; - }; - builders: { projectsCompleted: number; + bugsFixed: number; throughputPerWeek: number; activeBuilders: number; + projectsByProtocol: Record; }; consultation: { totalCount: number; @@ -173,10 +171,6 @@ export interface AnalyticsResponse { }>; byReviewType: Record; byProtocol: Record; - costByProject: Array<{ - projectId: string; - totalCost: number; - }>; }; errors?: { github?: string; diff --git a/packages/codev/src/agent-farm/__tests__/analytics.test.ts b/packages/codev/src/agent-farm/__tests__/analytics.test.ts index 8274be65..72faa92a 100644 --- a/packages/codev/src/agent-farm/__tests__/analytics.test.ts +++ b/packages/codev/src/agent-farm/__tests__/analytics.test.ts @@ -1,10 +1,8 @@ /** - * Unit tests for the analytics service (Spec 456, Phase 1). + * Unit tests for the analytics service (Bugfix #531). * - * Tests computeAnalytics() with mocked GitHub CLI and MetricsDB. + * Tests computeAnalytics() with mocked GitHub CLI, MetricsDB, and filesystem. * Tests fetchMergedPRs/fetchClosedIssues via child_process mock. - * - * costByProject integration tests live in consult/__tests__/metrics.test.ts. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -15,8 +13,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const execFileMock = vi.hoisted(() => vi.fn()); const mockSummary = vi.hoisted(() => vi.fn()); -const mockCostByProject = vi.hoisted(() => vi.fn()); const mockClose = vi.hoisted(() => vi.fn()); +const mockReaddirSync = vi.hoisted(() => vi.fn()); +const mockReadFileSync = vi.hoisted(() => vi.fn()); // Mock child_process + util (for GitHub CLI calls in github.ts) vi.mock('node:child_process', () => ({ @@ -30,11 +29,20 @@ vi.mock('node:util', () => ({ vi.mock('../../commands/consult/metrics.js', () => ({ MetricsDB: class MockMetricsDB { summary = mockSummary; - costByProject = mockCostByProject; close = mockClose; }, })); +// Mock fs for status.yaml reading +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + readdirSync: mockReaddirSync, + readFileSync: mockReadFileSync, + }; +}); + // --------------------------------------------------------------------------- // Static imports (resolved after mocks are hoisted) // --------------------------------------------------------------------------- @@ -56,9 +64,6 @@ function mockGhOutput(responses: Record) { if (argsStr.includes('issue') && argsStr.includes('list') && argsStr.includes('closed')) { return Promise.resolve({ stdout: responses.closedIssues ?? '[]' }); } - if (argsStr.includes('issue') && argsStr.includes('list') && !argsStr.includes('closed')) { - return Promise.resolve({ stdout: responses.openIssues ?? '[]' }); - } return Promise.resolve({ stdout: '[]' }); }); @@ -87,11 +92,15 @@ function defaultSummary() { }; } -function defaultCostByProject() { - return [ - { projectId: '42', totalCost: 8.50 }, - { projectId: '73', totalCost: 6.50 }, - ]; +function mockStatusYaml(entries: Array<{ dir: string; protocol: string; phase: string }>) { + mockReaddirSync.mockReturnValue(entries.map(e => e.dir)); + mockReadFileSync.mockImplementation((filePath: string) => { + const entry = entries.find(e => (filePath as string).includes(e.dir)); + if (entry) { + return `id: ${entry.dir}\ntitle: test\nprotocol: ${entry.protocol}\nphase: ${entry.phase}\n`; + } + throw new Error('ENOENT'); + }); } // --------------------------------------------------------------------------- @@ -202,7 +211,7 @@ describe('computeAnalytics', () => { clearAnalyticsCache(); vi.clearAllMocks(); mockSummary.mockReturnValue(defaultSummary()); - mockCostByProject.mockReturnValue(defaultCostByProject()); + mockStatusYaml([]); }); it('assembles full statistics from all data sources', async () => { @@ -215,25 +224,23 @@ describe('computeAnalytics', () => { { number: 42, title: 'Bug fix', createdAt: '2026-02-08T00:00:00Z', closedAt: '2026-02-11T12:00:00Z', labels: [{ name: 'bug' }] }, { number: 50, title: 'Feature', createdAt: '2026-02-09T00:00:00Z', closedAt: '2026-02-12T00:00:00Z', labels: [] }, ]), - openIssues: JSON.stringify([ - { number: 100, title: 'Open bug', url: '', labels: [{ name: 'bug' }], createdAt: '2026-02-01T00:00:00Z' }, - { number: 101, title: 'Open feature', url: '', labels: [], createdAt: '2026-02-02T00:00:00Z' }, - { number: 102, title: 'Another feature', url: '', labels: [], createdAt: '2026-02-03T00:00:00Z' }, - ]), }); + mockStatusYaml([ + { dir: '0042-feature', protocol: 'spir', phase: 'complete' }, + { dir: '0073-other', protocol: 'aspir', phase: 'complete' }, + ]); const result = await computeAnalytics('/tmp/workspace', '7', 3); expect(result.timeRange).toBe('7d'); - expect(result.github.prsMerged).toBe(2); - expect(result.github.avgTimeToMergeHours).toBeCloseTo(30); // (36+24)/2 - expect(result.github.bugBacklog).toBe(1); - expect(result.github.nonBugBacklog).toBe(2); - expect(result.github.issuesClosed).toBe(2); - expect(result.github.avgTimeToCloseBugsHours).toBeCloseTo(84); // 3.5 days for bug only - - expect(result.builders.projectsCompleted).toBe(2); // #42 (body) + #73 (title) - expect(result.builders.activeBuilders).toBe(3); + expect(result.activity.prsMerged).toBe(2); + expect(result.activity.avgTimeToMergeHours).toBeCloseTo(30); // (36+24)/2 + expect(result.activity.issuesClosed).toBe(2); + expect(result.activity.avgTimeToCloseBugsHours).toBeCloseTo(84); // 3.5 days for bug only + expect(result.activity.projectsCompleted).toBe(2); // #42 (body) + #73 (title) + expect(result.activity.bugsFixed).toBe(1); // Only issue #42 has bug label + expect(result.activity.activeBuilders).toBe(3); + expect(result.activity.projectsByProtocol).toEqual({ spir: 1, aspir: 1 }); expect(result.consultation.totalCount).toBe(5); expect(result.consultation.totalCostUsd).toBe(15.00); @@ -243,31 +250,44 @@ describe('computeAnalytics', () => { expect(result.consultation.byModel).toHaveLength(3); expect(result.consultation.byReviewType).toEqual({ spec: 2, pr: 3 }); expect(result.consultation.byProtocol).toEqual({ spir: 3, tick: 2 }); - expect(result.consultation.costByProject).toEqual(defaultCostByProject()); expect(result.errors).toBeUndefined(); }); + it('does not have github or builders top-level keys', async () => { + mockGhOutput({ mergedPRs: '[]', closedIssues: '[]' }); + const result = await computeAnalytics('/tmp/workspace', '7', 0); + expect(result).not.toHaveProperty('github'); + expect(result).not.toHaveProperty('builders'); + expect(result).toHaveProperty('activity'); + }); + + it('does not have costByProject in consultation', async () => { + mockGhOutput({ mergedPRs: '[]', closedIssues: '[]' }); + const result = await computeAnalytics('/tmp/workspace', '7', 0); + expect(result.consultation).not.toHaveProperty('costByProject'); + }); + it('returns 24h label for range "1"', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); + mockGhOutput({ mergedPRs: '[]', closedIssues: '[]' }); const result = await computeAnalytics('/tmp/workspace', '1', 0); expect(result.timeRange).toBe('24h'); }); it('returns 30d label for range "30"', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); + mockGhOutput({ mergedPRs: '[]', closedIssues: '[]' }); const result = await computeAnalytics('/tmp/workspace', '30', 0); expect(result.timeRange).toBe('30d'); }); it('returns all label for range "all"', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); + mockGhOutput({ mergedPRs: '[]', closedIssues: '[]' }); const result = await computeAnalytics('/tmp/workspace', 'all', 0); expect(result.timeRange).toBe('all'); }); it('passes null since date for "all" range', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); + mockGhOutput({ mergedPRs: '[]', closedIssues: '[]' }); await computeAnalytics('/tmp/workspace', 'all', 0); const prCall = execFileMock.mock.calls.find( @@ -278,7 +298,7 @@ describe('computeAnalytics', () => { }); it('passes a date string for "7" range', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); + mockGhOutput({ mergedPRs: '[]', closedIssues: '[]' }); await computeAnalytics('/tmp/workspace', '7', 0); const prCall = execFileMock.mock.calls.find( @@ -299,15 +319,14 @@ describe('computeAnalytics', () => { const result = await computeAnalytics('/tmp/workspace', '7', 2); expect(result.errors?.github).toBeDefined(); - expect(result.github.prsMerged).toBe(0); - expect(result.github.avgTimeToMergeHours).toBeNull(); - expect(result.github.bugBacklog).toBe(0); - expect(result.github.nonBugBacklog).toBe(0); - expect(result.github.issuesClosed).toBe(0); - expect(result.github.avgTimeToCloseBugsHours).toBeNull(); - expect(result.builders.projectsCompleted).toBe(0); - expect(result.builders.throughputPerWeek).toBe(0); - expect(result.builders.activeBuilders).toBe(2); + expect(result.activity.prsMerged).toBe(0); + expect(result.activity.avgTimeToMergeHours).toBeNull(); + expect(result.activity.issuesClosed).toBe(0); + expect(result.activity.avgTimeToCloseBugsHours).toBeNull(); + expect(result.activity.projectsCompleted).toBe(0); + expect(result.activity.bugsFixed).toBe(0); + expect(result.activity.throughputPerWeek).toBe(0); + expect(result.activity.activeBuilders).toBe(2); // Consultation still works expect(result.consultation.totalCount).toBe(5); expect(result.errors?.consultation).toBeUndefined(); @@ -316,7 +335,7 @@ describe('computeAnalytics', () => { // --- Partial failure: MetricsDB unavailable --- it('returns consultation defaults and error when MetricsDB fails', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); + mockGhOutput({ mergedPRs: '[]', closedIssues: '[]' }); mockSummary.mockImplementation(() => { throw new Error('DB file not found'); }); const result = await computeAnalytics('/tmp/workspace', '7', 0); @@ -330,24 +349,22 @@ describe('computeAnalytics', () => { expect(result.consultation.byModel).toEqual([]); expect(result.consultation.byReviewType).toEqual({}); expect(result.consultation.byProtocol).toEqual({}); - expect(result.consultation.costByProject).toEqual([]); expect(result.errors?.github).toBeUndefined(); }); // --- Null averages --- it('returns null averages when no data exists', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); + mockGhOutput({ mergedPRs: '[]', closedIssues: '[]' }); mockSummary.mockReturnValue({ totalCount: 0, totalDuration: 0, totalCost: null, costCount: 0, successCount: 0, byModel: [], byType: [], byProtocol: [], }); - mockCostByProject.mockReturnValue([]); const result = await computeAnalytics('/tmp/workspace', '7', 0); - expect(result.github.avgTimeToMergeHours).toBeNull(); - expect(result.github.avgTimeToCloseBugsHours).toBeNull(); + expect(result.activity.avgTimeToMergeHours).toBeNull(); + expect(result.activity.avgTimeToCloseBugsHours).toBeNull(); expect(result.consultation.avgLatencySeconds).toBeNull(); expect(result.consultation.successRate).toBeNull(); }); @@ -361,11 +378,10 @@ describe('computeAnalytics', () => { { number: 2, title: '[Spec 42] Feature', createdAt: '2026-02-10T00:00:00Z', mergedAt: '2026-02-11T00:00:00Z', body: '' }, ]), closedIssues: '[]', - openIssues: '[]', }); const result = await computeAnalytics('/tmp/workspace', '7', 0); - expect(result.builders.projectsCompleted).toBe(1); + expect(result.activity.projectsCompleted).toBe(1); }); it('counts all linked issues from a single PR with multiple references', async () => { @@ -374,11 +390,10 @@ describe('computeAnalytics', () => { { number: 1, title: 'Big PR', createdAt: '2026-02-10T00:00:00Z', mergedAt: '2026-02-11T00:00:00Z', body: 'Fixes #42 and Fixes #73' }, ]), closedIssues: '[]', - openIssues: '[]', }); const result = await computeAnalytics('/tmp/workspace', '7', 0); - expect(result.builders.projectsCompleted).toBe(2); // Both #42 and #73 + expect(result.activity.projectsCompleted).toBe(2); // Both #42 and #73 }); it('counts distinct issues when multiple PRs link to same issue', async () => { @@ -388,24 +403,26 @@ describe('computeAnalytics', () => { { number: 2, title: '[Spec 42] Part 2', createdAt: '2026-02-10T00:00:00Z', mergedAt: '2026-02-11T00:00:00Z', body: 'Closes #42' }, ]), closedIssues: '[]', - openIssues: '[]', }); const result = await computeAnalytics('/tmp/workspace', '7', 0); - expect(result.builders.projectsCompleted).toBe(1); + expect(result.activity.projectsCompleted).toBe(1); }); - it('counts multiple issues linked from a single PR', async () => { + // --- Bugs fixed --- + + it('counts bugs fixed from closed issues with bug label', async () => { mockGhOutput({ - mergedPRs: JSON.stringify([ - { number: 1, title: 'Big cleanup', createdAt: '2026-02-10T00:00:00Z', mergedAt: '2026-02-11T00:00:00Z', body: 'Fixes #42, Closes #73, Resolves #99' }, + mergedPRs: '[]', + closedIssues: JSON.stringify([ + { number: 1, title: 'Bug', createdAt: '2026-02-10T00:00:00Z', closedAt: '2026-02-11T00:00:00Z', labels: [{ name: 'bug' }] }, + { number: 2, title: 'Feature', createdAt: '2026-02-10T00:00:00Z', closedAt: '2026-02-15T00:00:00Z', labels: [{ name: 'enhancement' }] }, + { number: 3, title: 'Another bug', createdAt: '2026-02-10T00:00:00Z', closedAt: '2026-02-12T00:00:00Z', labels: [{ name: 'bug' }] }, ]), - closedIssues: '[]', - openIssues: '[]', }); const result = await computeAnalytics('/tmp/workspace', '7', 0); - expect(result.builders.projectsCompleted).toBe(3); + expect(result.activity.bugsFixed).toBe(2); }); // --- Bug-only avg time to close --- @@ -417,17 +434,16 @@ describe('computeAnalytics', () => { { number: 1, title: 'Bug', createdAt: '2026-02-10T00:00:00Z', closedAt: '2026-02-11T00:00:00Z', labels: [{ name: 'bug' }] }, { number: 2, title: 'Feature', createdAt: '2026-02-10T00:00:00Z', closedAt: '2026-02-15T00:00:00Z', labels: [{ name: 'enhancement' }] }, ]), - openIssues: '[]', }); const result = await computeAnalytics('/tmp/workspace', '7', 0); - expect(result.github.avgTimeToCloseBugsHours).toBeCloseTo(24); + expect(result.activity.avgTimeToCloseBugsHours).toBeCloseTo(24); }); // --- costByModel derivation --- it('derives costByModel correctly, excluding null costs', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); + mockGhOutput({ mergedPRs: '[]', closedIssues: '[]' }); mockSummary.mockReturnValue({ ...defaultSummary(), byModel: [ @@ -440,10 +456,48 @@ describe('computeAnalytics', () => { expect(result.consultation.costByModel).toEqual({ codex: 3.50 }); }); + // --- Protocol breakdown from status.yaml --- + + it('counts projects by protocol from status.yaml', async () => { + mockGhOutput({ mergedPRs: '[]', closedIssues: '[]' }); + mockStatusYaml([ + { dir: '0042-feature', protocol: 'spir', phase: 'complete' }, + { dir: '0073-other', protocol: 'spir', phase: 'review' }, + { dir: '0080-air', protocol: 'air', phase: 'implement' }, + { dir: 'bugfix-100', protocol: 'bugfix', phase: 'complete' }, + ]); + + const result = await computeAnalytics('/tmp/workspace', '7', 0); + expect(result.activity.projectsByProtocol).toEqual({ + spir: 2, + air: 1, + bugfix: 1, + }); + }); + + it('normalizes spider to spir in protocol breakdown', async () => { + mockGhOutput({ mergedPRs: '[]', closedIssues: '[]' }); + mockStatusYaml([ + { dir: '0087-old', protocol: 'spider', phase: 'complete' }, + { dir: '0088-new', protocol: 'spir', phase: 'complete' }, + ]); + + const result = await computeAnalytics('/tmp/workspace', '7', 0); + expect(result.activity.projectsByProtocol).toEqual({ spir: 2 }); + }); + + it('returns empty projectsByProtocol when no projects directory', async () => { + mockGhOutput({ mergedPRs: '[]', closedIssues: '[]' }); + mockReaddirSync.mockImplementation(() => { throw new Error('ENOENT'); }); + + const result = await computeAnalytics('/tmp/workspace', '7', 0); + expect(result.activity.projectsByProtocol).toEqual({}); + }); + // --- Caching --- it('returns cached result on second call within TTL', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); + mockGhOutput({ mergedPRs: '[]', closedIssues: '[]' }); const result1 = await computeAnalytics('/tmp/workspace', '7', 3); const result2 = await computeAnalytics('/tmp/workspace', '7', 3); @@ -453,7 +507,7 @@ describe('computeAnalytics', () => { }); it('bypasses cache when refresh=true', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); + mockGhOutput({ mergedPRs: '[]', closedIssues: '[]' }); await computeAnalytics('/tmp/workspace', '7', 3); await computeAnalytics('/tmp/workspace', '7', 3, true); @@ -462,7 +516,7 @@ describe('computeAnalytics', () => { }); it('does not share cache between different ranges', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); + mockGhOutput({ mergedPRs: '[]', closedIssues: '[]' }); await computeAnalytics('/tmp/workspace', '7', 3); await computeAnalytics('/tmp/workspace', '30', 3); @@ -481,12 +535,11 @@ describe('computeAnalytics', () => { { number: 4, title: 'PR', createdAt: '2026-02-01T00:00:00Z', mergedAt: '2026-02-02T00:00:00Z', body: 'Fixes #40' }, ]), closedIssues: '[]', - openIssues: '[]', }); const result = await computeAnalytics('/tmp/workspace', '30', 0); const expected = Math.round((4 / (30 / 7)) * 10) / 10; - expect(result.builders.throughputPerWeek).toBeCloseTo(expected, 1); + expect(result.activity.throughputPerWeek).toBeCloseTo(expected, 1); }); it('computes throughput for 7d range (equals projectsCompleted)', async () => { @@ -496,10 +549,9 @@ describe('computeAnalytics', () => { { number: 2, title: 'PR', createdAt: '2026-02-01T00:00:00Z', mergedAt: '2026-02-02T00:00:00Z', body: 'Fixes #20' }, ]), closedIssues: '[]', - openIssues: '[]', }); const result = await computeAnalytics('/tmp/workspace', '7', 0); - expect(result.builders.throughputPerWeek).toBe(2); + expect(result.activity.throughputPerWeek).toBe(2); }); }); diff --git a/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts b/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts index a765c23b..ce05c268 100644 --- a/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts +++ b/packages/codev/src/agent-farm/__tests__/tower-routes.test.ts @@ -1017,9 +1017,8 @@ describe('tower-routes', () => { describe('GET /api/analytics', () => { const fakeStats = { timeRange: '7d', - github: { prsMerged: 5, avgTimeToMergeHours: 2.5, bugBacklog: 3, nonBugBacklog: 7, issuesClosed: 4, avgTimeToCloseBugsHours: 1.2 }, - builders: { projectsCompleted: 3, throughputPerWeek: 3, activeBuilders: 1 }, - consultation: { totalCount: 10, totalCostUsd: 0.5, costByModel: {}, avgLatencySeconds: 12, successRate: 90, byModel: [], byReviewType: {}, byProtocol: {}, costByProject: [] }, + activity: { prsMerged: 5, avgTimeToMergeHours: 2.5, issuesClosed: 4, avgTimeToCloseBugsHours: 1.2, projectsCompleted: 3, bugsFixed: 1, throughputPerWeek: 3, activeBuilders: 1, projectsByProtocol: { spir: 2, bugfix: 1 } }, + consultation: { totalCount: 10, totalCostUsd: 0.5, costByModel: {}, avgLatencySeconds: 12, successRate: 90, byModel: [], byReviewType: {}, byProtocol: {} }, }; beforeEach(() => { @@ -1034,7 +1033,7 @@ describe('tower-routes', () => { expect(statusCode()).toBe(200); const parsed = JSON.parse(body()); - expect(parsed.github.prsMerged).toBe(5); + expect(parsed.activity.prsMerged).toBe(5); expect(mockComputeAnalytics).toHaveBeenCalledWith('/tmp/workspace', '7', 0, false); }); @@ -1074,8 +1073,8 @@ describe('tower-routes', () => { expect(statusCode()).toBe(200); const parsed = JSON.parse(body()); expect(parsed.timeRange).toBe('30d'); - expect(parsed.github.prsMerged).toBe(0); - expect(parsed.builders.activeBuilders).toBe(0); + expect(parsed.activity.prsMerged).toBe(0); + expect(parsed.activity.activeBuilders).toBe(0); expect(mockComputeAnalytics).not.toHaveBeenCalled(); }); diff --git a/packages/codev/src/agent-farm/servers/analytics.ts b/packages/codev/src/agent-farm/servers/analytics.ts index 2a518cf0..93759c5f 100644 --- a/packages/codev/src/agent-farm/servers/analytics.ts +++ b/packages/codev/src/agent-farm/servers/analytics.ts @@ -2,21 +2,22 @@ * Analytics aggregation service for the dashboard Analytics tab. * * Aggregates data from three sources: - * - GitHub CLI (merged PRs, closed issues, open issue backlogs) + * - GitHub CLI (merged PRs, closed issues) * - Consultation metrics DB (~/.codev/metrics.db) + * - Local project artifacts (codev/projects/ status.yaml for protocol breakdown) * - Active builder count (passed in from tower context) * * Each data source fails independently — partial results are returned * with error messages in the `errors` field. */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as yaml from 'js-yaml'; import { fetchMergedPRs, fetchClosedIssues, - fetchIssueList, parseAllLinkedIssues, - type MergedPR, - type ClosedIssue, } from '../../lib/github.js'; import { MetricsDB } from '../../commands/consult/metrics.js'; @@ -26,18 +27,16 @@ import { MetricsDB } from '../../commands/consult/metrics.js'; export interface AnalyticsResponse { timeRange: '24h' | '7d' | '30d' | 'all'; - github: { + activity: { prsMerged: number; avgTimeToMergeHours: number | null; - bugBacklog: number; - nonBugBacklog: number; issuesClosed: number; avgTimeToCloseBugsHours: number | null; - }; - builders: { projectsCompleted: number; + bugsFixed: number; throughputPerWeek: number; activeBuilders: number; + projectsByProtocol: Record; }; consultation: { totalCount: number; @@ -54,10 +53,6 @@ export interface AnalyticsResponse { }>; byReviewType: Record; byProtocol: Record; - costByProject: Array<{ - projectId: string; - totalCost: number; - }>; }; errors?: { github?: string; @@ -133,11 +128,10 @@ function computeAvgHours(items: Array<{ start: string; end: string }>): number | interface GitHubMetrics { prsMerged: number; avgTimeToMergeHours: number | null; - bugBacklog: number; - nonBugBacklog: number; issuesClosed: number; avgTimeToCloseBugsHours: number | null; projectsCompleted: number; + bugsFixed: number; } async function computeGitHubMetrics( @@ -145,13 +139,12 @@ async function computeGitHubMetrics( cwd: string, ): Promise { // Fetch merged PRs and closed issues in parallel - const [mergedPRs, closedIssues, openIssues] = await Promise.all([ + const [mergedPRs, closedIssues] = await Promise.all([ fetchMergedPRs(since, cwd), fetchClosedIssues(since, cwd), - fetchIssueList(cwd), ]); - if (mergedPRs === null && closedIssues === null && openIssues === null) { + if (mergedPRs === null && closedIssues === null) { throw new Error('GitHub CLI unavailable'); } @@ -164,13 +157,6 @@ async function computeGitHubMetrics( prs.filter(pr => pr.mergedAt).map(pr => ({ start: pr.createdAt, end: pr.mergedAt })), ); - // Backlogs (from open issues) - const issues = openIssues ?? []; - const bugBacklog = issues.filter(i => - i.labels.some(l => l.name === 'bug'), - ).length; - const nonBugBacklog = issues.length - bugBacklog; - // Closed issues const closed = closedIssues ?? []; const issuesClosed = closed.length; @@ -183,6 +169,9 @@ async function computeGitHubMetrics( closedBugs.map(i => ({ start: i.createdAt, end: i.closedAt })), ); + // Bugs fixed (closed issues with bug label) + const bugsFixed = closedBugs.length; + // Projects completed (distinct issue numbers from merged PRs via parseAllLinkedIssues) const linkedIssues = new Set(); for (const pr of prs) { @@ -195,11 +184,10 @@ async function computeGitHubMetrics( return { prsMerged, avgTimeToMergeHours, - bugBacklog, - nonBugBacklog, issuesClosed, avgTimeToCloseBugsHours, projectsCompleted, + bugsFixed, }; } @@ -222,10 +210,6 @@ interface ConsultationMetrics { }>; byReviewType: Record; byProtocol: Record; - costByProject: Array<{ - projectId: string; - totalCost: number; - }>; } function computeConsultationMetrics(days: number | undefined): ConsultationMetrics { @@ -233,7 +217,6 @@ function computeConsultationMetrics(days: number | undefined): ConsultationMetri try { const filters = days ? { days } : {}; const summary = db.summary(filters); - const projectCosts = db.costByProject(filters); // Derive costByModel from summary.byModel const costByModel: Record = {}; @@ -274,13 +257,50 @@ function computeConsultationMetrics(days: number | undefined): ConsultationMetri })), byReviewType, byProtocol, - costByProject: projectCosts, }; } finally { db.close(); } } +// ============================================================================= +// Project protocol breakdown (from status.yaml) +// ============================================================================= + +function normalizeProtocol(protocol: string): string { + // Legacy "spider" → "spir" + if (protocol === 'spider') return 'spir'; + return protocol; +} + +function computeProjectsByProtocol(workspaceRoot: string): Record { + const projectsDir = path.join(workspaceRoot, 'codev', 'projects'); + const result: Record = {}; + + let entries: string[]; + try { + entries = fs.readdirSync(projectsDir); + } catch { + return result; // No projects directory — return empty + } + + for (const entry of entries) { + const statusPath = path.join(projectsDir, entry, 'status.yaml'); + try { + const content = fs.readFileSync(statusPath, 'utf-8'); + const parsed = yaml.load(content) as Record | null; + if (parsed && typeof parsed.protocol === 'string') { + const proto = normalizeProtocol(parsed.protocol); + result[proto] = (result[proto] ?? 0) + 1; + } + } catch { + // Skip unreadable entries + } + } + + return result; +} + // ============================================================================= // Main computation // ============================================================================= @@ -324,11 +344,10 @@ export async function computeAnalytics( githubMetrics = { prsMerged: 0, avgTimeToMergeHours: null, - bugBacklog: 0, - nonBugBacklog: 0, issuesClosed: 0, avgTimeToCloseBugsHours: null, projectsCompleted: 0, + bugsFixed: 0, }; } @@ -348,26 +367,26 @@ export async function computeAnalytics( byModel: [], byReviewType: {}, byProtocol: {}, - costByProject: [], }; } + // Protocol breakdown from local status.yaml files (supplementary source) + const projectsByProtocol = computeProjectsByProtocol(workspaceRoot); + const result: AnalyticsResponse = { timeRange: rangeToLabel(range), - github: { + activity: { prsMerged: githubMetrics.prsMerged, avgTimeToMergeHours: githubMetrics.avgTimeToMergeHours, - bugBacklog: githubMetrics.bugBacklog, - nonBugBacklog: githubMetrics.nonBugBacklog, issuesClosed: githubMetrics.issuesClosed, avgTimeToCloseBugsHours: githubMetrics.avgTimeToCloseBugsHours, - }, - builders: { projectsCompleted: githubMetrics.projectsCompleted, + bugsFixed: githubMetrics.bugsFixed, throughputPerWeek: weeks > 0 ? Math.round((githubMetrics.projectsCompleted / weeks) * 10) / 10 : 0, activeBuilders, + projectsByProtocol, }, consultation: consultMetrics, }; diff --git a/packages/codev/src/agent-farm/servers/tower-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index 75c7e675..2b26721e 100644 --- a/packages/codev/src/agent-farm/servers/tower-routes.ts +++ b/packages/codev/src/agent-farm/servers/tower-routes.ts @@ -719,7 +719,7 @@ async function handleAnalytics(res: http.ServerResponse, url: URL, workspaceOver if (!workspaceRoot) { res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ timeRange: rangeLabel, github: { prsMerged: 0, avgTimeToMergeHours: null, bugBacklog: 0, nonBugBacklog: 0, issuesClosed: 0, avgTimeToCloseBugsHours: null }, builders: { projectsCompleted: 0, throughputPerWeek: 0, activeBuilders: 0 }, consultation: { totalCount: 0, totalCostUsd: null, costByModel: {}, avgLatencySeconds: null, successRate: null, byModel: [], byReviewType: {}, byProtocol: {}, costByProject: [] } })); + res.end(JSON.stringify({ timeRange: rangeLabel, activity: { prsMerged: 0, avgTimeToMergeHours: null, issuesClosed: 0, avgTimeToCloseBugsHours: null, projectsCompleted: 0, bugsFixed: 0, throughputPerWeek: 0, activeBuilders: 0, projectsByProtocol: {} }, consultation: { totalCount: 0, totalCostUsd: null, costByModel: {}, avgLatencySeconds: null, successRate: null, byModel: [], byReviewType: {}, byProtocol: {} } })); return; } const range = rangeParam as '1' | '7' | '30' | 'all';