hungry-ghost-hive 0.47.2 → 0.47.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/cli/commands/auth.js +4 -4
  2. package/dist/cli/commands/auth.js.map +1 -1
  3. package/dist/cli/commands/auth.test.js +42 -0
  4. package/dist/cli/commands/auth.test.js.map +1 -1
  5. package/dist/cli/commands/pr.d.ts.map +1 -1
  6. package/dist/cli/commands/pr.js +0 -22
  7. package/dist/cli/commands/pr.js.map +1 -1
  8. package/dist/cli/commands/pr.test.js +28 -0
  9. package/dist/cli/commands/pr.test.js.map +1 -1
  10. package/dist/cli/wizard/init-wizard.js +17 -2
  11. package/dist/cli/wizard/init-wizard.js.map +1 -1
  12. package/dist/cli/wizard/init-wizard.test.js +10 -4
  13. package/dist/cli/wizard/init-wizard.test.js.map +1 -1
  14. package/dist/config/schema.d.ts +19 -0
  15. package/dist/config/schema.d.ts.map +1 -1
  16. package/dist/config/schema.js +4 -0
  17. package/dist/config/schema.js.map +1 -1
  18. package/dist/context-files/index.test.js +1 -1
  19. package/dist/context-files/index.test.js.map +1 -1
  20. package/dist/test-validation.test.d.ts +2 -0
  21. package/dist/test-validation.test.d.ts.map +1 -0
  22. package/dist/test-validation.test.js +20 -0
  23. package/dist/test-validation.test.js.map +1 -0
  24. package/dist/utils/auto-merge.d.ts +8 -0
  25. package/dist/utils/auto-merge.d.ts.map +1 -1
  26. package/dist/utils/auto-merge.js +138 -0
  27. package/dist/utils/auto-merge.js.map +1 -1
  28. package/dist/utils/auto-merge.test.js +145 -1
  29. package/dist/utils/auto-merge.test.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/cli/commands/auth.test.ts +51 -0
  32. package/src/cli/commands/auth.ts +4 -4
  33. package/src/cli/commands/pr.test.ts +48 -0
  34. package/src/cli/commands/pr.ts +0 -30
  35. package/src/cli/wizard/init-wizard.test.ts +10 -4
  36. package/src/cli/wizard/init-wizard.ts +17 -2
  37. package/src/config/schema.ts +4 -0
  38. package/src/context-files/index.test.ts +1 -1
  39. package/src/test-validation.test.ts +24 -0
  40. package/src/utils/auto-merge.test.ts +213 -1
  41. package/src/utils/auto-merge.ts +179 -0
@@ -27,7 +27,7 @@ vi.mock('../connectors/project-management/operations.js', () => ({
27
27
  }));
28
28
 
29
29
  import { loadConfig } from '../config/loader.js';
30
- import { autoMergeApprovedPRs } from './auto-merge.js';
30
+ import { autoMergeApprovedPRs, checkPreexistingCIFailures } from './auto-merge.js';
31
31
 
32
32
  const mockLoadConfig = vi.mocked(loadConfig);
33
33
 
@@ -380,5 +380,217 @@ describe('auto-merge functionality', () => {
380
380
  expect(result).toBe(0);
381
381
  expect(getPullRequestById(db, pr.id)?.status).toBe('approved');
382
382
  });
