Skip to content

Commit d0feff0

Browse files
jonit-devTest Userclaude
authored
feat: add night-watch summary command for morning briefings
* feat: add `night-watch summary` command for morning briefings Adds a new `night-watch summary` command that aggregates data from existing sources into a single "morning briefing" view: - Recent job runs from `getJobRunsAnalytics()` - Open PRs from `collectPrInfo()` - Pending queue items from `getQueueStatus()` Usage: night-watch summary [--hours <n>] [--json] Options: --hours <n> Time window in hours (default: 12) --json Output summary as JSON Examples: night-watch summary # Last 12 hours night-watch summary --hours 24 # Last 24 hours night-watch summary --json # JSON output for scripting Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(qa): add automated QA tests for summary utility - Added 20 tests for getSummaryData core utility - Tests cover: windowHours, job counts, action items, PR integration, queue integration - All 31 summary-related tests pass (11 CLI + 20 core) Co-Authored-By: Claude <noreply@anthropic.com> * fix: address review feedback on summary command - Fix unmatched trailing `)` in queue display string - Fix early return that hid open PRs, queue status, and action items when no job runs existed; only the job table section is now conditional - Use path.basename() instead of POSIX-only split('/').pop() in getProjectName - Extract magic number 12 into DEFAULT_SUMMARY_WINDOW_HOURS constant in core/constants.ts - Import and use DEFAULT_SUMMARY_WINDOW_HOURS in both summary.ts files and CLI command - Add NaN/non-positive guard for --hours CLI input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Test User <test@test.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bb17afd commit d0feff0

8 files changed

Lines changed: 1325 additions & 18 deletions

