From 98cc0c7c6723ebcf2c9fb2b5d524063553743b4d Mon Sep 17 00:00:00 2001 From: M Waleed Kadous Date: Mon, 23 Feb 2026 09:55:57 -0800 Subject: [PATCH] =?UTF-8?q?[Bugfix=20#529]=20Fix:=20Redesign=20dashboard?= =?UTF-8?q?=20analytics=20=E2=80=94=20merge=20sections,=20replace=20data?= =?UTF-8?q?=20source,=20remove=20pie=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/__tests__/analytics.test.tsx | 94 ++-- .../src/components/AnalyticsView.tsx | 99 +--- packages/codev/dashboard/src/lib/api.ts | 21 +- .../agent-farm/__tests__/analytics.test.ts | 508 ++++++------------ .../agent-farm/__tests__/tower-routes.test.ts | 11 +- .../codev/src/agent-farm/servers/analytics.ts | 248 ++++----- .../src/agent-farm/servers/tower-routes.ts | 2 +- 7 files changed, 358 insertions(+), 625 deletions(-) diff --git a/packages/codev/dashboard/__tests__/analytics.test.tsx b/packages/codev/dashboard/__tests__/analytics.test.tsx index 140b97ae..ca00f310 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 (Spec 456, Bugfix #529). * * Tests: useAnalytics hook behavior, AnalyticsView rendering, * null value formatting, error states, and range switching. @@ -25,16 +25,11 @@ vi.mock('../src/lib/api.js', () => ({ function makeStats(overrides: Partial = {}): AnalyticsResponse { return { timeRange: '7d', - github: { - prsMerged: 12, - avgTimeToMergeHours: 3.5, - bugBacklog: 4, - nonBugBacklog: 8, - issuesClosed: 6, - avgTimeToCloseBugsHours: 1.2, - }, - builders: { + activity: { projectsCompleted: 5, + projectsByProtocol: { spir: 3, aspir: 2 }, + bugsFixed: 4, + avgTimeToMergeHours: 3.5, throughputPerWeek: 2.5, activeBuilders: 2, }, @@ -50,10 +45,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, }; @@ -78,13 +69,12 @@ describe('useAnalytics', () => { const { useAnalytics } = await import('../src/hooks/useAnalytics.js'); const { result } = renderHook(() => useAnalytics(true)); - // Flush the async effect await waitFor(() => { expect(result.current.data).not.toBeNull(); }); expect(mockFetchAnalytics).toHaveBeenCalledWith('7', false); - expect(result.current.data?.github.prsMerged).toBe(12); + expect(result.current.data?.activity.projectsCompleted).toBe(5); expect(result.current.loading).toBe(false); }); @@ -180,32 +170,47 @@ describe('AnalyticsView', () => { expect(screen.getByText('Loading analytics...')).toBeInTheDocument(); }); - it('renders all three section headers', async () => { + it('renders Activity and Consultation section headers', 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(); }); - it('renders GitHub metric values', async () => { + it('does not render separate GitHub or Builders sections', async () => { mockFetchAnalytics.mockResolvedValue(makeStats()); const { AnalyticsView } = await import('../src/components/AnalyticsView.js'); render(); await waitFor(() => { - expect(screen.getByText('PRs Merged')).toBeInTheDocument(); + expect(screen.getByText('Activity')).toBeInTheDocument(); }); - expect(screen.getByText('12')).toBeInTheDocument(); - expect(screen.getByText('3.5h')).toBeInTheDocument(); + expect(screen.queryByText('GitHub')).not.toBeInTheDocument(); + expect(screen.queryByText('Builders')).not.toBeInTheDocument(); + }); + + it('renders Activity metric values', async () => { + mockFetchAnalytics.mockResolvedValue(makeStats()); + + const { AnalyticsView } = await import('../src/components/AnalyticsView.js'); + render(); + + await waitFor(() => { + expect(screen.getByText('Projects Completed')).toBeInTheDocument(); + }); + + expect(screen.getByText('Bugs Fixed')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); // projectsCompleted + expect(screen.getByText('4')).toBeInTheDocument(); // bugsFixed + expect(screen.getByText('3.5h')).toBeInTheDocument(); // avgTimeToMerge }); it('renders consultation total cost', async () => { @@ -223,13 +228,13 @@ describe('AnalyticsView', () => { it('displays null values as em-dash', async () => { const stats = makeStats({ - github: { - prsMerged: 3, + activity: { + projectsCompleted: 3, + projectsByProtocol: {}, + bugsFixed: 0, avgTimeToMergeHours: null, - bugBacklog: 0, - nonBugBacklog: 0, - issuesClosed: 0, - avgTimeToCloseBugsHours: null, + throughputPerWeek: 0, + activeBuilders: 0, }, }); mockFetchAnalytics.mockResolvedValue(stats); @@ -238,16 +243,16 @@ describe('AnalyticsView', () => { render(); await waitFor(() => { - expect(screen.getByText('GitHub')).toBeInTheDocument(); + expect(screen.getByText('Activity')).toBeInTheDocument(); }); const dashes = screen.getAllByText('\u2014'); - expect(dashes.length).toBeGreaterThanOrEqual(2); + expect(dashes.length).toBeGreaterThanOrEqual(1); }); it('renders per-section error messages', async () => { const stats = makeStats({ - errors: { github: 'GitHub CLI unavailable' }, + errors: { activity: 'Project scan failed' }, }); mockFetchAnalytics.mockResolvedValue(stats); @@ -255,7 +260,7 @@ describe('AnalyticsView', () => { render(); await waitFor(() => { - expect(screen.getByText('GitHub CLI unavailable')).toBeInTheDocument(); + expect(screen.getByText('Project scan failed')).toBeInTheDocument(); }); }); @@ -273,19 +278,28 @@ 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('Cost per Project')).toBeInTheDocument(); + expect(screen.getByText('Consultation')).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('Cost per Project')).not.toBeInTheDocument(); + }); + + it('renders Projects by Protocol sub-section', async () => { + mockFetchAnalytics.mockResolvedValue(makeStats()); + + const { AnalyticsView } = await import('../src/components/AnalyticsView.js'); + render(); + + await waitFor(() => { + expect(screen.getByText('Projects by Protocol')).toBeInTheDocument(); + }); }); it('calls fetchAnalytics with new range when range button is clicked', async () => { @@ -295,7 +309,7 @@ describe('AnalyticsView', () => { render(); await waitFor(() => { - expect(screen.getByText('GitHub')).toBeInTheDocument(); + expect(screen.getByText('Activity')).toBeInTheDocument(); }); mockFetchAnalytics.mockResolvedValue(makeStats({ timeRange: '30d' })); @@ -313,7 +327,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..d61ea097 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,77 +98,31 @@ 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, + })); return ( -
+
- - - - + + + + + - {backlogData.length > 0 && ( + {protocolData.length > 0 && (
-

Open Issue Backlog

- +

Projects by Protocol

+
)}
); } -function BuildersSection({ builders }: { builders: AnalyticsResponse['builders'] }) { - return ( -
- - - - - -
- ); -} - function ConsultationSection({ consultation, errors }: { consultation: AnalyticsResponse['consultation']; errors?: AnalyticsResponse['errors'] }) { const modelData = consultation.byModel.map(m => ({ name: m.model, @@ -189,11 +142,6 @@ function ConsultationSection({ consultation, errors }: { consultation: Analytics value: count, })); - const projectData = consultation.costByProject.map(p => ({ - name: `#${p.projectId}`, - cost: p.totalCost, - })); - return (
@@ -248,29 +196,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 +248,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..41ed14dc 100644 --- a/packages/codev/dashboard/src/lib/api.ts +++ b/packages/codev/dashboard/src/lib/api.ts @@ -141,20 +141,15 @@ export interface OverviewData { errors?: { prs?: string; issues?: string }; } -// Spec 456: Analytics tab types and fetcher +// Spec 456 / Bugfix #529: Analytics tab types and fetcher export interface AnalyticsResponse { timeRange: '24h' | '7d' | '30d' | 'all'; - github: { - prsMerged: number; - avgTimeToMergeHours: number | null; - bugBacklog: number; - nonBugBacklog: number; - issuesClosed: number; - avgTimeToCloseBugsHours: number | null; - }; - builders: { + activity: { projectsCompleted: number; + projectsByProtocol: Record; + bugsFixed: number; + avgTimeToMergeHours: number | null; throughputPerWeek: number; activeBuilders: number; }; @@ -173,13 +168,9 @@ export interface AnalyticsResponse { }>; byReviewType: Record; byProtocol: Record; - costByProject: Array<{ - projectId: string; - totalCost: number; - }>; }; errors?: { - github?: string; + activity?: string; consultation?: 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..c150054f 100644 --- a/packages/codev/src/agent-farm/__tests__/analytics.test.ts +++ b/packages/codev/src/agent-farm/__tests__/analytics.test.ts @@ -1,69 +1,56 @@ /** - * Unit tests for the analytics service (Spec 456, Phase 1). + * Unit tests for the analytics service (Spec 456, Bugfix #529). * - * Tests computeAnalytics() with mocked GitHub CLI and MetricsDB. - * Tests fetchMergedPRs/fetchClosedIssues via child_process mock. - * - * costByProject integration tests live in consult/__tests__/metrics.test.ts. + * Tests computeAnalytics() with mocked project artifacts and MetricsDB. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; // --------------------------------------------------------------------------- -// Hoisted mocks — declared before all imports +// Hoisted mocks // --------------------------------------------------------------------------- -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 mockExistsSync = 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', () => ({ - execFile: execFileMock, -})); -vi.mock('node:util', () => ({ - promisify: () => execFileMock, -})); - -// Mock MetricsDB (for consultation metrics in analytics.ts) +// Mock MetricsDB vi.mock('../../commands/consult/metrics.js', () => ({ MetricsDB: class MockMetricsDB { summary = mockSummary; - costByProject = mockCostByProject; close = mockClose; }, })); +// Mock node:fs for project scanning +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual, + existsSync: mockExistsSync, + readdirSync: mockReaddirSync, + readFileSync: mockReadFileSync, + }, + existsSync: mockExistsSync, + readdirSync: mockReaddirSync, + readFileSync: mockReadFileSync, + }; +}); + // --------------------------------------------------------------------------- -// Static imports (resolved after mocks are hoisted) +// Static imports // --------------------------------------------------------------------------- -import { fetchMergedPRs, fetchClosedIssues } from '../../lib/github.js'; import { computeAnalytics, clearAnalyticsCache } from '../servers/analytics.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function mockGhOutput(responses: Record) { - execFileMock.mockImplementation((_cmd: string, args: string[]) => { - const argsStr = args.join(' '); - - if (argsStr.includes('pr') && argsStr.includes('list') && argsStr.includes('merged')) { - return Promise.resolve({ stdout: responses.mergedPRs ?? '[]' }); - } - 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: '[]' }); - }); -} - function defaultSummary() { return { totalCount: 5, @@ -87,111 +74,21 @@ function defaultSummary() { }; } -function defaultCostByProject() { - return [ - { projectId: '42', totalCost: 8.50 }, - { projectId: '73', totalCost: 6.50 }, - ]; +function makeDirent(name: string) { + return { name, isDirectory: () => true, isFile: () => false }; } -// --------------------------------------------------------------------------- -// fetchMergedPRs -// --------------------------------------------------------------------------- - -describe('fetchMergedPRs', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns parsed merged PRs from gh CLI', async () => { - const prs = [ - { number: 1, title: 'PR 1', createdAt: '2026-02-10T00:00:00Z', mergedAt: '2026-02-11T00:00:00Z', body: 'Fixes #42' }, - ]; - execFileMock.mockResolvedValueOnce({ stdout: JSON.stringify(prs) }); - - const result = await fetchMergedPRs('2026-02-10', '/tmp'); - expect(result).toEqual(prs); - }); - - it('includes --search merged:>=DATE when since is provided', async () => { - execFileMock.mockResolvedValueOnce({ stdout: '[]' }); - - await fetchMergedPRs('2026-02-14', '/tmp'); - - expect(execFileMock).toHaveBeenCalledWith( - 'gh', - expect.arrayContaining(['--search', 'merged:>=2026-02-14']), - expect.objectContaining({ cwd: '/tmp' }), - ); - }); - - it('omits --search when since is null', async () => { - execFileMock.mockResolvedValueOnce({ stdout: '[]' }); - - await fetchMergedPRs(null, '/tmp'); - - const args = execFileMock.mock.calls[0][1] as string[]; - expect(args).not.toContain('--search'); - }); - - it('returns null on failure', async () => { - execFileMock.mockRejectedValueOnce(new Error('gh not found')); - - const result = await fetchMergedPRs('2026-02-14', '/tmp'); - expect(result).toBeNull(); - }); - - it('passes --limit 1000', async () => { - execFileMock.mockResolvedValueOnce({ stdout: '[]' }); - - await fetchMergedPRs('2026-02-14', '/tmp'); - - expect(execFileMock).toHaveBeenCalledWith( - 'gh', - expect.arrayContaining(['--limit', '1000']), - expect.anything(), - ); - }); -}); - -// --------------------------------------------------------------------------- -// fetchClosedIssues -// --------------------------------------------------------------------------- - -describe('fetchClosedIssues', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns parsed closed issues from gh CLI', async () => { - const issues = [ - { number: 42, title: 'Bug', createdAt: '2026-02-10T00:00:00Z', closedAt: '2026-02-11T00:00:00Z', labels: [{ name: 'bug' }] }, - ]; - execFileMock.mockResolvedValueOnce({ stdout: JSON.stringify(issues) }); - - const result = await fetchClosedIssues('2026-02-10', '/tmp'); - expect(result).toEqual(issues); - }); - - it('includes --search closed:>=DATE when since is provided', async () => { - execFileMock.mockResolvedValueOnce({ stdout: '[]' }); - - await fetchClosedIssues('2026-02-14', '/tmp'); - - expect(execFileMock).toHaveBeenCalledWith( - 'gh', - expect.arrayContaining(['--search', 'closed:>=2026-02-14']), - expect.objectContaining({ cwd: '/tmp' }), - ); - }); - - it('returns null on failure', async () => { - execFileMock.mockRejectedValueOnce(new Error('gh not found')); - - const result = await fetchClosedIssues('2026-02-14', '/tmp'); - expect(result).toBeNull(); +function setupProjectMocks(projects: Array<{ dirName: string; yaml: string }>) { + mockExistsSync.mockReturnValue(true); + mockReaddirSync.mockReturnValue(projects.map(p => makeDirent(p.dirName))); + mockReadFileSync.mockImplementation((filePath: unknown) => { + const p = String(filePath); + for (const proj of projects) { + if (p.includes(proj.dirName)) return proj.yaml; + } + return ''; }); -}); +} // --------------------------------------------------------------------------- // computeAnalytics @@ -202,39 +99,38 @@ describe('computeAnalytics', () => { clearAnalyticsCache(); vi.clearAllMocks(); mockSummary.mockReturnValue(defaultSummary()); - mockCostByProject.mockReturnValue(defaultCostByProject()); + + setupProjectMocks([ + { + dirName: '0087-feature-a', + yaml: `protocol: spir\nphase: complete\nstarted_at: '2026-02-10T00:00:00Z'\nupdated_at: '2026-02-11T12:00:00Z'\n`, + }, + { + dirName: '0088-feature-b', + yaml: `protocol: aspir\nphase: complete\nstarted_at: '2026-02-12T00:00:00Z'\nupdated_at: '2026-02-13T00:00:00Z'\n`, + }, + { + dirName: 'bugfix-327-fix-thing', + yaml: `protocol: bugfix\nphase: complete\nstarted_at: '2026-02-14T00:00:00Z'\nupdated_at: '2026-02-14T02:00:00Z'\n`, + }, + ]); }); it('assembles full statistics from all data sources', async () => { - mockGhOutput({ - mergedPRs: JSON.stringify([ - { number: 1, title: '[Spec 42] Feature', createdAt: '2026-02-10T00:00:00Z', mergedAt: '2026-02-11T12:00:00Z', body: 'Closes #42' }, - { number: 2, title: '[Spec 73] Other', createdAt: '2026-02-12T00:00:00Z', mergedAt: '2026-02-13T00:00:00Z', body: '' }, - ]), - closedIssues: JSON.stringify([ - { 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' }, - ]), - }); + const result = await computeAnalytics('/tmp/workspace', 'all', 3); - 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.timeRange).toBe('all'); - expect(result.builders.projectsCompleted).toBe(2); // #42 (body) + #73 (title) - expect(result.builders.activeBuilders).toBe(3); + // Activity metrics from project artifacts + expect(result.activity.projectsCompleted).toBe(2); // spir + aspir (not bugfix) + expect(result.activity.bugsFixed).toBe(1); // bugfix-327 + expect(result.activity.projectsByProtocol).toEqual({ spir: 1, aspir: 1 }); + expect(result.activity.activeBuilders).toBe(3); + expect(result.activity.avgTimeToMergeHours).toBeCloseTo( + (36 + 24 + 2) / 3, // avg of 36h, 24h, 2h + ); + // Consultation metrics unchanged expect(result.consultation.totalCount).toBe(5); expect(result.consultation.totalCostUsd).toBe(15.00); expect(result.consultation.costByModel).toEqual({ gemini: 5.00, codex: 6.00, claude: 4.00 }); @@ -243,83 +139,31 @@ 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('returns 24h label for range "1"', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); - 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: '[]' }); - 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: '[]' }); - 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: '[]' }); - await computeAnalytics('/tmp/workspace', 'all', 0); - - const prCall = execFileMock.mock.calls.find( - (c: unknown[]) => (c[1] as string[]).includes('merged'), - ); - expect(prCall).toBeDefined(); - expect((prCall![1] as string[])).not.toContain('--search'); - }); - - it('passes a date string for "7" range', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); - await computeAnalytics('/tmp/workspace', '7', 0); - - const prCall = execFileMock.mock.calls.find( - (c: unknown[]) => (c[1] as string[]).includes('merged'), - ); - expect(prCall).toBeDefined(); - const args = prCall![1] as string[]; - const searchIdx = args.indexOf('--search'); - expect(searchIdx).toBeGreaterThan(-1); - expect(args[searchIdx + 1]).toMatch(/^merged:>=\d{4}-\d{2}-\d{2}$/); - }); - - // --- Partial failure: GitHub unavailable --- + it('returns correct time range labels', async () => { + const result1 = await computeAnalytics('/tmp/workspace', '1', 0); + expect(result1.timeRange).toBe('24h'); - it('returns GitHub defaults and error when all GitHub calls fail', async () => { - execFileMock.mockRejectedValue(new Error('gh not found')); + clearAnalyticsCache(); + const result7 = await computeAnalytics('/tmp/workspace', '7', 0); + expect(result7.timeRange).toBe('7d'); - const result = await computeAnalytics('/tmp/workspace', '7', 2); + clearAnalyticsCache(); + const result30 = await computeAnalytics('/tmp/workspace', '30', 0); + expect(result30.timeRange).toBe('30d'); - 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); - // Consultation still works - expect(result.consultation.totalCount).toBe(5); - expect(result.errors?.consultation).toBeUndefined(); + clearAnalyticsCache(); + const resultAll = await computeAnalytics('/tmp/workspace', 'all', 0); + expect(resultAll.timeRange).toBe('all'); }); - // --- Partial failure: MetricsDB unavailable --- - it('returns consultation defaults and error when MetricsDB fails', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); mockSummary.mockImplementation(() => { throw new Error('DB file not found'); }); - const result = await computeAnalytics('/tmp/workspace', '7', 0); + const result = await computeAnalytics('/tmp/workspace', 'all', 0); expect(result.errors?.consultation).toBe('DB file not found'); expect(result.consultation.totalCount).toBe(0); @@ -330,121 +174,67 @@ 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(); + expect(result.errors?.activity).toBeUndefined(); }); - // --- Null averages --- - - it('returns null averages when no data exists', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); + it('returns null averages when no complete projects exist', async () => { + setupProjectMocks([]); 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); + const result = await computeAnalytics('/tmp/workspace', 'all', 0); - expect(result.github.avgTimeToMergeHours).toBeNull(); - expect(result.github.avgTimeToCloseBugsHours).toBeNull(); + expect(result.activity.avgTimeToMergeHours).toBeNull(); + expect(result.activity.projectsCompleted).toBe(0); + expect(result.activity.bugsFixed).toBe(0); expect(result.consultation.avgLatencySeconds).toBeNull(); expect(result.consultation.successRate).toBeNull(); }); - // --- Projects completed --- - - it('excludes PRs without linked issues from projectsCompleted', async () => { - mockGhOutput({ - mergedPRs: JSON.stringify([ - { number: 1, title: 'No link', createdAt: '2026-02-10T00:00:00Z', mergedAt: '2026-02-11T00:00:00Z', body: 'No issue ref' }, - { 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); - }); - - it('counts all linked issues from a single PR with multiple references', async () => { - mockGhOutput({ - mergedPRs: JSON.stringify([ - { 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 - }); - - it('counts distinct issues when multiple PRs link to same issue', async () => { - mockGhOutput({ - mergedPRs: JSON.stringify([ - { number: 1, title: '[Spec 42] Part 1', createdAt: '2026-02-10T00:00:00Z', mergedAt: '2026-02-11T00:00:00Z', body: 'Fixes #42' }, - { number: 2, title: '[Spec 42] Part 2', createdAt: '2026-02-10T00:00:00Z', mergedAt: '2026-02-11T00:00:00Z', body: 'Closes #42' }, - ]), - closedIssues: '[]', - openIssues: '[]', - }); + it('only counts complete projects (not in-progress)', async () => { + setupProjectMocks([ + { + dirName: '0087-feature-a', + yaml: `protocol: spir\nphase: complete\nstarted_at: '2026-02-10T00:00:00Z'\nupdated_at: '2026-02-11T00:00:00Z'\n`, + }, + { + dirName: '0088-feature-b', + yaml: `protocol: spir\nphase: implement\nstarted_at: '2026-02-12T00:00:00Z'\nupdated_at: '2026-02-13T00:00:00Z'\n`, + }, + ]); - const result = await computeAnalytics('/tmp/workspace', '7', 0); - expect(result.builders.projectsCompleted).toBe(1); + const result = await computeAnalytics('/tmp/workspace', 'all', 0); + expect(result.activity.projectsCompleted).toBe(1); }); - it('counts multiple issues linked from a single PR', 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' }, - ]), - closedIssues: '[]', - openIssues: '[]', - }); + it('normalizes spider protocol to spir', async () => { + setupProjectMocks([ + { + dirName: '0087-old-project', + yaml: `protocol: spider\nphase: complete\nstarted_at: '2026-02-10T00:00:00Z'\nupdated_at: '2026-02-11T00:00:00Z'\n`, + }, + ]); - const result = await computeAnalytics('/tmp/workspace', '7', 0); - expect(result.builders.projectsCompleted).toBe(3); + const result = await computeAnalytics('/tmp/workspace', 'all', 0); + expect(result.activity.projectsByProtocol).toEqual({ spir: 1 }); }); - // --- Bug-only avg time to close --- - - it('only counts bug-labeled issues for avgTimeToCloseBugsHours', async () => { - mockGhOutput({ - 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' }] }, - ]), - openIssues: '[]', - }); - - const result = await computeAnalytics('/tmp/workspace', '7', 0); - expect(result.github.avgTimeToCloseBugsHours).toBeCloseTo(24); + it('does not include costByProject in consultation', async () => { + const result = await computeAnalytics('/tmp/workspace', 'all', 0); + expect((result.consultation as Record).costByProject).toBeUndefined(); }); - // --- costByModel derivation --- - - it('derives costByModel correctly, excluding null costs', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); - mockSummary.mockReturnValue({ - ...defaultSummary(), - byModel: [ - { model: 'gemini', count: 1, avgDuration: 60, totalCost: null, costCount: 0, successRate: 100, successCount: 1 }, - { model: 'codex', count: 1, avgDuration: 80, totalCost: 3.50, costCount: 1, successRate: 100, successCount: 1 }, - ], - }); - - const result = await computeAnalytics('/tmp/workspace', '7', 0); - expect(result.consultation.costByModel).toEqual({ codex: 3.50 }); + it('does not include github or builders top-level keys', async () => { + const result = await computeAnalytics('/tmp/workspace', 'all', 0); + expect((result as Record).github).toBeUndefined(); + expect((result as Record).builders).toBeUndefined(); }); // --- Caching --- it('returns cached result on second call within TTL', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); - const result1 = await computeAnalytics('/tmp/workspace', '7', 3); const result2 = await computeAnalytics('/tmp/workspace', '7', 3); @@ -453,8 +243,6 @@ describe('computeAnalytics', () => { }); it('bypasses cache when refresh=true', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); - await computeAnalytics('/tmp/workspace', '7', 3); await computeAnalytics('/tmp/workspace', '7', 3, true); @@ -462,8 +250,6 @@ describe('computeAnalytics', () => { }); it('does not share cache between different ranges', async () => { - mockGhOutput({ mergedPRs: '[]', closedIssues: '[]', openIssues: '[]' }); - await computeAnalytics('/tmp/workspace', '7', 3); await computeAnalytics('/tmp/workspace', '30', 3); @@ -472,34 +258,58 @@ describe('computeAnalytics', () => { // --- Throughput --- - it('computes throughput for 30d range', async () => { - mockGhOutput({ - mergedPRs: JSON.stringify([ - { number: 1, title: 'PR', createdAt: '2026-02-01T00:00:00Z', mergedAt: '2026-02-02T00:00:00Z', body: 'Fixes #10' }, - { number: 2, title: 'PR', createdAt: '2026-02-01T00:00:00Z', mergedAt: '2026-02-02T00:00:00Z', body: 'Fixes #20' }, - { number: 3, title: 'PR', createdAt: '2026-02-01T00:00:00Z', mergedAt: '2026-02-02T00:00:00Z', body: 'Fixes #30' }, - { number: 4, title: 'PR', createdAt: '2026-02-01T00:00:00Z', mergedAt: '2026-02-02T00:00:00Z', body: 'Fixes #40' }, - ]), - closedIssues: '[]', - openIssues: '[]', - }); - + it('computes throughput including both projects and bugs', async () => { + // 3 total complete projects (2 non-bug + 1 bug), over 30/7 weeks const result = await computeAnalytics('/tmp/workspace', '30', 0); - const expected = Math.round((4 / (30 / 7)) * 10) / 10; - expect(result.builders.throughputPerWeek).toBeCloseTo(expected, 1); + const expected = Math.round((3 / (30 / 7)) * 10) / 10; + expect(result.activity.throughputPerWeek).toBeCloseTo(expected, 1); + }); + + it('computes throughput for 7d range', async () => { + // Use recent dates so they fall within 7-day window + const now = new Date(); + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString(); + const oneDayAgo = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000).toISOString(); + setupProjectMocks([ + { dirName: 'a', yaml: `protocol: spir\nphase: complete\nstarted_at: '${twoDaysAgo}'\nupdated_at: '${oneDayAgo}'\n` }, + { dirName: 'b', yaml: `protocol: bugfix\nphase: complete\nstarted_at: '${twoDaysAgo}'\nupdated_at: '${oneDayAgo}'\n` }, + ]); + + const result = await computeAnalytics('/tmp/workspace', '7', 0); + // 2 total complete projects / 1 week = 2 + expect(result.activity.throughputPerWeek).toBe(2); }); - it('computes throughput for 7d range (equals projectsCompleted)', async () => { - mockGhOutput({ - mergedPRs: JSON.stringify([ - { number: 1, title: 'PR', createdAt: '2026-02-01T00:00:00Z', mergedAt: '2026-02-02T00:00:00Z', body: 'Fixes #10' }, - { number: 2, title: 'PR', createdAt: '2026-02-01T00:00:00Z', mergedAt: '2026-02-02T00:00:00Z', body: 'Fixes #20' }, - ]), - closedIssues: '[]', - openIssues: '[]', + // --- costByModel derivation --- + + it('derives costByModel correctly, excluding null costs', async () => { + mockSummary.mockReturnValue({ + ...defaultSummary(), + byModel: [ + { model: 'gemini', count: 1, avgDuration: 60, totalCost: null, costCount: 0, successRate: 100, successCount: 1 }, + { model: 'codex', count: 1, avgDuration: 80, totalCost: 3.50, costCount: 1, successRate: 100, successCount: 1 }, + ], }); const result = await computeAnalytics('/tmp/workspace', '7', 0); - expect(result.builders.throughputPerWeek).toBe(2); + expect(result.consultation.costByModel).toEqual({ codex: 3.50 }); + }); + + // --- Activity error handling --- + + it('returns activity defaults and error when project scan fails', async () => { + mockExistsSync.mockImplementation(() => { throw new Error('Permission denied'); }); + + const result = await computeAnalytics('/tmp/workspace', 'all', 2); + + expect(result.errors?.activity).toBe('Permission denied'); + expect(result.activity.projectsCompleted).toBe(0); + expect(result.activity.bugsFixed).toBe(0); + expect(result.activity.avgTimeToMergeHours).toBeNull(); + expect(result.activity.projectsByProtocol).toEqual({}); + expect(result.activity.activeBuilders).toBe(2); + // Consultation still works + expect(result.consultation.totalCount).toBe(5); + expect(result.errors?.consultation).toBeUndefined(); }); }); 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..0397915b 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: { projectsCompleted: 3, projectsByProtocol: { spir: 2, aspir: 1 }, bugsFixed: 2, avgTimeToMergeHours: 2.5, throughputPerWeek: 3, activeBuilders: 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.projectsCompleted).toBe(3); 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.projectsCompleted).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..4451a73a 100644 --- a/packages/codev/src/agent-farm/servers/analytics.ts +++ b/packages/codev/src/agent-farm/servers/analytics.ts @@ -1,23 +1,17 @@ /** * Analytics aggregation service for the dashboard Analytics tab. * - * Aggregates data from three sources: - * - GitHub CLI (merged PRs, closed issues, open issue backlogs) + * Aggregates data from two sources: + * - Project artifacts (codev/projects//status.yaml) for activity metrics * - Consultation metrics DB (~/.codev/metrics.db) - * - 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 { - fetchMergedPRs, - fetchClosedIssues, - fetchIssueList, - parseAllLinkedIssues, - type MergedPR, - type ClosedIssue, -} from '../../lib/github.js'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as yaml from 'js-yaml'; import { MetricsDB } from '../../commands/consult/metrics.js'; // ============================================================================= @@ -26,16 +20,11 @@ import { MetricsDB } from '../../commands/consult/metrics.js'; export interface AnalyticsResponse { timeRange: '24h' | '7d' | '30d' | 'all'; - github: { - prsMerged: number; - avgTimeToMergeHours: number | null; - bugBacklog: number; - nonBugBacklog: number; - issuesClosed: number; - avgTimeToCloseBugsHours: number | null; - }; - builders: { + activity: { projectsCompleted: number; + projectsByProtocol: Record; + bugsFixed: number; + avgTimeToMergeHours: number | null; throughputPerWeek: number; activeBuilders: number; }; @@ -54,13 +43,9 @@ export interface AnalyticsResponse { }>; byReviewType: Record; byProtocol: Record; - costByProject: Array<{ - projectId: string; - totalCost: number; - }>; }; errors?: { - github?: string; + activity?: string; consultation?: string; }; } @@ -102,104 +87,117 @@ function rangeToDays(range: RangeParam): number | undefined { return undefined; } -function rangeToSinceDate(range: RangeParam): string | null { - const days = rangeToDays(range); - if (!days) return null; - const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); - return since.toISOString().split('T')[0]; // YYYY-MM-DD -} - function rangeToWeeks(range: RangeParam): number { if (range === '1') return 1 / 7; if (range === '7') return 1; if (range === '30') return 30 / 7; // For "all", we can't know the true range without data, so return 1 - // (throughput = projectsCompleted / 1 = total projects) return 1; } // ============================================================================= -// GitHub metrics computation +// Project artifact scanning // ============================================================================= -function computeAvgHours(items: Array<{ start: string; end: string }>): number | null { - if (items.length === 0) return null; - const totalMs = items.reduce((sum, item) => { - return sum + (new Date(item.end).getTime() - new Date(item.start).getTime()); - }, 0); - return totalMs / items.length / (1000 * 60 * 60); +interface ProjectStatus { + protocol: string; + phase: string; + startedAt: string | null; + updatedAt: string | null; } -interface GitHubMetrics { - prsMerged: number; - avgTimeToMergeHours: number | null; - bugBacklog: number; - nonBugBacklog: number; - issuesClosed: number; - avgTimeToCloseBugsHours: number | null; +/** + * Scan codev/projects//status.yaml for project statuses. + * Exported for testing. + */ +export function scanProjectStatuses(workspaceRoot: string): ProjectStatus[] { + const projectsDir = path.join(workspaceRoot, 'codev', 'projects'); + if (!fs.existsSync(projectsDir)) return []; + + const entries = fs.readdirSync(projectsDir, { withFileTypes: true }); + const statuses: ProjectStatus[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const statusFile = path.join(projectsDir, entry.name, 'status.yaml'); + if (!fs.existsSync(statusFile)) continue; + try { + const content = fs.readFileSync(statusFile, 'utf-8'); + const parsed = yaml.load(content) as Record; + if (!parsed || typeof parsed !== 'object') continue; + statuses.push({ + protocol: String(parsed.protocol ?? ''), + phase: String(parsed.phase ?? ''), + startedAt: parsed.started_at ? String(parsed.started_at) : null, + updatedAt: parsed.updated_at ? String(parsed.updated_at) : null, + }); + } catch { + // Skip unparseable files + } + } + + return statuses; +} + +// Normalize protocol names (spider → spir) +function normalizeProtocol(protocol: string): string { + if (protocol === 'spider') return 'spir'; + return protocol; +} + +interface ActivityMetrics { projectsCompleted: number; + projectsByProtocol: Record; + bugsFixed: number; + avgTimeToMergeHours: number | null; } -async function computeGitHubMetrics( - since: string | null, - cwd: string, -): Promise { - // Fetch merged PRs and closed issues in parallel - const [mergedPRs, closedIssues, openIssues] = await Promise.all([ - fetchMergedPRs(since, cwd), - fetchClosedIssues(since, cwd), - fetchIssueList(cwd), - ]); - - if (mergedPRs === null && closedIssues === null && openIssues === null) { - throw new Error('GitHub CLI unavailable'); +function computeActivityMetrics( + workspaceRoot: string, + range: RangeParam, +): ActivityMetrics { + const allStatuses = scanProjectStatuses(workspaceRoot); + const days = rangeToDays(range); + const sinceMs = days ? Date.now() - days * 24 * 60 * 60 * 1000 : null; + + // Filter to complete projects, optionally within time range + const completeProjects = allStatuses.filter(p => { + if (p.phase !== 'complete') return false; + if (sinceMs && p.updatedAt) { + return new Date(p.updatedAt).getTime() >= sinceMs; + } + // If no time filter or no updatedAt, include for 'all' range only + return !sinceMs; + }); + + // Split bugs vs non-bug projects + const bugProjects = completeProjects.filter(p => normalizeProtocol(p.protocol) === 'bugfix'); + const nonBugProjects = completeProjects.filter(p => normalizeProtocol(p.protocol) !== 'bugfix'); + + // Group non-bug projects by protocol + const projectsByProtocol: Record = {}; + for (const p of nonBugProjects) { + const proto = normalizeProtocol(p.protocol); + projectsByProtocol[proto] = (projectsByProtocol[proto] ?? 0) + 1; } - // PRs merged - const prs = mergedPRs ?? []; - const prsMerged = prs.length; - - // Average time to merge - const avgTimeToMergeHours = computeAvgHours( - 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; - - // Average time to close bugs - const closedBugs = closed.filter(i => - i.labels.some(l => l.name === 'bug') && i.closedAt, - ); - const avgTimeToCloseBugsHours = computeAvgHours( - closedBugs.map(i => ({ start: i.createdAt, end: i.closedAt })), - ); - - // Projects completed (distinct issue numbers from merged PRs via parseAllLinkedIssues) - const linkedIssues = new Set(); - for (const pr of prs) { - for (const issueNum of parseAllLinkedIssues(pr.body ?? '', pr.title)) { - linkedIssues.add(issueNum); + // Avg time to complete (started_at → updated_at) for all complete projects + const durations: number[] = []; + for (const p of completeProjects) { + if (p.startedAt && p.updatedAt) { + const ms = new Date(p.updatedAt).getTime() - new Date(p.startedAt).getTime(); + if (ms > 0) durations.push(ms); } } - const projectsCompleted = linkedIssues.size; + const avgTimeToMergeHours = durations.length > 0 + ? durations.reduce((a, b) => a + b, 0) / durations.length / (1000 * 60 * 60) + : null; return { - prsMerged, + projectsCompleted: nonBugProjects.length, + projectsByProtocol, + bugsFixed: bugProjects.length, avgTimeToMergeHours, - bugBacklog, - nonBugBacklog, - issuesClosed, - avgTimeToCloseBugsHours, - projectsCompleted, }; } @@ -222,10 +220,6 @@ interface ConsultationMetrics { }>; byReviewType: Record; byProtocol: Record; - costByProject: Array<{ - projectId: string; - totalCost: number; - }>; } function computeConsultationMetrics(days: number | undefined): ConsultationMetrics { @@ -233,7 +227,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,7 +267,6 @@ function computeConsultationMetrics(days: number | undefined): ConsultationMetri })), byReviewType, byProtocol, - costByProject: projectCosts, }; } finally { db.close(); @@ -288,7 +280,7 @@ function computeConsultationMetrics(days: number | undefined): ConsultationMetri /** * Compute analytics for the dashboard Analytics tab. * - * @param workspaceRoot - Path to the workspace root (used as cwd for gh CLI) + * @param workspaceRoot - Path to the workspace root (used for project scanning and as cwd for gh CLI) * @param range - Time range: '1', '7', '30', or 'all' * @param activeBuilders - Current active builder count (from tower context) * @param refresh - If true, bypass the cache @@ -309,26 +301,22 @@ export async function computeAnalytics( } } - const since = rangeToSinceDate(range); const days = rangeToDays(range); const weeks = rangeToWeeks(range); - const errors: { github?: string; consultation?: string } = {}; + const errors: { activity?: string; consultation?: string } = {}; - // GitHub metrics - let githubMetrics: GitHubMetrics; + // Activity metrics (from project artifacts) + let activityMetrics: ActivityMetrics; try { - githubMetrics = await computeGitHubMetrics(since, workspaceRoot); + activityMetrics = computeActivityMetrics(workspaceRoot, range); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - errors.github = msg; - githubMetrics = { - prsMerged: 0, - avgTimeToMergeHours: null, - bugBacklog: 0, - nonBugBacklog: 0, - issuesClosed: 0, - avgTimeToCloseBugsHours: null, + errors.activity = msg; + activityMetrics = { projectsCompleted: 0, + projectsByProtocol: {}, + bugsFixed: 0, + avgTimeToMergeHours: null, }; } @@ -348,24 +336,20 @@ export async function computeAnalytics( byModel: [], byReviewType: {}, byProtocol: {}, - costByProject: [], }; } + const totalCompleted = activityMetrics.projectsCompleted + activityMetrics.bugsFixed; + const result: AnalyticsResponse = { timeRange: rangeToLabel(range), - github: { - prsMerged: githubMetrics.prsMerged, - avgTimeToMergeHours: githubMetrics.avgTimeToMergeHours, - bugBacklog: githubMetrics.bugBacklog, - nonBugBacklog: githubMetrics.nonBugBacklog, - issuesClosed: githubMetrics.issuesClosed, - avgTimeToCloseBugsHours: githubMetrics.avgTimeToCloseBugsHours, - }, - builders: { - projectsCompleted: githubMetrics.projectsCompleted, + activity: { + projectsCompleted: activityMetrics.projectsCompleted, + projectsByProtocol: activityMetrics.projectsByProtocol, + bugsFixed: activityMetrics.bugsFixed, + avgTimeToMergeHours: activityMetrics.avgTimeToMergeHours, throughputPerWeek: weeks > 0 - ? Math.round((githubMetrics.projectsCompleted / weeks) * 10) / 10 + ? Math.round((totalCompleted / weeks) * 10) / 10 : 0, activeBuilders, }, diff --git a/packages/codev/src/agent-farm/servers/tower-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index 75c7e675..5b2520f0 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: { projectsCompleted: 0, projectsByProtocol: {}, bugsFixed: 0, avgTimeToMergeHours: null, throughputPerWeek: 0, activeBuilders: 0 }, consultation: { totalCount: 0, totalCostUsd: null, costByModel: {}, avgLatencySeconds: null, successRate: null, byModel: [], byReviewType: {}, byProtocol: {} } })); return; } const range = rangeParam as '1' | '7' | '30' | 'all';