383
+
384
+ it('should bypass BLOCKED status when all CI failures are pre-existing on base branch', async () => {
385
+ const pr = createPullRequest(db, {
386
+ storyId,
387
+ teamId,
388
+ branchName: 'feature/preexisting-ci',
389
+ githubPrNumber: 555,
390
+ });
391
+ updatePullRequest(db, pr.id, { status: 'approved' });
392
+
393
+ mockLoadConfig.mockReturnValue({
394
+ integrations: {
395
+ autonomy: { level: 'full', allow_preexisting_ci_failures: true },
396
+ source_control: { provider: 'github' },
397
+ project_management: { provider: 'none' },
398
+ },
399
+ } as any);
400
+
401
+ const mockExecSync = vi.fn();
402
+ // 1. gh pr view → BLOCKED but MERGEABLE
403
+ mockExecSync.mockReturnValueOnce(
404
+ JSON.stringify({ state: 'OPEN', mergeable: 'MERGEABLE', mergeStateStatus: 'BLOCKED' })
405
+ );
406
+ // 2. gh pr checks → one failing check
407
+ mockExecSync.mockReturnValueOnce(
408
+ JSON.stringify([
409
+ { name: 'build', state: 'SUCCESS' },
410
+ { name: 'lint', state: 'FAIL' },
411
+ ])
412
+ );
413
+ // 3. gh pr view baseRefName
414
+ mockExecSync.mockReturnValueOnce(JSON.stringify({ baseRefName: 'main' }));
415
+ // 4. gh api base branch check-runs → same check failing
416
+ mockExecSync.mockReturnValueOnce(
417
+ JSON.stringify([
418
+ { name: 'build', conclusion: 'success' },
419
+ { name: 'lint', conclusion: 'failure' },
420
+ ])
421
+ );
422
+ // 5. gh pr merge --admin → success
423
+ mockExecSync.mockReturnValueOnce(undefined);
424
+
425
+ vi.doMock('child_process', () => ({ execSync: mockExecSync }));
426
+
427
+ const dbClient = { db, save: vi.fn(), close: vi.fn(), runMigrations: vi.fn() };
428
+ const result = await autoMergeApprovedPRs('/mock/root', dbClient);
429
+
430
+ expect(result).toBe(1);
431
+ expect(getPullRequestById(db, pr.id)?.status).toBe('merged');
432
+ });
433
+
434
+ it('should not bypass BLOCKED status when PR has new CI failures not on base branch', async () => {
435
+ const pr = createPullRequest(db, {
436
+ storyId,
437
+ teamId,
438
+ branchName: 'feature/new-ci-failure',
439
+ githubPrNumber: 556,
440
+ });
441
+ updatePullRequest(db, pr.id, { status: 'approved' });
442
+
443
+ mockLoadConfig.mockReturnValue({
444
+ integrations: {
445
+ autonomy: { level: 'full', allow_preexisting_ci_failures: true },
446
+ source_control: { provider: 'github' },
447
+ project_management: { provider: 'none' },
448
+ },
449
+ } as any);
450
+
451
+ const mockExecSync = vi.fn();
452
+ // 1. gh pr view → BLOCKED but MERGEABLE
453
+ mockExecSync.mockReturnValueOnce(
454
+ JSON.stringify({ state: 'OPEN', mergeable: 'MERGEABLE', mergeStateStatus: 'BLOCKED' })
455
+ );
456
+ // 2. gh pr checks → two failing checks
457
+ mockExecSync.mockReturnValueOnce(
458
+ JSON.stringify([
459
+ { name: 'build', state: 'FAIL' },
460
+ { name: 'lint', state: 'FAIL' },
461
+ ])
462
+ );
463
+ // 3. gh pr view baseRefName
464
+ mockExecSync.mockReturnValueOnce(JSON.stringify({ baseRefName: 'main' }));
465
+ // 4. gh api base branch check-runs → only lint fails on base
466
+ mockExecSync.mockReturnValueOnce(
467
+ JSON.stringify([
468
+ { name: 'build', conclusion: 'success' },
469
+ { name: 'lint', conclusion: 'failure' },
470
+ ])
471
+ );
472
+
473
+ vi.doMock('child_process', () => ({ execSync: mockExecSync }));
474
+
475
+ const dbClient = { db, save: vi.fn(), close: vi.fn(), runMigrations: vi.fn() };
476
+ const result = await autoMergeApprovedPRs('/mock/root', dbClient);
477
+
478
+ // Should not merge — 'build' is a new failure
479
+ expect(result).toBe(0);
480
+ // PR should be reset to approved (ci_blocked outcome)
481
+ expect(getPullRequestById(db, pr.id)?.status).toBe('approved');
482
+ });
483
+
484
+ it('should skip BLOCKED PR when allow_preexisting_ci_failures is false', async () => {
485
+ const pr = createPullRequest(db, {
486
+ storyId,
487
+ teamId,
488
+ branchName: 'feature/ci-blocked-no-bypass',
489
+ githubPrNumber: 557,
490
+ });
491
+ updatePullRequest(db, pr.id, { status: 'approved' });
492
+
493
+ mockLoadConfig.mockReturnValue({
494
+ integrations: {
495
+ autonomy: { level: 'full', allow_preexisting_ci_failures: false },
496
+ source_control: { provider: 'github' },
497
+ project_management: { provider: 'none' },
498
+ },
499
+ } as any);
500
+
501
+ const mockExecSync = vi.fn();
502
+ // gh pr view → BLOCKED but MERGEABLE
503
+ mockExecSync.mockReturnValueOnce(
504
+ JSON.stringify({ state: 'OPEN', mergeable: 'MERGEABLE', mergeStateStatus: 'BLOCKED' })
505
+ );
506
+
507
+ vi.doMock('child_process', () => ({ execSync: mockExecSync }));
508
+
509
+ const dbClient = { db, save: vi.fn(), close: vi.fn(), runMigrations: vi.fn() };
510
+ const result = await autoMergeApprovedPRs('/mock/root', dbClient);
511
+
512
+ expect(result).toBe(0);
513
+ expect(getPullRequestById(db, pr.id)?.status).toBe('approved');
514
+ });
515
+ });
516
+
517
+ describe('checkPreexistingCIFailures', () => {
518
+ it('should return bypassed=true when all PR failures exist on base branch', () => {
519
+ const mockExecSync = vi.fn();
520
+ // gh pr checks
521
+ mockExecSync.mockReturnValueOnce(
522
+ JSON.stringify([
523
+ { name: 'build', state: 'SUCCESS' },
524
+ { name: 'lint', state: 'FAIL' },
525
+ { name: 'test', state: 'FAIL' },
526
+ ])
527
+ );
528
+ // gh pr view baseRefName
529
+ mockExecSync.mockReturnValueOnce(JSON.stringify({ baseRefName: 'main' }));
530
+ // gh api check-runs
531
+ mockExecSync.mockReturnValueOnce(
532
+ JSON.stringify([
533
+ { name: 'build', conclusion: 'success' },
534
+ { name: 'lint', conclusion: 'failure' },
535
+ { name: 'test', conclusion: 'failure' },
536
+ ])
537
+ );
538
+
539
+ const result = checkPreexistingCIFailures(
540
+ 123,
541
+ '/repo',
542
+ '',
543
+ 'owner/repo',
544
+ mockExecSync as any
545
+ );
546
+ expect(result.bypassed).toBe(true);
547
+ expect(result.bypassedChecks).toEqual(expect.arrayContaining(['lint', 'test']));
548
+ });
549
+
550
+ it('should return bypassed=false when PR has unique failures', () => {
551
+ const mockExecSync = vi.fn();
552
+ mockExecSync.mockReturnValueOnce(JSON.stringify([{ name: 'build', state: 'FAIL' }]));
553
+ mockExecSync.mockReturnValueOnce(JSON.stringify({ baseRefName: 'main' }));
554
+ mockExecSync.mockReturnValueOnce(JSON.stringify([{ name: 'build', conclusion: 'success' }]));
555
+
556
+ const result = checkPreexistingCIFailures(
557
+ 123,
558
+ '/repo',
559
+ '',
560
+ 'owner/repo',
561
+ mockExecSync as any
562
+ );
563
+ expect(result.bypassed).toBe(false);
564
+ });
565
+
566
+ it('should return bypassed=false when no PR checks are failing', () => {
567
+ const mockExecSync = vi.fn();
568
+ mockExecSync.mockReturnValueOnce(JSON.stringify([{ name: 'build', state: 'SUCCESS' }]));
569
+
570
+ const result = checkPreexistingCIFailures(
571
+ 123,
572
+ '/repo',
573
+ '',
574
+ 'owner/repo',
575
+ mockExecSync as any
576
+ );
577
+ expect(result.bypassed).toBe(false);
578
+ expect(result.bypassedChecks).toEqual([]);
579
+ });
580
+
581
+ it('should return bypassed=false when execSync throws', () => {
582
+ const mockExecSync = vi.fn().mockImplementation(() => {
583
+ throw new Error('gh not found');
584
+ });
585
+
586
+ const result = checkPreexistingCIFailures(
587
+ 123,
588
+ '/repo',
589
+ '',
590
+ 'owner/repo',
591
+ mockExecSync as any
592
+ );
593
+ expect(result.bypassed).toBe(false);
594
+ });
383
595
  });
