Skip to content

Commit cb02c31

Browse files
author
Test User
committed
fix: defer executor PR creation and support no-verify pushes
1 parent 5467172 commit cb02c31

15 files changed

Lines changed: 352 additions & 27 deletions

File tree

packages/cli/src/__tests__/commands/shared/env-builder.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,13 @@ describe('buildBaseEnvVars', () => {
165165
expect(env.NW_DEFAULT_BRANCH).toBe('main');
166166
});
167167

168+
it('should export NW_GIT_PUSH_NO_VERIFY when configured', () => {
169+
const config = createTestConfig({ gitPushNoVerify: true });
170+
const env = buildBaseEnvVars(config, 'executor', false);
171+
172+
expect(env.NW_GIT_PUSH_NO_VERIFY).toBe('1');
173+
});
174+
168175
it('should use job-specific provider when configured', () => {
169176
const config = createTestConfig({
170177
provider: 'claude',

packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,216 @@ describe('core flow smoke tests (bash scripts)', () => {
234234
).toBe(true);
235235
});
236236

237+
it('executor should bypass blocking pre-push hooks when NW_GIT_PUSH_NO_VERIFY=1', () => {
238+
const projectDir = mkTempDir('nw-smoke-executor-no-verify-');
239+
initGitRepo(projectDir);
240+
createPrd(projectDir, '01-smoke-no-verify');
241+
commitAll(projectDir, 'add PRD');
242+
243+
const remoteDir = mkTempDir('nw-smoke-executor-no-verify-origin-');
244+
execSync('git init --bare', { cwd: remoteDir, stdio: 'ignore' });
245+
execSync(`git remote add origin "${remoteDir}"`, { cwd: projectDir, stdio: 'ignore' });
246+
247+
const hookLog = path.join(projectDir, '.smoke-pre-push-hook.log');
248+
const createdFlag = path.join(projectDir, '.smoke-pr-created');
249+
const branchName = 'night-watch/01-smoke-no-verify';
250+
fs.writeFileSync(
251+
path.join(projectDir, '.git', 'hooks', 'pre-push'),
252+
'#!/usr/bin/env bash\n' +
253+
'printf \'hook-ran\\n\' >> "$NW_SMOKE_PRE_PUSH_HOOK_LOG"\n' +
254+
'exit 1\n',
255+
{ encoding: 'utf-8', mode: 0o755 },
256+
);
257+
258+
const fakeBin = mkTempDir('nw-smoke-bin-no-verify-');
259+
fs.writeFileSync(
260+
path.join(fakeBin, 'claude'),
261+
'#!/usr/bin/env bash\n' +
262+
"printf 'provider change\\n' > smoke-provider-change.txt\n" +
263+
'git add smoke-provider-change.txt\n' +
264+
'git commit -m "feat: smoke provider progress" >/dev/null 2>&1\n' +
265+
'exit 0\n',
266+
{
267+
encoding: 'utf-8',
268+
mode: 0o755,
269+
},
270+
);
271+
fs.writeFileSync(
272+
path.join(fakeBin, 'gh'),
273+
'#!/usr/bin/env bash\n' +
274+
'if [[ "$1" == "pr" && "$2" == "list" ]]; then\n' +
275+
' if [[ -f "$NW_SMOKE_CREATED_FLAG" ]]; then\n' +
276+
' printf \'[{"number":123,"headRefName":"%s","url":"https://example.test/pull/123","title":"Smoke","isDraft":true,"labels":[],"createdAt":"2026-01-01T00:00:00Z"}]\\n\' "$NW_SMOKE_BRANCH"\n' +
277+
' else\n' +
278+
" echo '[]'\n" +
279+
' fi\n' +
280+
' exit 0\n' +
281+
'fi\n' +
282+
'if [[ "$1" == "pr" && "$2" == "create" ]]; then\n' +
283+
' touch "$NW_SMOKE_CREATED_FLAG"\n' +
284+
" echo 'https://example.test/pull/123'\n" +
285+
' exit 0\n' +
286+
'fi\n' +
287+
'exit 0\n',
288+
{ encoding: 'utf-8', mode: 0o755 },
289+
);
290+
291+
const result = runScript(executorScript, projectDir, {
292+
PATH: `${fakeBin}:${process.env.PATH}`,
293+
NW_PROVIDER_CMD: 'claude',
294+
NW_PRD_DIR: 'docs/PRDs/night-watch',
295+
NW_DEFAULT_BRANCH: 'main',
296+
NW_GIT_PUSH_NO_VERIFY: '1',
297+
NW_SMOKE_PRE_PUSH_HOOK_LOG: hookLog,
298+
NW_SMOKE_CREATED_FLAG: createdFlag,
299+
NW_SMOKE_BRANCH: branchName,
300+
});
301+
302+
expect(result.status).toBe(1);
303+
expect(result.stdout).toContain('NIGHT_WATCH_RESULT:');
304+
expect(fs.existsSync(hookLog)).toBe(false);
305+
306+
const remoteBranches = execSync('git for-each-ref --format="%(refname:short)" refs/heads', {
307+
cwd: remoteDir,
308+
encoding: 'utf-8',
309+
});
310+
expect(remoteBranches).toContain('night-watch/01-smoke-no-verify');
311+
});
312+
313+
it('executor should not push or create a PR before the branch is ahead of its resolved base', () => {
314+
const projectDir = mkTempDir('nw-smoke-executor-pr-before-commit-');
315+
initGitRepo(projectDir);
316+
createPrd(projectDir, '01-smoke-pr-before-commit');
317+
commitAll(projectDir, 'add PRD');
318+
319+
const remoteDir = mkTempDir('nw-smoke-executor-pr-before-commit-origin-');
320+
execSync('git init --bare', { cwd: remoteDir, stdio: 'ignore' });
321+
execSync(`git remote add origin "${remoteDir}"`, { cwd: projectDir, stdio: 'ignore' });
322+
323+
const hookLog = path.join(projectDir, '.smoke-pre-push-hook.log');
324+
const prCreateLog = path.join(projectDir, '.smoke-pr-create.log');
325+
fs.writeFileSync(
326+
path.join(projectDir, '.git', 'hooks', 'pre-push'),
327+
'#!/usr/bin/env bash\n' +
328+
'printf \'hook-ran\\n\' >> "$NW_SMOKE_PRE_PUSH_HOOK_LOG"\n' +
329+
'exit 1\n',
330+
{ encoding: 'utf-8', mode: 0o755 },
331+
);
332+
333+
const fakeBin = mkTempDir('nw-smoke-bin-pr-before-commit-');
334+
fs.writeFileSync(path.join(fakeBin, 'claude'), '#!/usr/bin/env bash\nexit 1\n', {
335+
encoding: 'utf-8',
336+
mode: 0o755,
337+
});
338+
fs.writeFileSync(
339+
path.join(fakeBin, 'gh'),
340+
'#!/usr/bin/env bash\n' +
341+
'if [[ "$1" == "pr" && "$2" == "list" ]]; then\n' +
342+
" echo '[]'\n" +
343+
' exit 0\n' +
344+
'fi\n' +
345+
'if [[ "$1" == "pr" && "$2" == "create" ]]; then\n' +
346+
' printf \'pr-create\\n\' >> "$NW_SMOKE_PR_CREATE_LOG"\n' +
347+
' exit 1\n' +
348+
'fi\n' +
349+
'exit 0\n',
350+
{ encoding: 'utf-8', mode: 0o755 },
351+
);
352+
353+
const result = runScript(executorScript, projectDir, {
354+
PATH: `${fakeBin}:${process.env.PATH}`,
355+
NW_PROVIDER_CMD: 'claude',
356+
NW_PRD_DIR: 'docs/PRDs/night-watch',
357+
NW_DEFAULT_BRANCH: 'trunk',
358+
NW_SMOKE_PRE_PUSH_HOOK_LOG: hookLog,
359+
NW_SMOKE_PR_CREATE_LOG: prCreateLog,
360+
});
361+
362+
expect(result.status).toBe(1);
363+
expect(result.stdout).toContain('reason=provider_exit');
364+
expect(result.stdout).not.toContain('reason=pr_setup_failed');
365+
expect(fs.existsSync(hookLog)).toBe(false);
366+
expect(fs.existsSync(prCreateLog)).toBe(false);
367+
});
368+
369+
it('executor should defer draft PR creation until the branch has a real commit', () => {
370+
const projectDir = mkTempDir('nw-smoke-executor-pr-deferred-');
371+
initGitRepo(projectDir);
372+
createPrd(projectDir, '01-smoke-pr-deferred');
373+
commitAll(projectDir, 'add PRD');
374+
375+
const fakeBin = mkTempDir('nw-smoke-bin-pr-deferred-');
376+
const providerDoneFlag = path.join(projectDir, '.smoke-provider-done');
377+
const createdFlag = path.join(projectDir, '.smoke-pr-created');
378+
const branchName = 'night-watch/01-smoke-pr-deferred';
379+
380+
fs.writeFileSync(
381+
path.join(fakeBin, 'claude'),
382+
'#!/usr/bin/env bash\n' +
383+
"printf 'provider change\\n' > smoke-provider-change.txt\n" +
384+
'git add smoke-provider-change.txt\n' +
385+
'git commit -m "feat: smoke provider progress" >/dev/null 2>&1\n' +
386+
'touch "$NW_SMOKE_PROVIDER_DONE_FLAG"\n' +
387+
'exit 0\n',
388+
{ encoding: 'utf-8', mode: 0o755 },
389+
);
390+
391+
fs.writeFileSync(
392+
path.join(fakeBin, 'gh'),
393+
'#!/usr/bin/env bash\n' +
394+
'if [[ "$1" == "pr" && "$2" == "list" ]]; then\n' +
395+
' jq_query=""\n' +
396+
' for ((i=1; i<=$#; i++)); do\n' +
397+
' if [[ "${!i}" == "--jq" ]]; then\n' +
398+
' j=$((i+1))\n' +
399+
' jq_query="${!j}"\n' +
400+
' fi\n' +
401+
' done\n' +
402+
' if [[ -f "$NW_SMOKE_CREATED_FLAG" ]]; then\n' +
403+
' if [[ "$jq_query" == *".url"* ]]; then\n' +
404+
" echo 'https://example.test/pull/123'\n" +
405+
' elif [[ -n "$jq_query" ]]; then\n' +
406+
' echo "$NW_SMOKE_BRANCH"\n' +
407+
' else\n' +
408+
' printf \'[{"number":123,"headRefName":"%s","url":"https://example.test/pull/123","title":"Smoke","isDraft":true,"labels":[],"createdAt":"2026-01-01T00:00:00Z"}]\\n\' "$NW_SMOKE_BRANCH"\n' +
409+
' fi\n' +
410+
' else\n' +
411+
' if [[ -n "$jq_query" ]]; then\n' +
412+
' exit 0\n' +
413+
' fi\n' +
414+
" echo '[]'\n" +
415+
' fi\n' +
416+
' exit 0\n' +
417+
'fi\n' +
418+
'if [[ "$1" == "pr" && "$2" == "create" ]]; then\n' +
419+
' if [[ ! -f "$NW_SMOKE_PROVIDER_DONE_FLAG" ]]; then\n' +
420+
" echo 'create called before provider completed' >&2\n" +
421+
' exit 1\n' +
422+
' fi\n' +
423+
' touch "$NW_SMOKE_CREATED_FLAG"\n' +
424+
" echo 'https://example.test/pull/123'\n" +
425+
' exit 0\n' +
426+
'fi\n' +
427+
'exit 0\n',
428+
{ encoding: 'utf-8', mode: 0o755 },
429+
);
430+
431+
const result = runScript(executorScript, projectDir, {
432+
PATH: `${fakeBin}:${process.env.PATH}`,
433+
NW_PROVIDER_CMD: 'claude',
434+
NW_PRD_DIR: 'docs/PRDs/night-watch',
435+
NW_DEFAULT_BRANCH: 'main',
436+
NW_SMOKE_PROVIDER_DONE_FLAG: providerDoneFlag,
437+
NW_SMOKE_CREATED_FLAG: createdFlag,
438+
NW_SMOKE_BRANCH: branchName,
439+
});
440+
441+
expect(result.status).toBe(0);
442+
expect(result.stdout).toContain('NIGHT_WATCH_RESULT:success_open_pr');
443+
expect(result.stdout).toContain('pr_url=https://example.test/pull/123');
444+
expect(fs.existsSync(createdFlag)).toBe(true);
445+
});
446+
237447
it('executor filesystem mode should preserve queue cleanup after claiming a PRD', () => {
238448
const projectDir = mkTempDir('nw-smoke-executor-queue-filesystem-');
239449
initGitRepo(projectDir);

packages/cli/src/commands/dashboard/tab-config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export const CONFIG_FIELDS: IConfigField[] = [
8080
{ key: 'defaultBranch', label: 'Default Branch', type: 'string' },
8181
{ key: 'prdDir', label: 'PRD Directory', type: 'string' },
8282
{ key: 'branchPrefix', label: 'Branch Prefix', type: 'string' },
83+
{ key: 'gitPushNoVerify', label: 'Push With --no-verify', type: 'boolean' },
8384
{ key: 'branchPatterns', label: 'Branch Patterns', type: 'string[]' },
8485
{ key: 'cronSchedule', label: 'Executor Schedule', type: 'string' },
8586
{ key: 'reviewerSchedule', label: 'Reviewer Schedule', type: 'string' },

packages/cli/src/commands/shared/env-builder.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ export function buildBaseEnvVars(
9898
env.NW_DEFAULT_BRANCH = config.defaultBranch;
9999
}
100100

101+
env.NW_GIT_PUSH_NO_VERIFY = config.gitPushNoVerify ? '1' : '0';
102+
101103
// Provider environment variables (API keys, base URLs, etc.)
102104
// First apply config.providerEnv for backward compat
103105
if (config.providerEnv) {

packages/core/src/config-normalize.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export function normalizeConfig(rawConfig: Record<string, unknown>): Partial<INi
7575
readStringArray(rawConfig.branchPatterns) ?? readStringArray(review?.branchPatterns);
7676
normalized.minReviewScore = readNumber(rawConfig.minReviewScore) ?? readNumber(review?.minScore);
7777
normalized.maxLogSize = readNumber(rawConfig.maxLogSize) ?? readNumber(logging?.maxLogSize);
78+
normalized.gitPushNoVerify = readBoolean(rawConfig.gitPushNoVerify);
7879
normalized.cronSchedule =
7980
readString(rawConfig.cronSchedule) ?? readString(cron?.executorSchedule);
8081
normalized.reviewerSchedule =

packages/core/src/config.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import {
2121
DEFAULT_ANALYTICS,
2222
DEFAULT_AUDIT,
2323
DEFAULT_AUTO_MERGE,
24-
DEFAULT_MERGER,
2524
DEFAULT_AUTO_MERGE_METHOD,
2625
DEFAULT_BOARD_PROVIDER,
2726
DEFAULT_BRANCH_PATTERNS,
@@ -36,6 +35,7 @@ import {
3635
DEFAULT_MAX_LOG_SIZE,
3736
DEFAULT_MAX_RETRIES,
3837
DEFAULT_MAX_RUNTIME,
38+
DEFAULT_MERGER,
3939
DEFAULT_MIN_REVIEW_SCORE,
4040
DEFAULT_NOTIFICATIONS,
4141
DEFAULT_PRD_DIR,
@@ -76,6 +76,7 @@ export function getDefaultConfig(): INightWatchConfig {
7676
branchPatterns: [...DEFAULT_BRANCH_PATTERNS],
7777
minReviewScore: DEFAULT_MIN_REVIEW_SCORE,
7878
maxLogSize: DEFAULT_MAX_LOG_SIZE,
79+
gitPushNoVerify: false,
7980
cronSchedule: DEFAULT_CRON_SCHEDULE,
8081
reviewerSchedule: DEFAULT_REVIEWER_SCHEDULE,
8182
scheduleBundleId: 'always-on',
@@ -220,7 +221,9 @@ function mergeConfigs(
220221
merged.merger = {
221222
...merged.merger,
222223
enabled: true,
223-
mergeMethod: (merged as unknown as Record<string, unknown>).autoMergeMethod as IMergerConfig['mergeMethod'] ?? 'squash',
224+
mergeMethod:
225+
((merged as unknown as Record<string, unknown>)
226+
.autoMergeMethod as IMergerConfig['mergeMethod']) ?? 'squash',
224227
};
225228
}
226229

packages/core/src/shared/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ export interface INightWatchConfig {
244244
branchPatterns: string[];
245245
minReviewScore: number;
246246
maxLogSize: number;
247+
gitPushNoVerify?: boolean;
247248
cronSchedule: string;
248249
reviewerSchedule: string;
249250
scheduleBundleId?: string | null;

packages/core/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,12 @@ export interface INightWatchConfig {
151151
/** Maximum log file size in bytes before rotation */
152152
maxLogSize: number;
153153

154+
/**
155+
* When true, Night Watch uses `git push --no-verify` for its automated pushes
156+
* in this project to bypass local pre-push hooks.
157+
*/
158+
gitPushNoVerify?: boolean;
159+
154160
// Cron scheduling configuration
155161

156162
/** Cron schedule for PRD execution */

packages/server/src/routes/config.routes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ function validateConfigChanges(
118118
return 'maxLogSize must be a positive number';
119119
}
120120

121+
if (changes.gitPushNoVerify !== undefined && typeof changes.gitPushNoVerify !== 'boolean') {
122+
return 'gitPushNoVerify must be a boolean';
123+
}
124+
121125
if (
122126
changes.maxRetries !== undefined &&
123127
(typeof changes.maxRetries !== 'number' ||

0 commit comments

Comments
 (0)