hungry-ghost-hive 0.40.3 → 0.41.1

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 (50) hide show
  1. package/dist/cli/commands/init.d.ts.map +1 -1
  2. package/dist/cli/commands/init.js +8 -0
  3. package/dist/cli/commands/init.js.map +1 -1
  4. package/dist/cli/commands/manager/auto-assignment.js +4 -4
  5. package/dist/cli/commands/manager/auto-assignment.js.map +1 -1
  6. package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.d.ts +2 -0
  7. package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.d.ts.map +1 -0
  8. package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.js +462 -0
  9. package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.js.map +1 -0
  10. package/dist/cli/commands/manager/index.d.ts +12 -0
  11. package/dist/cli/commands/manager/index.d.ts.map +1 -1
  12. package/dist/cli/commands/manager/index.js +171 -3
  13. package/dist/cli/commands/manager/index.js.map +1 -1
  14. package/dist/cli/wizard/init-wizard.d.ts +2 -1
  15. package/dist/cli/wizard/init-wizard.d.ts.map +1 -1
  16. package/dist/cli/wizard/init-wizard.js +56 -3
  17. package/dist/cli/wizard/init-wizard.js.map +1 -1
  18. package/dist/cli/wizard/init-wizard.test.js +56 -9
  19. package/dist/cli/wizard/init-wizard.test.js.map +1 -1
  20. package/dist/config/schema.d.ts +389 -0
  21. package/dist/config/schema.d.ts.map +1 -1
  22. package/dist/config/schema.js +18 -0
  23. package/dist/config/schema.js.map +1 -1
  24. package/dist/context-files/index.test.js +6 -0
  25. package/dist/context-files/index.test.js.map +1 -1
  26. package/dist/git/github.d.ts +9 -0
  27. package/dist/git/github.d.ts.map +1 -1
  28. package/dist/git/github.js +15 -0
  29. package/dist/git/github.js.map +1 -1
  30. package/dist/git/github.test.js +77 -0
  31. package/dist/git/github.test.js.map +1 -1
  32. package/dist/orchestrator/scheduler.d.ts.map +1 -1
  33. package/dist/orchestrator/scheduler.js +1 -0
  34. package/dist/orchestrator/scheduler.js.map +1 -1
  35. package/dist/tmux/manager.d.ts.map +1 -1
  36. package/dist/tmux/manager.js +15 -8
  37. package/dist/tmux/manager.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/cli/commands/init.ts +8 -0
  40. package/src/cli/commands/manager/auto-assignment.ts +4 -4
  41. package/src/cli/commands/manager/auto-reject-comment-only-reviews.test.ts +589 -0
  42. package/src/cli/commands/manager/index.ts +228 -3
  43. package/src/cli/wizard/init-wizard.test.ts +62 -9
  44. package/src/cli/wizard/init-wizard.ts +66 -4
  45. package/src/config/schema.ts +20 -0
  46. package/src/context-files/index.test.ts +6 -0
  47. package/src/git/github.test.ts +93 -0
  48. package/src/git/github.ts +28 -0
  49. package/src/orchestrator/scheduler.ts +1 -0
  50. package/src/tmux/manager.ts +19 -8