384
596
  });
@@ -44,9 +44,83 @@ interface ClaimedPR {
44
44
  pr: PullRequestRow;
45
45
  repoCwd: string;
46
46
  repoFlag: string;
47
+ repoSlug: string | null;
47
48
  teamId: string | null;
48
49
  }
49
50
 
51
+ /**
52
+ * Compare CI check failures between a PR and its base branch.
53
+ * Returns bypassed=true when every failing check on the PR also fails on the base branch.
54
+ */
55
+ export function checkPreexistingCIFailures(
56
+ prNumber: number,
57
+ repoCwd: string,
58
+ repoFlag: string,
59
+ repoSlug: string | null,
60
+ execSyncFn: typeof import('child_process').execSync
61
+ ): { bypassed: boolean; bypassedChecks: string[] } {
62
+ try {
63
+ // Get failing checks on the PR
64
+ const prChecksRaw = execSyncFn(`gh pr checks ${prNumber} --json name,state${repoFlag}`, {
65
+ stdio: 'pipe',
66
+ cwd: repoCwd,
67
+ encoding: 'utf-8',
68
+ timeout: PR_STATE_CHECK_TIMEOUT_MS,
69
+ }) as string;
70
+ const prChecks: Array<{ name: string; state: string }> = JSON.parse(prChecksRaw);
71
+ const prFailingNames = new Set(prChecks.filter(c => c.state === 'FAIL').map(c => c.name));
72
+
73
+ if (prFailingNames.size === 0) return { bypassed: false, bypassedChecks: [] };
74
+
75
+ // Get the base branch ref
76
+ const baseRefRaw = execSyncFn(`gh pr view ${prNumber} --json baseRefName${repoFlag}`, {
77
+ stdio: 'pipe',
78
+ cwd: repoCwd,
79
+ encoding: 'utf-8',
80
+ timeout: PR_STATE_CHECK_TIMEOUT_MS,
81
+ }) as string;
82
+ const { baseRefName } = JSON.parse(baseRefRaw);
83
+
84
+ // Determine repo slug for API call
85
+ let slug = repoSlug;
86
+ if (!slug) {
87
+ try {
88
+ slug = (
89
+ execSyncFn('gh repo view --json nameWithOwner -q .nameWithOwner', {
90
+ stdio: 'pipe',
91
+ cwd: repoCwd,
92
+ encoding: 'utf-8',
93
+ timeout: PR_STATE_CHECK_TIMEOUT_MS,
94
+ }) as string
95
+ ).trim();
96
+ } catch {
97
+ return { bypassed: false, bypassedChecks: [] };
98
+ }
99
+ }
100
+
101
+ // Get check runs on the base branch
102
+ const baseChecksRaw = execSyncFn(
103
+ `gh api repos/${slug}/commits/${baseRefName}/check-runs --jq '.check_runs'`,
104
+ { stdio: 'pipe', cwd: repoCwd, encoding: 'utf-8', timeout: PR_STATE_CHECK_TIMEOUT_MS }
105
+ ) as string;
106
+ const baseCheckRuns: Array<{ name: string; conclusion: string }> = JSON.parse(baseChecksRaw);
107
+ const baseFailingNames = new Set(
108
+ baseCheckRuns.filter(c => c.conclusion === 'failure').map(c => c.name)
109
+ );
110
+
111
+ // Check if all PR failures also fail on base
112
+ const prOnlyFailures = [...prFailingNames].filter(name => !baseFailingNames.has(name));
113
+
114
+ if (prOnlyFailures.length === 0) {
115
+ return { bypassed: true, bypassedChecks: [...prFailingNames] };
116
+ }
117
+
118
+ return { bypassed: false, bypassedChecks: [] };
119
+ } catch {
120
+ return { bypassed: false, bypassedChecks: [] };
121
+ }
122
+ }
123
+
50
124
  /**
51
125
  * Auto-merge all approved PRs that are ready to merge.
52
126
  * Can be called immediately after PR approval or from manager daemon.
@@ -130,6 +204,7 @@ export async function autoMergeApprovedPRs(
130
204
  pr,
131
205
  repoCwd,
132
206
  repoFlag: repoSlug ? ` -R ${repoSlug}` : '',
207
+ repoSlug,
133
208
  teamId,
134
209
  });
135
210
  }
@@ -154,6 +229,8 @@ export async function autoMergeApprovedPRs(
154
229
  | { type: 'branch_updated' }
155
230
  | { type: 'already_closed'; prState: GitHubPRState }
156
231
  | { type: 'conflicts' }
232
+ | { type: 'ci_blocked' }
233
+ | { type: 'ci_bypassed'; bypassedChecks: string[] }
157
234
  | { type: 'unknown_state' }
158
235
  | { type: 'merge_failed'; error: Error };
159
236
  }
@@ -207,6 +284,44 @@ export async function autoMergeApprovedPRs(
207
284
  continue;
208
285
  }
209
286
 
287
+ // Handle BLOCKED mergeStateStatus (CI checks failing)
288
+ if (prState.mergeStateStatus === 'BLOCKED') {
289
+ if (config.integrations.autonomy.allow_preexisting_ci_failures) {
290
+ const ciResult = checkPreexistingCIFailures(
291
+ pr.github_pr_number!,
292
+ repoCwd,
293
+ repoFlag,
294
+ claimed.repoSlug,
295
+ execSync
296
+ );
297
+ if (ciResult.bypassed) {
298
+ // All CI failures also exist on the base branch — attempt merge with admin bypass
299
+ try {
300
+ execSync(
301
+ `gh pr merge ${pr.github_pr_number} --squash --delete-branch --admin${repoFlag}`,
302
+ { stdio: 'pipe', cwd: repoCwd, timeout: PR_MERGE_TIMEOUT_MS }
303
+ );
304
+ results.push({
305
+ claimed,
306
+ outcome: { type: 'ci_bypassed', bypassedChecks: ciResult.bypassedChecks },
307
+ });
308
+ } catch (mergeErr) {
309
+ results.push({
310
+ claimed,
311
+ outcome: {
312
+ type: 'merge_failed',
313
+ error: mergeErr instanceof Error ? mergeErr : new Error(String(mergeErr)),
314
+ },
315
+ });
316
+ }
317
+ continue;
318
+ }
319
+ }
320
+ // New CI failures or config disabled — skip
321
+ results.push({ claimed, outcome: { type: 'ci_blocked' } });
322
+ continue;
323
+ }
324
+
210
325
  // Attempt merge
211
326
  try {
212
327
  execSync(`gh pr merge ${pr.github_pr_number} --auto --squash --delete-branch${repoFlag}`, {
@@ -337,6 +452,70 @@ export async function autoMergeApprovedPRs(
337
452
  break;
338
453
  }
339
454
 
455
+ case 'ci_blocked': {
456
+ await withTransaction(
457
+ phaseDb.db,
458
+ () => {
459
+ updatePullRequest(phaseDb.db, pr.id, { status: 'approved' });
460
+ createLog(phaseDb.db, {
461
+ agentId: 'manager',
462
+ storyId: pr.story_id || undefined,
463
+ eventType: 'PR_MERGE_SKIPPED',
464
+ status: 'warn',
465
+ message: `Skipped auto-merge of PR #${pr.github_pr_number}: CI checks are failing (not pre-existing on base branch)`,
466
+ metadata: { pr_id: pr.id },
467
+ });
468
+ },
469
+ () => phaseDb.save()
470
+ );
471
+ break;
472
+ }
473
+
474
+ case 'ci_bypassed': {
475
+ const storyId = pr.story_id;
476
+ const bypassedChecks = result.outcome.bypassedChecks;
477
+ await withTransaction(
478
+ phaseDb.db,
479
+ () => {
480
+ updatePullRequest(phaseDb.db, pr.id, { status: 'merged' });
481
+ if (storyId) {
482
+ updateStory(phaseDb.db, storyId, { status: 'merged' });
483
+ const story = getStoryById(phaseDb.db, storyId);
484
+ if (story?.assigned_agent_id) {
485
+ const agent = getAgentById(phaseDb.db, story.assigned_agent_id);
486
+ if (agent && agent.current_story_id === storyId) {
487
+ updateAgent(phaseDb.db, agent.id, { currentStoryId: null, status: 'idle' });
488
+ }
489
+ }
490
+ createLog(phaseDb.db, {
491
+ agentId: 'manager',
492
+ storyId,
493
+ eventType: 'STORY_MERGED',
494
+ message: `Story auto-merged from GitHub PR #${pr.github_pr_number} (bypassed pre-existing CI failures: ${bypassedChecks.join(', ')})`,
495
+ });
496
+ } else {
497
+ createLog(phaseDb.db, {
498
+ agentId: 'manager',
499
+ eventType: 'PR_MERGED',
500
+ message: `PR ${pr.id} auto-merged (GitHub PR #${pr.github_pr_number}, bypassed pre-existing CI failures: ${bypassedChecks.join(', ')})`,
501
+ metadata: { pr_id: pr.id },
502
+ });
503
+ }
504
+ },
505
+ () => phaseDb.save()
506
+ );
507
+
508
+ mergedCount++;
509
+
510
+ if (storyId) {
511
+ postLifecycleComment(phaseDb.db, paths.hiveDir, config, storyId, 'merged').catch(() => {
512
+ /* non-fatal */
513
+ });
514
+ syncStatusForStory(root, phaseDb.db, storyId, 'merged');
515
+ }
516
+ break;
517
+ }
518
+
340
519
  case 'merged': {
341
520
  const storyId = pr.story_id;
342
521
  await withTransaction(