File tree

Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
/**
2+
* Tests for summary command
3+
*/
4+
5+
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
6+
import * as fs from 'fs';
7+
import * as path from 'path';
8+
import * as os from 'os';
9+
10+
// Mock process.cwd to return our temp directory
11+
let mockProjectDir: string;
12+
13+
vi.mock('child_process', () => ({
14+
exec: vi.fn(),
15+
execFile: vi.fn(),
16+
execSync: vi.fn(),
17+
spawn: vi.fn(),
18+
}));
19+
20+
vi.mock('@night-watch/core/utils/crontab.js', () => ({
21+
getEntries: vi.fn(() => []),
22+
getProjectEntries: vi.fn(() => []),
23+
generateMarker: vi.fn((name: string) => `# night-watch-cli: ${name}`),
24+
}));
25+
26+
// Mock job-queue module
27+
vi.mock('@night-watch/core/utils/job-queue.js', () => ({
28+
getJobRunsAnalytics: vi.fn(() => ({
29+
recentRuns: [],
30+
byProviderBucket: {},
31+
averageWaitSeconds: null,
32+
oldestPendingAge: null,
33+
})),
34+
getQueueStatus: vi.fn(() => ({
35+
enabled: true,
36+
running: null,
37+
pending: { total: 0, byType: {}, byProviderBucket: {} },
38+
items: [],
39+
averageWaitSeconds: null,
40+
oldestPendingAge: null,
41+
})),
42+
}));
43+
44+
// Mock status-data module
45+
vi.mock('@night-watch/core/utils/status-data.js', () => ({
46+
collectPrInfo: vi.fn(async () => []),
47+
}));
48+
49+
// Mock process.cwd before importing module
50+
const originalCwd = process.cwd;
51+
process.cwd = () => mockProjectDir;
52+
53+
// Import after mocking
54+
import { summaryCommand } from '@/cli/commands/summary.js';
55+
import { Command } from 'commander';
56+
import { getJobRunsAnalytics, getQueueStatus } from '@night-watch/core/utils/job-queue.js';
57+
import { collectPrInfo } from '@night-watch/core/utils/status-data.js';
58+
59+
describe('summary command', () => {
60+
let tempDir: string;
61+
let consoleSpy: ReturnType<typeof vi.spyOn>;
62+
63+
beforeEach(() => {
64+
vi.clearAllMocks();
65+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'night-watch-summary-test-'));
66+
mockProjectDir = tempDir;
67+
68+
// Create basic package.json
69+
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'test-project' }));
70+
71+
// Create config file
72+
fs.writeFileSync(
73+
path.join(tempDir, 'night-watch.config.json'),
74+
JSON.stringify(
75+
{
76+
projectName: 'test-project',
77+
defaultBranch: 'main',
78+
provider: 'claude',
79+
reviewerEnabled: true,
80+
prdDir: 'docs/PRDs/night-watch',
81+
maxRuntime: 7200,
82+
reviewerMaxRuntime: 3600,
83+
branchPatterns: ['feat/', 'night-watch/'],
84+
notifications: { webhooks: [] },
85+
},
86+
null,
87+
2,
88+
),
89+
);
90+
91+
// Reset mocks to return default values
92+
vi.mocked(getJobRunsAnalytics).mockReturnValue({
93+
recentRuns: [],
94+
byProviderBucket: {},
95+
averageWaitSeconds: null,
96+
oldestPendingAge: null,
97+
});
98+
99+
vi.mocked(getQueueStatus).mockReturnValue({
100+
enabled: true,
101+
running: null,
102+
pending: { total: 0, byType: {}, byProviderBucket: {} },
103+
items: [],
104+
averageWaitSeconds: null,
105+
oldestPendingAge: null,
106+
});
107+
108+
vi.mocked(collectPrInfo).mockResolvedValue([]);
109+
110+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
111+
});
112+
113+
afterEach(() => {
114+
fs.rmSync(tempDir, { recursive: true, force: true });
115+
consoleSpy.mockRestore();
116+
});
117+
118+
afterAll(() => {
119+
process.cwd = originalCwd;
120+
});
121+
122+
describe('help text', () => {
123+
it('should show help text with --help flag', async () => {
124+
const program = new Command();
125+
summaryCommand(program);
126+
127+
program.exitOverride();
128+
let capturedOutput = '';
129+
program.configureOutput({
130+
writeOut: (str: string) => {
131+
capturedOutput += str;
132+
},
133+
});
134+
135+
try {
136+
await program.parseAsync(['node', 'test', 'summary', '--help']);
137+
} catch {
138+
// Help throws by default in commander
139+
}
140+
141+
expect(capturedOutput).toContain('--hours');
142+
expect(capturedOutput).toContain('--json');
143+
});
144+
});
145+
146+
describe('formatted output', () => {
147+
it('should display summary header with time window', async () => {
148+
const program = new Command();
149+
summaryCommand(program);
150+
151+
await program.parseAsync(['node', 'test', 'summary']);
152+
153+
const output = consoleSpy.mock.calls.map((call) => call.join(' ')).join('\n');
154+
expect(output).toContain('Night Watch Summary');
155+
expect(output).toContain('last 12h');
156+
});
157+
});
158+
describe('JSON output', () => {
159+
it('should output valid JSON when --json flag is used', async () => {
160+
const program = new Command();
161+
summaryCommand(program);
162+
163+
await program.parseAsync(['node', 'test', 'summary', '--json']);
164+
165+
const output = consoleSpy.mock.calls[0]?.[0] || '';
166+
const parsed = JSON.parse(output);
167+
168+
expect(parsed).toHaveProperty('windowHours');
169+
expect(parsed).toHaveProperty('jobRuns');
170+
expect(parsed).toHaveProperty('counts');
171+
expect(parsed).toHaveProperty('openPrs');
172+
expect(parsed).toHaveProperty('pendingQueueItems');
173+
expect(parsed).toHaveProperty('actionItems');
174+
});
175+
176+
it('should include correct windowHours in JSON output', async () => {
177+
const program = new Command();
178+
summaryCommand(program);
179+
180+
await program.parseAsync(['node', 'test', 'summary', '--json', '--hours', '8']);
181+
182+
const output = consoleSpy.mock.calls[0]?.[0] || '';
183+
const parsed = JSON.parse(output);
184+
185+
expect(parsed.windowHours).toBe(8);
186+
});
187+
});
188+
189+
describe('job counts', () => {
190+
it('should use default 12 hours when --hours not specified', async () => {
191+
const program = new Command();
192+
summaryCommand(program);
193+
194+
await program.parseAsync(['node', 'test', 'summary']);
195+
196+
expect(vi.mocked(getJobRunsAnalytics)).toHaveBeenCalledWith(12);
197+
});
198+
199+
it('should respect custom --hours value', async () => {
200+
const program = new Command();
201+
summaryCommand(program);
202+
203+
await program.parseAsync(['node', 'test', 'summary', '--hours', '24']);
204+
205+
expect(vi.mocked(getJobRunsAnalytics)).toHaveBeenCalledWith(24);
206+
});
207+
208+
it('should show "No recent activity" when no jobs ran', async () => {
209+
const program = new Command();
210+
summaryCommand(program);
211+
212+
await program.parseAsync(['node', 'test', 'summary']);
213+
214+
const output = consoleSpy.mock.calls.map((call) => call.join(' ')).join('\n');
215+
expect(output).toContain('No recent activity');
216+
});
217+
218+
it('should show job counts from analytics data', async () => {
219+
vi.mocked(getJobRunsAnalytics).mockReturnValue({
220+
recentRuns: [
221+
{
222+
id: 1,
223+
projectPath: '/project',
224+
jobType: 'executor',
225+
providerKey: 'claude',
226+
status: 'success',
227+
startedAt: Math.floor(Date.now() / 1000) - 3600,
228+
finishedAt: Math.floor(Date.now() / 1000),
229+
waitSeconds: 10,
230+
durationSeconds: 300,
231+
throttledCount: 0,
232+
},
233+
{
234+
id: 2,
235+
projectPath: '/project',
236+
jobType: 'reviewer',
237+
providerKey: 'claude',
238+
status: 'failure',
239+
startedAt: Math.floor(Date.now() / 1000) - 3600,
240+
finishedAt: Math.floor(Date.now() / 1000),
241+
waitSeconds: 5,
242+
durationSeconds: 180,
243+
throttledCount: 0,
244+
},
245+
],
246+
byProviderBucket: {},
247+
averageWaitSeconds: 7,
248+
oldestPendingAge: null,
249+
});
250+
251+
const program = new Command();
252+
summaryCommand(program);
253+
254+
await program.parseAsync(['node', 'test', 'summary']);
255+
256+
const output = consoleSpy.mock.calls.map((call) => call.join(' ')).join('\n');
257+
expect(output).toContain('1 succeeded');
258+
expect(output).toContain('1 failed');
259+
});
260+
261+
it('should generate action items for failed jobs', async () => {
262+
vi.mocked(getJobRunsAnalytics).mockReturnValue({
263+
recentRuns: [
264+
{
265+
id: 1,
266+
projectPath: '/project',
267+
jobType: 'executor',
268+
providerKey: 'claude',
269+
status: 'failure',
270+
startedAt: Math.floor(Date.now() / 1000) - 3600,
271+
finishedAt: Math.floor(Date.now() / 1000),
272+
waitSeconds: 10,
273+
durationSeconds: 300,
274+
throttledCount: 0,
275+
},
276+
],
277+
byProviderBucket: {},
278+
averageWaitSeconds: null,
279+
oldestPendingAge: null,
280+
});
281+
282+
const program = new Command();
283+
summaryCommand(program);
284+
285+
await program.parseAsync(['node', 'test', 'summary']);
286+
287+
const output = consoleSpy.mock.calls.map((call) => call.join(' ')).join('\n');
288+
expect(output).toContain('Action needed');
289+
expect(output).toContain('night-watch logs');
290+
});
291+
292+
it('should show "No action needed" when all jobs healthy', async () => {
293+
vi.mocked(getJobRunsAnalytics).mockReturnValue({
294+
recentRuns: [
295+
{
296+
id: 1,
297+
projectPath: '/project',
298+
jobType: 'executor',
299+
providerKey: 'claude',
300+
status: 'success',
301+
startedAt: Math.floor(Date.now() / 1000) - 3600,
302+
finishedAt: Math.floor(Date.now() / 1000),
303+
waitSeconds: 10,
304+
durationSeconds: 300,
305+
throttledCount: 0,
306+
},
307+
],
308+
byProviderBucket: {},
309+
averageWaitSeconds: null,
310+
oldestPendingAge: null,
311+
});
312+
313+
vi.mocked(getQueueStatus).mockReturnValue({
314+
enabled: true,
315+
running: null,
316+
pending: { total: 0, byType: {}, byProviderBucket: {} },
317+
items: [],
318+
averageWaitSeconds: null,
319+
oldestPendingAge: null,
320+
});
321+
322+
const program = new Command();
323+
summaryCommand(program);
324+
325+
await program.parseAsync(['node', 'test', 'summary']);
326+
327+
const output = consoleSpy.mock.calls.map((call) => call.join(' ')).join('\n');
328+
expect(output).toContain('No action needed');
329+
});
330+
});
331+
332+
describe('PR data', () => {
333+
it('should generate action items for PRs with failing CI', async () => {
334+
vi.mocked(getJobRunsAnalytics).mockReturnValue({
335+
recentRuns: [
336+
{
337+
id: 1,
338+
projectPath: '/project',
339+
jobType: 'executor',
340+
providerKey: 'claude',
341+
status: 'success',
342+
startedAt: Math.floor(Date.now() / 1000) - 3600,
343+
finishedAt: Math.floor(Date.now() / 1000),
344+
waitSeconds: 10,
345+
durationSeconds: 300,
346+
throttledCount: 0,
347+
},
348+
],
349+
byProviderBucket: {},
350+
averageWaitSeconds: null,
351+
oldestPendingAge: null,
352+
});
353+
354+
vi.mocked(collectPrInfo).mockResolvedValue([
355+
{
356+
number: 42,
357+
title: 'Test PR',
358+
branch: 'feat/test',
359+
url: 'https://github.com/test/repo/pull/42',
360+
ciStatus: 'fail',
361+
reviewScore: null,
362+
},
363+
]);
364+
365+
const program = new Command();
366+
summaryCommand(program);
367+
368+
await program.parseAsync(['node', 'test', 'summary']);
369+
370+
const output = consoleSpy.mock.calls.map((call) => call.join(' ')).join('\n');
371+
expect(output).toContain('Action needed');
372+
expect(output).toContain('PR #42');
373+
});
374+
});
375+
});

packages/cli/src/cli.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { createStateCommand } from './commands/state.js';
3131
import { boardCommand } from './commands/board.js';
3232
import { queueCommand } from './commands/queue.js';
3333
import { notifyCommand } from './commands/notify.js';
34+
import { summaryCommand } from './commands/summary.js';
3435

3536
// Find the package root (works from both src/ in dev and dist/src/ in production)
3637
const __filename = fileURLToPath(import.meta.url);
@@ -125,4 +126,7 @@ queueCommand(program);
125126
// Register notify command (send notification events from bash scripts)
126127
notifyCommand(program);
127128

129+
// Register summary command (morning briefing)
130+
summaryCommand(program);
131+
128132
program.parse();

0 commit comments

Comments
 (0)