@@ -0,0 +1,589 @@
1
+ // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
+
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { AgentState } from '../../../state-detectors/types.js';
5
+ import type { ManagerCheckContext } from './types.js';
6
+
7
+ // ── Mocks ──────────────────────────────────────────────────────────────────
8
+
9
+ vi.mock('../../../db/queries/pull-requests.js', () => ({
10
+ getPullRequestsByStatus: vi.fn(() => []),
11
+ updatePullRequest: vi.fn(),
12
+ getApprovedPullRequests: vi.fn(() => []),
13
+ backfillGithubPrNumbers: vi.fn(),
14
+ createPullRequest: vi.fn(),
15
+ getMergeQueue: vi.fn(() => []),
16
+ getOpenPullRequestsByStory: vi.fn(() => []),
17
+ }));
18
+
19
+ vi.mock('../../../db/queries/stories.js', () => ({
20
+ getStoriesByStatus: vi.fn(() => []),
21
+ getStoryById: vi.fn(),
22
+ updateStory: vi.fn(),
23
+ }));
24
+
25
+ vi.mock('../../../db/queries/logs.js', () => ({
26
+ createLog: vi.fn(),
27
+ }));
28
+
29
+ vi.mock('../../../db/queries/teams.js', () => ({
30
+ getAllTeams: vi.fn(() => []),
31
+ }));
32
+
33
+ vi.mock('../../../db/client.js', () => ({
34
+ queryAll: vi.fn(() => []),
35
+ queryOne: vi.fn(),
36
+ withTransaction: vi.fn((_db: unknown, fn: () => void, saveFn: () => void) => {
37
+ fn();
38
+ saveFn();
39
+ }),
40
+ }));
41
+
42
+ vi.mock('../../../git/github.js', () => ({
43
+ getPullRequestReviews: vi.fn(() => Promise.resolve([])),
44
+ getPullRequestComments: vi.fn(() => Promise.resolve([])),
45
+ isGitHubAuthenticated: vi.fn(),
46
+ isGitHubCLIAvailable: vi.fn(),
47
+ }));
48
+
49
+ vi.mock('../../../tmux/manager.js', () => ({
50
+ sendMessageWithConfirmation: vi.fn(),
51
+ getHiveSessions: vi.fn(),
52
+ sendToTmuxSession: vi.fn(),
53
+ sendEnterToTmuxSession: vi.fn(),
54
+ captureTmuxPane: vi.fn(),
55
+ isManagerRunning: vi.fn(),
56
+ stopManager: vi.fn(),
57
+ killTmuxSession: vi.fn(),
58
+ }));
59
+
60
+ vi.mock('./agent-monitoring.js', () => ({
61
+ agentStates: new Map(),
62
+ createManagerNudgeEnvelope: vi.fn((msg: string) => ({
63
+ text: `[START]${msg}[END]`,
64
+ nudgeId: 'nudge-123',
65
+ })),
66
+ submitManagerNudgeWithVerification: vi.fn(() =>
67
+ Promise.resolve({ confirmed: true, enterPresses: 1, retryEnters: 0, checks: 1 })
68
+ ),
69
+ detectAgentState: vi.fn(),
70
+ enforceBypassMode: vi.fn(),
71
+ forwardMessages: vi.fn(),
72
+ getAgentSafetyMode: vi.fn(),
73
+ handlePermissionPrompt: vi.fn(),
74
+ handlePlanApproval: vi.fn(),
75
+ nudgeAgent: vi.fn(),
76
+ updateAgentStateTracking: vi.fn(),
77
+ }));
78
+
79
+ vi.mock('./auto-assignment.js', () => ({
80
+ autoAssignPlannedStories: vi.fn(),
81
+ }));
82
+
83
+ vi.mock('./done-intelligence.js', () => ({
84
+ assessCompletionFromOutput: vi.fn(),
85
+ }));
86
+
87
+ vi.mock('./escalation-handler.js', () => ({
88
+ handleEscalationAndNudge: vi.fn(),
89
+ }));
90
+
91
+ vi.mock('./feature-sign-off.js', () => ({
92
+ checkFeatureSignOff: vi.fn(),
93
+ }));
94
+
95
+ vi.mock('./feature-test-result.js', () => ({
96
+ checkFeatureTestResult: vi.fn(),
97
+ }));
98
+
99
+ vi.mock('./handoff-recovery.js', () => ({
100
+ handleStalledPlanningHandoff: vi.fn(),
101
+ }));
102
+
103
+ vi.mock('./merged-story-cleanup.js', () => ({
104
+ cleanupAgentsReferencingMergedStory: vi.fn(),
105
+ }));
106
+
107
+ vi.mock('./orphaned-escalations.js', () => ({
108
+ shouldAutoResolveOrphanedManagerEscalation: vi.fn(),
109
+ }));
110
+
111
+ vi.mock('./session-resolution.js', () => ({
112
+ findSessionForAgent: vi.fn(),
113
+ }));
114
+
115
+ vi.mock('./spin-down.js', () => ({
116
+ spinDownIdleAgents: vi.fn(),
117
+ spinDownMergedAgents: vi.fn(),
118
+ }));
119
+
120
+ vi.mock('./stale-escalations.js', () => ({
121
+ findStaleSessionEscalations: vi.fn(() => []),
122
+ }));
123
+
124
+ vi.mock('../../../utils/auto-merge.js', () => ({
125
+ autoMergeApprovedPRs: vi.fn(),
126
+ }));
127
+
128
+ vi.mock('../../../utils/cli-commands.js', () => ({
129
+ getAvailableCommands: vi.fn(() => ({})),
130
+ }));
131
+
132
+ vi.mock('../../../utils/pr-sync.js', () => ({
133
+ fetchOpenGitHubPRs: vi.fn(),
134
+ getExistingPRIdentifiers: vi.fn(),
135
+ ghRepoSlug: vi.fn(),
136
+ }));
137
+
138
+ vi.mock('../../../utils/story-id.js', () => ({
139
+ extractStoryIdFromBranch: vi.fn(),
140
+ }));
141
+
142
+ vi.mock('../../../utils/with-hive-context.js', () => ({
143
+ withHiveContext: vi.fn(),
144
+ withHiveRoot: vi.fn(),
145
+ }));
146
+
147
+ vi.mock('../../../utils/paths.js', () => ({
148
+ findHiveRoot: vi.fn(),
149
+ getHivePaths: vi.fn(() => ({ hiveDir: '/tmp/.hive' })),
150
+ }));
151
+
152
+ vi.mock('../../../orchestrator/scheduler.js', () => ({
153
+ Scheduler: vi.fn().mockImplementation(() => ({})),
154
+ }));
155
+
156
+ // ── Imports (after mocks) ──────────────────────────────────────────────────
157
+
158
+ import { createLog } from '../../../db/queries/logs.js';
159
+ import { getPullRequestsByStatus, updatePullRequest } from '../../../db/queries/pull-requests.js';
160
+ import { updateStory } from '../../../db/queries/stories.js';
161
+ import { getAllTeams } from '../../../db/queries/teams.js';
162
+ import { getPullRequestComments, getPullRequestReviews } from '../../../git/github.js';
163
+ import { sendToTmuxSession } from '../../../tmux/manager.js';
164
+ import { agentStates } from './agent-monitoring.js';
165
+ import { autoRejectCommentOnlyReviews } from './index.js';
166
+
167
+ // ── Helpers ────────────────────────────────────────────────────────────────
168
+
169
+ function makeMockCtx(overrides: Partial<ManagerCheckContext> = {}): ManagerCheckContext {
170
+ const mockDb = { db: {} as any, save: vi.fn(), close: vi.fn() };
171
+ return {
172
+ root: '/test/project',
173
+ verbose: false,
174
+ config: { manager: {} } as any,
175
+ paths: { hiveDir: '/test/project/.hive' } as any,
176
+ withDb: vi.fn(async (fn: any) => fn(mockDb, {})),
177
+ hiveSessions: [],
178
+ counters: {
179
+ nudged: 0,
180
+ nudgeEnterPresses: 0,
181
+ nudgeEnterRetries: 0,
182
+ nudgeSubmitUnconfirmed: 0,
183
+ autoProgressed: 0,
184
+ messagesForwarded: 0,
185
+ escalationsCreated: 0,
186
+ escalationsResolved: 0,
187
+ queuedPRCount: 0,
188
+ reviewingPRCount: 0,
189
+ handoffPromoted: 0,
190
+ handoffAutoAssigned: 0,
191
+ plannedAutoAssigned: 0,
192
+ jiraSynced: 0,
193
+ featureTestsSpawned: 0,
194
+ },
195
+ escalatedSessions: new Set(),
196
+ agentsBySessionName: new Map(),
197
+ messagesToMarkRead: [],
198
+ ...overrides,
199
+ };
200
+ }
201
+
202
+ function makeReviewingPR(overrides: Record<string, unknown> = {}) {
203
+ return {
204
+ id: 'pr-test-1',
205
+ story_id: 'STORY-001',
206
+ team_id: 'team-1',
207
+ branch_name: 'feature/STORY-001-test',
208
+ github_pr_number: 42,
209
+ github_pr_url: 'https://github.com/test/repo/pull/42',
210
+ status: 'reviewing' as const,
211
+ submitted_by: 'hive-senior-test',
212
+ reviewed_by: 'hive-qa-test',
213
+ review_notes: null,
214
+ created_at: new Date().toISOString(),
215
+ updated_at: new Date().toISOString(),
216
+ reviewed_at: null,
217
+ ...overrides,
218
+ };
219
+ }
220
+
221
+ // ── Tests ──────────────────────────────────────────────────────────────────
222
+
223
+ describe('autoRejectCommentOnlyReviews', () => {
224
+ beforeEach(() => {
225
+ vi.clearAllMocks();
226
+ (agentStates as Map<string, any>).clear();
227
+ });
228
+
229
+ it('should return early when no PRs are in reviewing status', async () => {
230
+ vi.mocked(getPullRequestsByStatus).mockReturnValue([]);
231
+
232
+ const ctx = makeMockCtx();
233
+ await autoRejectCommentOnlyReviews(ctx);
234
+
235
+ expect(getPullRequestReviews).not.toHaveBeenCalled();
236
+ expect(getPullRequestComments).not.toHaveBeenCalled();
237
+ expect(updatePullRequest).not.toHaveBeenCalled();
238
+ });
239
+
240
+ it('should return early when reviewing PRs have no github_pr_number', async () => {
241
+ vi.mocked(getPullRequestsByStatus).mockReturnValue([
242
+ makeReviewingPR({ github_pr_number: null }),
243
+ ] as any);
244
+
245
+ const ctx = makeMockCtx();
246
+ await autoRejectCommentOnlyReviews(ctx);
247
+
248
+ expect(getPullRequestReviews).not.toHaveBeenCalled();
249
+ });
250
+
251
+ it('should skip PRs where QA agent is not idle', async () => {
252
+ vi.mocked(getPullRequestsByStatus).mockReturnValue([makeReviewingPR()] as any);
253
+ vi.mocked(getAllTeams).mockReturnValue([
254
+ { id: 'team-1', repo_path: 'repos/mini-marty' },
255
+ ] as any);
256
+
257
+ const ctx = makeMockCtx({
258
+ agentsBySessionName: new Map([
259
+ ['hive-qa-test', { status: 'working', session_name: 'hive-qa-test' } as any],
260
+ ]),
261
+ });
262
+
263
+ // QA agent is working, not idle
264
+ (agentStates as Map<string, any>).set('hive-qa-test', {
265
+ lastState: AgentState.TOOL_RUNNING,
266
+ lastStateChangeTime: Date.now(),
267
+ lastNudgeTime: 0,
268
+ });
269
+
270
+ await autoRejectCommentOnlyReviews(ctx);
271
+
272
+ expect(getPullRequestReviews).not.toHaveBeenCalled();
273
+ expect(updatePullRequest).not.toHaveBeenCalled();
274
+ });
275
+
276
+ it('should skip PRs when QA has formal APPROVED review on GitHub', async () => {
277
+ vi.mocked(getPullRequestsByStatus).mockReturnValue([makeReviewingPR()] as any);
278
+ vi.mocked(getAllTeams).mockReturnValue([
279
+ { id: 'team-1', repo_path: 'repos/mini-marty' },
280
+ ] as any);
281
+
282
+ const ctx = makeMockCtx({
283
+ agentsBySessionName: new Map([
284
+ ['hive-qa-test', { status: 'idle', session_name: 'hive-qa-test' } as any],
285
+ ]),
286
+ });
287
+
288
+ (agentStates as Map<string, any>).set('hive-qa-test', {
289
+ lastState: AgentState.IDLE_AT_PROMPT,
290
+ lastStateChangeTime: Date.now(),
291
+ lastNudgeTime: 0,
292
+ });
293
+
294
+ vi.mocked(getPullRequestReviews).mockResolvedValue([
295
+ { author: 'qa-bot', state: 'APPROVED', body: 'Looks good!' },
296
+ ]);
297
+ vi.mocked(getPullRequestComments).mockResolvedValue([]);
298
+
299
+ await autoRejectCommentOnlyReviews(ctx);
300
+
301
+ expect(getPullRequestReviews).toHaveBeenCalledWith('/test/project/repos/mini-marty', 42);
302
+ expect(updatePullRequest).not.toHaveBeenCalled();
303
+ });
304
+
305
+ it('should auto-reject when QA left CHANGES_REQUESTED review', async () => {
306
+ vi.mocked(getPullRequestsByStatus).mockReturnValue([makeReviewingPR()] as any);
307
+ vi.mocked(getAllTeams).mockReturnValue([
308
+ { id: 'team-1', repo_path: 'repos/mini-marty' },
309
+ ] as any);
310
+
311
+ const ctx = makeMockCtx({
312
+ agentsBySessionName: new Map([
313
+ ['hive-qa-test', { status: 'idle', session_name: 'hive-qa-test' } as any],
314
+ ]),
315
+ hiveSessions: [{ name: 'hive-senior-test' } as any],
316
+ });
317
+
318
+ (agentStates as Map<string, any>).set('hive-qa-test', {
319
+ lastState: AgentState.IDLE_AT_PROMPT,
320
+ lastStateChangeTime: Date.now(),
321
+ lastNudgeTime: 0,
322
+ });
323
+
324
+ vi.mocked(getPullRequestReviews).mockResolvedValue([
325
+ { author: 'qa-bot', state: 'CHANGES_REQUESTED', body: 'Coverage below 80%.' },
326
+ ]);
327
+ vi.mocked(getPullRequestComments).mockResolvedValue([]);
328
+
329
+ await autoRejectCommentOnlyReviews(ctx);
330
+
331
+ expect(updatePullRequest).toHaveBeenCalledWith(
332
+ expect.anything(),
333
+ 'pr-test-1',
334
+ expect.objectContaining({
335
+ status: 'rejected',
336
+ reviewNotes: expect.stringContaining('Coverage below 80%'),
337
+ })
338
+ );
339
+ expect(updateStory).toHaveBeenCalledWith(expect.anything(), 'STORY-001', {
340
+ status: 'qa_failed',
341
+ });
342
+ expect(createLog).toHaveBeenCalledWith(
343
+ expect.anything(),
344
+ expect.objectContaining({
345
+ agentId: 'manager',
346
+ eventType: 'PR_REJECTED',
347
+ storyId: 'STORY-001',
348
+ metadata: expect.objectContaining({ auto_rejected: true }),
349
+ })
350
+ );
351
+ });
352
+
353
+ it('should auto-reject when QA left substantive comments (>= 20 chars)', async () => {
354
+ vi.mocked(getPullRequestsByStatus).mockReturnValue([makeReviewingPR()] as any);
355
+ vi.mocked(getAllTeams).mockReturnValue([
356
+ { id: 'team-1', repo_path: 'repos/mini-marty' },
357
+ ] as any);
358
+
359
+ const ctx = makeMockCtx({
360
+ agentsBySessionName: new Map([
361
+ ['hive-qa-test', { status: 'idle', session_name: 'hive-qa-test' } as any],
362
+ ]),
363
+ hiveSessions: [{ name: 'hive-senior-test' } as any],
364
+ });
365
+
366
+ (agentStates as Map<string, any>).set('hive-qa-test', {
367
+ lastState: AgentState.IDLE_AT_PROMPT,
368
+ lastStateChangeTime: Date.now(),
369
+ lastNudgeTime: 0,
370
+ });
371
+
372
+ vi.mocked(getPullRequestReviews).mockResolvedValue([]);
373
+ vi.mocked(getPullRequestComments).mockResolvedValue([
374
+ {
375
+ author: 'qa-agent',
376
+ body: 'The test coverage thresholds are not met. Please add more tests.',
377
+ createdAt: '2026-03-01T10:00:00Z',
378
+ },
379
+ ]);
380
+
381
+ await autoRejectCommentOnlyReviews(ctx);
382
+
383
+ expect(updatePullRequest).toHaveBeenCalledWith(
384
+ expect.anything(),
385
+ 'pr-test-1',
386
+ expect.objectContaining({
387
+ status: 'rejected',
388
+ reviewNotes: expect.stringContaining('test coverage thresholds'),
389
+ })
390
+ );
391
+ });
392
+
393
+ it('should ignore short comments (< 20 chars) and not reject', async () => {
394
+ vi.mocked(getPullRequestsByStatus).mockReturnValue([makeReviewingPR()] as any);
395
+ vi.mocked(getAllTeams).mockReturnValue([
396
+ { id: 'team-1', repo_path: 'repos/mini-marty' },
397
+ ] as any);
398
+
399
+ const ctx = makeMockCtx({
400
+ agentsBySessionName: new Map([
401
+ ['hive-qa-test', { status: 'idle', session_name: 'hive-qa-test' } as any],
402
+ ]),
403
+ });
404
+
405
+ (agentStates as Map<string, any>).set('hive-qa-test', {
406
+ lastState: AgentState.IDLE_AT_PROMPT,
407
+ lastStateChangeTime: Date.now(),
408
+ lastNudgeTime: 0,
409
+ });
410
+
411
+ vi.mocked(getPullRequestReviews).mockResolvedValue([]);
412
+ vi.mocked(getPullRequestComments).mockResolvedValue([
413
+ { author: 'user', body: 'LGTM', createdAt: '2026-03-01T10:00:00Z' },
414
+ ]);
415
+
416
+ await autoRejectCommentOnlyReviews(ctx);
417
+
418
+ // Short comments should be filtered out → no rejection
419
+ expect(updatePullRequest).not.toHaveBeenCalled();
420
+ });
421
+
422
+ it('should skip bot "Looks good to me" comments under 100 chars', async () => {
423
+ vi.mocked(getPullRequestsByStatus).mockReturnValue([makeReviewingPR()] as any);
424
+ vi.mocked(getAllTeams).mockReturnValue([
425
+ { id: 'team-1', repo_path: 'repos/mini-marty' },
426
+ ] as any);
427
+
428
+ const ctx = makeMockCtx({
429
+ agentsBySessionName: new Map([
430
+ ['hive-qa-test', { status: 'idle', session_name: 'hive-qa-test' } as any],
431
+ ]),
432
+ });
433
+
434
+ (agentStates as Map<string, any>).set('hive-qa-test', {
435
+ lastState: AgentState.IDLE_AT_PROMPT,
436
+ lastStateChangeTime: Date.now(),
437
+ lastNudgeTime: 0,
438
+ });
439
+
440
+ vi.mocked(getPullRequestReviews).mockResolvedValue([]);
441
+ vi.mocked(getPullRequestComments).mockResolvedValue([
442
+ {
443
+ author: 'ellipsis-bot',
444
+ body: 'Looks good to me. No major issues.',
445
+ createdAt: '2026-03-01T10:00:00Z',
446
+ },
447
+ ]);
448
+
449
+ await autoRejectCommentOnlyReviews(ctx);
450
+
451
+ expect(updatePullRequest).not.toHaveBeenCalled();
452
+ });
453
+
454
+ it('should gracefully skip PR when GitHub API call fails', async () => {
455
+ vi.mocked(getPullRequestsByStatus).mockReturnValue([makeReviewingPR()] as any);
456
+ vi.mocked(getAllTeams).mockReturnValue([
457
+ { id: 'team-1', repo_path: 'repos/mini-marty' },
458
+ ] as any);
459
+
460
+ const ctx = makeMockCtx({
461
+ agentsBySessionName: new Map([
462
+ ['hive-qa-test', { status: 'idle', session_name: 'hive-qa-test' } as any],
463
+ ]),
464
+ });
465
+
466
+ (agentStates as Map<string, any>).set('hive-qa-test', {
467
+ lastState: AgentState.IDLE_AT_PROMPT,
468
+ lastStateChangeTime: Date.now(),
469
+ lastNudgeTime: 0,
470
+ });
471
+
472
+ // Both API calls fail
473
+ vi.mocked(getPullRequestReviews).mockRejectedValue(new Error('gh CLI not found'));
474
+ vi.mocked(getPullRequestComments).mockRejectedValue(new Error('gh CLI not found'));
475
+
476
+ await autoRejectCommentOnlyReviews(ctx);
477
+
478
+ // Should not throw, should not reject
479
+ expect(updatePullRequest).not.toHaveBeenCalled();
480
+ });
481
+
482
+ it('should notify developer agent via tmux after rejection', async () => {
483
+ vi.mocked(getPullRequestsByStatus).mockReturnValue([makeReviewingPR()] as any);
484
+ vi.mocked(getAllTeams).mockReturnValue([
485
+ { id: 'team-1', repo_path: 'repos/mini-marty' },
486
+ ] as any);
487
+
488
+ const ctx = makeMockCtx({
489
+ agentsBySessionName: new Map([
490
+ ['hive-qa-test', { status: 'idle', session_name: 'hive-qa-test' } as any],
491
+ ]),
492
+ hiveSessions: [{ name: 'hive-senior-test' } as any],
493
+ });
494
+
495
+ (agentStates as Map<string, any>).set('hive-qa-test', {
496
+ lastState: AgentState.IDLE_AT_PROMPT,
497
+ lastStateChangeTime: Date.now(),
498
+ lastNudgeTime: 0,
499
+ });
500
+
501
+ vi.mocked(getPullRequestReviews).mockResolvedValue([
502
+ { author: 'qa', state: 'CHANGES_REQUESTED', body: 'Tests failing.' },
503
+ ]);
504
+ vi.mocked(getPullRequestComments).mockResolvedValue([]);
505
+
506
+ await autoRejectCommentOnlyReviews(ctx);
507
+
508
+ // Phase 4: Should notify the developer
509
+ expect(sendToTmuxSession).toHaveBeenCalledWith(
510
+ 'hive-senior-test',
511
+ expect.stringContaining('PR AUTO-REJECTED')
512
+ );
513
+ });
514
+
515
+ it('should handle idle status from agent record even without agentStates entry', async () => {
516
+ vi.mocked(getPullRequestsByStatus).mockReturnValue([makeReviewingPR()] as any);
517
+ vi.mocked(getAllTeams).mockReturnValue([
518
+ { id: 'team-1', repo_path: 'repos/mini-marty' },
519
+ ] as any);
520
+
521
+ const ctx = makeMockCtx({
522
+ agentsBySessionName: new Map([
523
+ ['hive-qa-test', { status: 'idle', session_name: 'hive-qa-test' } as any],
524
+ ]),
525
+ });
526
+
527
+ // No agentStates entry - but the agent record shows idle
528
+ vi.mocked(getPullRequestReviews).mockResolvedValue([
529
+ { author: 'qa', state: 'CHANGES_REQUESTED', body: 'Missing error handling in module.' },
530
+ ]);
531
+ vi.mocked(getPullRequestComments).mockResolvedValue([]);
532
+
533
+ await autoRejectCommentOnlyReviews(ctx);
534
+
535
+ expect(updatePullRequest).toHaveBeenCalledWith(
536
+ expect.anything(),
537
+ 'pr-test-1',
538
+ expect.objectContaining({ status: 'rejected' })
539
+ );
540
+ });
541
+
542
+ it('should combine feedback from both reviews and comments in rejection reason', async () => {
543
+ vi.mocked(getPullRequestsByStatus).mockReturnValue([makeReviewingPR()] as any);
544
+ vi.mocked(getAllTeams).mockReturnValue([
545
+ { id: 'team-1', repo_path: 'repos/mini-marty' },
546
+ ] as any);
547
+
548
+ const ctx = makeMockCtx({
549
+ agentsBySessionName: new Map([
550
+ ['hive-qa-test', { status: 'idle', session_name: 'hive-qa-test' } as any],
551
+ ]),
552
+ hiveSessions: [{ name: 'hive-senior-test' } as any],
553
+ });
554
+
555
+ (agentStates as Map<string, any>).set('hive-qa-test', {
556
+ lastState: AgentState.IDLE_AT_PROMPT,
557
+ lastStateChangeTime: Date.now(),
558
+ lastNudgeTime: 0,
559
+ });
560
+
561
+ vi.mocked(getPullRequestReviews).mockResolvedValue([
562
+ { author: 'qa', state: 'CHANGES_REQUESTED', body: 'Coverage below threshold.' },
563
+ ]);
564
+ vi.mocked(getPullRequestComments).mockResolvedValue([
565
+ {
566
+ author: 'qa',
567
+ body: 'Also the vitest config needs updating for proper coverage.',
568
+ createdAt: '2026-03-01T10:00:00Z',
569
+ },
570
+ ]);
571
+
572
+ await autoRejectCommentOnlyReviews(ctx);
573
+
574
+ expect(updatePullRequest).toHaveBeenCalledWith(
575
+ expect.anything(),
576
+ 'pr-test-1',
577
+ expect.objectContaining({
578
+ reviewNotes: expect.stringContaining('Coverage below threshold'),
579
+ })
580
+ );
581
+ expect(updatePullRequest).toHaveBeenCalledWith(
582
+ expect.anything(),
583
+ 'pr-test-1',
584
+ expect.objectContaining({
585
+ reviewNotes: expect.stringContaining('vitest config needs updating'),
586
+ })
587
+ );
588
+ });
589
+ });