Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@
"tsyringe": "^4.10.0"
},
"devDependencies": {
"@night-watch/core": "*",
"@night-watch/server": "*",
"@types/blessed": "^0.1.27",
"@types/node": "^22.0.0",
"esbuild": "^0.25.0",
Expand Down
59 changes: 59 additions & 0 deletions packages/cli/src/__tests__/commands/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,17 @@ import {
scanPrdDirectory,
getRateLimitFallbackTelegramWebhooks,
isRateLimitFallbackTriggered,
recordRunSessionOutcome,
resolveRunNotificationEvent,
shouldAttemptCrossProjectFallback,
} from '@/cli/commands/run.js';
import { applyScheduleOffset, buildCronPathPrefix } from '@/cli/commands/install.js';
import { INightWatchConfig } from '@night-watch/core/types.js';
import { closeDb } from '@night-watch/core/storage/sqlite/client.js';
import {
getRepositories,
resetRepositories,
} from '@night-watch/core/storage/repositories/index.js';
import { sendNotifications } from '@night-watch/core/utils/notify.js';

// Helper to create a valid config without budget fields
Expand All @@ -62,13 +68,15 @@ function createTestConfig(overrides: Partial<INightWatchConfig> = {}): INightWat
describe('run command', () => {
let tempDir: string;
let originalEnv: NodeJS.ProcessEnv;
let originalNightWatchHome: string | undefined;

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'night-watch-test-'));
mockCwd.mockReturnValue(tempDir);

// Save original environment
originalEnv = { ...process.env };
originalNightWatchHome = process.env.NIGHT_WATCH_HOME;

// Clear NW_* environment variables
for (const key of Object.keys(process.env)) {
Expand All @@ -81,8 +89,16 @@ describe('run command', () => {
});

afterEach(() => {
closeDb();
resetRepositories();
fs.rmSync(tempDir, { recursive: true, force: true });

if (originalNightWatchHome === undefined) {
delete process.env.NIGHT_WATCH_HOME;
} else {
process.env.NIGHT_WATCH_HOME = originalNightWatchHome;
}

// Restore original environment
for (const key of Object.keys(process.env)) {
if (key.startsWith('NW_')) {
Expand Down Expand Up @@ -480,6 +496,49 @@ describe('run command', () => {
});
});

describe('outcome recording', () => {
it('should record executor outcome after script exits', () => {
process.env.NIGHT_WATCH_HOME = path.join(tempDir, '.night-watch-home');
closeDb();
resetRepositories();

const config = createTestConfig();
const startedAt = 1_700_000_000_000;
const finishedAt = 1_700_000_003_000;

recordRunSessionOutcome({
projectDir: tempDir,
config,
envVars: {
NW_PROVIDER_KEY: 'claude-native',
NW_PROVIDER_CMD: 'claude',
NW_PROVIDER_LABEL: 'Claude',
},
startedAt,
finishedAt,
exitCode: 1,
stderr: "packages/core/src/index.ts:1:1 - error TS2305: Module has no exported member 'x'.",
scriptResult: {
status: 'failure',
data: { prd: '97-feedback.md', branch: 'night-watch/nw-97' },
},
});

const outcomes = getRepositories().sessionOutcomes.queryOutcomes({
projectPath: tempDir,
jobType: 'executor',
});

expect(outcomes).toHaveLength(1);
expect(outcomes[0].providerKey).toBe('claude-native');
expect(outcomes[0].durationSeconds).toBe(3);
expect(outcomes[0].outcome).toBe('failure');
expect(outcomes[0].failureCategory).toBe('typescript');
expect(outcomes[0].prdFile).toBe('97-feedback.md');
expect(outcomes[0].branchName).toBe('night-watch/nw-97');
});
});

describe('applyScheduleOffset', () => {
it('should replace minute field with offset', () => {
expect(applyScheduleOffset('0 0-21 * * *', 15)).toBe('15 0-21 * * *');
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ function commitAll(projectDir: string, message: string): void {
});
}

function writeFakeClaude(fakeBin: string): void {
fs.writeFileSync(path.join(fakeBin, 'claude'), '#!/usr/bin/env bash\nexit 0\n', {
encoding: 'utf-8',
mode: 0o755,
});
}

afterEach(() => {
for (const dir of tempDirs) {
fs.rmSync(dir, { recursive: true, force: true });
Expand Down Expand Up @@ -1932,6 +1939,7 @@ describe('core flow smoke tests (bash scripts)', () => {
fs.mkdirSync(path.join(projectDir, 'logs'), { recursive: true });

const fakeBin = mkTempDir('nw-smoke-reviewer-score-threshold-bin-');
writeFakeClaude(fakeBin);

fs.writeFileSync(
path.join(fakeBin, 'gh'),
Expand Down Expand Up @@ -2070,6 +2078,7 @@ describe('core flow smoke tests (bash scripts)', () => {
fs.mkdirSync(path.join(projectDir, 'logs'), { recursive: true });

const fakeBin = mkTempDir('nw-smoke-reviewer-needs-human-review-bin-');
writeFakeClaude(fakeBin);

fs.writeFileSync(
path.join(fakeBin, 'gh'),
Expand Down Expand Up @@ -2339,6 +2348,7 @@ describe('core flow smoke tests (bash scripts)', () => {
fs.mkdirSync(path.join(projectDir, 'logs'), { recursive: true });

const fakeBin = mkTempDir('nw-smoke-reviewer-max-prs-per-run-bin-');
writeFakeClaude(fakeBin);

fs.writeFileSync(
path.join(fakeBin, 'gh'),
Expand Down
58 changes: 58 additions & 0 deletions packages/cli/src/commands/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
runAnalytics,
} from '@night-watch/core';
import { maybeApplyCronSchedulingDelay } from './shared/env-builder.js';
import { recordJobOutcome } from './shared/feedback.js';

export interface IAnalyticsOptions {
dryRun: boolean;
Expand All @@ -31,7 +32,7 @@
.option('--dry-run', 'Show what would be executed without running')
.option('--timeout <seconds>', 'Override max runtime in seconds')
.option('--provider <string>', 'AI provider to use (claude or codex)')
.action(async (options: IAnalyticsOptions) => {

Check warning on line 35 in packages/cli/src/commands/analytics.ts

View workflow job for this annotation

GitHub Actions / lint

Refactor this function to reduce its Cognitive Complexity from 23 to the 20 allowed
const projectDir = process.cwd();
let config = loadConfig(projectDir);

Expand All @@ -58,6 +59,27 @@
const apiKey = config.providerEnv?.AMPLITUDE_API_KEY;
const secretKey = config.providerEnv?.AMPLITUDE_SECRET_KEY;
if (!apiKey || !secretKey) {
const now = Date.now();
if (!options.dryRun) {
try {
recordJobOutcome({
config,
exitCode: 1,
finishedAt: now,
jobType: 'analytics',
metadata: {
missingAmplitudeCredentials: true,
},
projectDir,
providerKey: resolveJobProvider(config, 'analytics'),
startedAt: now,
stderr:
'AMPLITUDE_API_KEY and AMPLITUDE_SECRET_KEY must be set in providerEnv to run analytics.',
});
} catch {
// Outcome persistence must not change command exit behavior.
}
}
info(
'AMPLITUDE_API_KEY and AMPLITUDE_SECRET_KEY must be set in providerEnv to run analytics.',
);
Expand All @@ -84,13 +106,49 @@

const spinner = createSpinner('Running analytics job...');
spinner.start();
const startedAt = Date.now();

try {
await maybeApplyCronSchedulingDelay(config, 'analytics', projectDir);
const result = await runAnalytics(config, projectDir);
try {
recordJobOutcome({
config,
exitCode: 0,
finishedAt: Date.now(),
jobType: 'analytics',
metadata: {
lookbackDays: config.analytics.lookbackDays,
summary: result.summary,
},
projectDir,
providerKey: resolveJobProvider(config, 'analytics'),
startedAt,
stdout: result.summary,
});
} catch {
// Outcome persistence must not change command exit behavior.
}

spinner.succeed(`Analytics complete — ${result.summary}`);
} catch (err) {
try {
recordJobOutcome({
config,
exitCode: 1,
finishedAt: Date.now(),
jobType: 'analytics',
metadata: {
lookbackDays: config.analytics.lookbackDays,
},
projectDir,
providerKey: resolveJobProvider(config, 'analytics'),
startedAt,
stderr: err instanceof Error ? err.message : String(err),
});
} catch {
// Outcome persistence must not change command exit behavior.
}
spinner.fail(`Analytics failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/src/commands/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
getTelegramStatusWebhooks,
maybeApplyCronSchedulingDelay,
} from './shared/env-builder.js';
import { recordJobOutcome } from './shared/feedback.js';

export interface IAuditOptions {
dryRun: boolean;
Expand Down Expand Up @@ -69,7 +70,7 @@
.option('--dry-run', 'Show what would be executed without running')
.option('--timeout <seconds>', 'Override max runtime in seconds')
.option('--provider <string>', 'AI provider to use (claude or codex)')
.action(async (options: IAuditOptions) => {

Check warning on line 73 in packages/cli/src/commands/audit.ts

View workflow job for this annotation

GitHub Actions / lint

Refactor this function to reduce its Cognitive Complexity from 44 to the 20 allowed
const projectDir = process.cwd();
let config = loadConfig(projectDir);

Expand Down Expand Up @@ -130,6 +131,7 @@

const spinner = createSpinner('Running code audit...');
spinner.start();
const startedAt = Date.now();

try {
await maybeApplyCronSchedulingDelay(config, 'audit', projectDir);
Expand All @@ -138,8 +140,32 @@
[projectDir],
envVars,
);
const finishedAt = Date.now();
const scriptResult = parseScriptResult(`${stdout}\n${stderr}`);

if (!options.dryRun) {
try {
recordJobOutcome({
config,
exitCode,
finishedAt,
jobType: 'audit',
metadata: {
providerCommand: envVars.NW_PROVIDER_CMD,
providerLabel: envVars.NW_PROVIDER_LABEL,
},
projectDir,
providerKey: envVars.NW_PROVIDER_KEY ?? resolveJobProvider(config, 'audit'),
scriptResult,
startedAt,
stderr,
stdout,
});
} catch {
// Outcome persistence must not change command exit behavior.
}
}

if (exitCode === 0) {
if (scriptResult?.status === 'queued') {
spinner.succeed('Code audit queued — another job is currently running');
Expand Down Expand Up @@ -186,6 +212,24 @@
process.exit(exitCode || 1);
}
} catch (err) {
try {
recordJobOutcome({
config,
exitCode: 1,
finishedAt: Date.now(),
jobType: 'audit',
metadata: {
providerCommand: envVars.NW_PROVIDER_CMD,
providerLabel: envVars.NW_PROVIDER_LABEL,
},
projectDir,
providerKey: envVars.NW_PROVIDER_KEY ?? resolveJobProvider(config, 'audit'),
startedAt,
stderr: err instanceof Error ? err.message : String(err),
});
} catch {
// Outcome persistence must not change command exit behavior.
}
spinner.fail(`Code audit failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@
},
audit: { ...defaults.audit },
analytics: { ...defaults.analytics },
feedback: { ...defaults.feedback },
merger: { ...defaults.merger },
prResolver: { ...defaults.prResolver },
jobProviders: { ...defaults.jobProviders },
Expand Down Expand Up @@ -499,7 +500,7 @@
console.log(` Updated: ${gitignorePath} (added ${missing.map((e) => e.label).join(', ')})`);
}

function installSkills(

Check warning on line 503 in packages/cli/src/commands/init.ts

View workflow job for this annotation

GitHub Actions / lint

Refactor this function to reduce its Cognitive Complexity from 22 to the 20 allowed
cwd: string,
provider: Provider,
force: boolean,
Expand Down Expand Up @@ -585,7 +586,7 @@
.option('-d, --prd-dir <path>', 'Path to PRD directory')
.option('-p, --provider <name>', 'AI provider to use (claude or codex)')
.option('--no-reviewer', 'Disable reviewer cron job')
.action(async (options: IInitOptions) => {

Check warning on line 589 in packages/cli/src/commands/init.ts

View workflow job for this annotation

GitHub Actions / lint

Refactor this function to reduce its Cognitive Complexity from 84 to the 20 allowed
const cwd = process.cwd();
const force = options.force || false;
const prdDir = options.prdDir || DEFAULT_PRD_DIR;
Expand Down
33 changes: 30 additions & 3 deletions packages/cli/src/commands/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
formatProviderDisplay,
maybeApplyCronSchedulingDelay,
} from './shared/env-builder.js';
import { recordJobOutcome } from './shared/feedback.js';
import * as path from 'path';

/**
Expand All @@ -49,9 +50,7 @@ export function buildEnvVars(
env.NW_MERGER_MERGE_METHOD = config.merger.mergeMethod;
env.NW_MERGER_MIN_REVIEW_SCORE = String(config.merger.minReviewScore);
env.NW_MERGER_BRANCH_PATTERNS = (
config.merger.branchPatterns.length > 0
? config.merger.branchPatterns
: config.branchPatterns
config.merger.branchPatterns.length > 0 ? config.merger.branchPatterns : config.branchPatterns
).join(',');
env.NW_MERGER_REBASE_BEFORE_MERGE = config.merger.rebaseBeforeMerge ? '1' : '0';
env.NW_MERGER_MAX_PRS_PER_RUN = String(config.merger.maxPrsPerRun);
Expand Down Expand Up @@ -186,12 +185,14 @@ export function mergeCommand(program: Command): void {
spinner.start();

try {
const startedAt = Date.now();
await maybeApplyCronSchedulingDelay(config, 'merger', projectDir);
const { exitCode, stdout, stderr } = await executeScriptWithOutput(
scriptPath,
[projectDir],
envVars,
);
const finishedAt = Date.now();
const scriptResult = parseScriptResult(`${stdout}\n${stderr}`);

if (exitCode === 0) {
Expand All @@ -212,6 +213,32 @@ export function mergeCommand(program: Command): void {

const notificationEvent = resolveMergeNotificationEvent(exitCode, mergedCount, failedCount);

if (!options.dryRun) {
try {
recordJobOutcome({
config,
exitCode,
finishedAt,
jobType: 'merger',
metadata: {
failedCount,
mergedCount,
providerCommand: envVars.NW_PROVIDER_CMD,
providerLabel: envVars.NW_PROVIDER_LABEL,
},
minReviewScore: config.merger.minReviewScore,
projectDir,
providerKey: envVars.NW_PROVIDER_KEY ?? resolveJobProvider(config, 'merger'),
scriptResult,
startedAt,
stderr,
stdout,
});
} catch {
// Outcome persistence must not change command exit behavior.
}
}

if (notificationEvent) {
await sendNotifications(config, {
event: notificationEvent,
Expand Down
Loading
Loading