diff --git a/packages/cli/src/__tests__/commands/summary.test.ts b/packages/cli/src/__tests__/commands/summary.test.ts new file mode 100644 index 00000000..c936af86 --- /dev/null +++ b/packages/cli/src/__tests__/commands/summary.test.ts @@ -0,0 +1,375 @@ +/** + * Tests for summary command + */ + +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Mock process.cwd to return our temp directory +let mockProjectDir: string; + +vi.mock('child_process', () => ({ + exec: vi.fn(), + execFile: vi.fn(), + execSync: vi.fn(), + spawn: vi.fn(), +})); + +vi.mock('@night-watch/core/utils/crontab.js', () => ({ + getEntries: vi.fn(() => []), + getProjectEntries: vi.fn(() => []), + generateMarker: vi.fn((name: string) => `# night-watch-cli: ${name}`), +})); + +// Mock job-queue module +vi.mock('@night-watch/core/utils/job-queue.js', () => ({ + getJobRunsAnalytics: vi.fn(() => ({ + recentRuns: [], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + })), + getQueueStatus: vi.fn(() => ({ + enabled: true, + running: null, + pending: { total: 0, byType: {}, byProviderBucket: {} }, + items: [], + averageWaitSeconds: null, + oldestPendingAge: null, + })), +})); + +// Mock status-data module +vi.mock('@night-watch/core/utils/status-data.js', () => ({ + collectPrInfo: vi.fn(async () => []), +})); + +// Mock process.cwd before importing module +const originalCwd = process.cwd; +process.cwd = () => mockProjectDir; + +// Import after mocking +import { summaryCommand } from '@/cli/commands/summary.js'; +import { Command } from 'commander'; +import { getJobRunsAnalytics, getQueueStatus } from '@night-watch/core/utils/job-queue.js'; +import { collectPrInfo } from '@night-watch/core/utils/status-data.js'; + +describe('summary command', () => { + let tempDir: string; + let consoleSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'night-watch-summary-test-')); + mockProjectDir = tempDir; + + // Create basic package.json + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'test-project' })); + + // Create config file + fs.writeFileSync( + path.join(tempDir, 'night-watch.config.json'), + JSON.stringify( + { + projectName: 'test-project', + defaultBranch: 'main', + provider: 'claude', + reviewerEnabled: true, + prdDir: 'docs/PRDs/night-watch', + maxRuntime: 7200, + reviewerMaxRuntime: 3600, + branchPatterns: ['feat/', 'night-watch/'], + notifications: { webhooks: [] }, + }, + null, + 2, + ), + ); + + // Reset mocks to return default values + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + vi.mocked(getQueueStatus).mockReturnValue({ + enabled: true, + running: null, + pending: { total: 0, byType: {}, byProviderBucket: {} }, + items: [], + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + vi.mocked(collectPrInfo).mockResolvedValue([]); + + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + consoleSpy.mockRestore(); + }); + + afterAll(() => { + process.cwd = originalCwd; + }); + + describe('help text', () => { + it('should show help text with --help flag', async () => { + const program = new Command(); + summaryCommand(program); + + program.exitOverride(); + let capturedOutput = ''; + program.configureOutput({ + writeOut: (str: string) => { + capturedOutput += str; + }, + }); + + try { + await program.parseAsync(['node', 'test', 'summary', '--help']); + } catch { + // Help throws by default in commander + } + + expect(capturedOutput).toContain('--hours'); + expect(capturedOutput).toContain('--json'); + }); + }); + + describe('formatted output', () => { + it('should display summary header with time window', async () => { + const program = new Command(); + summaryCommand(program); + + await program.parseAsync(['node', 'test', 'summary']); + + const output = consoleSpy.mock.calls.map((call) => call.join(' ')).join('\n'); + expect(output).toContain('Night Watch Summary'); + expect(output).toContain('last 12h'); + }); + }); + describe('JSON output', () => { + it('should output valid JSON when --json flag is used', async () => { + const program = new Command(); + summaryCommand(program); + + await program.parseAsync(['node', 'test', 'summary', '--json']); + + const output = consoleSpy.mock.calls[0]?.[0] || ''; + const parsed = JSON.parse(output); + + expect(parsed).toHaveProperty('windowHours'); + expect(parsed).toHaveProperty('jobRuns'); + expect(parsed).toHaveProperty('counts'); + expect(parsed).toHaveProperty('openPrs'); + expect(parsed).toHaveProperty('pendingQueueItems'); + expect(parsed).toHaveProperty('actionItems'); + }); + + it('should include correct windowHours in JSON output', async () => { + const program = new Command(); + summaryCommand(program); + + await program.parseAsync(['node', 'test', 'summary', '--json', '--hours', '8']); + + const output = consoleSpy.mock.calls[0]?.[0] || ''; + const parsed = JSON.parse(output); + + expect(parsed.windowHours).toBe(8); + }); + }); + + describe('job counts', () => { + it('should use default 12 hours when --hours not specified', async () => { + const program = new Command(); + summaryCommand(program); + + await program.parseAsync(['node', 'test', 'summary']); + + expect(vi.mocked(getJobRunsAnalytics)).toHaveBeenCalledWith(12); + }); + + it('should respect custom --hours value', async () => { + const program = new Command(); + summaryCommand(program); + + await program.parseAsync(['node', 'test', 'summary', '--hours', '24']); + + expect(vi.mocked(getJobRunsAnalytics)).toHaveBeenCalledWith(24); + }); + + it('should show "No recent activity" when no jobs ran', async () => { + const program = new Command(); + summaryCommand(program); + + await program.parseAsync(['node', 'test', 'summary']); + + const output = consoleSpy.mock.calls.map((call) => call.join(' ')).join('\n'); + expect(output).toContain('No recent activity'); + }); + + it('should show job counts from analytics data', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { + id: 1, + projectPath: '/project', + jobType: 'executor', + providerKey: 'claude', + status: 'success', + startedAt: Math.floor(Date.now() / 1000) - 3600, + finishedAt: Math.floor(Date.now() / 1000), + waitSeconds: 10, + durationSeconds: 300, + throttledCount: 0, + }, + { + id: 2, + projectPath: '/project', + jobType: 'reviewer', + providerKey: 'claude', + status: 'failure', + startedAt: Math.floor(Date.now() / 1000) - 3600, + finishedAt: Math.floor(Date.now() / 1000), + waitSeconds: 5, + durationSeconds: 180, + throttledCount: 0, + }, + ], + byProviderBucket: {}, + averageWaitSeconds: 7, + oldestPendingAge: null, + }); + + const program = new Command(); + summaryCommand(program); + + await program.parseAsync(['node', 'test', 'summary']); + + const output = consoleSpy.mock.calls.map((call) => call.join(' ')).join('\n'); + expect(output).toContain('1 succeeded'); + expect(output).toContain('1 failed'); + }); + + it('should generate action items for failed jobs', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { + id: 1, + projectPath: '/project', + jobType: 'executor', + providerKey: 'claude', + status: 'failure', + startedAt: Math.floor(Date.now() / 1000) - 3600, + finishedAt: Math.floor(Date.now() / 1000), + waitSeconds: 10, + durationSeconds: 300, + throttledCount: 0, + }, + ], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + const program = new Command(); + summaryCommand(program); + + await program.parseAsync(['node', 'test', 'summary']); + + const output = consoleSpy.mock.calls.map((call) => call.join(' ')).join('\n'); + expect(output).toContain('Action needed'); + expect(output).toContain('night-watch logs'); + }); + + it('should show "No action needed" when all jobs healthy', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { + id: 1, + projectPath: '/project', + jobType: 'executor', + providerKey: 'claude', + status: 'success', + startedAt: Math.floor(Date.now() / 1000) - 3600, + finishedAt: Math.floor(Date.now() / 1000), + waitSeconds: 10, + durationSeconds: 300, + throttledCount: 0, + }, + ], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + vi.mocked(getQueueStatus).mockReturnValue({ + enabled: true, + running: null, + pending: { total: 0, byType: {}, byProviderBucket: {} }, + items: [], + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + const program = new Command(); + summaryCommand(program); + + await program.parseAsync(['node', 'test', 'summary']); + + const output = consoleSpy.mock.calls.map((call) => call.join(' ')).join('\n'); + expect(output).toContain('No action needed'); + }); + }); + + describe('PR data', () => { + it('should generate action items for PRs with failing CI', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { + id: 1, + projectPath: '/project', + jobType: 'executor', + providerKey: 'claude', + status: 'success', + startedAt: Math.floor(Date.now() / 1000) - 3600, + finishedAt: Math.floor(Date.now() / 1000), + waitSeconds: 10, + durationSeconds: 300, + throttledCount: 0, + }, + ], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + vi.mocked(collectPrInfo).mockResolvedValue([ + { + number: 42, + title: 'Test PR', + branch: 'feat/test', + url: 'https://github.com/test/repo/pull/42', + ciStatus: 'fail', + reviewScore: null, + }, + ]); + + const program = new Command(); + summaryCommand(program); + + await program.parseAsync(['node', 'test', 'summary']); + + const output = consoleSpy.mock.calls.map((call) => call.join(' ')).join('\n'); + expect(output).toContain('Action needed'); + expect(output).toContain('PR #42'); + }); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index da7dfeac..97777853 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -31,6 +31,7 @@ import { createStateCommand } from './commands/state.js'; import { boardCommand } from './commands/board.js'; import { queueCommand } from './commands/queue.js'; import { notifyCommand } from './commands/notify.js'; +import { summaryCommand } from './commands/summary.js'; // Find the package root (works from both src/ in dev and dist/src/ in production) const __filename = fileURLToPath(import.meta.url); @@ -125,4 +126,7 @@ queueCommand(program); // Register notify command (send notification events from bash scripts) notifyCommand(program); +// Register summary command (morning briefing) +summaryCommand(program); + program.parse(); diff --git a/packages/cli/src/commands/summary.ts b/packages/cli/src/commands/summary.ts new file mode 100644 index 00000000..327efc22 --- /dev/null +++ b/packages/cli/src/commands/summary.ts @@ -0,0 +1,223 @@ +/** + * Summary command for Night Watch CLI + * Shows a "morning briefing" combining job runs, PRs, and queue status + */ + +import path from 'path'; + +import { Command } from 'commander'; +import chalk from 'chalk'; +import { + DEFAULT_SUMMARY_WINDOW_HOURS, + createTable, + dim, + getSummaryData, + header, + info, + loadConfig, +} from '@night-watch/core'; +import type { IPrInfo } from '@night-watch/core'; + +export interface ISummaryOptions { + hours?: string; + json?: boolean; +} + +/** + * Format duration from seconds to human-readable string (e.g., "8m 32s") + */ +function formatDuration(seconds: number | null): string { + if (seconds === null) return '-'; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + if (mins === 0) return `${secs}s`; + return `${mins}m ${secs}s`; +} + +/** + * Format CI status with color coding + */ +function formatCiStatus(status: IPrInfo['ciStatus']): string { + if (status === 'pass') return chalk.green('pass'); + if (status === 'fail') return chalk.red('fail'); + if (status === 'pending') return chalk.yellow('pending'); + return chalk.dim('unknown'); +} + +/** + * Format review score with color coding + */ +function formatReviewScore(score: number | null): string { + if (score === null) return chalk.dim('-'); + if (score >= 80) return chalk.green(String(score)); + if (score >= 60) return chalk.yellow(String(score)); + return chalk.red(String(score)); +} + +/** + * Format job status with color coding + */ +function formatJobStatus(status: string): string { + if (status === 'success') return chalk.green('success'); + if (status === 'failure') return chalk.red('failure'); + if (status === 'timeout') return chalk.yellow('timeout'); + if (status === 'rate_limited') return chalk.magenta('rate_limited'); + if (status === 'skipped') return chalk.dim('skipped'); + return chalk.dim(status); +} + +/** + * Extract project name from path + */ +function getProjectName(projectPath: string): string { + return path.basename(projectPath) || projectPath; +} + +/** + * Format provider key for display + */ +function formatProvider(providerKey: string): string { + return providerKey.split(':')[0] || providerKey; +} + +/** + * Summary command implementation + */ +export function summaryCommand(program: Command): void { + program + .command('summary') + .description('Show a summary of recent Night Watch activity') + .option( + '--hours ', + 'Time window in hours (default: 12)', + String(DEFAULT_SUMMARY_WINDOW_HOURS), + ) + .option('--json', 'Output summary as JSON') + .action(async (options: ISummaryOptions) => { + try { + const projectDir = process.cwd(); + const config = loadConfig(projectDir); + const hours = parseInt(options.hours || String(DEFAULT_SUMMARY_WINDOW_HOURS), 10); + if (isNaN(hours) || hours <= 0) { + console.error('Error: --hours must be a positive integer'); + process.exit(1); + } + + const data = await getSummaryData(projectDir, hours, config.branchPatterns); + + // Output as JSON if requested + if (options.json) { + console.log(JSON.stringify(data, null, 2)); + return; + } + + // Print header + console.log(); + console.log(chalk.bold.cyan(`Night Watch Summary (last ${data.windowHours}h)`)); + console.log(chalk.dim('─'.repeat(40))); + console.log(); + + // Jobs executed section + if (data.jobRuns.length === 0) { + info('No recent activity in this time window.'); + console.log(); + } else { + // Job counts with colored indicators + const countParts: string[] = []; + if (data.counts.succeeded > 0) { + countParts.push(chalk.green(`✓ ${data.counts.succeeded} succeeded`)); + } + if (data.counts.failed > 0) { + countParts.push(chalk.red(`✗ ${data.counts.failed} failed`)); + } + if (data.counts.timedOut > 0) { + countParts.push(chalk.yellow(`⏱ ${data.counts.timedOut} timed out`)); + } + if (data.counts.rateLimited > 0) { + countParts.push(chalk.magenta(`⏳ ${data.counts.rateLimited} rate limited`)); + } + if (data.counts.skipped > 0) { + countParts.push(chalk.dim(`${data.counts.skipped} skipped`)); + } + + console.log(`Jobs Executed: ${data.counts.total}`); + if (countParts.length > 0) { + console.log(` ${countParts.join(' ')}`); + } + console.log(); + + // Job runs table + const table = createTable({ + head: ['Job', 'Status', 'Project', 'Provider', 'Duration'], + colWidths: [12, 12, 20, 12, 12], + }); + + for (const run of data.jobRuns.slice(0, 10)) { + table.push([ + run.jobType, + formatJobStatus(run.status), + getProjectName(run.projectPath), + formatProvider(run.providerKey), + formatDuration(run.durationSeconds), + ]); + } + + console.log(table.toString()); + if (data.jobRuns.length > 10) { + dim(` ... and ${data.jobRuns.length - 10} more`); + } + console.log(); + } + + // Open PRs section + if (data.openPrs.length > 0) { + header(`Open PRs (${data.openPrs.length})`); + + const prTable = createTable({ + head: ['#', 'Title', 'CI', 'Score'], + colWidths: [6, 40, 10, 8], + }); + + for (const pr of data.openPrs) { + const title = pr.title.length > 37 ? pr.title.substring(0, 34) + '...' : pr.title; + prTable.push([ + String(pr.number), + title, + formatCiStatus(pr.ciStatus), + formatReviewScore(pr.reviewScore), + ]); + } + + console.log(prTable.toString()); + console.log(); + } + + // Queue section + if (data.pendingQueueItems.length > 0) { + const jobTypes = [...new Set(data.pendingQueueItems.map((item) => item.jobType))]; + const projectNames = [...new Set(data.pendingQueueItems.map((item) => item.projectName))]; + dim( + `Queue: ${data.pendingQueueItems.length} pending (${jobTypes.join(', ')}) for ${projectNames.join(', ')}`, + ); + console.log(); + } + + // Action items or "all healthy" message + if (data.actionItems.length > 0) { + console.log(chalk.yellow('⚠ Action needed:')); + for (const item of data.actionItems) { + console.log(` • ${item}`); + } + } else { + console.log(chalk.green('✓ No action needed — all jobs healthy.')); + } + + console.log(); + } catch (error) { + console.error( + `Error getting summary: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } + }); +} diff --git a/packages/core/src/__tests__/utils/summary.test.ts b/packages/core/src/__tests__/utils/summary.test.ts new file mode 100644 index 00000000..318ee137 --- /dev/null +++ b/packages/core/src/__tests__/utils/summary.test.ts @@ -0,0 +1,557 @@ +/** + * Tests for summary data aggregator utility + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +// Mock dependencies +vi.mock('../../utils/job-queue.js', () => ({ + getJobRunsAnalytics: vi.fn(() => ({ + recentRuns: [], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + })), + getQueueStatus: vi.fn(() => ({ + enabled: true, + running: null, + pending: { total: 0, byType: {}, byProviderBucket: {} }, + items: [], + averageWaitSeconds: null, + oldestPendingAge: null, + })), +})); + +vi.mock('../../utils/status-data.js', () => ({ + collectPrInfo: vi.fn(async () => []), +})); + +// Import after mocking +import { getSummaryData } from '../../utils/summary.js'; +import { getJobRunsAnalytics, getQueueStatus } from '../../utils/job-queue.js'; +import { collectPrInfo } from '../../utils/status-data.js'; + +describe('getSummaryData', () => { + let tempDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nw-summary-test-')); + + // Reset mocks to default values + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + vi.mocked(getQueueStatus).mockReturnValue({ + enabled: true, + running: null, + pending: { total: 0, byType: {}, byProviderBucket: {} }, + items: [], + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + vi.mocked(collectPrInfo).mockResolvedValue([]); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('windowHours', () => { + it('should default to 12 hours', async () => { + const data = await getSummaryData(tempDir); + expect(data.windowHours).toBe(12); + }); + + it('should respect custom window hours', async () => { + const data = await getSummaryData(tempDir, 24); + expect(data.windowHours).toBe(24); + expect(getJobRunsAnalytics).toHaveBeenCalledWith(24); + }); + }); + + describe('job counts', () => { + it('should return zero counts when no job runs', async () => { + const data = await getSummaryData(tempDir); + expect(data.counts.total).toBe(0); + expect(data.counts.succeeded).toBe(0); + expect(data.counts.failed).toBe(0); + expect(data.counts.timedOut).toBe(0); + expect(data.counts.rateLimited).toBe(0); + expect(data.counts.skipped).toBe(0); + }); + + it('should count succeeded jobs', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { + id: 1, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'success', + startedAt: Math.floor(Date.now() / 1000) - 3600, + finishedAt: Math.floor(Date.now() / 1000), + waitSeconds: 10, + durationSeconds: 300, + throttledCount: 0, + }, + { + id: 2, + projectPath: tempDir, + jobType: 'reviewer', + providerKey: 'claude', + status: 'success', + startedAt: Math.floor(Date.now() / 1000) - 3600, + finishedAt: Math.floor(Date.now() / 1000), + waitSeconds: 5, + durationSeconds: 180, + throttledCount: 0, + }, + ], + byProviderBucket: {}, + averageWaitSeconds: 7, + oldestPendingAge: null, + }); + + const data = await getSummaryData(tempDir); + expect(data.counts.total).toBe(2); + expect(data.counts.succeeded).toBe(2); + }); + + it('should count failed jobs', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { + id: 1, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'failure', + startedAt: Math.floor(Date.now() / 1000) - 3600, + finishedAt: Math.floor(Date.now() / 1000), + waitSeconds: 10, + durationSeconds: 300, + throttledCount: 0, + }, + ], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + const data = await getSummaryData(tempDir); + expect(data.counts.failed).toBe(1); + }); + + it('should count timed out jobs', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { + id: 1, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'timeout', + startedAt: Math.floor(Date.now() / 1000) - 3600, + finishedAt: Math.floor(Date.now() / 1000), + waitSeconds: 10, + durationSeconds: 300, + throttledCount: 0, + }, + ], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + const data = await getSummaryData(tempDir); + expect(data.counts.timedOut).toBe(1); + }); + + it('should count rate limited jobs', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { + id: 1, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'rate_limited', + startedAt: Math.floor(Date.now() / 1000) - 3600, + finishedAt: Math.floor(Date.now() / 1000), + waitSeconds: 10, + durationSeconds: 300, + throttledCount: 0, + }, + ], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + const data = await getSummaryData(tempDir); + expect(data.counts.rateLimited).toBe(1); + }); + + it('should count skipped jobs', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { + id: 1, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'skipped', + startedAt: Math.floor(Date.now() / 1000) - 3600, + finishedAt: Math.floor(Date.now() / 1000), + waitSeconds: 10, + durationSeconds: 300, + throttledCount: 0, + }, + ], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + const data = await getSummaryData(tempDir); + expect(data.counts.skipped).toBe(1); + }); + + it('should count mixed job statuses correctly', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { id: 1, projectPath: tempDir, jobType: 'executor', providerKey: 'claude', status: 'success', startedAt: 1, finishedAt: 2, waitSeconds: 0, durationSeconds: 1, throttledCount: 0 }, + { id: 2, projectPath: tempDir, jobType: 'executor', providerKey: 'claude', status: 'failure', startedAt: 1, finishedAt: 2, waitSeconds: 0, durationSeconds: 1, throttledCount: 0 }, + { id: 3, projectPath: tempDir, jobType: 'executor', providerKey: 'claude', status: 'timeout', startedAt: 1, finishedAt: 2, waitSeconds: 0, durationSeconds: 1, throttledCount: 0 }, + { id: 4, projectPath: tempDir, jobType: 'executor', providerKey: 'claude', status: 'rate_limited', startedAt: 1, finishedAt: 2, waitSeconds: 0, durationSeconds: 1, throttledCount: 0 }, + { id: 5, projectPath: tempDir, jobType: 'executor', providerKey: 'claude', status: 'skipped', startedAt: 1, finishedAt: 2, waitSeconds: 0, durationSeconds: 1, throttledCount: 0 }, + ], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + const data = await getSummaryData(tempDir); + expect(data.counts.total).toBe(5); + expect(data.counts.succeeded).toBe(1); + expect(data.counts.failed).toBe(1); + expect(data.counts.timedOut).toBe(1); + expect(data.counts.rateLimited).toBe(1); + expect(data.counts.skipped).toBe(1); + }); + }); + + describe('action items', () => { + it('should return empty action items when all healthy', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { + id: 1, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'success', + startedAt: 1, + finishedAt: 2, + waitSeconds: 0, + durationSeconds: 1, + throttledCount: 0, + }, + ], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + const data = await getSummaryData(tempDir); + expect(data.actionItems).toHaveLength(0); + }); + + it('should include action item for failed jobs', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { + id: 1, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'failure', + startedAt: 1, + finishedAt: 2, + waitSeconds: 0, + durationSeconds: 1, + throttledCount: 0, + }, + ], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + const data = await getSummaryData(tempDir); + expect(data.actionItems).toHaveLength(1); + expect(data.actionItems[0]).toContain('failed job'); + expect(data.actionItems[0]).toContain('night-watch logs'); + }); + + it('should include action item for timed out jobs', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { + id: 1, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'timeout', + startedAt: 1, + finishedAt: 2, + waitSeconds: 0, + durationSeconds: 1, + throttledCount: 0, + }, + ], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + const data = await getSummaryData(tempDir); + expect(data.actionItems).toHaveLength(1); + expect(data.actionItems[0]).toContain('timed out job'); + }); + + it('should include action item for rate limited jobs', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { + id: 1, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'rate_limited', + startedAt: 1, + finishedAt: 2, + waitSeconds: 0, + durationSeconds: 1, + throttledCount: 0, + }, + ], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + const data = await getSummaryData(tempDir); + expect(data.actionItems).toHaveLength(1); + expect(data.actionItems[0]).toContain('rate-limited job'); + }); + + it('should include action item for PRs with failing CI', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { + id: 1, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'success', + startedAt: 1, + finishedAt: 2, + waitSeconds: 0, + durationSeconds: 1, + throttledCount: 0, + }, + ], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + vi.mocked(collectPrInfo).mockResolvedValue([ + { + number: 42, + title: 'Test PR', + branch: 'feat/test', + url: 'https://github.com/test/repo/pull/42', + ciStatus: 'fail', + reviewScore: null, + }, + ]); + + const data = await getSummaryData(tempDir); + const ciActionItem = data.actionItems.find((item) => item.includes('PR #42')); + expect(ciActionItem).toBeDefined(); + expect(ciActionItem).toContain('failing CI'); + }); + + it('should include action item for pending queue items', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { + id: 1, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'success', + startedAt: 1, + finishedAt: 2, + waitSeconds: 0, + durationSeconds: 1, + throttledCount: 0, + }, + ], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + vi.mocked(getQueueStatus).mockReturnValue({ + enabled: true, + running: null, + pending: { total: 1, byType: { executor: 1 }, byProviderBucket: {} }, + items: [ + { + id: 100, + projectPath: tempDir, + projectName: 'test-project', + jobType: 'executor', + providerKey: 'claude', + status: 'pending', + envJson: {}, + createdAt: Math.floor(Date.now() / 1000), + }, + ], + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + const data = await getSummaryData(tempDir); + const queueActionItem = data.actionItems.find((item) => item.includes('pending in queue')); + expect(queueActionItem).toBeDefined(); + }); + + it('should use singular "job" for single items', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { + id: 1, + projectPath: tempDir, + jobType: 'executor', + providerKey: 'claude', + status: 'failure', + startedAt: 1, + finishedAt: 2, + waitSeconds: 0, + durationSeconds: 1, + throttledCount: 0, + }, + ], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + const data = await getSummaryData(tempDir); + expect(data.actionItems[0]).toContain('1 failed job'); + expect(data.actionItems[0]).not.toContain('jobs'); + }); + + it('should use plural "jobs" for multiple items', async () => { + vi.mocked(getJobRunsAnalytics).mockReturnValue({ + recentRuns: [ + { id: 1, projectPath: tempDir, jobType: 'executor', providerKey: 'claude', status: 'failure', startedAt: 1, finishedAt: 2, waitSeconds: 0, durationSeconds: 1, throttledCount: 0 }, + { id: 2, projectPath: tempDir, jobType: 'executor', providerKey: 'claude', status: 'failure', startedAt: 1, finishedAt: 2, waitSeconds: 0, durationSeconds: 1, throttledCount: 0 }, + ], + byProviderBucket: {}, + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + const data = await getSummaryData(tempDir); + expect(data.actionItems[0]).toContain('2 failed jobs'); + }); + }); + + describe('PR integration', () => { + it('should pass branch patterns to collectPrInfo', async () => { + await getSummaryData(tempDir, 12, ['feat/', 'fix/']); + expect(collectPrInfo).toHaveBeenCalledWith(tempDir, ['feat/', 'fix/']); + }); + + it('should include open PRs in response', async () => { + vi.mocked(collectPrInfo).mockResolvedValue([ + { + number: 1, + title: 'Test PR', + branch: 'feat/test', + url: 'https://github.com/test/repo/pull/1', + ciStatus: 'pass', + reviewScore: 85, + }, + ]); + + const data = await getSummaryData(tempDir); + expect(data.openPrs).toHaveLength(1); + expect(data.openPrs[0].number).toBe(1); + }); + }); + + describe('queue integration', () => { + it('should only include pending queue items', async () => { + vi.mocked(getQueueStatus).mockReturnValue({ + enabled: true, + running: { + id: 50, + projectPath: tempDir, + projectName: 'test', + jobType: 'executor', + providerKey: 'claude', + status: 'running', + envJson: {}, + createdAt: Math.floor(Date.now() / 1000), + startedAt: Math.floor(Date.now() / 1000), + }, + pending: { total: 1, byType: {}, byProviderBucket: {} }, + items: [ + { + id: 100, + projectPath: tempDir, + projectName: 'test-project', + jobType: 'executor', + providerKey: 'claude', + status: 'pending', + envJson: {}, + createdAt: Math.floor(Date.now() / 1000), + }, + { + id: 50, + projectPath: tempDir, + projectName: 'test-project', + jobType: 'executor', + providerKey: 'claude', + status: 'running', + envJson: {}, + createdAt: Math.floor(Date.now() / 1000), + startedAt: Math.floor(Date.now() / 1000), + }, + ], + averageWaitSeconds: null, + oldestPendingAge: null, + }); + + const data = await getSummaryData(tempDir); + expect(data.pendingQueueItems).toHaveLength(1); + expect(data.pendingQueueItems[0].status).toBe('pending'); + }); + }); +}); diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index c271a1ef..6fb12160 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -27,6 +27,9 @@ export const DEFAULT_DEFAULT_BRANCH = ''; // empty = auto-detect // PRD Configuration export const DEFAULT_PRD_DIR = 'docs/prds'; +// Summary Configuration +export const DEFAULT_SUMMARY_WINDOW_HOURS = 12; + // Runtime Configuration (in seconds) export const DEFAULT_MAX_RUNTIME = 7200; export const DEFAULT_REVIEWER_MAX_RUNTIME = 3600; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c6fecd16..6ddf68ba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -43,6 +43,7 @@ export * from './utils/ui.js'; export * from './utils/webhook-validator.js'; export * from './utils/worktree-manager.js'; export * from './utils/job-queue.js'; +export * from './utils/summary.js'; export * from './analytics/index.js'; export * from './templates/prd-template.js'; export * from './templates/slicer-prompt.js'; diff --git a/packages/core/src/utils/summary.ts b/packages/core/src/utils/summary.ts new file mode 100644 index 00000000..b14ee3fb --- /dev/null +++ b/packages/core/src/utils/summary.ts @@ -0,0 +1,162 @@ +/** + * Summary data aggregator for Night Watch CLI + * Provides a "morning briefing" combining job runs, PRs, and queue status + */ + +import { DEFAULT_SUMMARY_WINDOW_HOURS } from '../constants.js'; +import type { IJobRunAnalytics, IQueueEntry } from '../types.js'; +import type { IPrInfo } from './status-data.js'; +import { collectPrInfo } from './status-data.js'; +import { getJobRunsAnalytics, getQueueStatus } from './job-queue.js'; + +/** + * Counts of job runs by status + */ +export interface IJobRunCounts { + total: number; + succeeded: number; + failed: number; + timedOut: number; + rateLimited: number; + skipped: number; +} + +/** + * Aggregated summary data for the morning briefing + */ +export interface ISummaryData { + /** Time window in hours */ + windowHours: number; + /** Recent job runs within the window */ + jobRuns: IJobRunAnalytics['recentRuns']; + /** Counts by status */ + counts: IJobRunCounts; + /** Open PRs matching branch patterns */ + openPrs: IPrInfo[]; + /** Pending queue items */ + pendingQueueItems: IQueueEntry[]; + /** Actionable suggestions for the user */ + actionItems: string[]; +} + +/** + * Compute counts by filtering recent runs by status + */ +function computeCounts(runs: IJobRunAnalytics['recentRuns']): IJobRunCounts { + const counts: IJobRunCounts = { + total: runs.length, + succeeded: 0, + failed: 0, + timedOut: 0, + rateLimited: 0, + skipped: 0, + }; + + for (const run of runs) { + switch (run.status) { + case 'success': + counts.succeeded++; + break; + case 'failure': + counts.failed++; + break; + case 'timeout': + counts.timedOut++; + break; + case 'rate_limited': + counts.rateLimited++; + break; + case 'skipped': + counts.skipped++; + break; + } + } + + return counts; +} + +/** + * Build action items based on failed jobs and failing CI PRs + */ +function buildActionItems( + counts: IJobRunCounts, + prs: IPrInfo[], + pendingItems: IQueueEntry[], +): string[] { + const items: string[] = []; + + // Failed jobs + if (counts.failed > 0) { + items.push( + `${counts.failed} failed job${counts.failed > 1 ? 's' : ''} — run \`night-watch logs\` to investigate`, + ); + } + + // Timed out jobs + if (counts.timedOut > 0) { + items.push( + `${counts.timedOut} timed out job${counts.timedOut > 1 ? 's' : ''} — check logs for details`, + ); + } + + // Rate limited jobs + if (counts.rateLimited > 0) { + items.push( + `${counts.rateLimited} rate-limited job${counts.rateLimited > 1 ? 's' : ''} — consider adjusting schedule`, + ); + } + + // PRs with failing CI + const failingCiPrs = prs.filter((pr) => pr.ciStatus === 'fail'); + for (const pr of failingCiPrs) { + items.push(`PR #${pr.number} has failing CI — check ${pr.url}`); + } + + // Pending queue items (informational) + if (pendingItems.length > 0) { + const jobTypes = [...new Set(pendingItems.map((item) => item.jobType))]; + items.push( + `${pendingItems.length} job${pendingItems.length > 1 ? 's' : ''} pending in queue (${jobTypes.join(', ')})`, + ); + } + + return items; +} + +/** + * Get aggregated summary data for the "morning briefing" + * + * @param projectDir - Absolute path to the project directory + * @param windowHours - Time window in hours (default: 12) + * @param branchPatterns - Branch patterns to filter PRs + * @returns Aggregated summary data + */ +export async function getSummaryData( + projectDir: string, + windowHours = DEFAULT_SUMMARY_WINDOW_HOURS, + branchPatterns: string[] = [], +): Promise { + // Get job runs analytics + const analytics = getJobRunsAnalytics(windowHours); + const jobRuns = analytics.recentRuns; + const counts = computeCounts(jobRuns); + + // Get open PRs + const openPrs = await collectPrInfo(projectDir, branchPatterns); + + // Get queue status + const queueStatus = getQueueStatus(); + const pendingQueueItems = queueStatus.items.filter((item) => item.status === 'pending'); + + // Build action items + const actionItems = buildActionItems(counts, openPrs, pendingQueueItems); + + return { + windowHours, + jobRuns, + counts, + openPrs, + pendingQueueItems, + actionItems, + }; +} diff --git a/yarn.lock b/yarn.lock index fcc279a8..a57108cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -378,24 +378,6 @@ resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz" integrity sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg== -"@jonit-dev/night-watch-cli@file:/home/joao/projects/night-watch-cli/packages/cli": - version "1.7.93" - resolved "file:packages/cli" - dependencies: - better-sqlite3 "^12.6.2" - blessed "^0.1.81" - chalk "^5.6.2" - cli-table3 "^0.6.5" - commander "^12.0.0" - concurrently "^9.2.1" - cors "^2.8.6" - cron-parser "^5.5.0" - cronstrue "^3.12.0" - express "^5.2.1" - ora "^9.3.0" - reflect-metadata "^0.2.2" - tsyringe "^4.10.0" - "@jridgewell/resolve-uri@^3.1.0": version "3.1.2" resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz"